OXYGENHUB

Spectra stands alone: its own identities, signatures and CBOR; projects of any kind of work (#26, #27) 98a0fb9f0948

Spectra no longer uses Oxygen in any way. Composite Ed25519 + ML-DSA-65
identities with delegation and expiry, deterministic CBOR and atomic
writes are Spectra's own code, designed after Oxygen's and sharing none.
init, agent add and connect sign delegations themselves once the owner
confirms. A project is any work with an optional folder that is never
read; the owner links projects. deps/oxygen and oxy-core are gone.
inbox --follow no longer misses a message posted as it starts. Spec
Draft 0.5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key fdf1b15e9f7d
When
· 2026-11-03 15:22 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
98a0fb9f094806b4deb462510587e3a11cd5bd742de72d6155d2637bc1203e87
Depends on
037c45d8093c 09752034b5a8 200875410937 258bb3824813 2fc88a7b3a5c 36537112fcac 39f5c913f617 571572f5fb2c 5d695d869fbf 5eb57578f4ef 67bfa06a894f 77df591d5a2f 813b6330f3ad 8dc6df8c4d4e 98a16830a3d3 aa72eb904932 b576d05cf862 d17cbc7069ce da84e98f104e e071e91a14ef eca5af92279b
Depended on by
1876f2ce71e4 862f0db224aa 87f601ccce6d c7161ec1a607 b044d90930d9 f7986cb07b0e 8f87df88babf
In bookmarks
main
In tags
v0.1.0
Edits
339 atoms, 7 tree operations, 53 files

Browse the tree at this change

added crates/spectra-core/src/cbor.rs +267 −0
1 +
//! Deterministic CBOR (RFC 8949 §4.2), as much of it as Spectra encodes:
2 +
//! unsigned integers, byte and text strings, arrays, maps with integer
3 +
//! keys, null and booleans. Shortest forms, definite lengths, map keys in
4 +
//! ascending order; the decoder refuses anything else, so one value has
5 +
//! exactly one encoding and hashing it is meaningful (spec §9.5).
6 +
7 +
use std::collections::BTreeMap;
8 +
9 +
#[derive(Clone, Debug, PartialEq, Eq)]
10 +
pub enum Value {
11 +
    Uint(u64),
12 +
    Bytes(Vec<u8>),
13 +
    Text(String),
14 +
    Array(Vec<Value>),
15 +
    Map(BTreeMap<u64, Value>),
16 +
    Bool(bool),
17 +
    Null,
18 +
}
19 +
20 +
impl Value {
21 +
    pub fn map<const N: usize>(entries: [(u64, Value); N]) -> Value {
22 +
        Value::Map(entries.into_iter().collect())
23 +
    }
24 +
    pub fn as_uint(&self) -> Option<u64> {
25 +
        match self {
26 +
            Value::Uint(u) => Some(*u),
27 +
            _ => None,
28 +
        }
29 +
    }
30 +
    pub fn as_bytes(&self) -> Option<&[u8]> {
31 +
        match self {
32 +
            Value::Bytes(b) => Some(b),
33 +
            _ => None,
34 +
        }
35 +
    }
36 +
    pub fn as_text(&self) -> Option<&str> {
37 +
        match self {
38 +
            Value::Text(t) => Some(t),
39 +
            _ => None,
40 +
        }
41 +
    }
42 +
    pub fn as_array(&self) -> Option<&[Value]> {
43 +
        match self {
44 +
            Value::Array(a) => Some(a),
45 +
            _ => None,
46 +
        }
47 +
    }
48 +
    pub fn as_map(&self) -> Option<&BTreeMap<u64, Value>> {
49 +
        match self {
50 +
            Value::Map(m) => Some(m),
51 +
            _ => None,
52 +
        }
53 +
    }
54 +
    pub fn is_null(&self) -> bool {
55 +
        matches!(self, Value::Null)
56 +
    }
57 +
}
58 +
59 +
fn head(out: &mut Vec<u8>, major: u8, n: u64) {
60 +
    let m = major << 5;
61 +
    match n {
62 +
        0..=23 => out.push(m | n as u8),
63 +
        24..=0xff => out.extend_from_slice(&[m | 24, n as u8]),
64 +
        0x100..=0xffff => {
65 +
            out.push(m | 25);
66 +
            out.extend_from_slice(&(n as u16).to_be_bytes());
67 +
        }
68 +
        0x1_0000..=0xffff_ffff => {
69 +
            out.push(m | 26);
70 +
            out.extend_from_slice(&(n as u32).to_be_bytes());
71 +
        }
72 +
        _ => {
73 +
            out.push(m | 27);
74 +
            out.extend_from_slice(&n.to_be_bytes());
75 +
        }
76 +
    }
77 +
}
78 +
79 +
fn put(out: &mut Vec<u8>, v: &Value) {
80 +
    match v {
81 +
        Value::Uint(n) => head(out, 0, *n),
82 +
        Value::Bytes(b) => {
83 +
            head(out, 2, b.len() as u64);
84 +
            out.extend_from_slice(b);
85 +
        }
86 +
        Value::Text(t) => {
87 +
            head(out, 3, t.len() as u64);
88 +
            out.extend_from_slice(t.as_bytes());
89 +
        }
90 +
        Value::Array(a) => {
91 +
            head(out, 4, a.len() as u64);
92 +
            for x in a {
93 +
                put(out, x);
94 +
            }
95 +
        }
96 +
        // BTreeMap iterates keys ascending, which for unsigned integers is
97 +
        // the order of their shortest encodings.
98 +
        Value::Map(m) => {
99 +
            head(out, 5, m.len() as u64);
100 +
            for (k, x) in m {
101 +
                head(out, 0, *k);
102 +
                put(out, x);
103 +
            }
104 +
        }
105 +
        Value::Bool(false) => out.push(0xf4),
106 +
        Value::Bool(true) => out.push(0xf5),
107 +
        Value::Null => out.push(0xf6),
108 +
    }
109 +
}
110 +
111 +
pub fn encode(v: &Value) -> Vec<u8> {
112 +
    let mut out = Vec::new();
113 +
    put(&mut out, v);
114 +
    out
115 +
}
116 +
117 +
/// Why bytes are not a value Spectra encodes.
118 +
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
119 +
#[error("malformed CBOR: {0}")]
120 +
pub struct Error(pub String);
121 +
122 +
struct Reader<'a> {
123 +
    b: &'a [u8],
124 +
    at: usize,
125 +
    depth: usize,
126 +
}
127 +
128 +
const MAX_DEPTH: usize = 32;
129 +
130 +
impl<'a> Reader<'a> {
131 +
    fn take(&mut self, n: usize) -> Result<&'a [u8], Error> {
132 +
        let end = self.at.checked_add(n).filter(|e| *e <= self.b.len()).ok_or_else(|| Error("truncated".into()))?;
133 +
        let s = &self.b[self.at..end];
134 +
        self.at = end;
135 +
        Ok(s)
136 +
    }
137 +
138 +
    fn head(&mut self) -> Result<(u8, u64), Error> {
139 +
        let first = self.take(1)?[0];
140 +
        let (major, info) = (first >> 5, first & 0x1f);
141 +
        let n = match info {
142 +
            0..=23 => info as u64,
143 +
            24 => self.take(1)?[0] as u64,
144 +
            25 => u16::from_be_bytes(self.take(2)?.try_into().unwrap()) as u64,
145 +
            26 => u32::from_be_bytes(self.take(4)?.try_into().unwrap()) as u64,
146 +
            27 => u64::from_be_bytes(self.take(8)?.try_into().unwrap()),
147 +
            _ if major == 7 => return Ok((7, info as u64)),
148 +
            _ => return Err(Error("an indefinite length or a reserved form".into())),
149 +
        };
150 +
        let minimal = match info {
151 +
            24 => n >= 24,
152 +
            25 => n > 0xff,
153 +
            26 => n > 0xffff,
154 +
            27 => n > 0xffff_ffff,
155 +
            _ => true,
156 +
        };
157 +
        if !minimal && major != 7 {
158 +
            return Err(Error("a length or number not in its shortest form".into()));
159 +
        }
160 +
        Ok((major, n))
161 +
    }
162 +
163 +
    fn len(&mut self, n: u64) -> Result<usize, Error> {
164 +
        let n = usize::try_from(n).map_err(|_| Error("too long".into()))?;
165 +
        if n > self.b.len() - self.at {
166 +
            return Err(Error("truncated".into()));
167 +
        }
168 +
        Ok(n)
169 +
    }
170 +
171 +
    fn value(&mut self) -> Result<Value, Error> {
172 +
        self.depth += 1;
173 +
        if self.depth > MAX_DEPTH {
174 +
            return Err(Error("nested too deeply".into()));
175 +
        }
176 +
        let (major, n) = self.head()?;
177 +
        let v = match major {
178 +
            0 => Value::Uint(n),
179 +
            2 => {
180 +
                let l = self.len(n)?;
181 +
                Value::Bytes(self.take(l)?.to_vec())
182 +
            }
183 +
            3 => {
184 +
                let l = self.len(n)?;
185 +
                Value::Text(String::from_utf8(self.take(l)?.to_vec()).map_err(|_| Error("text that is not UTF-8".into()))?)
186 +
            }
187 +
            4 => {
188 +
                let l = self.len(n)?;
189 +
                let mut a = Vec::with_capacity(l);
190 +
                for _ in 0..l {
191 +
                    a.push(self.value()?);
192 +
                }
193 +
                Value::Array(a)
194 +
            }
195 +
            5 => {
196 +
                let l = self.len(n)?;
197 +
                let mut m = BTreeMap::new();
198 +
                let mut last: Option<u64> = None;
199 +
                for _ in 0..l {
200 +
                    let (km, k) = self.head()?;
201 +
                    if km != 0 {
202 +
                        return Err(Error("a map key that is not an unsigned integer".into()));
203 +
                    }
204 +
                    if last.is_some_and(|p| k <= p) {
205 +
                        return Err(Error("map keys out of order or repeated".into()));
206 +
                    }
207 +
                    last = Some(k);
208 +
                    m.insert(k, self.value()?);
209 +
                }
210 +
                Value::Map(m)
211 +
            }
212 +
            7 => match n {
213 +
                20 => Value::Bool(false),
214 +
                21 => Value::Bool(true),
215 +
                22 => Value::Null,
216 +
                _ => return Err(Error("an unsupported simple value or float".into())),
217 +
            },
218 +
            _ => return Err(Error("a negative integer or a tag".into())),
219 +
        };
220 +
        self.depth -= 1;
221 +
        Ok(v)
222 +
    }
223 +
}
224 +
225 +
/// Decode exactly one value, refusing any non-canonical encoding.
226 +
pub fn decode(b: &[u8]) -> Result<Value, Error> {
227 +
    let mut r = Reader { b, at: 0, depth: 0 };
228 +
    let v = r.value()?;
229 +
    if r.at != b.len() {
230 +
        return Err(Error("bytes after the value".into()));
231 +
    }
232 +
    Ok(v)
233 +
}
234 +
235 +
#[cfg(test)]
236 +
mod tests {
237 +
    use super::*;
238 +
239 +
    #[test]
240 +
    fn round_trips_and_known_encodings() {
241 +
        let v = Value::map([
242 +
            (0, Value::Uint(1)),
243 +
            (1, Value::Text("é".into())),
244 +
            (2, Value::Bytes(vec![1, 2])),
245 +
            (3, Value::Array(vec![Value::Null, Value::Bool(true), Value::Uint(500)])),
246 +
            (24, Value::Uint(u64::MAX)),
247 +
        ]);
248 +
        let b = encode(&v);
249 +
        assert_eq!(decode(&b).unwrap(), v);
250 +
        assert_eq!(encode(&Value::Uint(23)), [0x17]);
251 +
        assert_eq!(encode(&Value::Uint(24)), [0x18, 24]);
252 +
        assert_eq!(encode(&Value::Uint(1000)), [0x19, 0x03, 0xe8]);
253 +
        assert_eq!(encode(&Value::Text("a".into())), [0x61, b'a']);
254 +
    }
255 +
256 +
    #[test]
257 +
    fn refuses_what_is_not_canonical() {
258 +
        assert!(decode(&[0x18, 5]).is_err(), "5 in two bytes");
259 +
        assert!(decode(&[0xa2, 0x01, 0x00, 0x00, 0x00]).is_err(), "keys out of order");
260 +
        assert!(decode(&[0x5f]).is_err(), "indefinite length");
261 +
        assert!(decode(&[0x01, 0x02]).is_err(), "trailing bytes");
262 +
        assert!(decode(&[0x5a, 0xff, 0xff, 0xff, 0xff]).is_err(), "truncated");
263 +
        assert!(decode(&[0x20]).is_err(), "negative");
264 +
        let deep = [0x81u8; 64];
265 +
        assert!(decode(&deep).is_err(), "too deep");
266 +
    }
267 +
}
added crates/spectra-core/src/durable.rs +39 −0
1 +
//! Writing files so a crash leaves the old content or the new, never half:
2 +
//! a temporary file in the same directory, flushed, then renamed over.
3 +
4 +
use std::io::Write;
5 +
use std::path::Path;
6 +
7 +
fn write(path: &Path, bytes: &[u8], private: bool) -> std::io::Result<()> {
8 +
    let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
9 +
    std::fs::create_dir_all(dir)?;
10 +
    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
11 +
    let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id()));
12 +
    let mut opts = std::fs::OpenOptions::new();
13 +
    opts.write(true).create(true).truncate(true);
14 +
    #[cfg(unix)]
15 +
    {
16 +
        use std::os::unix::fs::OpenOptionsExt;
17 +
        opts.mode(if private { 0o600 } else { 0o644 });
18 +
    }
19 +
    #[cfg(not(unix))]
20 +
    let _ = private;
21 +
    let mut f = opts.open(&tmp)?;
22 +
    f.write_all(bytes)?;
23 +
    f.sync_all()?;
24 +
    drop(f);
25 +
    std::fs::rename(&tmp, path)?;
26 +
    if let Ok(d) = std::fs::File::open(dir) {
27 +
        let _ = d.sync_all();
28 +
    }
29 +
    Ok(())
30 +
}
31 +
32 +
pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
33 +
    write(path, bytes, false)
34 +
}
35 +
36 +
/// As [`write_atomic`], readable by its owner only.
37 +
pub fn write_atomic_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
38 +
    write(path, bytes, true)
39 +
}
added crates/spectra-core/src/identity.rs +391 −0
1 +
//! Spectra's identities and composite signatures (spec §8.0).
2 +
//!
3 +
//! An identity is a composite key pair, Ed25519 and ML-DSA-65, with a name,
4 +
//! the identity it succeeds and the keys it delegates to. Both halves sign
5 +
//! the same target and both must verify, so a signature stays sound while
6 +
//! either algorithm does. The design follows the lessons of Oxygen's
7 +
//! identities; the code, encoding and files are Spectra's own.
8 +
9 +
use crate::cbor::{self, Value};
10 +
use ed25519_dalek::ed25519::signature::Signer as _;
11 +
use ed25519_dalek::{Signature as EdSignature, SigningKey as EdSigningKey, VerifyingKey as EdVerifyingKey};
12 +
use ml_dsa::{
13 +
    EncodedSignature, EncodedVerifyingKey, MlDsa65, Seed, Signature as MlSignature, SigningKey as MlSigningKey,
14 +
    VerifyingKey as MlVerifyingKey,
15 +
};
16 +
use rand::RngCore;
17 +
18 +
const VERSION: u64 = 1;
19 +
20 +
/// An identity's name: `BLAKE3("spectra1 identity " || encoding)`.
21 +
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
22 +
pub struct KeyId(pub [u8; 32]);
23 +
24 +
impl KeyId {
25 +
    pub fn to_hex(&self) -> String {
26 +
        hex::encode(self.0)
27 +
    }
28 +
    pub fn from_hex(s: &str) -> Option<KeyId> {
29 +
        hex::decode(s.trim()).ok()?.try_into().ok().map(KeyId)
30 +
    }
31 +
    /// The first `n` hex digits, for display.
32 +
    pub fn short(&self, n: usize) -> String {
33 +
        let h = self.to_hex();
34 +
        h[..n.min(h.len())].to_string()
35 +
    }
36 +
}
37 +
38 +
impl std::fmt::Debug for KeyId {
39 +
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 +
        write!(f, "KeyId({})", self.short(12))
41 +
    }
42 +
}
43 +
44 +
impl std::fmt::Display for KeyId {
45 +
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 +
        f.write_str(&self.to_hex())
47 +
    }
48 +
}
49 +
50 +
/// A key an identity lets act for it, until `expires` if set (ms).
51 +
#[derive(Clone, Debug, PartialEq, Eq)]
52 +
pub struct Delegate {
53 +
    pub key: KeyId,
54 +
    pub expires: Option<u64>,
55 +
}
56 +
57 +
impl Delegate {
58 +
    pub fn allows(&self, at_ms: u64) -> bool {
59 +
        self.expires.is_none_or(|e| at_ms < e)
60 +
    }
61 +
}
62 +
63 +
#[derive(Clone, Debug, PartialEq, Eq)]
64 +
pub struct Identity {
65 +
    pub ed25519_pk: [u8; 32],
66 +
    pub mldsa65_pk: Vec<u8>,
67 +
    pub name: String,
68 +
    pub contact: String,
69 +
    pub created: u64,
70 +
    pub expires: Option<u64>,
71 +
    pub previous: Option<KeyId>,
72 +
    pub delegates: Vec<Delegate>,
73 +
}
74 +
75 +
/// Why an identity, a secret or a signature could not be read or checked.
76 +
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
77 +
pub enum Error {
78 +
    #[error(transparent)]
79 +
    Cbor(#[from] cbor::Error),
80 +
    #[error("{0}")]
81 +
    Shape(String),
82 +
    #[error("the signature is by another key")]
83 +
    KeyMismatch,
84 +
    #[error("a public key that does not decode")]
85 +
    BadKey,
86 +
    #[error("the Ed25519 half does not verify")]
87 +
    Ed25519,
88 +
    #[error("the ML-DSA-65 half does not verify")]
89 +
    MlDsa,
90 +
}
91 +
92 +
fn shape(s: &str) -> Error {
93 +
    Error::Shape(s.into())
94 +
}
95 +
96 +
fn bytes_of(v: Option<&Value>, what: &str) -> Result<Vec<u8>, Error> {
97 +
    v.and_then(Value::as_bytes).map(|b| b.to_vec()).ok_or_else(|| shape(what))
98 +
}
99 +
100 +
fn key_of(v: Option<&Value>, what: &str) -> Result<KeyId, Error> {
101 +
    bytes_of(v, what)?.try_into().map(KeyId).map_err(|_| shape(what))
102 +
}
103 +
104 +
fn opt_uint(v: Option<&Value>, what: &str) -> Result<Option<u64>, Error> {
105 +
    match v {
106 +
        None | Some(Value::Null) => Ok(None),
107 +
        Some(Value::Uint(u)) => Ok(Some(*u)),
108 +
        _ => Err(shape(what)),
109 +
    }
110 +
}
111 +
112 +
fn opt_val<T>(o: Option<T>, f: impl Fn(T) -> Value) -> Value {
113 +
    o.map(f).unwrap_or(Value::Null)
114 +
}
115 +
116 +
impl Identity {
117 +
    pub fn to_value(&self) -> Value {
118 +
        Value::map([
119 +
            (0, Value::Uint(VERSION)),
120 +
            (1, Value::Bytes(self.ed25519_pk.to_vec())),
121 +
            (2, Value::Bytes(self.mldsa65_pk.clone())),
122 +
            (3, Value::Text(self.name.clone())),
123 +
            (4, Value::Text(self.contact.clone())),
124 +
            (5, Value::Uint(self.created)),
125 +
            (6, opt_val(self.expires, Value::Uint)),
126 +
            (7, opt_val(self.previous, |k| Value::Bytes(k.0.to_vec()))),
127 +
            (
128 +
                8,
129 +
                Value::Array(
130 +
                    self.delegates
131 +
                        .iter()
132 +
                        .map(|d| Value::map([(0, Value::Bytes(d.key.0.to_vec())), (1, opt_val(d.expires, Value::Uint))]))
133 +
                        .collect(),
134 +
                ),
135 +
            ),
136 +
        ])
137 +
    }
138 +
139 +
    pub fn encode(&self) -> Vec<u8> {
140 +
        cbor::encode(&self.to_value())
141 +
    }
142 +
143 +
    pub fn hash(&self) -> KeyId {
144 +
        let mut h = blake3::Hasher::new();
145 +
        h.update(b"spectra1 identity ");
146 +
        h.update(&self.encode());
147 +
        KeyId(*h.finalize().as_bytes())
148 +
    }
149 +
150 +
    pub fn decode(b: &[u8]) -> Result<Identity, Error> {
151 +
        let v = cbor::decode(b)?;
152 +
        let m = v.as_map().ok_or_else(|| shape("an identity is a map"))?;
153 +
        if m.get(&0).and_then(Value::as_uint) != Some(VERSION) {
154 +
            return Err(shape("an identity of an unknown version"));
155 +
        }
156 +
        let text = |k: u64, what: &str| m.get(&k).and_then(Value::as_text).map(String::from).ok_or_else(|| shape(what));
157 +
        let delegates = m
158 +
            .get(&8)
159 +
            .and_then(Value::as_array)
160 +
            .ok_or_else(|| shape("delegates"))?
161 +
            .iter()
162 +
            .map(|d| {
163 +
                let dm = d.as_map().ok_or_else(|| shape("a delegate is a map"))?;
164 +
                Ok(Delegate { key: key_of(dm.get(&0), "a delegate's key")?, expires: opt_uint(dm.get(&1), "a delegate's expiry")? })
165 +
            })
166 +
            .collect::<Result<Vec<_>, Error>>()?;
167 +
        let id = Identity {
168 +
            ed25519_pk: bytes_of(m.get(&1), "the Ed25519 key")?.try_into().map_err(|_| shape("the Ed25519 key"))?,
169 +
            mldsa65_pk: bytes_of(m.get(&2), "the ML-DSA-65 key")?,
170 +
            name: text(3, "the name")?,
171 +
            contact: text(4, "the contact")?,
172 +
            created: m.get(&5).and_then(Value::as_uint).ok_or_else(|| shape("the creation time"))?,
173 +
            expires: opt_uint(m.get(&6), "the expiry")?,
174 +
            previous: match m.get(&7) {
175 +
                None | Some(Value::Null) => None,
176 +
                v => Some(key_of(v, "the previous identity")?),
177 +
            },
178 +
            delegates,
179 +
        };
180 +
        // One identity, one encoding: what was read must be what we write.
181 +
        if id.encode() != b {
182 +
            return Err(shape("an identity not in Spectra's own encoding"));
183 +
        }
184 +
        Ok(id)
185 +
    }
186 +
187 +
    pub fn delegate(&self, key: &KeyId) -> Option<&Delegate> {
188 +
        self.delegates.iter().find(|d| &d.key == key)
189 +
    }
190 +
191 +
    /// Whether this identity lets `key` act for it at `at_ms`.
192 +
    pub fn delegates_to(&self, key: &KeyId, at_ms: u64) -> bool {
193 +
        !self.is_expired_at(at_ms) && self.delegate(key).is_some_and(|d| d.allows(at_ms))
194 +
    }
195 +
196 +
    pub fn is_expired_at(&self, at_ms: u64) -> bool {
197 +
        self.expires.is_some_and(|e| at_ms >= e)
198 +
    }
199 +
200 +
    /// The next identity: the same key pair with these delegates.
201 +
    pub fn successor(&self, delegates: Vec<Delegate>, expires: Option<u64>, created: u64) -> Identity {
202 +
        Identity { delegates, expires, created, previous: Some(self.hash()), ..self.clone() }
203 +
    }
204 +
205 +
    fn ed_key(&self) -> Result<EdVerifyingKey, Error> {
206 +
        EdVerifyingKey::from_bytes(&self.ed25519_pk).map_err(|_| Error::BadKey)
207 +
    }
208 +
209 +
    fn ml_key(&self) -> Result<MlVerifyingKey<MlDsa65>, Error> {
210 +
        let enc = EncodedVerifyingKey::<MlDsa65>::try_from(self.mldsa65_pk.as_slice()).map_err(|_| Error::BadKey)?;
211 +
        Ok(MlVerifyingKey::decode(&enc))
212 +
    }
213 +
}
214 +
215 +
/// What both halves sign: the signer's KeyId and the message, under
216 +
/// Spectra's prefix, so a signature means one thing by one key.
217 +
fn target(key: &KeyId, message: &[u8]) -> [u8; 32] {
218 +
    let mut h = blake3::Hasher::new();
219 +
    h.update(b"spectra1 sign ");
220 +
    h.update(&key.0);
221 +
    h.update(message);
222 +
    *h.finalize().as_bytes()
223 +
}
224 +
225 +
#[derive(Clone, PartialEq, Eq)]
226 +
pub struct Signature {
227 +
    pub key: KeyId,
228 +
    pub ed: [u8; 64],
229 +
    pub ml: Vec<u8>,
230 +
}
231 +
232 +
impl std::fmt::Debug for Signature {
233 +
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 +
        write!(f, "Signature({:?})", self.key)
235 +
    }
236 +
}
237 +
238 +
impl Signature {
239 +
    pub fn to_value(&self) -> Value {
240 +
        Value::map([(0, Value::Bytes(self.key.0.to_vec())), (1, Value::Bytes(self.ed.to_vec())), (2, Value::Bytes(self.ml.clone()))])
241 +
    }
242 +
243 +
    pub fn from_value(v: &Value) -> Result<Signature, Error> {
244 +
        let m = v.as_map().ok_or_else(|| shape("a signature is a map"))?;
245 +
        Ok(Signature {
246 +
            key: key_of(m.get(&0), "the signature's key")?,
247 +
            ed: bytes_of(m.get(&1), "the Ed25519 signature")?.try_into().map_err(|_| shape("the Ed25519 signature"))?,
248 +
            ml: bytes_of(m.get(&2), "the ML-DSA-65 signature")?,
249 +
        })
250 +
    }
251 +
252 +
    pub fn encode(&self) -> Vec<u8> {
253 +
        cbor::encode(&self.to_value())
254 +
    }
255 +
256 +
    pub fn decode(b: &[u8]) -> Result<Signature, Error> {
257 +
        Signature::from_value(&cbor::decode(b)?)
258 +
    }
259 +
}
260 +
261 +
/// Check a signature by `identity` over `message`.
262 +
pub fn verify(identity: &Identity, sig: &Signature, message: &[u8]) -> Result<(), Error> {
263 +
    let key = identity.hash();
264 +
    if sig.key != key {
265 +
        return Err(Error::KeyMismatch);
266 +
    }
267 +
    let t = target(&key, message);
268 +
    identity.ed_key()?.verify_strict(&t, &EdSignature::from_bytes(&sig.ed)).map_err(|_| Error::Ed25519)?;
269 +
    let enc = EncodedSignature::<MlDsa65>::try_from(sig.ml.as_slice()).map_err(|_| Error::MlDsa)?;
270 +
    let ml = MlSignature::<MlDsa65>::decode(&enc).ok_or(Error::MlDsa)?;
271 +
    if !identity.ml_key()?.verify_with_context(&t, b"", &ml) {
272 +
        return Err(Error::MlDsa);
273 +
    }
274 +
    Ok(())
275 +
}
276 +
277 +
/// The seeds of a key pair; stored only in its identity home, 0600.
278 +
#[derive(Clone)]
279 +
pub struct SecretKey {
280 +
    ed_seed: [u8; 32],
281 +
    ml_seed: [u8; 32],
282 +
}
283 +
284 +
impl std::fmt::Debug for SecretKey {
285 +
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 +
        f.write_str("SecretKey(..)")
287 +
    }
288 +
}
289 +
290 +
impl SecretKey {
291 +
    pub fn generate() -> SecretKey {
292 +
        let (mut ed_seed, mut ml_seed) = ([0u8; 32], [0u8; 32]);
293 +
        rand::rngs::OsRng.fill_bytes(&mut ed_seed);
294 +
        rand::rngs::OsRng.fill_bytes(&mut ml_seed);
295 +
        SecretKey { ed_seed, ml_seed }
296 +
    }
297 +
298 +
    fn ed(&self) -> EdSigningKey {
299 +
        EdSigningKey::from_bytes(&self.ed_seed)
300 +
    }
301 +
302 +
    fn ml(&self) -> MlSigningKey<MlDsa65> {
303 +
        MlSigningKey::from_seed(&Seed::from(self.ml_seed))
304 +
    }
305 +
306 +
    /// The identity of this key pair, delegating to nobody.
307 +
    pub fn identity(&self, name: &str, contact: &str, created: u64) -> Identity {
308 +
        Identity {
309 +
            ed25519_pk: self.ed().verifying_key().to_bytes(),
310 +
            mldsa65_pk: self.ml().expanded_key().verifying_key().encode().as_slice().to_vec(),
311 +
            name: name.into(),
312 +
            contact: contact.into(),
313 +
            created,
314 +
            expires: None,
315 +
            previous: None,
316 +
            delegates: vec![],
317 +
        }
318 +
    }
319 +
320 +
    /// Sign `message` as the identity named `key` (this key pair's).
321 +
    pub fn sign(&self, key: &KeyId, message: &[u8]) -> Signature {
322 +
        let t = target(key, message);
323 +
        let ed = self.ed().sign(&t).to_bytes();
324 +
        let ml = self.ml().expanded_key().sign_deterministic(&t, b"").expect("an empty context is valid").encode().as_slice().to_vec();
325 +
        Signature { key: *key, ed, ml }
326 +
    }
327 +
328 +
    /// Whether this is the key pair of `identity`.
329 +
    pub fn matches(&self, identity: &Identity) -> bool {
330 +
        let mine = self.identity("", "", 0);
331 +
        mine.ed25519_pk == identity.ed25519_pk && mine.mldsa65_pk == identity.mldsa65_pk
332 +
    }
333 +
334 +
    pub fn encode(&self, key: &KeyId) -> Vec<u8> {
335 +
        cbor::encode(&Value::map([
336 +
            (0, Value::Uint(VERSION)),
337 +
            (1, Value::Bytes(key.0.to_vec())),
338 +
            (2, Value::Bytes(self.ed_seed.to_vec())),
339 +
            (3, Value::Bytes(self.ml_seed.to_vec())),
340 +
        ]))
341 +
    }
342 +
343 +
    pub fn decode(b: &[u8]) -> Result<(KeyId, SecretKey), Error> {
344 +
        let v = cbor::decode(b)?;
345 +
        let m = v.as_map().ok_or_else(|| shape("a secret key is a map"))?;
346 +
        if m.get(&0).and_then(Value::as_uint) != Some(VERSION) {
347 +
            return Err(shape("a secret key of an unknown version"));
348 +
        }
349 +
        let seed = |k: u64| -> Result<[u8; 32], Error> { bytes_of(m.get(&k), "a seed")?.try_into().map_err(|_| shape("a seed")) };
350 +
        Ok((key_of(m.get(&1), "the secret's key")?, SecretKey { ed_seed: seed(2)?, ml_seed: seed(3)? }))
351 +
    }
352 +
}
353 +
354 +
#[cfg(test)]
355 +
mod tests {
356 +
    use super::*;
357 +
358 +
    #[test]
359 +
    fn a_signature_binds_the_key_and_the_message_and_needs_both_halves() {
360 +
        let sk = SecretKey::generate();
361 +
        let id = sk.identity("Andy", "a@example", 1);
362 +
        let key = id.hash();
363 +
        let sig = sk.sign(&key, b"spectra1 test hello");
364 +
        verify(&id, &sig, b"spectra1 test hello").unwrap();
365 +
        assert_eq!(verify(&id, &sig, b"spectra1 test other"), Err(Error::Ed25519));
366 +
        let other = SecretKey::generate().identity("B", "", 1);
367 +
        assert_eq!(verify(&other, &sig, b"spectra1 test hello"), Err(Error::KeyMismatch));
368 +
        let mut half = sig.clone();
369 +
        half.ml[0] ^= 1;
370 +
        assert!(verify(&id, &half, b"spectra1 test hello").is_err(), "a broken ML-DSA half fails");
371 +
        assert_eq!(Signature::decode(&sig.encode()).unwrap(), sig);
372 +
    }
373 +
374 +
    #[test]
375 +
    fn identities_round_trip_and_successors_chain() {
376 +
        let sk = SecretKey::generate();
377 +
        let id = sk.identity("Andy", "", 5);
378 +
        assert_eq!(Identity::decode(&id.encode()).unwrap(), id);
379 +
        let agent = SecretKey::generate().identity("claude", "", 5).hash();
380 +
        let next = id.successor(vec![Delegate { key: agent, expires: Some(100) }], None, 6);
381 +
        assert_eq!(next.previous, Some(id.hash()));
382 +
        assert_ne!(next.hash(), id.hash());
383 +
        assert!(next.delegates_to(&agent, 99));
384 +
        assert!(!next.delegates_to(&agent, 100), "expired");
385 +
        assert!(!id.delegates_to(&agent, 1));
386 +
        assert!(sk.matches(&next));
387 +
        let (k, back) = SecretKey::decode(&sk.encode(&next.hash())).unwrap();
388 +
        assert_eq!(k, next.hash());
389 +
        assert!(back.matches(&id));
390 +
    }
391 +
}
removed .oxymodules +0 −0
…
3 unchanged lines
removed deps +0 −0

No line changes.

removed deps/oxygen +0 −0

No line changes.

removed src/cli/json.rs +0 −0
…
25 unchanged lines
modified CHANGELOG.md +3 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Changed: Spectra stands alone.** It no longer uses Oxygen in any way: no `oxy` command, no Oxygen library, no Oxygen files. Identities, composite Ed25519 + ML-DSA-65 signatures and deterministic CBOR are Spectra's own. `spectra identity create` makes your identity in `~/.config/spectra` (`SPECTRA_HOME`); `spectra init` delegates to the machine's instance key itself; `spectra agent add` shows the agent and asks before delegating to it (`--yes` for scripts, `--expires 8h` for an overnight agent), `spectra agent renew` gives it more time and `agent disable` takes the delegation back; `spectra connect` shows the server's instance key and asks you to confirm it (`--expect` checks it instead). Ledgers and identity homes made before this change are not readable: run `spectra identity create` and `spectra init` again.
6 +
- **Changed: a project is any kind of work.** `spectra project add NAME [--path DIR] [-d TEXT] [--url URL]`, or `spectra project add FOLDER`; Spectra records the folder and reads nothing in it. `spectra project link P --depends-on Q` draws the links `to: downstream` follows, where they used to be read from `.oxymodules`.
7 +
- **Fixed: `spectra inbox --follow` could miss a message posted just as it started.** It now subscribes first and reads the inbox once the server says the subscription holds.
5 8
- **Fixed: `agent add` printed a shim line without `--home` when Spectra is on another machine,** where the shim needs it.
6 9
- **Added: `make install-server HOST=user@host` (#25).** Builds a release, copies a stripped binary and the systemd user unit over SSH, and enables the service. The unit now waits until a Spectra exists instead of failing in a loop. `make install` copies the binary rather than linking into the build tree, so cleaning a build directory no longer breaks an installed service. The README describes setting up the daemon on another machine.
7 10
- **Added: Radar from another machine (#24).** With a server configured, `spectra open` serves Radar on `http://127.0.0.1:7350` (`--port` to change it) through a proxy to the pinned server, and signs you in through it; pages, forms and the live stream all pass through. The proxy answers only its own loopback host and origin, so no other site can reach the server through it. Ctrl-C stops it.
…
19 unchanged lines
modified Cargo.lock +18 −415
…
2 unchanged lines
3 3
version = 4
4 4
5 5
[[package]]
6 -
name = "adler2"
7 -
version = "2.0.1"
8 -
source = "registry+https://github.com/rust-lang/crates.io-index"
9 -
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10 -
11 -
[[package]]
12 6
name = "aho-corasick"
13 7
version = "1.1.5"
14 8
source = "registry+https://github.com/rust-lang/crates.io-index"
…
136 unchanged lines
151 145
 "nom",
152 146
 "num-traits",
153 147
 "rusticata-macros",
154 -
 "thiserror 2.0.21",
148 +
 "thiserror",
155 149
 "time",
156 150
]
157 151
…
130 unchanged lines
288 282
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
289 283
dependencies = [
290 284
 "serde",
291 -
]
292 -
293 -
[[package]]
294 -
name = "bit-set"
295 -
version = "0.8.0"
296 -
source = "registry+https://github.com/rust-lang/crates.io-index"
297 -
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
298 -
dependencies = [
299 -
 "bit-vec 0.8.0",
300 285
]
301 -
302 -
[[package]]
303 -
name = "bit-vec"
304 -
version = "0.8.0"
305 -
source = "registry+https://github.com/rust-lang/crates.io-index"
306 -
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
307 286
308 287
[[package]]
309 288
name = "bit-vec"
…
24 unchanged lines
334 313
 "cfg-if",
335 314
 "constant_time_eq",
336 315
 "cpufeatures 0.3.1",
337 -
 "rayon-core",
338 316
]
339 317
340 318
[[package]]
…
12 unchanged lines
353 331
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
354 332
dependencies = [
355 333
 "hybrid-array",
356 -
]
357 -
358 -
[[package]]
359 -
name = "bstr"
360 -
version = "1.13.1"
361 -
source = "registry+https://github.com/rust-lang/crates.io-index"
362 -
checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
363 -
dependencies = [
364 -
 "memchr",
365 -
 "serde_core",
366 334
]
367 335
368 336
[[package]]
…
21 unchanged lines
390 358
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
391 359
dependencies = [
392 360
 "find-msvc-tools",
393 -
 "jobserver",
394 -
 "libc",
395 361
 "shlex",
396 362
]
397 363
…
118 unchanged lines
516 482
version = "2.5.0"
517 483
source = "registry+https://github.com/rust-lang/crates.io-index"
518 484
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
519 -
520 -
[[package]]
521 -
name = "crc32fast"
522 -
version = "1.5.2"
523 -
source = "registry+https://github.com/rust-lang/crates.io-index"
524 -
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
525 -
dependencies = [
526 -
 "cfg-if",
527 -
]
528 -
529 -
[[package]]
530 -
name = "crossbeam-deque"
531 -
version = "0.8.8"
532 -
source = "registry+https://github.com/rust-lang/crates.io-index"
533 -
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
534 -
dependencies = [
535 -
 "crossbeam-epoch",
536 -
 "crossbeam-utils",
537 -
]
538 -
539 -
[[package]]
540 -
name = "crossbeam-epoch"
541 -
version = "0.9.21"
542 -
source = "registry+https://github.com/rust-lang/crates.io-index"
543 -
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
544 -
dependencies = [
545 -
 "crossbeam-utils",
546 -
]
547 485
548 486
[[package]]
549 487
name = "crossbeam-queue"
…
144 unchanged lines
694 632
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
695 633
dependencies = [
696 634
 "block-buffer 0.12.1",
697 -
 "const-oid 0.10.2",
698 635
 "crypto-common 0.2.2",
699 636
]
700 637
…
126 unchanged lines
827 764
version = "0.1.14"
828 765
source = "registry+https://github.com/rust-lang/crates.io-index"
829 766
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
830 -
831 -
[[package]]
832 -
name = "flate2"
833 -
version = "1.1.10"
834 -
source = "registry+https://github.com/rust-lang/crates.io-index"
835 -
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
836 -
dependencies = [
837 -
 "crc32fast",
838 -
 "miniz_oxide",
839 -
 "zlib-rs",
840 -
]
841 767
842 768
[[package]]
843 769
name = "flume"
…
5 unchanged lines
849 775
 "futures-sink",
850 776
 "spin",
851 777
]
852 -
853 -
[[package]]
854 -
name = "fnv"
855 -
version = "1.0.7"
856 -
source = "registry+https://github.com/rust-lang/crates.io-index"
857 -
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
858 778
859 779
[[package]]
860 780
name = "foldhash"
…
152 unchanged lines
1013 933
 "libc",
1014 934
 "r-efi 6.0.0",
1015 935
 "rand_core 0.10.1",
1016 -
]
1017 -
1018 -
[[package]]
1019 -
name = "globset"
1020 -
version = "0.4.20"
1021 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1022 -
checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
1023 -
dependencies = [
1024 -
 "aho-corasick",
1025 -
 "bstr",
1026 -
 "log",
1027 -
 "regex-automata",
1028 -
 "regex-syntax",
1029 936
]
1030 937
1031 938
[[package]]
…
268 unchanged lines
1300 1207
]
1301 1208
1302 1209
[[package]]
1303 -
name = "ignore"
1304 -
version = "0.4.33"
1305 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1306 -
checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f"
1307 -
dependencies = [
1308 -
 "crossbeam-deque",
1309 -
 "globset",
1310 -
 "log",
1311 -
 "memchr",
1312 -
 "regex-automata",
1313 -
 "same-file",
1314 -
 "walkdir",
1315 -
 "winapi-util",
1316 -
]
1317 -
1318 -
[[package]]
1319 1210
name = "indexmap"
1320 1211
version = "2.14.2"
1321 1212
source = "registry+https://github.com/rust-lang/crates.io-index"
…
27 unchanged lines
1349 1240
version = "1.0.18"
1350 1241
source = "registry+https://github.com/rust-lang/crates.io-index"
1351 1242
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1352 -
1353 -
[[package]]
1354 -
name = "jobserver"
1355 -
version = "0.1.35"
1356 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1357 -
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
1358 -
dependencies = [
1359 -
 "getrandom 0.4.3",
1360 -
 "libc",
1361 -
]
1362 1243
1363 1244
[[package]]
1364 1245
name = "js-sys"
…
164 unchanged lines
1529 1410
version = "0.2.1"
1530 1411
source = "registry+https://github.com/rust-lang/crates.io-index"
1531 1412
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
1532 -
1533 -
[[package]]
1534 -
name = "miniz_oxide"
1535 -
version = "0.9.1"
1536 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1537 -
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
1538 -
dependencies = [
1539 -
 "adler2",
1540 -
 "simd-adler32",
1541 -
]
1542 1413
1543 1414
[[package]]
1544 1415
name = "mio"
…
79 unchanged lines
1624 1495
 "num-integer",
1625 1496
 "num-iter",
1626 1497
 "num-traits",
1627 -
 "rand 0.8.8",
1498 +
 "rand",
1628 1499
 "smallvec",
1629 1500
 "zeroize",
1630 1501
]
…
61 unchanged lines
1692 1563
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1693 1564
1694 1565
[[package]]
1695 -
name = "oxy-core"
1696 -
version = "0.1.0"
1697 -
dependencies = [
1698 -
 "blake3",
1699 -
 "ed25519-dalek",
1700 -
 "globset",
1701 -
 "hex",
1702 -
 "ignore",
1703 -
 "libc",
1704 -
 "memchr",
1705 -
 "ml-dsa",
1706 -
 "proptest",
1707 -
 "rand 0.8.8",
1708 -
 "rayon",
1709 -
 "rustls",
1710 -
 "rustls-pemfile",
1711 -
 "serde_json",
1712 -
 "sha2 0.11.0",
1713 -
 "similar",
1714 -
 "tempfile",
1715 -
 "thiserror 1.0.69",
1716 -
 "ureq",
1717 -
 "webpki-roots 1.0.9",
1718 -
 "zstd",
1719 -
]
1720 -
1721 -
[[package]]
1722 1566
name = "parking"
1723 1567
version = "2.2.1"
1724 1568
source = "registry+https://github.com/rust-lang/crates.io-index"
…
178 unchanged lines
1903 1747
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
1904 1748
dependencies = [
1905 1749
 "unicode-ident",
1906 -
]
1907 -
1908 -
[[package]]
1909 -
name = "proptest"
1910 -
version = "1.11.0"
1911 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1912 -
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
1913 -
dependencies = [
1914 -
 "bit-set",
1915 -
 "bit-vec 0.8.0",
1916 -
 "bitflags",
1917 -
 "num-traits",
1918 -
 "rand 0.9.5",
1919 -
 "rand_chacha 0.9.0",
1920 -
 "rand_xorshift",
1921 -
 "regex-syntax",
1922 -
 "rusty-fork",
1923 -
 "tempfile",
1924 -
 "unarray",
1925 1750
]
1926 1751
1927 1752
[[package]]
…
13 unchanged lines
1941 1766
version = "0.11.0"
1942 1767
source = "registry+https://github.com/rust-lang/crates.io-index"
1943 1768
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
1944 -
1945 -
[[package]]
1946 -
name = "quick-error"
1947 -
version = "1.2.3"
1948 -
source = "registry+https://github.com/rust-lang/crates.io-index"
1949 -
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
1950 1769
1951 1770
[[package]]
1952 1771
name = "quote"
…
26 unchanged lines
1979 1798
 "axum",
1980 1799
 "futures",
1981 1800
 "hex",
1982 -
 "oxy-core",
1983 1801
 "pulldown-cmark",
1984 -
 "rand 0.8.8",
1802 +
 "rand",
1985 1803
 "serde",
1986 1804
 "serde_json",
1987 1805
 "spectra-core",
…
11 unchanged lines
1999 1817
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
2000 1818
dependencies = [
2001 1819
 "libc",
2002 -
 "rand_chacha 0.3.1",
1820 +
 "rand_chacha",
2003 1821
 "rand_core 0.6.4",
2004 1822
]
2005 1823
2006 1824
[[package]]
2007 -
name = "rand"
2008 -
version = "0.9.5"
2009 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2010 -
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
2011 -
dependencies = [
2012 -
 "rand_chacha 0.9.0",
2013 -
 "rand_core 0.9.5",
2014 -
]
2015 -
2016 -
[[package]]
2017 1825
name = "rand_chacha"
2018 1826
version = "0.3.1"
2019 1827
source = "registry+https://github.com/rust-lang/crates.io-index"
…
4 unchanged lines
2024 1832
]
2025 1833
2026 1834
[[package]]
2027 -
name = "rand_chacha"
2028 -
version = "0.9.0"
2029 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2030 -
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
2031 -
dependencies = [
2032 -
 "ppv-lite86",
2033 -
 "rand_core 0.9.5",
2034 -
]
2035 -
2036 -
[[package]]
2037 1835
name = "rand_core"
2038 1836
version = "0.6.4"
2039 1837
source = "registry+https://github.com/rust-lang/crates.io-index"
2040 1838
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
2041 1839
dependencies = [
2042 1840
 "getrandom 0.2.17",
2043 -
]
2044 -
2045 -
[[package]]
2046 -
name = "rand_core"
2047 -
version = "0.9.5"
2048 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2049 -
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
2050 -
dependencies = [
2051 -
 "getrandom 0.3.4",
2052 1841
]
2053 1842
2054 1843
[[package]]
…
1 unchanged lines
2056 1845
version = "0.10.1"
2057 1846
source = "registry+https://github.com/rust-lang/crates.io-index"
2058 1847
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
2059 -
2060 -
[[package]]
2061 -
name = "rand_xorshift"
2062 -
version = "0.4.0"
2063 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2064 -
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
2065 -
dependencies = [
2066 -
 "rand_core 0.9.5",
2067 -
]
2068 -
2069 -
[[package]]
2070 -
name = "rayon"
2071 -
version = "1.12.0"
2072 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2073 -
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
2074 -
dependencies = [
2075 -
 "either",
2076 -
 "rayon-core",
2077 -
]
2078 -
2079 -
[[package]]
2080 -
name = "rayon-core"
2081 -
version = "1.13.0"
2082 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2083 -
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
2084 -
dependencies = [
2085 -
 "crossbeam-deque",
2086 -
 "crossbeam-utils",
2087 -
]
2088 1848
2089 1849
[[package]]
2090 1850
name = "rcgen"
…
141 unchanged lines
2232 1992
source = "registry+https://github.com/rust-lang/crates.io-index"
2233 1993
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
2234 1994
dependencies = [
2235 -
 "log",
2236 1995
 "once_cell",
2237 1996
 "ring",
2238 1997
 "rustls-pki-types",
…
36 unchanged lines
2275 2034
version = "1.0.23"
2276 2035
source = "registry+https://github.com/rust-lang/crates.io-index"
2277 2036
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
2278 -
2279 -
[[package]]
2280 -
name = "rusty-fork"
2281 -
version = "0.3.1"
2282 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2283 -
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
2284 -
dependencies = [
2285 -
 "fnv",
2286 -
 "quick-error",
2287 -
 "tempfile",
2288 -
 "wait-timeout",
2289 -
]
2290 2037
2291 2038
[[package]]
2292 2039
name = "ryu"
2293 2040
version = "1.0.23"
2294 2041
source = "registry+https://github.com/rust-lang/crates.io-index"
2295 2042
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
2296 -
2297 -
[[package]]
2298 -
name = "same-file"
2299 -
version = "1.0.6"
2300 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2301 -
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
2302 -
dependencies = [
2303 -
 "winapi-util",
2304 -
]
2305 2043
2306 2044
[[package]]
2307 2045
name = "schemars"
…
206 unchanged lines
2514 2252
 "digest 0.11.3",
2515 2253
 "rand_core 0.10.1",
2516 2254
]
2517 -
2518 -
[[package]]
2519 -
name = "simd-adler32"
2520 -
version = "0.3.10"
2521 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2522 -
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
2523 -
2524 -
[[package]]
2525 -
name = "similar"
2526 -
version = "2.7.0"
2527 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2528 -
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
2529 2255
2530 2256
[[package]]
2531 2257
name = "siphasher"
…
38 unchanged lines
2570 2296
 "indicatif",
2571 2297
 "libc",
2572 2298
 "mimalloc",
2573 -
 "oxy-core",
2574 2299
 "radar",
2575 2300
 "serde_json",
2576 2301
 "spectra-api",
…
22 unchanged lines
2599 2324
dependencies = [
2600 2325
 "anyhow",
2601 2326
 "blake3",
2327 +
 "ed25519-dalek",
2602 2328
 "hex",
2603 -
 "oxy-core",
2604 -
 "rand 0.8.8",
2329 +
 "ml-dsa",
2330 +
 "rand",
2605 2331
 "serde",
2606 2332
 "serde_json",
2607 2333
 "sqlx",
2608 2334
 "tempfile",
2609 -
 "thiserror 2.0.21",
2335 +
 "thiserror",
2610 2336
 "tokio",
2611 2337
 "toml",
2612 2338
 "tracing",
…
10 unchanged lines
2623 2349
 "http-body-util",
2624 2350
 "hyper",
2625 2351
 "hyper-util",
2626 -
 "oxy-core",
2627 -
 "rand 0.8.8",
2352 +
 "rand",
2628 2353
 "rcgen",
2629 2354
 "ring",
2630 2355
 "rustls",
…
86 unchanged lines
2717 2442
 "serde_json",
2718 2443
 "sha2 0.10.9",
2719 2444
 "smallvec",
2720 -
 "thiserror 2.0.21",
2445 +
 "thiserror",
2721 2446
 "tokio",
2722 2447
 "tokio-stream",
2723 2448
 "tracing",
…
65 unchanged lines
2789 2514
 "memchr",
2790 2515
 "once_cell",
2791 2516
 "percent-encoding",
2792 -
 "rand 0.8.8",
2517 +
 "rand",
2793 2518
 "rsa",
2794 2519
 "sha1",
2795 2520
 "sha2 0.10.9",
2796 2521
 "smallvec",
2797 2522
 "sqlx-core",
2798 2523
 "stringprep",
2799 -
 "thiserror 2.0.21",
2524 +
 "thiserror",
2800 2525
 "tracing",
2801 2526
 "whoami",
2802 2527
]
…
23 unchanged lines
2826 2551
 "md-5",
2827 2552
 "memchr",
2828 2553
 "once_cell",
2829 -
 "rand 0.8.8",
2554 +
 "rand",
2830 2555
 "serde",
2831 2556
 "serde_json",
2832 2557
 "sha2 0.10.9",
2833 2558
 "smallvec",
2834 2559
 "sqlx-core",
2835 2560
 "stringprep",
2836 -
 "thiserror 2.0.21",
2561 +
 "thiserror",
2837 2562
 "tracing",
2838 2563
 "whoami",
2839 2564
]
…
17 unchanged lines
2857 2582
 "serde",
2858 2583
 "serde_urlencoded",
2859 2584
 "sqlx-core",
2860 -
 "thiserror 2.0.21",
2585 +
 "thiserror",
2861 2586
 "tracing",
2862 2587
 "url",
2863 2588
]
…
121 unchanged lines
2985 2710
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
2986 2711
dependencies = [
2987 2712
 "new_debug_unreachable",
2988 -
]
2989 -
2990 -
[[package]]
2991 -
name = "thiserror"
2992 -
version = "1.0.69"
2993 -
source = "registry+https://github.com/rust-lang/crates.io-index"
2994 -
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
2995 -
dependencies = [
2996 -
 "thiserror-impl 1.0.69",
2997 2713
]
2998 2714
2999 2715
[[package]]
…
1 unchanged lines
3001 2717
version = "2.0.21"
3002 2718
source = "registry+https://github.com/rust-lang/crates.io-index"
3003 2719
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
3004 -
dependencies = [
3005 -
 "thiserror-impl 2.0.21",
3006 -
]
3007 -
3008 -
[[package]]
3009 -
name = "thiserror-impl"
3010 -
version = "1.0.69"
3011 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3012 -
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
3013 2720
dependencies = [
3014 -
 "proc-macro2",
3015 -
 "quote",
3016 -
 "syn 2.0.119",
2721 +
 "thiserror-impl",
3017 2722
]
3018 2723
3019 2724
[[package]]
…
268 unchanged lines
3288 2993
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
3289 2994
3290 2995
[[package]]
3291 -
name = "unarray"
3292 -
version = "0.1.4"
3293 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3294 -
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
3295 -
3296 -
[[package]]
3297 2996
name = "unicase"
3298 2997
version = "2.10.0"
3299 2998
source = "registry+https://github.com/rust-lang/crates.io-index"
…
37 unchanged lines
3337 3036
version = "0.9.0"
3338 3037
source = "registry+https://github.com/rust-lang/crates.io-index"
3339 3038
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
3340 -
3341 -
[[package]]
3342 -
name = "ureq"
3343 -
version = "2.12.1"
3344 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3345 -
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
3346 -
dependencies = [
3347 -
 "base64 0.22.1",
3348 -
 "flate2",
3349 -
 "log",
3350 -
 "once_cell",
3351 -
 "rustls",
3352 -
 "rustls-pki-types",
3353 -
 "url",
3354 -
 "webpki-roots 0.26.11",
3355 -
]
3356 3039
3357 3040
[[package]]
3358 3041
name = "url"
…
36 unchanged lines
3395 3078
version = "0.9.5"
3396 3079
source = "registry+https://github.com/rust-lang/crates.io-index"
3397 3080
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
3398 -
3399 -
[[package]]
3400 -
name = "wait-timeout"
3401 -
version = "0.2.1"
3402 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3403 -
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
3404 -
dependencies = [
3405 -
 "libc",
3406 -
]
3407 -
3408 -
[[package]]
3409 -
name = "walkdir"
3410 -
version = "2.5.0"
3411 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3412 -
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
3413 -
dependencies = [
3414 -
 "same-file",
3415 -
 "winapi-util",
3416 -
]
3417 3081
3418 3082
[[package]]
3419 3083
name = "want"
…
90 unchanged lines
3510 3174
 "phf_codegen",
3511 3175
 "string_cache",
3512 3176
 "string_cache_codegen",
3513 -
]
3514 -
3515 -
[[package]]
3516 -
name = "webpki-roots"
3517 -
version = "0.26.11"
3518 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3519 -
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
3520 -
dependencies = [
3521 -
 "webpki-roots 1.0.9",
3522 3177
]
3523 3178
3524 3179
[[package]]
3525 -
name = "webpki-roots"
3526 -
version = "1.0.9"
3527 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3528 -
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
3529 -
dependencies = [
3530 -
 "rustls-pki-types",
3531 -
]
3532 -
3533 -
[[package]]
3534 3180
name = "whoami"
3535 3181
version = "1.6.1"
3536 3182
source = "registry+https://github.com/rust-lang/crates.io-index"
…
1 unchanged lines
3538 3184
dependencies = [
3539 3185
 "libredox",
3540 3186
 "wasite",
3541 -
]
3542 -
3543 -
[[package]]
3544 -
name = "winapi-util"
3545 -
version = "0.1.11"
3546 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3547 -
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
3548 -
dependencies = [
3549 -
 "windows-sys 0.48.0",
3550 3187
]
3551 3188
3552 3189
[[package]]
…
194 unchanged lines
3747 3384
 "oid-registry",
3748 3385
 "ring",
3749 3386
 "rusticata-macros",
3750 -
 "thiserror 2.0.21",
3387 +
 "thiserror",
3751 3388
 "time",
3752 3389
]
3753 3390
…
3 unchanged lines
3757 3394
source = "registry+https://github.com/rust-lang/crates.io-index"
3758 3395
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
3759 3396
dependencies = [
3760 -
 "bit-vec 0.9.1",
3397 +
 "bit-vec",
3761 3398
 "time",
3762 3399
]
3763 3400
…
99 unchanged lines
3863 3500
 "quote",
3864 3501
 "syn 3.0.6",
3865 3502
]
3866 -
3867 -
[[package]]
3868 -
name = "zlib-rs"
3869 -
version = "0.6.8"
3870 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3871 -
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
3872 3503
3873 3504
[[package]]
3874 3505
name = "zmij"
3875 3506
version = "1.0.23"
3876 3507
source = "registry+https://github.com/rust-lang/crates.io-index"
3877 3508
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
3878 -
3879 -
[[package]]
3880 -
name = "zstd"
3881 -
version = "0.13.3"
3882 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3883 -
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
3884 -
dependencies = [
3885 -
 "zstd-safe",
3886 -
]
3887 -
3888 -
[[package]]
3889 -
name = "zstd-safe"
3890 -
version = "7.3.0"
3891 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3892 -
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
3893 -
dependencies = [
3894 -
 "zstd-sys",
3895 -
]
3896 -
3897 -
[[package]]
3898 -
name = "zstd-sys"
3899 -
version = "2.1.0+zstd.1.5.7"
3900 -
source = "registry+https://github.com/rust-lang/crates.io-index"
3901 -
checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
3902 -
dependencies = [
3903 -
 "cc",
3904 -
 "pkg-config",
3905 -
]
modified Cargo.toml +0 −8
1 1
[workspace]
2 2
members = ["crates/spectra-core", "crates/spectra-mcp", "crates/spectra-api", "crates/radar", "."]
3 -
# The pinned Oxygen (deps/oxygen) is a workspace of its own.
4 -
exclude = ["deps"]
5 3
resolver = "2"
6 4
7 5
[workspace.package]
…
2 unchanged lines
10 8
license = "MIT OR Apache-2.0"
11 9
12 10
[workspace.dependencies]
13 -
# Identities, delegation, composite signatures, deterministic CBOR and
14 -
# hashing are Oxygen's: Spectra links them from the pinned subrepo and never
15 -
# reimplements them (spec §7.3, §9.5).
16 -
oxy-core = { path = "deps/oxygen/crates/oxy-core" }
17 11
spectra-core = { path = "crates/spectra-core" }
18 12
spectra-mcp = { path = "crates/spectra-mcp" }
19 13
spectra-api = { path = "crates/spectra-api" }
…
42 unchanged lines
62 56
spectra-mcp.workspace = true
63 57
spectra-api.workspace = true
64 58
radar.workspace = true
65 -
oxy-core.workspace = true
66 59
anyhow.workspace = true
67 60
clap.workspace = true
68 61
serde_json.workspace = true
…
11 unchanged lines
80 73
81 74
[dev-dependencies]
82 75
tempfile.workspace = true
83 -
oxy-core.workspace = true
84 76
spectra-mcp.workspace = true
85 77
sqlx.workspace = true
86 78
serde_json.workspace = true
…
3 unchanged lines
modified README.md +24 −31
…
1 unchanged lines
2 2
3 3
[![OxygenFlow](https://oxygenhub.io/andymccall/spectra/flow/badge.svg)](https://oxygenhub.io/andymccall/spectra/flow)
4 4
5 -
Spectra is a control plane for AI coding agents, written in Rust. Several agents on one machine (Claude Code in one checkout, Antigravity in another, Kimi Code in a third) connect to one `spectra` process over the Model Context Protocol to find each other, talk in persistent channels, keep to the house rules, search what was said, and let you watch all of it live. See [docs/spec.md](docs/spec.md) for the specification.
5 +
Spectra is a control plane for AI agents, written in Rust. Several agents (Claude Code on one piece of work, Antigravity on another, Kimi Code on a third) connect to one `spectra` daemon over the Model Context Protocol to find each other, talk in persistent channels, keep to the house rules, search what was said, and let you watch all of it live. The work can be anything: software, a book being restructured, a research report. Spectra needs no other tool and reads nothing inside your work. See [docs/spec.md](docs/spec.md) for the specification.
6 6
7 -
Every agent is its owner's Oxygen delegate: its key is listed in your identity with a scope and an expiry, so "claude-oxygen · Andy McCall's agent" is a fact Spectra checks, not a label. Every request is signed, every event is chained into a tamper-evident ledger with checkpoints signed by this machine's key, and `spectra admin verify` checks all of it from the ledger alone.
7 +
Every agent is somebody's: its key is a delegate of its owner's Spectra identity, with an expiry if you set one, so "claude-oxygen · Andy McCall's agent" is a fact Spectra checks, not a label. Identities are composite Ed25519 + ML-DSA-65 key pairs that Spectra makes itself. Every request is signed, every event is chained into a tamper-evident ledger with checkpoints signed by the daemon's own key, and `spectra admin verify` checks all of it from the ledger alone.
8 8
9 -
The hosted, multi-machine layer is [SpectraHub](../spectrahub); the editor integrations are [spectra-vscode](../spectra-vscode) and [spectrahub-vscode](../spectrahub-vscode). The split mirrors Oxygen and OxygenHub.
9 +
The hosted, multi-person layer is [SpectraHub](../spectrahub); the editor integrations are [spectra-vscode](../spectra-vscode) and [spectrahub-vscode](../spectrahub-vscode).
10 10
11 11
## Status
12 12
13 -
0.1, Talk (Sprint 1): the ledger, owned agents with signed requests, channels, threads, the inbox and search, the MCP server over a Unix socket with the stdio shim, Radar, the JSON API, and the command line. Tasks, handoffs and the passkey are 0.2; see the [board](https://oxygenhub.io/projects/andymccall/7).
13 +
0.1, Talk: the ledger, owned agents with signed requests, channels, threads, the inbox and search, the MCP server with the stdio shim, Radar, the JSON API, the command line, and the daemon on another machine over TLS. Tasks, handoffs and the passkey are 0.2; see the [board](https://oxygenhub.io/projects/andymccall/7).
14 14
15 15
## Building
16 16
…
10 unchanged lines
27 27
28 28
## Quick start
29 29
30 -
You need an Oxygen identity of your own (`oxy identity create`). Then:
30 +
On one machine:
31 31
32 32
```
33 -
spectra init                                   # records your identity, makes this machine's key
34 -
oxy identity delegate add ~/.local/share/spectra/keys/instance.identity --scope changes
35 -
spectra serve                                  # or: cp packaging/spectra.service ~/.config/systemd/user/
36 -
spectra project add ~/development/oxygen
37 -
spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.oxy-agents/claude-oxygen
33 +
spectra identity create --name "Your Name"     # your own key pair, in ~/.config/spectra
34 +
spectra init                                   # this machine's Spectra, owned by you
35 +
spectra serve                                  # or: make install, then the systemd user unit
36 +
spectra project add ~/Documents/book -d "the manuscript"
37 +
spectra agent add claude-book --kind claude-code --project book --home ~/.spectra-agents/claude-book --expires 8h
38 38
```
39 39
40 -
`agent add` prints the one command that makes the agent yours and waits for it:
40 +
`agent add` shows you the agent and its key and asks before letting it act for you; `--expires` makes an overnight agent stop by itself, and `spectra agent renew` gives it more time. A project is any work with a name; the folder is optional and Spectra never reads it. `spectra project link index --depends-on book` says one piece of work depends on another.
41 41
42 -
```
43 -
oxy identity delegate add ~/.oxy-agents/claude-oxygen/keys/<KEY>.identity --scope changes --expires 8h
44 -
```
45 -
46 42
Connect the agent's client with one line and nothing secret in it:
47 43
48 44
```
49 -
claude mcp add spectra -- spectra mcp --agent claude-oxygen            # Claude Code
50 -
kimi mcp add spectra -- spectra mcp --agent kimi-oxygenflow            # Kimi Code
45 +
claude mcp add spectra -- spectra mcp --agent claude-book            # Claude Code
46 +
kimi mcp add spectra -- spectra mcp --agent kimi-index               # Kimi Code
51 47
```
52 48
53 49
Antigravity, in `~/.gemini/config/mcp_config.json`:
54 50
55 51
```json
56 -
{ "mcpServers": { "spectra": { "command": "spectra", "args": ["mcp", "--agent", "antigravity-oxygenhub"] } } }
52 +
{ "mcpServers": { "spectra": { "command": "spectra", "args": ["mcp", "--agent", "antigravity-index"] } } }
57 53
```
58 54
59 -
Open Radar with `spectra open`. From a terminal: `spectra say "…"`, `spectra inbox --follow`, `spectra search "pin bump"`, `spectra agent list`, and `spectra admin verify`.
55 +
Open Radar with `spectra open`. From a terminal: `spectra say "…"`, `spectra inbox --follow`, `spectra search "chapter 3"`, `spectra agent list`, and `spectra admin verify`.
60 56
61 57
## The daemon on another machine
62 58
63 -
Spectra can run on a server while you, your keys and your agents stay on your workstation (spec §8.6). Nothing secret goes to the server: it holds only its own instance key, and every request is still signed where the key lives. Traffic is TLS with a certificate the server makes itself; your workstation trusts it because your own identity delegates to the server's instance key, which vouches for the certificate. No certificate authority is involved.
59 +
Spectra can run on a server while you, your keys and your agents stay on your workstation (spec §8.6). Nothing secret goes to the server: it holds only its own instance key, and every request is still signed where the key lives. Traffic is TLS with a certificate the server makes itself and vouches for with its instance key; your workstation trusts the server once you confirm that key and your identity delegates to it. No certificate authority is involved.
64 60
65 -
On the workstation, install the server and note your KeyId:
61 +
On the workstation:
66 62
67 63
```
64 +
spectra identity create --name "Your Name"     # if you have not already; note the key it prints
68 65
make install-server HOST=claude@earth
69 -
oxy identity show                              # the 64-hex key
70 66
```
71 67
72 68
On the server:
73 69
74 70
```
75 -
spectra init --owner KEYID --name earth        # no key of yours is needed there
71 +
spectra init --owner KEYID --name earth        # your key's id only: no secret of yours goes there
76 72
# in ~/.local/share/spectra/spectra.toml, under [server]:
77 73
#   bind = "[::]:7350"
78 74
#   names = ["earth", "earth.example"]
79 75
systemctl --user start spectra
80 76
```
81 77
82 -
On the workstation:
78 +
`init` prints the server's instance key. On the workstation:
83 79
84 80
```
85 -
spectra connect https://earth.example:7350     # prints the delegation to run, the first time
86 -
oxy identity delegate add ~/.config/spectra/earth-instance.identity --scope changes
87 -
spectra connect https://earth.example:7350     # trusted, pinned, and claimed as yours
81 +
spectra connect https://earth.example:7350     # shows the instance key; confirm it matches
88 82
```
89 83
90 -
From then on every command on the workstation works against the server, with no ledger file locally. Agents name their home, since it is on the workstation: `spectra mcp --agent claude-oxygen --home ~/.oxy-agents/claude-oxygen`. `spectra open` serves Radar on `http://127.0.0.1:7350` through a proxy to the server.
84 +
From then on every command on the workstation works against the server, with no ledger file locally. Agents name their home, since it is on the workstation: `spectra mcp --agent claude-book --home ~/.spectra-agents/claude-book`. `spectra open` serves Radar on `http://127.0.0.1:7350` through a proxy to the server.
91 85
92 -
Output uses colour and glyphs on a UTF-8 terminal, like `oxy`'s, and falls back to plain ASCII when piped, with `--plain`, or with `NO_COLOR`.
86 +
Output uses colour and glyphs on a UTF-8 terminal and falls back to plain ASCII when piped, with `--plain`, or with `NO_COLOR`.
93 87
94 88
## Layout
95 89
96 90
| Path | Contents |
97 91
|---|---|
98 -
| `crates/spectra-core` | The model: the ledger, identities and principals, conversation, presence, rules, the daemon's state |
92 +
| `crates/spectra-core` | The model: the ledger, Spectra's own identities, signatures and CBOR, principals, conversation, presence, rules, the daemon's state |
99 93
| `crates/spectra-mcp` | Tuner (MCP over the Unix socket and loopback), the JSON API, the signing client and the browser sessions |
100 94
| `crates/spectra-api` | The JSON API's types; `make ts` writes `ts/index.d.ts` for the extensions |
101 95
| `crates/radar` | Radar, the owner's web pages, with its templates and assets |
102 96
| `src/cli` | The `spectra` command line |
103 -
| `deps/oxygen` | Oxygen, pinned: identities, signatures, CBOR and hashing come from `oxy-core` |
104 97
| `docs/spec.md` | The specification |
105 98
| `docs/plan.md` | The sprint plan and its log |
106 99
| `packaging/spectra.service` | The systemd user unit |
…
4 unchanged lines
modified crates/radar/Cargo.toml +0 −1
…
7 unchanged lines
8 8
[dependencies]
9 9
spectra-core.workspace = true
10 10
spectra-mcp.workspace = true
11 -
oxy-core.workspace = true
12 11
anyhow.workspace = true
13 12
axum.workspace = true
14 13
askama.workspace = true
…
11 unchanged lines
modified crates/radar/src/lib.rs +3 −3
…
122 unchanged lines
123 123
        .principals
124 124
        .read()
125 125
        .await
126 -
        .principal(&oxy_core_key(session.key), now)
126 +
        .principal(&key_id(session.key), now)
127 127
        .map_err(|why| signin_page(&format!("This session no longer belongs to the owner: {why}."), StatusCode::UNAUTHORIZED))?;
128 128
    if !matches!(principal, Principal::Owner { .. }) {
129 129
        return Err(signin_page("Radar shows Spectra to its owner only.", StatusCode::FORBIDDEN));
…
2 unchanged lines
132 132
    Ok(Owner { session, principal, shell })
133 133
}
134 134
135 -
fn oxy_core_key(k: [u8; 32]) -> oxy_core::hash::KeyId {
136 -
    oxy_core::hash::ObjectRef(k)
135 +
fn key_id(k: [u8; 32]) -> spectra_core::identity::KeyId {
136 +
    spectra_core::identity::KeyId(k)
137 137
}
138 138
139 139
#[derive(Template)]
…
500 unchanged lines
modified crates/spectra-api/src/lib.rs +6 −2
…
50 unchanged lines
51 51
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
52 52
pub struct Project {
53 53
    pub name: String,
54 -
    pub path: String,
55 -
    pub remote: Option<String>,
54 +
    /// A folder, if the work has one; Spectra never reads it.
55 +
    pub path: Option<String>,
56 +
    #[serde(default)]
57 +
    pub description: String,
58 +
    /// A link to the work, if any.
59 +
    pub url: Option<String>,
56 60
    pub depends_on: Vec<String>,
57 61
    pub depended_on_by: Vec<String>,
58 62
    /// Its agents that are not disabled.
…
151 unchanged lines
modified crates/spectra-api/ts/index.d.ts +3 −2
…
31 unchanged lines
32 32
  agents?: string[];
33 33
  depended_on_by: string[];
34 34
  depends_on: string[];
35 +
  description?: string;
35 36
  name: string;
36 -
  path: string;
37 -
  remote?: string | null;
37 +
  path?: string | null;
38 +
  url?: string | null;
38 39
};
39 40
40 41
export type Channel = {
…
94 unchanged lines
modified crates/spectra-core/Cargo.toml +3 −1
…
5 unchanged lines
6 6
description = "Spectra's model: the ledger, projections, principals and the bus"
7 7
8 8
[dependencies]
9 -
oxy-core.workspace = true
10 9
anyhow.workspace = true
11 10
thiserror.workspace = true
12 11
serde.workspace = true
…
5 unchanged lines
18 17
hex.workspace = true
19 18
tracing.workspace = true
20 19
toml.workspace = true
20 +
# Spectra's own identities (spec §8.0): composite Ed25519 + ML-DSA-65.
21 +
ed25519-dalek = "3"
22 +
ml-dsa = "0.1"
21 23
22 24
[dev-dependencies]
23 25
tempfile.workspace = true
…
1 unchanged lines
modified crates/spectra-core/migrations/0003_projects_presence.sql +7 −8
1 -
-- Projects and how they depend on one another (spec §23.1), and agents'
2 -
-- presence and sessions (spec §8.1, §8.2).
1 +
-- Projects, any kind of work, and the links the owner draws between them
2 +
-- (spec §23.1); agents' presence and sessions (spec §8.1, §8.2).
3 3
CREATE TABLE projects (
4 -
    name     TEXT PRIMARY KEY,
5 -
    path     TEXT NOT NULL,
6 -
    repo_id  TEXT,
7 -
    remote   TEXT,
8 -
    modules  TEXT NOT NULL,               -- JSON array of the URLs in .oxymodules
9 -
    seq      INTEGER NOT NULL
4 +
    name         TEXT PRIMARY KEY,
5 +
    path         TEXT,                    -- a folder, if the work has one; never read
6 +
    description  TEXT NOT NULL,
7 +
    url          TEXT,                    -- a link to it, if any
8 +
    seq          INTEGER NOT NULL
10 9
);
11 10
12 11
CREATE TABLE project_relations (
…
22 unchanged lines
modified crates/spectra-core/src/attachments.rs +1 −1
…
11 unchanged lines
12 12
    let path = dir.join(&hash);
13 13
    if !path.exists() {
14 14
        std::fs::create_dir_all(&dir)?;
15 -
        oxy_core::durable::write_atomic(&path, bytes)?;
15 +
        crate::durable::write_atomic(&path, bytes)?;
16 16
    }
17 17
    Ok(hash)
18 18
}
…
8 unchanged lines
modified crates/spectra-core/src/authn.rs +1 −1
…
67 unchanged lines
68 68
        if skew.unsigned_abs() > WINDOW_MS {
69 69
            return Err(Refusal::Stale(skew / 1000));
70 70
        }
71 -
        let key = oxy_core::hash::ObjectRef(proof.key());
71 +
        let key = crate::identity::KeyId(proof.key());
72 72
        let principal = principals.principal(&key, now_ms).map_err(Refusal::NotAPrincipal)?;
73 73
        let identity = principals.identity(&key).ok_or_else(|| Refusal::NotAPrincipal("an unknown key".into()))?;
74 74
        proof.verify(identity, Some(body)).map_err(Refusal::BadSignature)?;
…
23 unchanged lines
modified crates/spectra-core/src/daemon.rs +3 −2
…
17 unchanged lines
18 18
19 19
/// Kinds the owner's CLI may hand the daemon, already signed over their
20 20
/// payload (spec §8.5, "One writer").
21 -
pub const OWNER_KINDS: &[&str] = &["identity.rotated", "agent.added", "agent.disabled", "project.added"];
21 +
pub const OWNER_KINDS: &[&str] =
22 +
    &["identity.rotated", "agent.added", "agent.disabled", "project.added", "project.linked", "project.unlinked"];
22 23
23 24
pub struct Daemon {
24 25
    pub data: PathBuf,
…
155 unchanged lines
180 181
            let probe = probe_event(&n);
181 182
            principals.check(&probe).map_err(|why| anyhow::anyhow!("refused {}: {why}", n.kind))?;
182 183
            if n.kind != "identity.rotated" {
183 -
                let key = oxy_core::hash::ObjectRef(n.auth.key.context("unsigned")?);
184 +
                let key = crate::identity::KeyId(n.auth.key.context("unsigned")?);
184 185
                if !matches!(principals.principal(&key, crate::ids::now_ms()), Ok(Principal::Owner { .. })) {
185 186
                    bail!("refused {}: not signed by the owner", n.kind);
186 187
                }
…
129 unchanged lines
modified crates/spectra-core/src/event.rs +1 −1
…
8 unchanged lines
9 9
//! The envelope is frozen from the first release: fields may be added at new
10 10
//! keys, never changed or removed, so a hub merges logs from any version.
11 11
12 +
use crate::cbor::{self, Value};
12 13
use crate::hlc::Hlc;
13 -
use oxy_core::cbor::{self, Value};
14 14
15 15
/// The envelope's version, key 0 of the body.
16 16
pub const ENVELOPE_VERSION: u64 = 1;
…
276 unchanged lines
modified crates/spectra-core/src/ids.rs +1 −1
…
28 unchanged lines
29 29
    let secs = (ms / 1000) as i64;
30 30
    let days = secs.div_euclid(86_400);
31 31
    let rem = secs.rem_euclid(86_400);
32 -
    // Civil from days (Howard Hinnant), as oxy's `format::when`.
32 +
    // Civil from days (Howard Hinnant).
33 33
    let z = days + 719_468;
34 34
    let era = z.div_euclid(146_097);
35 35
    let doe = z - era * 146_097;
…
24 unchanged lines
modified crates/spectra-core/src/keys.rs +119 −51
1 -
//! Keys and identities on disk, laid out exactly as `oxy` lays them out
2 -
//! (spec §8.1, §21.3): an `OXY_HOME` holds `keys/<KEYID>.secret` (private),
3 -
//! `keys/<KEYID>.identity` and `config.toml` naming the key as `author.key`.
4 -
//! Spectra writes them with `oxy-core`'s own functions, so `oxy` reads what
5 -
//! Spectra made and the other way round.
1 +
//! Identity homes on disk (spec §8.0, §21.3): `keys/KEYID.secret` (0600),
2 +
//! `keys/KEYID.identity`, and `identity.toml` naming the current KeyId. The
3 +
//! owner's home is `$SPECTRA_HOME`, else `$XDG_CONFIG_HOME/spectra`, else
4 +
//! `~/.config/spectra`; each agent's is the `--home` it was added with.
6 5
//!
7 6
//! The instance key (spec §9.5) is kept in the data directory instead, at
8 7
//! `keys/instance.secret` and `keys/instance.identity`.
9 8
9 +
use crate::durable::{write_atomic, write_atomic_private};
10 +
use crate::identity::{Delegate, Identity, KeyId, SecretKey, Signature};
10 11
use anyhow::{bail, Context, Result};
11 -
use oxy_core::durable::{write_atomic, write_atomic_private};
12 -
use oxy_core::hash::KeyId;
13 -
use oxy_core::identity::{Identity, SecretKey, Signature};
14 12
use std::fs;
15 13
use std::path::{Path, PathBuf};
16 14
15 +
/// The owner's identity home.
16 +
pub fn default_home() -> PathBuf {
17 +
    if let Some(h) = std::env::var_os("SPECTRA_HOME").filter(|h| !h.is_empty()) {
18 +
        return PathBuf::from(h);
19 +
    }
20 +
    std::env::var_os("XDG_CONFIG_HOME")
21 +
        .filter(|p| !p.is_empty())
22 +
        .map(PathBuf::from)
23 +
        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
24 +
        .unwrap_or_else(|| PathBuf::from("."))
25 +
        .join("spectra")
26 +
}
27 +
17 28
/// A key pair with its identity: the instance, an agent, or the owner.
18 29
#[derive(Clone, Debug)]
19 30
pub struct KeyPair {
…
4 unchanged lines
24 35
25 36
impl KeyPair {
26 37
    /// A fresh key pair named `name`, made now.
27 -
    pub fn generate(name: &str, email: &str) -> KeyPair {
38 +
    pub fn generate(name: &str, contact: &str) -> KeyPair {
28 39
        let secret = SecretKey::generate();
29 -
        let identity = secret.identity(name, email, crate::ids::now_ms());
40 +
        let identity = secret.identity(name, contact, crate::ids::now_ms());
30 41
        KeyPair { key: identity.hash(), identity, secret }
31 42
    }
32 43
33 -
    /// Sign `message` (a Spectra domain-prefixed byte string, at least 16
34 -
    /// bytes) through Oxygen's challenge signing: the target is
35 -
    /// `BLAKE3("oxy1 challenge " || message)`, so a Spectra signature can
36 -
    /// never be taken for a change, an attestation or a policy.
44 +
    /// Sign `message`, which carries its own `spectra1 …` prefix.
37 45
    pub fn sign(&self, message: &[u8]) -> Signature {
38 -
        self.secret.sign_challenge(&self.key, message).expect("Spectra messages are longer than 16 bytes")
46 +
        self.secret.sign(&self.key, message)
39 47
    }
40 48
41 -
    /// Make a key pair in an `OXY_HOME` laid out as `oxy identity create`
42 -
    /// lays one out, and name it the home's author.
43 -
    pub fn create_home(home: &Path, name: &str, email: &str) -> Result<KeyPair> {
44 -
        let pair = KeyPair::generate(name, email);
45 -
        let keys = home.join("keys");
46 -
        fs::create_dir_all(&keys).with_context(|| format!("creating {}", keys.display()))?;
49 +
    /// Make a key pair in a new identity home and name it current.
50 +
    pub fn create_home(home: &Path, name: &str, contact: &str) -> Result<KeyPair> {
51 +
        if current_key(home)?.is_some() {
52 +
            bail!("{} already holds an identity", home.display());
53 +
        }
54 +
        let pair = KeyPair::generate(name, contact);
55 +
        fs::create_dir_all(home).with_context(|| format!("creating {}", home.display()))?;
47 56
        #[cfg(unix)]
48 57
        {
49 58
            use std::os::unix::fs::PermissionsExt;
50 59
            fs::set_permissions(home, fs::Permissions::from_mode(0o700))?;
51 60
        }
52 -
        write_atomic_private(&keys.join(format!("{}.secret", pair.key.to_hex())), &pair.secret.encode(&pair.key))?;
53 -
        write_atomic(&keys.join(format!("{}.identity", pair.key.to_hex())), &pair.identity.encode())?;
54 -
        let config = home.join("config.toml");
55 -
        oxy_core::repo::Config::set_user(&config, "author.name", name)?;
56 -
        oxy_core::repo::Config::set_user(&config, "author.email", email)?;
57 -
        oxy_core::repo::Config::set_user(&config, "author.key", &pair.key.to_hex())?;
61 +
        pair.write(home)?;
58 62
        Ok(pair)
63 +
    }
64 +
65 +
    /// Write this key pair into `home` and name it current.
66 +
    fn write(&self, home: &Path) -> Result<()> {
67 +
        let keys = home.join("keys");
68 +
        write_atomic_private(&keys.join(format!("{}.secret", self.key.to_hex())), &self.secret.encode(&self.key))?;
69 +
        write_atomic(&keys.join(format!("{}.identity", self.key.to_hex())), &self.identity.encode())?;
70 +
        write_atomic(
71 +
            &home.join("identity.toml"),
72 +
            format!(
73 +
                "# The identity this home signs with (spec §8.0).\ncurrent = \"{}\"\nname = {}\n",
74 +
                self.key.to_hex(),
75 +
                toml_str(&self.identity.name)
76 +
            )
77 +
            .as_bytes(),
78 +
        )?;
79 +
        Ok(())
59 80
    }
60 81
61 -
    /// The key pair an `OXY_HOME` signs with: `author.key` from its
62 -
    /// `config.toml`, else its only secret key.
82 +
    /// The key pair a home signs with: `current` in `identity.toml`, else
83 +
    /// its only secret key.
63 84
    pub fn load_home(home: &Path) -> Result<KeyPair> {
64 85
        let keys = home.join("keys");
65 -
        let key = match author_key(home)? {
86 +
        let key = match current_key(home)? {
66 87
            Some(k) => k,
67 88
            None => {
68 89
                let secrets: Vec<PathBuf> = fs::read_dir(&keys)
69 -
                    .with_context(|| format!("reading {}", keys.display()))?
90 +
                    .with_context(|| format!("{} holds no identity (spectra identity create)", home.display()))?
70 91
                    .filter_map(|e| e.ok().map(|e| e.path()))
71 92
                    .filter(|p| p.extension().is_some_and(|x| x == "secret"))
72 93
                    .collect();
73 94
                match secrets.as_slice() {
74 95
                    [one] => KeyId::from_hex(one.file_stem().and_then(|s| s.to_str()).unwrap_or(""))
75 96
                        .context("a secret key file is not named by its key")?,
76 -
                    [] => bail!("{} holds no secret key: an agent home is made by `spectra agent add`", home.display()),
77 -
                    _ => bail!("{} holds several keys and its config.toml names none (author.key)", home.display()),
97 +
                    [] => bail!("{} holds no identity (spectra identity create)", home.display()),
98 +
                    _ => bail!("{} holds several keys and its identity.toml names none", home.display()),
78 99
                }
79 100
            }
80 101
        };
102 +
        KeyPair::load_key(home, &key)
103 +
    }
104 +
105 +
    /// The key pair of the identity `key` in `home`.
106 +
    pub fn load_key(home: &Path, key: &KeyId) -> Result<KeyPair> {
107 +
        let keys = home.join("keys");
81 108
        KeyPair::load(&keys.join(format!("{}.secret", key.to_hex())), &keys.join(format!("{}.identity", key.to_hex())))
82 109
    }
83 110
…
6 unchanged lines
90 117
        if identity.hash() != key {
91 118
            bail!("{} is not the identity of {}", identity.name, secret.display());
92 119
        }
93 -
        if secret_key.identity(&identity.name, &identity.email, identity.created).ed25519_pk != identity.ed25519_pk {
120 +
        if !secret_key.matches(&identity) {
94 121
            bail!("the secret key in {} does not match its identity", secret.display());
95 122
        }
96 123
        Ok(KeyPair { key, identity, secret: secret_key })
…
11 unchanged lines
108 135
        write_atomic_private(&secret, &pair.secret.encode(&pair.key))?;
109 136
        write_atomic(&identity, &pair.identity.encode())?;
110 137
        Ok(pair)
138 +
    }
139 +
140 +
    /// The successor of this identity with `delegates`, written into `home`
141 +
    /// and made current there.
142 +
    pub fn succeed(&self, home: &Path, delegates: Vec<Delegate>) -> Result<KeyPair> {
143 +
        let identity = self.identity.successor(delegates, self.identity.expires, crate::ids::now_ms());
144 +
        let next = KeyPair { key: identity.hash(), identity, secret: self.secret.clone() };
145 +
        next.write(home)?;
146 +
        Ok(next)
111 147
    }
148 +
}
149 +
150 +
/// Delegate to `key` from the current identity in `home` (replacing any
151 +
/// delegation to it): the owner's successor, made current.
152 +
pub fn delegate(home: &Path, key: KeyId, expires: Option<u64>) -> Result<KeyPair> {
153 +
    let cur = KeyPair::load_home(home)?;
154 +
    let mut d: Vec<Delegate> = cur.identity.delegates.iter().filter(|x| x.key != key).cloned().collect();
155 +
    d.push(Delegate { key, expires });
156 +
    cur.succeed(home, d)
112 157
}
113 158
114 -
/// The identity an `OXY_HOME` names as its author (`oxy identity export`
115 -
/// without the CLI): for reading the owner's identity at `spectra init`.
159 +
/// Stop delegating to `key`: the owner's successor without it.
160 +
pub fn undelegate(home: &Path, key: KeyId) -> Result<KeyPair> {
161 +
    let cur = KeyPair::load_home(home)?;
162 +
    let d = cur.identity.delegates.iter().filter(|x| x.key != key).cloned().collect();
163 +
    cur.succeed(home, d)
164 +
}
165 +
166 +
/// The identity a home names as current.
116 167
pub fn home_identity(home: &Path) -> Result<Identity> {
117 -
    let key = author_key(home)?.with_context(|| format!("{} names no identity (author.key in config.toml)", home.display()))?;
168 +
    let key = current_key(home)?.with_context(|| format!("{} holds no identity (spectra identity create)", home.display()))?;
118 169
    let path = home.join("keys").join(format!("{}.identity", key.to_hex()));
119 170
    Identity::decode(&fs::read(&path).with_context(|| format!("reading {}", path.display()))?)
120 171
        .map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))
172 +
}
173 +
174 +
/// An identity file of any home, by its KeyId.
175 +
pub fn identity_in(home: &Path, key: &KeyId) -> Option<Identity> {
176 +
    let path = home.join("keys").join(format!("{}.identity", key.to_hex()));
177 +
    Identity::decode(&fs::read(path).ok()?).ok()
178 +
}
179 +
180 +
fn current_key(home: &Path) -> Result<Option<KeyId>> {
181 +
    let path = home.join("identity.toml");
182 +
    let Ok(text) = fs::read_to_string(&path) else { return Ok(None) };
183 +
    let table: toml::Table = text.parse().with_context(|| format!("reading {}", path.display()))?;
184 +
    match table.get("current").and_then(|v| v.as_str()) {
185 +
        Some(hex) => Ok(Some(KeyId::from_hex(hex).with_context(|| format!("{}: current is not a KeyId", path.display()))?)),
186 +
        None => Ok(None),
187 +
    }
121 188
}
122 189
123 -
fn author_key(home: &Path) -> Result<Option<KeyId>> {
124 -
    let config = home.join("config.toml");
125 -
    let Ok(text) = fs::read_to_string(&config) else { return Ok(None) };
126 -
    let table: toml::Table = text.parse().with_context(|| format!("reading {}", config.display()))?;
127 -
    let found = table.get("author").and_then(|a| a.get("key")).or_else(|| table.get("author.key")).and_then(|v| v.as_str());
128 -
    Ok(match found {
129 -
        Some(hex) => Some(KeyId::from_hex(hex).with_context(|| format!("{}: author.key is not a key", config.display()))?),
130 -
        None => None,
131 -
    })
190 +
fn toml_str(s: &str) -> String {
191 +
    toml::Value::String(s.to_string()).to_string()
132 192
}
133 193
134 194
#[cfg(test)]
…
1 unchanged lines
136 196
    use super::*;
137 197
138 198
    #[test]
139 -
    fn a_home_made_here_reads_back_and_signs_verifiably() {
199 +
    fn a_home_made_here_reads_back_signs_and_delegates() {
140 200
        let dir = tempfile::tempdir().unwrap();
141 -
        let made = KeyPair::create_home(dir.path(), "claude-oxygen", "").unwrap();
201 +
        let made = KeyPair::create_home(dir.path(), "Andy McCall", "").unwrap();
142 202
        let read = KeyPair::load_home(dir.path()).unwrap();
143 203
        assert_eq!(made.key, read.key);
144 204
        assert_eq!(home_identity(dir.path()).unwrap().hash(), made.key);
145 205
        let msg = b"spectra1 test message of some length";
146 -
        let sig = read.sign(msg);
147 -
        oxy_core::identity::verify_challenge(&made.identity, &sig, msg).unwrap();
148 -
        assert!(oxy_core::identity::verify_challenge(&made.identity, &sig, b"spectra1 another message entirely").is_err());
206 +
        crate::identity::verify(&made.identity, &read.sign(msg), msg).unwrap();
207 +
        assert!(KeyPair::create_home(dir.path(), "again", "").is_err());
208 +
209 +
        let agent = KeyPair::generate("claude", "");
210 +
        let next = delegate(dir.path(), agent.key, Some(u64::MAX)).unwrap();
211 +
        assert_eq!(next.identity.previous, Some(made.key));
212 +
        assert_eq!(KeyPair::load_home(dir.path()).unwrap().key, next.key, "the successor is current");
213 +
        assert!(next.identity.delegates_to(&agent.key, 1));
214 +
        assert!(KeyPair::load_key(dir.path(), &made.key).is_ok(), "the old identity is kept");
215 +
        let after = undelegate(dir.path(), agent.key).unwrap();
216 +
        assert!(!after.identity.delegates_to(&agent.key, 1));
149 217
    }
150 218
151 219
    #[test]
…
7 unchanged lines
modified crates/spectra-core/src/ledger.rs +5 −11
…
7 unchanged lines
8 8
9 9
use crate::event::{canonical_json, Auth, Event, Method, NewEvent};
10 10
use crate::hlc::{Clock, Hlc};
11 +
use crate::identity::KeyId;
12 +
use crate::identity::{Identity, Signature};
11 13
use crate::keys::KeyPair;
12 14
use crate::{ids, projections};
13 15
use anyhow::{bail, Context, Result};
14 -
use oxy_core::hash::KeyId;
15 -
use oxy_core::identity::{Identity, Signature};
16 16
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteRow, SqliteSynchronous};
17 17
use sqlx::{Row, SqlitePool};
18 18
use std::path::Path;
…
340 unchanged lines
359 359
        let sig = instance.sign(&checkpoint_message(seq, &hash));
360 360
        let n = NewEvent::system("ledger.checkpoint", serde_json::json!({ "seq": seq, "hash": hex::encode(hash), "count": count })).by(
361 361
            "system",
362 -
            Auth {
363 -
                key: Some(instance.key.0),
364 -
                method: Method::Signature,
365 -
                assurance: 2,
366 -
                proof: Some(oxy_core::cbor::encode(&sig.to_value())),
367 -
            },
362 +
            Auth { key: Some(instance.key.0), method: Method::Signature, assurance: 2, proof: Some(crate::cbor::encode(&sig.to_value())) },
368 363
        );
369 364
        let e = self.chain(h, n);
370 365
        h.since_checkpoint = 0;
…
52 unchanged lines
423 418
        return Err("signed by a key other than the instance's".into());
424 419
    }
425 420
    let proof = e.auth.proof.as_deref().ok_or("unsigned")?;
426 -
    let sig = oxy_core::cbor::decode(proof).ok().and_then(|v| Signature::from_value(&v).ok()).ok_or("malformed signature")?;
427 -
    oxy_core::identity::verify_challenge(instance, &sig, &checkpoint_message(seq, &hash))
428 -
        .map_err(|e| format!("signature does not verify: {e}"))
421 +
    let sig = crate::cbor::decode(proof).ok().and_then(|v| Signature::from_value(&v).ok()).ok_or("malformed signature")?;
422 +
    crate::identity::verify(instance, &sig, &checkpoint_message(seq, &hash)).map_err(|e| format!("signature does not verify: {e}"))
429 423
}
430 424
431 425
/// Checks the proof of a signed event: the identities and delegations that
…
163 unchanged lines
modified crates/spectra-core/src/lib.rs +3 −0
…
6 unchanged lines
7 7
8 8
pub mod attachments;
9 9
pub mod authn;
10 +
pub mod cbor;
10 11
pub mod conversation;
11 12
pub mod daemon;
13 +
pub mod durable;
12 14
pub mod event;
13 15
pub mod hlc;
16 +
pub mod identity;
14 17
pub mod ids;
15 18
pub mod keys;
16 19
pub mod ledger;
…
10 unchanged lines
modified crates/spectra-core/src/principals.rs +6 −9
…
15 unchanged lines
16 16
//! checks every signed event against who was who at that moment.
17 17
18 18
use crate::event::{Event, Method};
19 +
use crate::identity::Identity;
20 +
use crate::identity::KeyId;
19 21
use crate::ledger::KeyResolver;
20 22
use crate::proof::Proof;
21 -
use oxy_core::hash::KeyId;
22 -
use oxy_core::identity::{Identity, Scope};
23 23
use serde_json::Value as Json;
24 24
use sqlx::SqliteConnection;
25 25
use std::collections::HashMap;
…
1 unchanged lines
27 27
/// The scopes a new agent gets (spec §8.4).
28 28
pub const DEFAULT_SCOPES: &[&str] = &["read", "say", "tasks", "claims", "decisions", "status", "search", "schedule:self"];
29 29
30 -
/// The Oxygen delegation scope an agent's key needs from its owner.
31 -
pub const DELEGATION_SCOPE: Scope = Scope::CHANGES;
32 -
33 30
/// Who caused something.
34 31
#[derive(Clone, Debug, PartialEq, Eq)]
35 32
pub enum Principal {
…
93 unchanged lines
129 126
            return Err(format!("agent {} is disabled", a.name));
130 127
        }
131 128
        let owner = self.owner().ok_or("no owner is recorded")?;
132 -
        if !owner.delegates_to(key, DELEGATION_SCOPE, at_ms) {
129 +
        if !owner.delegates_to(key, at_ms) {
133 130
            return Err(match owner.delegate(key) {
134 131
                Some(d) if d.expires.is_some_and(|x| at_ms > x) => format!("agent {}'s delegation from {} has expired", a.name, owner.name),
135 132
                Some(_) => format!("{}'s delegation to agent {} does not include `changes`", owner.name, a.name),
…
48 unchanged lines
184 181
                let id = identity_of(&p)?;
185 182
                let key = id.hash();
186 183
                let owner = self.owner_signed(e)?;
187 -
                if !owner.delegates_to(&key, DELEGATION_SCOPE, e.hlc.wall_ms) {
184 +
                if !owner.delegates_to(&key, e.hlc.wall_ms) {
188 185
                    return Err("added without the owner's delegation to its key".into());
189 186
                }
190 187
                let name = text(&p, "name")?;
…
58 unchanged lines
249 246
    /// request for the sign-in link, so it must verify against the owner,
250 247
    /// and only the owner acts this way.
251 248
    pub fn check_session(&self, e: &Event) -> Result<(), String> {
252 -
        let key = oxy_core::hash::ObjectRef(e.auth.key.ok_or("a session event names no key")?);
249 +
        let key = crate::identity::KeyId(e.auth.key.ok_or("a session event names no key")?);
253 250
        if !matches!(self.principal(&key, e.hlc.wall_ms)?, Principal::Owner { .. }) || e.actor != "human" {
254 251
            return Err("only the owner acts through a Radar session".into());
255 252
        }
…
8 unchanged lines
264 261
    /// Check a signed event's proof against the principal its key was at
265 262
    /// the event's time.
266 263
    pub fn check_signed(&self, e: &Event) -> Result<(), String> {
267 -
        let key = oxy_core::hash::ObjectRef(e.auth.key.ok_or("signed, but names no key")?);
264 +
        let key = crate::identity::KeyId(e.auth.key.ok_or("signed, but names no key")?);
268 265
        let who = self.principal(&key, e.hlc.wall_ms)?;
269 266
        // The actor an event names must be the principal that signed it:
270 267
        // an agent's key never makes an event in another's name.
…
172 unchanged lines
modified crates/spectra-core/src/projects.rs +26 −103
1 -
//! Projects (spec §23.1): checkouts the agents work in, with the
2 -
//! repository id and remote read from `.oxy/`, and dependencies read from
3 -
//! `.oxymodules`, matched to other registered projects by remote URL.
1 +
//! Projects (spec §23.1): any kind of work a group of agents shares. A
2 +
//! project has a name, a description, and optionally a folder and a link;
3 +
//! Spectra records where the folder is and reads nothing inside it. The
4 +
//! owner links projects (`project.linked`: one depends on another), which
5 +
//! is what `to: "downstream"` follows.
4 6
5 7
use crate::event::Event;
6 8
use anyhow::{Context, Result};
…
1 unchanged lines
8 10
use sqlx::SqliteConnection;
9 11
use std::path::Path;
10 12
11 -
/// What a checkout says about itself.
12 -
#[derive(Clone, Debug, Default, PartialEq, Eq)]
13 -
pub struct Checkout {
14 -
    pub repo_id: Option<String>,
15 -
    pub remote: Option<String>,
16 -
    /// The URLs of the subrepos it pins (`.oxymodules`).
17 -
    pub modules: Vec<String>,
18 -
}
19 -
20 -
/// Read `.oxy/id`, the remote (`origin`, else the first) and `.oxymodules`.
21 -
pub fn inspect(path: &Path) -> Result<Checkout> {
22 -
    if !path.is_dir() {
23 -
        anyhow::bail!("{} is not a directory", path.display());
24 -
    }
25 -
    let oxy = path.join(".oxy");
26 -
    let repo_id = std::fs::read_to_string(oxy.join("id")).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
27 -
    let mut remotes: Vec<(String, String)> = Vec::new();
28 -
    if let Ok(text) = std::fs::read_to_string(oxy.join("config")) {
29 -
        for line in text.lines() {
30 -
            if let Some((k, v)) = line.split_once('=') {
31 -
                let k = k.trim();
32 -
                if let Some(name) = k.strip_prefix("remote.").and_then(|r| r.strip_suffix(".url")) {
33 -
                    remotes.push((name.to_string(), v.trim().trim_matches('"').to_string()));
34 -
                }
35 -
            }
36 -
        }
37 -
    }
38 -
    let remote = remotes.iter().find(|(n, _)| n == "origin").or(remotes.first()).map(|(_, u)| u.clone());
39 -
    let modules = std::fs::read_to_string(path.join(".oxymodules"))
40 -
        .map(|t| {
41 -
            t.lines()
42 -
                .filter_map(|l| l.trim().strip_prefix("url").map(|r| r.trim_start().trim_start_matches('=').trim().to_string()))
43 -
                .collect()
44 -
        })
45 -
        .unwrap_or_default();
46 -
    Ok(Checkout { repo_id, remote, modules })
47 -
}
13 +
/// Kinds of event the owner signs about projects.
14 +
pub const KINDS: &[&str] = &["project.added", "project.linked", "project.unlinked"];
48 15
49 -
/// A project's name: the directory's, lower-cased, made valid.
16 +
/// A project's name from a folder: its name, lower-cased, made valid.
50 17
pub fn default_name(path: &Path) -> Result<String> {
51 18
    let base = path.canonicalize()?.file_name().and_then(|n| n.to_str()).map(str::to_lowercase).context("the path has no name")?;
52 19
    Ok(base.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect())
53 -
}
54 -
55 -
fn same_repo(a: &str, b: &str) -> bool {
56 -
    let n = |s: &str| s.trim_end_matches('/').trim_end_matches(".git").to_lowercase();
57 -
    n(a) == n(b)
58 20
}
59 21
60 -
/// Apply `project.added` to the tables, deriving relations both ways.
22 +
/// Apply a project event to the tables.
61 23
pub async fn project(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> {
62 -
    if e.kind != "project.added" {
63 -
        return Ok(());
64 -
    }
65 24
    let p = e.json();
66 25
    let s = |k: &str| p.get(k).and_then(Json::as_str).map(String::from);
67 -
    let Some(name) = s("name") else { return Ok(()) };
68 -
    let modules: Vec<String> = p
69 -
        .get("modules")
70 -
        .and_then(Json::as_array)
71 -
        .map(|a| a.iter().filter_map(|m| m.as_str().map(String::from)).collect())
72 -
        .unwrap_or_default();
73 -
    let remote = s("remote");
74 -
    sqlx::query("INSERT OR REPLACE INTO projects (name, path, repo_id, remote, modules, seq) VALUES (?, ?, ?, ?, ?, ?)")
75 -
        .bind(&name)
76 -
        .bind(s("path").unwrap_or_default())
77 -
        .bind(s("repo_id"))
78 -
        .bind(&remote)
79 -
        .bind(serde_json::to_string(&modules).unwrap_or_default())
80 -
        .bind(e.seq as i64)
81 -
        .execute(&mut *conn)
82 -
        .await?;
83 -
    let others: Vec<(String, Option<String>, String)> =
84 -
        sqlx::query_as("SELECT name, remote, modules FROM projects WHERE name != ?").bind(&name).fetch_all(&mut *conn).await?;
85 -
    for (other, other_remote, other_modules) in others {
86 -
        if other_remote.as_deref().is_some_and(|r| modules.iter().any(|m| same_repo(m, r))) {
87 -
            sqlx::query("INSERT OR IGNORE INTO project_relations (project, depends_on) VALUES (?, ?)")
26 +
    match e.kind.as_str() {
27 +
        "project.added" => {
28 +
            let Some(name) = s("name") else { return Ok(()) };
29 +
            sqlx::query("INSERT OR REPLACE INTO projects (name, path, description, url, seq) VALUES (?, ?, ?, ?, ?)")
88 30
                .bind(&name)
89 -
                .bind(&other)
31 +
                .bind(s("path").filter(|p| !p.is_empty()))
32 +
                .bind(s("description").unwrap_or_default())
33 +
                .bind(s("url"))
34 +
                .bind(e.seq as i64)
90 35
                .execute(&mut *conn)
91 36
                .await?;
92 37
        }
93 -
        let theirs: Vec<String> = serde_json::from_str(&other_modules).unwrap_or_default();
94 -
        if remote.as_deref().is_some_and(|r| theirs.iter().any(|m| same_repo(m, r))) {
95 -
            sqlx::query("INSERT OR IGNORE INTO project_relations (project, depends_on) VALUES (?, ?)")
96 -
                .bind(&other)
97 -
                .bind(&name)
98 -
                .execute(&mut *conn)
99 -
                .await?;
38 +
        "project.linked" | "project.unlinked" => {
39 +
            let (Some(project), Some(on)) = (s("project"), s("depends_on")) else { return Ok(()) };
40 +
            let sql = if e.kind == "project.linked" {
41 +
                "INSERT OR IGNORE INTO project_relations (project, depends_on) VALUES (?, ?)"
42 +
            } else {
43 +
                "DELETE FROM project_relations WHERE project = ? AND depends_on = ?"
44 +
            };
45 +
            sqlx::query(sql).bind(project).bind(on).execute(&mut *conn).await?;
100 46
        }
47 +
        _ => {}
101 48
    }
102 49
    Ok(())
103 -
}
104 -
105 -
#[cfg(test)]
106 -
mod tests {
107 -
    use super::*;
108 -
109 -
    #[test]
110 -
    fn a_checkout_reports_its_id_remote_and_subrepos() {
111 -
        let dir = tempfile::tempdir().unwrap();
112 -
        std::fs::create_dir_all(dir.path().join(".oxy")).unwrap();
113 -
        std::fs::write(dir.path().join(".oxy/id"), "97e6").unwrap();
114 -
        std::fs::write(
115 -
            dir.path().join(".oxy/config"),
116 -
            "remote.earth.url = \"https://hub/a/hub\"\nremote.origin.url = \"https://hub/a/x\"\n",
117 -
        )
118 -
        .unwrap();
119 -
        std::fs::write(dir.path().join(".oxymodules"), "[deps/flow]\nurl = https://hub/a/flow\nbookmark = main\n").unwrap();
120 -
        let c = inspect(dir.path()).unwrap();
121 -
        assert_eq!(
122 -
            c,
123 -
            Checkout { repo_id: Some("97e6".into()), remote: Some("https://hub/a/x".into()), modules: vec!["https://hub/a/flow".into()] }
124 -
        );
125 -
        assert!(same_repo("https://hub/a/flow/", "https://HUB/a/flow.git"));
126 -
    }
127 50
}
modified crates/spectra-core/src/proof.rs +3 −3
…
9 unchanged lines
10 10
//! `body` = the event's canonical payload, so its proof also binds the
11 11
//! event's content.
12 12
13 +
use crate::cbor::{self, Value};
14 +
use crate::identity::{Identity, Signature};
13 15
use crate::keys::KeyPair;
14 -
use oxy_core::cbor::{self, Value};
15 -
use oxy_core::identity::{Identity, Signature};
16 16
use rand::RngCore;
17 17
18 18
/// How far a request's time may be from the server's clock (spec §8.5).
…
36 unchanged lines
55 55
            }
56 56
        }
57 57
        let msg = message(&self.method, &self.body_hash, self.time_ms, &self.nonce);
58 -
        oxy_core::identity::verify_challenge(identity, &self.sig, &msg).map_err(|e| format!("signature does not verify: {e}"))
58 +
        crate::identity::verify(identity, &self.sig, &msg).map_err(|e| format!("signature does not verify: {e}"))
59 59
    }
60 60
61 61
    pub fn encode(&self) -> Vec<u8> {
…
56 unchanged lines
modified crates/spectra-core/tests/common/mod.rs +2 −12
…
2 unchanged lines
3 3
//! recorded as `spectra init`, `project add` and `agent add` record them.
4 4
#![allow(dead_code)]
5 5
6 -
use oxy_core::durable::{write_atomic, write_atomic_private};
7 -
use oxy_core::identity::{Delegate, SecretKey};
8 6
use spectra_core::conversation::{default_channel_events, project_channel_event, Author, Signer};
9 7
use spectra_core::ids::now_ms;
10 8
use spectra_core::keys::KeyPair;
11 9
use spectra_core::ledger::Ledger;
12 -
use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DELEGATION_SCOPE};
10 +
use spectra_core::principals::{identity_payload, signed_event, Principal, Principals};
13 11
use std::path::PathBuf;
14 12
15 13
pub struct World {
…
28 unchanged lines
44 42
    /// Delegate to a new agent and add it, as `spectra agent add` does.
45 43
    pub async fn agent(&mut self, name: &str, project: &str) -> KeyPair {
46 44
        let agent = KeyPair::generate(name, "");
47 -
        let keys = self.owner_home.join("keys");
48 -
        let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", self.owner.key.to_hex()))).unwrap()).unwrap();
49 -
        let mut d = self.owner.identity.delegates.clone();
50 -
        d.push(Delegate { key: agent.key, scope: DELEGATION_SCOPE, expires: None });
51 -
        let next = self.owner.identity.successor(d, None, now_ms());
52 -
        let k = next.hash();
53 -
        write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap();
54 -
        write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap();
55 -
        oxy_core::repo::Config::set_user(&self.owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap();
45 +
        spectra_core::keys::delegate(&self.owner_home, agent.key, None).unwrap();
56 46
        self.owner = KeyPair::load_home(&self.owner_home).unwrap();
57 47
        let mut rotated = identity_payload(&self.owner.identity);
58 48
        rotated["chain"] = serde_json::json!([]);
…
32 unchanged lines
modified crates/spectra-core/tests/ledger.rs +1 −1
…
110 unchanged lines
111 111
    drop(ledger);
112 112
    let mallory = KeyPair::generate("mallory", "");
113 113
    let sig = mallory.sign(&spectra_core::ledger::checkpoint_message(cp.seq - 1, &cp.prev));
114 -
    let proof = hex::encode(oxy_core::cbor::encode(&sig.to_value()));
114 +
    let proof = hex::encode(spectra_core::cbor::encode(&sig.to_value()));
115 115
    raw(dir.path(), &format!("UPDATE events SET auth_proof = X'{proof}' WHERE seq = {}", cp.seq)).await;
116 116
    let v = open(dir.path()).await.verify(&mut NoKeys).await.unwrap();
117 117
    assert_eq!(v.findings.len(), 1, "{:?}", v.findings);
…
120 unchanged lines
modified crates/spectra-core/tests/principals.rs +6 −21
…
2 unchanged lines
3 3
//! delegation expires or is disabled, and a request is refused unless
4 4
//! signed, fresh, unreplayed and from the bound Unix user.
5 5
6 -
use oxy_core::durable::{write_atomic, write_atomic_private};
7 -
use oxy_core::identity::{Delegate, Identity, SecretKey};
8 6
use spectra_core::authn::{Authenticator, Refusal};
9 7
use spectra_core::event::NewEvent;
8 +
use spectra_core::identity::{Delegate, Identity};
10 9
use spectra_core::ids::now_ms;
11 10
use spectra_core::keys::KeyPair;
12 11
use spectra_core::ledger::{Ledger, Problem};
13 -
use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DELEGATION_SCOPE};
12 +
use spectra_core::principals::{identity_payload, signed_event, Principal, Principals};
14 13
use spectra_core::proof::Proof;
15 14
use std::path::Path;
16 15
17 -
/// What `oxy identity delegate add KEY --scope changes [--expires]` does to
18 -
/// the owner's home: a successor with the same key pair, its own secret
19 -
/// file, and `author.key` naming it. Only the owner's tools do this.
16 +
/// The owner delegates to `agent`: a successor with the same key pair,
17 +
/// made current in the owner's home. Only the owner's own tools do this.
20 18
fn delegate(owner_home: &Path, agent: &Identity, expires: Option<u64>) -> KeyPair {
21 -
    let cur = KeyPair::load_home(owner_home).unwrap();
22 -
    let keys = owner_home.join("keys");
23 -
    let secret_file = std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap();
24 -
    let (_, secret) = SecretKey::decode(&secret_file).unwrap();
25 -
    let mut delegates = cur.identity.delegates.clone();
26 -
    delegates.retain(|d| d.key != agent.hash());
27 -
    delegates.push(Delegate { key: agent.hash(), scope: DELEGATION_SCOPE, expires });
28 -
    let next = cur.identity.successor(delegates, None, now_ms());
29 -
    let key = next.hash();
30 -
    write_atomic_private(&keys.join(format!("{}.secret", key.to_hex())), &secret.encode(&key)).unwrap();
31 -
    write_atomic(&keys.join(format!("{}.identity", key.to_hex())), &next.encode()).unwrap();
32 -
    oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &key.to_hex()).unwrap();
33 -
    KeyPair::load_home(owner_home).unwrap()
19 +
    spectra_core::keys::delegate(owner_home, agent.hash(), expires).unwrap()
34 20
}
35 21
36 22
struct World {
…
88 unchanged lines
125 111
    let mut w = world().await;
126 112
    let a = add_agent(&mut w, "claude-oxygen", None).await;
127 113
    // A successor of the owner that the agent made: it has no owner key to sign it with.
128 -
    let forged =
129 -
        w.owner.identity.successor(vec![Delegate { key: a.key, scope: oxy_core::identity::Scope::ALL, expires: None }], None, now_ms());
114 +
    let forged = w.owner.identity.successor(vec![Delegate { key: a.key, expires: None }], None, now_ms());
130 115
    let mut p = identity_payload(&forged);
131 116
    p["chain"] = serde_json::json!([]);
132 117
    w.ledger.append_one(signed_event(&a, "human", "identity.rotated", p)).await.unwrap();
…
110 unchanged lines
modified crates/spectra-mcp/Cargo.toml +0 −1
…
7 unchanged lines
8 8
[dependencies]
9 9
spectra-core.workspace = true
10 10
spectra-api.workspace = true
11 -
oxy-core.workspace = true
12 11
anyhow.workspace = true
13 12
serde.workspace = true
14 13
serde_json.workspace = true
…
21 unchanged lines
modified crates/spectra-mcp/src/api.rs +10 −7
…
81 unchanged lines
82 82
            if !crate::http::origin_ok(headers, &st.origins) {
83 83
                return Err(problem(StatusCode::FORBIDDEN, "this origin may not read Spectra", None));
84 84
            }
85 -
            let key = oxy_core::hash::ObjectRef(s.key);
85 +
            let key = spectra_core::identity::KeyId(s.key);
86 86
            let principal = st
87 87
                .daemon
88 88
                .principals
…
86 unchanged lines
175 175
    let now = now_ms();
176 176
    rows.into_iter()
177 177
        .map(|(name, owner, key, kind, project, status, disabled)| {
178 -
            let k = oxy_core::hash::ObjectRef::from_hex(&key);
178 +
            let k = spectra_core::identity::KeyId::from_hex(&key);
179 179
            let state = match k.map(|k| principals.principal(&k, now)) {
180 180
                Some(Ok(_)) => "ok".to_string(),
181 181
                Some(Err(why)) => why,
…
35 unchanged lines
217 217
) -> Response {
218 218
    let _ = who!(st, peer, headers, uri, "GET", b"", true);
219 219
    let pool = st.daemon.ledger.pool();
220 -
    let rows: Vec<(String, String, Option<String>)> =
221 -
        sqlx::query_as("SELECT name, path, remote FROM projects ORDER BY name").fetch_all(pool).await.unwrap_or_default();
220 +
    let rows: Vec<(String, Option<String>, String, Option<String>)> =
221 +
        sqlx::query_as("SELECT name, path, description, url FROM projects ORDER BY name").fetch_all(pool).await.unwrap_or_default();
222 222
    let mut out = Vec::new();
223 -
    for (name, path, remote) in rows {
223 +
    for (name, path, description, url) in rows {
224 224
        let depends_on = sqlx::query_scalar("SELECT depends_on FROM project_relations WHERE project = ? ORDER BY 1")
225 225
            .bind(&name)
226 226
            .fetch_all(pool)
…
9 unchanged lines
236 236
            .fetch_all(pool)
237 237
            .await
238 238
            .unwrap_or_default();
239 -
        out.push(api::Project { name, path, remote, depends_on, depended_on_by, agents });
239 +
        out.push(api::Project { name, path, description, url, depends_on, depended_on_by, agents });
240 240
    }
241 241
    json_etag(&headers, &out)
242 242
}
…
277 unchanged lines
520 520
                }
521 521
            })
522 522
            .take(q.max.unwrap_or(usize::MAX));
523 -
        return Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response();
523 +
        // A comment first: the follower knows it is subscribed, so it can
524 +
        // read the state and miss nothing that happens after.
525 +
        let hello = futures::stream::once(async { Ok::<_, std::convert::Infallible>(SseEvent::default().comment("subscribed")) });
526 +
        return Sse::new(hello.chain(stream)).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response();
524 527
    }
525 528
    let limit = q.limit.unwrap_or(100).clamp(1, 1000);
526 529
    let from = q.after.map(|a| a + 1).unwrap_or(1);
…
12 unchanged lines
modified crates/spectra-mcp/src/client.rs +1 −1
…
197 unchanged lines
198 198
        ("spectra-key", p.sig.key.to_hex()),
199 199
        ("spectra-time", p.time_ms.to_string()),
200 200
        ("spectra-nonce", hex::encode(p.nonce)),
201 -
        ("spectra-signature", hex::encode(oxy_core::cbor::encode(&p.sig.to_value()))),
201 +
        ("spectra-signature", hex::encode(spectra_core::cbor::encode(&p.sig.to_value()))),
202 202
    ]
203 203
}
204 204
…
17 unchanged lines
modified crates/spectra-mcp/src/http.rs +1 −1
…
116 unchanged lines
117 117
    let time_ms = get("spectra-time")?.parse().ok()?;
118 118
    let nonce: [u8; 16] = hex::decode(get("spectra-nonce")?).ok()?.try_into().ok()?;
119 119
    let sig_bytes = hex::decode(get("spectra-signature")?).ok()?;
120 -
    let sig = oxy_core::cbor::decode(&sig_bytes).ok().and_then(|v| oxy_core::identity::Signature::from_value(&v).ok())?;
120 +
    let sig = spectra_core::cbor::decode(&sig_bytes).ok().and_then(|v| spectra_core::identity::Signature::from_value(&v).ok())?;
121 121
    if sig.key.to_hex() != key {
122 122
        return None;
123 123
    }
…
142 unchanged lines
modified crates/spectra-mcp/src/instance.rs +1 −1
…
2 unchanged lines
3 3
//! check a client makes of it.
4 4
5 5
use anyhow::{bail, Context, Result};
6 -
use oxy_core::identity::Identity;
7 6
use serde_json::{json, Value as Json};
8 7
use spectra_core::event::Event;
8 +
use spectra_core::identity::Identity;
9 9
use spectra_core::keys::KeyPair;
10 10
use spectra_core::ledger::Ledger;
11 11
use spectra_core::proof::Proof;
…
68 unchanged lines
modified docs/plan.md +20 −10
1 1
# Sprint plan: Spectra 0.1, Talk
2 2
3 -
Each loop iteration: pick the first unchecked item, implement it with tests, run the gated checks, commit with `oxy`, tick the box here with a one-line note, and commit again. Update `docs/spec.md` (and its changelog) whenever behaviour the spec describes changes. If an item cannot be finished in two iterations, note why under it and move on. The issues are on https://oxygenhub.io/projects/andymccall/7. Sprint 1 built 0.1 on one machine; Sprint 2 makes it client and server (spec §8.6), since Andy runs the daemon on Earth and everything else on his workstation.
3 +
Each loop iteration: pick the first unchecked item, implement it with tests, run the gated checks, commit with `oxy`, tick the box here with a one-line note, and commit again. Update `docs/spec.md` (and its changelog) whenever behaviour the spec describes changes. If an item cannot be finished in two iterations, note why under it and move on. The issues are on https://oxygenhub.io/projects/andymccall/7. Sprint 1 built 0.1 on one machine; Sprint 2 made it client and server (spec §8.6), since Andy runs the daemon on Earth and everything else on his workstation; Sprint 3 makes Spectra its own program, with nothing of Oxygen in it (spec Draft 0.5).
4 4
5 5
## Rules
6 6
…
3 unchanged lines
10 10
- Every code change adds a CHANGELOG.md line under `## Unreleased`, or sets `NO_CHANGELOG=1`.
11 11
- Never weaken a security goal of spec §5 to make a test pass.
12 12
- Never commit or push anything under `docs/prompts/` (Andy's prompts; ignored in `.oxyignore`). Name the paths in every commit.
13 -
- Never act as Andy: owner-only steps (delegating to an agent key, the acceptance run) wait for him.
13 +
- Never act as Andy: owner-only steps (creating his identity, confirming a delegation, the acceptance run) wait for him.
14 +
- Spectra uses Oxygen in no way: no `oxy` command, library or file in the product. This repository is still kept with `oxy` and built on OxygenFlow; that is how it is developed, not what it is.
14 15
15 16
## Sprint 1 (done)
16 17
…
19 unchanged lines
36 37
- [x] #23 The CLI without a ledger file — done 2026-11-03: every command through the API when a daemon serves, local or remote; admin verify via the server; tests/client.rs runs them all from a workstation with no data directory.
37 38
- [x] #24 Radar from a workstation — done 2026-11-03: spectra open serves a loopback proxy to the pinned server and signs in through it; tests/client.rs; checked by hand in Chrome on a demo server with a real oxy identity (sign-in, live update from the CLI, a post from the compose box, verify).
38 39
- [x] #25 Run the daemon on Earth — done 2026-11-03: make install-server, a unit that waits for a Spectra, make install copies; Earth runs `spectra serve` on [::]:7350 as the claude account from boot, set up with `spectra init --owner d89664ced1fc…` (Andy's public KeyId) and waiting for his claim; /api/v1/instance answers from the workstation.
40 +
- #13 Acceptance moved to Sprint 3, after Spectra stood alone.
41 +
42 +
## Sprint 3: Spectra stands alone
43 +
44 +
- [x] #26 Own identities, signatures and CBOR — done 2026-11-03: spectra-core's identity, cbor and durable modules (designed after Oxygen's, no shared code); spectra identity create; init, agent add (confirm, --expires, renew, disable) and connect (confirm or --expect) sign the delegation themselves; deps/oxygen and oxy-core removed.
45 +
- [x] #27 A project is any kind of work — done 2026-11-03: name, optional folder never read, description, link; spectra project link replaces .oxymodules; tests/cli_project.rs adds a book. Found: inbox --follow could miss a message posted as it started; the stream now announces its subscription.
46 +
- [ ] #28 The companion specs without Oxygen: SpectraHub, spectra-vscode, spectrahub-vscode.
39 47
- [ ] #13 Acceptance (with Andy), across two machines. Ready when the rest is done:
40 -
  1. Done for Andy: Earth runs the daemon, set up for his KeyId and waiting for his claim. Before step 2, `spectra --version` here should be the release (`make install`).
41 -
  2. On the workstation: `spectra connect https://earth.andymccall.xyz:7350`; run the printed `oxy identity delegate add ~/.config/spectra/earth-instance.identity --scope changes`; `spectra connect` again, which claims Earth as Andy's.
42 -
  3. `spectra project add ~/development/oxygen` and `~/development/oxygenhub`.
43 -
  4. `spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.oxy-agents/claude-oxygen`, then the printed delegation; the same for `antigravity-oxygenhub`.
44 -
  5. Claude Code in oxygen: `claude mcp add spectra -- spectra mcp --agent claude-oxygen --home ~/.oxy-agents/claude-oxygen`; Antigravity in oxygenhub: the README's `mcp_config.json` line with `--home` added.
45 -
  6. Both call `brief`, post in #general, and see each other's messages; watch with `spectra open`.
46 -
  7. `spectra admin verify` (Earth verifies its own ledger); on Earth, on a copy of the database, edit a row and see `spectra --data COPY admin verify` catch it.
47 -
  8. Cut 0.1.0 in CHANGELOG.md and tag it.
48 +
  1. On the workstation: `make install`; `spectra identity create --name "Andy McCall" --contact [email protected]`.
49 +
  2. On Earth (claude account; the KeyId only): `spectra init --owner KEYID --name earth`, `[server] bind` and `names` set, the service started. It prints Earth's instance key.
50 +
  3. On the workstation: `spectra connect https://earth.andymccall.xyz:7350`, confirming the instance key matches.
51 +
  4. `spectra project add ~/development/oxygen` and `~/development/oxygenhub`.
52 +
  5. `spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.spectra-agents/claude-oxygen --expires 8h`; the same for `antigravity-oxygenhub`.
53 +
  6. Claude Code in oxygen: `claude mcp add spectra -- spectra mcp --agent claude-oxygen --home ~/.spectra-agents/claude-oxygen`; Antigravity in oxygenhub: the README's `mcp_config.json` line with `--home` added.
54 +
  7. Both call `brief`, post in #general, and see each other's messages; watch with `spectra open`.
55 +
  8. `spectra admin verify`; on Earth, on a copy of the database, edit a row and see `spectra --data COPY admin verify` catch it.
56 +
  9. Cut 0.1.0 in CHANGELOG.md and tag it.
48 57
49 58
## Log
50 59
…
3 unchanged lines
54 63
- 2026-11-02 Andy asked for an oxy-style CLI: every command now reports through oxy's ui module. #5 done.
55 64
- 2026-11-02 #11 and #12 done. Measured in release with 1,000 messages, round trip over the socket including signing (tests/bench.rs): brief 2.7 ms (target 5), say 3.1 ms (target 10), wait wakes 4.2 ms after a mention (target 100). A single ledger append 0.15 ms. 58 tests. #13 is ready for Andy.
56 65
- 2026-11-03 Sprint 2 set up: Andy wants the daemon on Earth and the rest on his workstation. Spec Draft 0.4 adds client and server (§8.6); #20–#25 opened, #13 moved and rewritten for two machines. The binary and an enabled, waiting user unit are already on Earth (claude account, lingering on).
66 +
- 2026-11-03 Andy: Spectra must be its own program, not tied to Oxygen or to programming work. Spec Draft 0.5; Sprint 3 opened with #26–#28; #13 moved again. Earth's first ledger (Oxygen identities) is set aside, not deleted.
modified docs/spec.md +101 −84
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.4 |
5 +
| **Status** | Draft 0.5 |
6 6
| **Date** | 2026-11-03 |
7 -
| **Scope** | One Rust binary, `spectra`, that AI coding agents on one machine connect to over the Model Context Protocol (MCP), from that machine or from others over TLS (§8.6), to find each other, talk, hand work over, keep to the house rules, remember what was said and decided, and let the human see all of it live. The hosted, multi-person, multi-machine layer is SpectraHub (`../spectrahub/docs/spec.md`). |
7 +
| **Scope** | One Rust binary, `spectra`, that AI agents connect to over the Model Context Protocol (MCP), from that machine or from others over TLS (§8.6), to find each other, talk, hand work over, keep to the house rules, remember what was said and decided, and let the human see all of it live. The hosted, multi-person, multi-machine layer is SpectraHub (`../spectrahub/docs/spec.md`). |
8 8
| **Companion documents** | `../spectrahub/docs/spec.md`, `../spectra-vscode/docs/spec.md`, `../spectrahub-vscode/docs/spec.md` |
9 9
| **Audience** | Implementers, security reviewers, and the person who runs it |
10 10
…
25 unchanged lines
36 36
20. [Guard rails: loops, budgets and injection](#20-guard-rails-loops-budgets-and-injection)
37 37
21. [Security](#21-security)
38 38
22. [Operations: configuration, storage, backup](#22-operations-configuration-storage-backup)
39 -
23. [Integration with Oxygen and OxygenHub](#23-integration-with-oxygen-and-oxygenhub)
39 +
23. [Integrations and SpectraHub](#23-integrations-and-spectrahub)
40 40
24. [Performance targets](#24-performance-targets)
41 41
25. [Milestones](#25-milestones)
42 42
26. [Open questions](#26-open-questions)
…
18 unchanged lines
61 61
- **AI first, through MCP, and honest about MCP.** Agents reach Spectra as an ordinary MCP server with a small set of well-named tools. MCP is stateless and gives a server no way to speak into an agent's turn uninvited, so Spectra specifies three delivery tiers (§14) and says exactly what each can and cannot do, instead of pretending to a capability the protocol lacks.
62 62
- **Rules are text, not summaries.** The house rules are Markdown files the human edits. Spectra never paraphrases them; it compiles the layers that apply to an agent, hashes the result, and hands that exact text over. Every tool result carries the hash, so an agent working on stale rules finds out on its next call.
63 63
- **Handoffs are contracts.** A task names its requester, its assignee, what is wanted, what done looks like, and optional JSON Schemas for its input and result. It moves through a fixed lifecycle. Nothing is passed by reference to someone else's context window.
64 -
- **Every event is signed and chained; every agent is somebody's.** The log is a hash chain whose entries carry the signature of the key that caused them, and an agent's key is a delegate of its owner's Oxygen identity (oxygen spec §14.2), so "claude-oxygen is Andy McCall's agent" is a verifiable fact in the ledger and a forged event needs the secret key of the principal it names. Actions that carry the human's authority need a passkey that no agent can use (§8.5).
64 +
- **Every event is signed and chained; every agent is somebody's.** The log is a hash chain whose entries carry the signature of the key that caused them, and an agent's key is a delegate of its owner's identity (§8), so "claude-oxygen is Andy McCall's agent" is a verifiable fact in the ledger and a forged event needs the secret key of the principal it names. Actions that carry the human's authority need a passkey that no agent can use (§8.5).
65 65
- **The human is a principal, not a spectator.** The human posts in channels, is assigned tasks, approves, pauses and overrides, from the same model the agents use. Radar is a window onto that model, not a separate system.
66 66
- **One binary, one directory.** `spectra serve` is the MCP server, the JSON API, the live web interface and the scheduler in one process over one data directory. A backup is a copy of that directory.
67 67
…
1 unchanged lines
69 69
70 70
### 2.1 Goals
71 71
72 -
1. **Coordination across projects on one machine, today.** An agent in `oxygenhub` learns within a second that an agent in `oxygen` changed the wire protocol, and can ask it what to do about the pin, without the human carrying the message.
72 +
1. **Coordination across projects, today.** An agent working on one part of a job learns within a second that another agent changed something it depends on, and can ask it what to do, without the human carrying the message. The job may be software, a book being restructured, a research report or anything else agents can work on: Spectra knows nothing of the work's format.
73 73
2. **Zero-trust observability.** No agent's activity is a black box: its presence, its tool calls to Spectra with their payloads, its messages and its tasks are all visible, live and after the fact.
74 74
3. **Immutable, exact rule delivery.** What the human wrote is what every agent reads, byte for byte, with a version it can quote.
75 75
4. **Searchable history for agents.** "What did we decide about the pin bump last Tuesday?" is one tool call with a useful answer.
…
5 unchanged lines
81 81
82 82
- **Running agents.** Spectra does not start, host, prompt or bill LLMs. Agents are started by their own tools (a terminal, an IDE) and connect to Spectra. Spectra is the switchboard, not the workforce. (SpectraHub may add a hosted runner later; that is a Hub question.)
83 83
- **Being the agent's memory of its own code.** Agents keep their own working context. Echo stores what was said and decided between agents, not file contents.
84 -
- **Replacing version control, review or CI.** Those are Oxygen, OxygenHub and OxygenFlow. Spectra links to them (§23) and never duplicates them.
85 -
- **A workflow graph language.** Spectra has tasks with dependencies, not a DAG DSL. If a pipeline is wanted, OxygenFlow is the pipeline.
84 +
- **Replacing version control, review, CI or a document store.** Spectra coordinates the agents doing the work; it does not hold the work. It depends on no other tool, and integrations with any are optional (§23).
85 +
- **A workflow graph language.** Spectra has tasks with dependencies, not a DAG DSL. If a pipeline is wanted, a pipeline tool is the pipeline.
86 86
- **Multi-human access control in version 1.** `spectra` serves one human, its owner. Accounts, teams and roles are SpectraHub.
87 87
88 88
### 2.3 Lineage
89 89
90 -
The task lifecycle (submitted, working, input-required, completed, failed, cancelled, rejected) and the idea of an agent card come from Google's Agent2Agent protocol (A2A). The contract-shaped handoff and the per-run budget are the lessons of LangGraph (checkpointed state, human-in-the-loop interrupts) and the OpenAI Agents SDK (handoffs and guardrails as first-class objects). Group chat with a speaker-selection rule and a termination condition is AutoGen's model; Spectra keeps the room and replaces implicit termination with explicit tasks and budgets. The role-and-task vocabulary is CrewAI's, kept where it reads naturally and dropped where it hides state. The event-sourced core follows the hub's event bus and Oxygen's operation log: events say what happened and name ids; projections are rebuilt from them. The MCP surface follows `oxy mcp`: typed tools, JSON back, nothing destructive unless allowed, a bounded result.
90 +
The task lifecycle (submitted, working, input-required, completed, failed, cancelled, rejected) and the idea of an agent card come from Google's Agent2Agent protocol (A2A). The contract-shaped handoff and the per-run budget are the lessons of LangGraph (checkpointed state, human-in-the-loop interrupts) and the OpenAI Agents SDK (handoffs and guardrails as first-class objects). Group chat with a speaker-selection rule and a termination condition is AutoGen's model; Spectra keeps the room and replaces implicit termination with explicit tasks and budgets. The role-and-task vocabulary is CrewAI's, kept where it reads naturally and dropped where it hides state. The event-sourced core takes the lessons of operation logs in version control (Oxygen's among them): events say what happened and name ids; projections are rebuilt from them. Identities, delegation with expiry, composite post-quantum signatures and deterministic encoding are lessons from Oxygen too, implemented afresh: Spectra shares no code, file or command with it. The MCP surface keeps to typed tools, JSON back, nothing destructive unless allowed, and a bounded result.
91 91
92 92
## 3. The problem, concretely
93 93
94 -
One person runs several projects that depend on each other (`oxygen` → `oxygenflow` → `oxygenhub` → the extensions and the Android app), with a different AI agent open in each, for hours at a time and overnight. Today the agents do not know of each other. The person is the message bus: they notice that `oxy` grew a flag, open the `oxygenhub` session and type it in. Rules that apply to every project (changelog lines, no `git init`, hermetic tests, how to commit with `oxy`) live in several `CLAUDE.md` files that drift. What an agent decided at 03:00 is in a scroll-back buffer nobody will read.
94 +
One person runs several pieces of work that depend on each other, with a different AI agent open on each, for hours at a time and overnight. It might be a set of software projects (a library, the service that uses it, the apps on top), or a book being restructured (one agent reorganising chapters, one keeping the index and cross-references straight, one checking the style guide). Today the agents do not know of each other. The person is the message bus: they notice that chapter 3 became chapter 5, open the indexing agent's session and type it in. Rules that apply to everything (the style guide, how to name files, what never to touch) live in several instruction files that drift. What an agent decided at 03:00 is in a scroll-back buffer nobody will read.
95 95
96 96
Spectra's shape follows from that:
97 97
98 -
- a **project** is a checkout, and agents belong to one;
98 +
- a **project** is a piece of work, often a folder, and agents belong to one;
99 99
- a **channel** per project and a few shared ones, with the history kept;
100 100
- a **task** is how one agent gets another to do something, and how the human gets either to;
101 101
- **rules** are layered: site, project, agent kind;
…
37 unchanged lines
139 139
- **G4 Bounded resource use.** No sequence of tool calls can make Spectra use unbounded memory, disk, or wall-clock in a request.
140 140
- **G5 Local by default.** Out of the box Spectra listens on a Unix socket and loopback only, validates `Origin`, and authenticates every request.
141 141
- **G6 Tamper evidence.** The ledger is a hash chain with signed checkpoints: an edited, removed or reordered event is detectable by `spectra admin verify` on the machine, and by a hub holding the checkpoints, without trusting the database.
142 -
- **G7 Ownership.** Every agent is a delegate of a human's Oxygen identity, with a scope and an expiry the human set; Spectra shows the owner beside the agent everywhere and refuses an agent whose delegation has expired or been revoked.
142 +
- **G7 Ownership.** Every agent is a delegate of a human's identity, with an expiry the human set; Spectra shows the owner beside the agent everywhere and refuses an agent whose delegation has expired or been revoked.
143 143
- **G8 The human's authority is out of agents' reach.** Answering a request to the human, a permission verdict, a rule change, an identity change and pruning need an assurance level (§8.5) that a key on disk cannot provide, so an agent with a shell on the human's machine still cannot do them.
144 144
145 145
### 5.3 Explicitly out of scope, and what is said plainly instead
…
2 unchanged lines
148 148
- Confidentiality of the data directory against a local user with read access to it.
149 149
- Preventing an agent from doing harm with tools it has outside Spectra. Spectra can record, flag and escalate; it does not sandbox the agent's shell.
150 150
- Verifying that an agent's self-reported status is true.
151 -
- Recovery from a compromised key beyond revocation (`oxy identity revoke`, `oxy identity delegate remove`) and the marking of what it signed.
151 +
- Recovery from a compromised key beyond revocation (`spectra agent disable`, a successor identity without the delegation) and the marking of what it signed.
152 152
153 153
## 6. Terminology and the component lexicon
154 154
155 155
| Term | Meaning |
156 156
|---|---|
157 157
| **Principal** | Something that can cause events: an **agent** or the **human** (the owner). |
158 -
| **Agent** | A connected AI session with its own Oxygen identity (a delegate of its owner), name and kind, belonging to a project. |
158 +
| **Agent** | A connected AI session with its own identity (a delegate of its owner), name and kind, belonging to a project. |
159 159
| **Kind** | The agent's software: `claude-code`, `antigravity`, `kimi-code`, `codex`, `other`. Rules can be layered by kind. |
160 160
| **Session** | A span of an agent's activity between a `brief` call (or first contact) and an idle timeout. Server-minted id; groups events. |
161 -
| **Project** | A directory the agents work in, usually an Oxygen checkout; has a channel, rules and relations to other projects. |
161 +
| **Project** | A piece of work agents share: a name, a description and optionally a folder (code, a manuscript, documents; Spectra reads nothing in it); has a channel, rules and links to other projects. |
162 162
| **Channel** | A named, persistent conversation: one per project, shared ones, direct messages. |
163 163
| **Thread** | Messages replying to a root message within a channel. |
164 164
| **Message** | An immutable post by a principal in a channel, with a kind and optional structured payload. |
165 165
| **Task** | A unit of work with a requester, an assignee, a contract and a lifecycle. |
166 166
| **Handoff** | A task whose contract carries input and result schemas; the strict form. |
167 -
| **Claim** | An advisory lease on a named resource ("oxygen wire protocol") with a holder and a TTL. |
167 +
| **Claim** | An advisory lease on a named resource ("chapter 3", "the wire protocol") with a holder and a TTL. |
168 168
| **Decision** | A recorded conclusion with a rationale, searchable, linkable, supersedable. |
169 169
| **Rules** | The house rules: layered Markdown; the **rules version** is the BLAKE3 of the compiled text an agent receives. |
170 170
| **Event** | One immutable record in the log: a sequence number, a time, a kind, an actor and a JSON payload. |
171 171
| **Brief** | The one tool an agent calls first: who it is, its rules, its unread inbox, its tasks, who else is here. |
172 -
| **Identity** | An Oxygen identity object (oxygen spec §14.2): a composite Ed25519 + ML-DSA-65 key pair with a name, named by its **KeyId**. The human, every agent and the Spectra instance each have one. |
173 -
| **Owner** | The human whose identity lists an agent's key as a **delegate** (oxygen spec §14.2), with a scope and an expiry. |
172 +
| **Identity** | A Spectra identity (§8.0): a composite Ed25519 + ML-DSA-65 key pair with a name and the keys it delegates to, named by its **KeyId**. The human, every agent and the Spectra instance each have one. |
173 +
| **Owner** | The human whose identity lists an agent's key as a **delegate**, with an optional expiry. |
174 174
| **Ledger** | The event log as a hash chain: every event carries the hash of the previous one and the proof that authenticated it, and checkpoints of the chain head are signed by the instance key (§9.5). |
175 175
| **Assurance** | How an event was authenticated: level 1 a bearer token, level 2 a signature by a key on disk, level 3 a signature with user presence (a passkey). Policy says which actions need which (§8.5). |
176 176
| **Instance key** | The identity of one `spectra` installation, made at `spectra init`, a delegate of the owner; it signs checkpoints and system events. |
…
48 unchanged lines
225 225
  Cargo.toml            workspace
226 226
  crates/spectra-core   the model: store (sqlx, migrations), events, the ledger (chain, signatures,
227 227
                        checkpoints, verify), projections, bus, rules, tasks, claims, search,
228 -
                        scheduler, principals; depends on oxy-core for identities, signatures and encoding
228 +
                        scheduler, principals, and Spectra's own identities, signatures and encoding
229 229
  crates/spectra-mcp    the MCP server (Tuner): protocol (stateless HTTP and stdio), tools,
230 230
                        resources, prompts; depends on spectra-core
231 231
  crates/spectra-api    JSON API types with serde and JSON Schema derives, shared with
…
15 unchanged lines
247 247
248 248
## 8. Principals: agents and the human
249 249
250 +
### 8.0 Identities
251 +
252 +
Spectra has identities of its own and needs no other program to make or use them. Every principal, the human, each agent and each Spectra instance, has one:
253 +
254 +
```
255 +
Identity {
256 +
  ed25519_pk     32 bytes
257 +
  mldsa65_pk     1952 bytes               // ML-DSA-65 (FIPS 204)
258 +
  name           text                     // "Andy McCall", "claude-oxygen", "earth"
259 +
  contact        text                     // optional: an email, a URL
260 +
  created_ms     u64
261 +
  expires_ms     u64?
262 +
  previous       KeyId?                   // the identity this one succeeds
263 +
  delegates      [{ key: KeyId, expires_ms: u64? }]
264 +
}
265 +
```
266 +
267 +
It is encoded as deterministic CBOR with integer keys (§9.5) and named by its **KeyId**, `BLAKE3("spectra1 identity " || encoding)`. A signature is composite: both halves sign `BLAKE3("spectra1 sign " || message)`, every message carries its own domain prefix (`spectra1 request `, `spectra1 checkpoint `, …), and a signature verifies only if both halves do, so it stays sound if either algorithm falls. Adding or removing a delegate, or changing an expiry, makes a **successor**: the same key pair, a new KeyId, `previous` naming the old one; a successor is recorded in the ledger signed by its own key, so possession is proven at each step.
268 +
269 +
An identity lives in an **identity home**: `keys/KEYID.secret` (0600), `keys/KEYID.identity`, and `identity.toml` naming the current KeyId. The owner's is `$SPECTRA_HOME`, else `$XDG_CONFIG_HOME/spectra`, else `~/.config/spectra`; each agent's is the directory `spectra agent add --home` names; the instance's is `<data>/keys`. `spectra identity create --name NAME [--contact TEXT]` makes the owner's. The design owes much to Oxygen's identities; Spectra shares no code, file or command with Oxygen and works on machines that have never heard of it.
270 +
250 271
### 8.1 Agents
251 272
252 -
An agent is its owner's **delegate** in Oxygen's sense (oxygen spec §14.2; `oxygen/docs/ai_native_agent_workflows.md` §2): it has an identity of its own, a composite key pair in an agent home of its own, and the owner's identity object lists that key under `delegates` with a scope and an expiry. Spectra adds nothing to that model; it verifies it, and it uses the same key for everything the agent does, so the key that signs an agent's `oxy` commits is the key that signs its requests to Spectra. One agent, one key, one owner.
273 +
An agent is its owner's **delegate**: it has an identity of its own, a composite key pair in an agent home of its own, and the owner's identity lists that key under `delegates`, with an optional expiry. One agent, one key, one owner.
253 274
254 275
```
255 -
spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.oxy-agents/claude-oxygen
276 +
spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.spectra-agents/claude-oxygen [--expires 8h]
256 277
```
257 278
258 -
1. Refuses a name already taken. Creates the agent's key pair and identity in `--home` (an `OXY_HOME`, laid out as `oxy identity create` lays one out, through `oxy-core`), unless `--identity FILE` names an identity the agent already has. The secret key never leaves that directory and is never read by `spectra serve`.
259 -
2. Prints the one command the owner runs to make it theirs, and waits for it (`--wait`, ten minutes by default; running `add` again resumes with the same key): `oxy identity delegate add ~/.oxy-agents/claude-oxygen/identity --scope changes --expires 8h`. The owner's current identity (§8.3) must list the key before the agent is recorded; `spectra agent add` refuses to finish otherwise, so there is no such thing as an agent without an owner.
260 -
3. Records `agent.added` in the ledger with the agent's identity object, its owner's KeyId, and the delegation as found.
279 +
1. Refuses a name already taken. Creates the agent's key pair and identity in `--home` unless one is there already. The secret key never leaves that directory and is never read by `spectra serve`.
280 +
2. Shows the agent, its key and the expiry, and asks the owner to confirm (`--yes` for scripts). Then signs a successor of the owner's identity that delegates to the agent's key, with the expiry if one was given. There is no such thing as an agent without an owner, and no second program to run.
281 +
3. Records the owner's successor (`identity.rotated`) and `agent.added`, with the agent's identity and the delegation, in one batch.
261 282
262 283
```
263 284
Agent {
…
5 unchanged lines
269 290
  owner       KeyId                   // the human whose identity delegates to `key`
270 291
  project     Project id
271 292
  scopes      [scope]                 // §8.4
272 -
  home        path                    // the agent's OXY_HOME, for the shims
293 +
  home        path                    // the agent's identity home, for the shims
273 294
  uid         u32?                    // the Unix user the key may be used from (§8.5)
274 295
  created_ms, disabled_ms?
275 296
}
276 297
```
277 298
278 -
**Ownership is shown everywhere.** Radar, the CLI and every tool result name an agent as `claude-oxygen · Andy McCall's agent`, from the owner identity's `name`; on a hub, with the account the identity is linked to. An event made by an agent carries the agent's key and, in projections, its owner, as OxygenHub shows "Andy McCall · via Claude Code".
299 +
**Ownership is shown everywhere.** Radar, the CLI and every tool result name an agent as `claude-oxygen · Andy McCall's agent`, from the owner identity's `name`; on a hub, with the account the identity is linked to. An event made by an agent carries the agent's key and, in projections, its owner.
279 300
280 -
**Expiry and revocation are Oxygen's.** Authentication (§8.5) checks that the owner's current identity still delegates to the agent's key at the event's time (`delegates_to`, with `expires`). An expired delegation is refused with a message naming the `oxy identity delegate add` to run; an overnight agent given `--expires 8h` stops being able to act in the morning without anyone remembering to switch it off. A delegation removed in a successor identity, or a `revoke` attestation, disables the agent, and `spectra admin verify` (§9.5) marks what it signed as *valid but revoked*, as `oxy verify` does. The owner's successor identities reach Spectra through `spectra identity refresh`, which `agent add` and `agent disable` also run: it reads the current identity from the owner's home, follows `previous` back to the recorded owner, and records it signed with that identity's own key, so possession is proven at every step. A successor must keep the key pair; a fresh one (`oxy identity rotate`) needs its rotation attestation, which 0.1 does not accept yet.
301 +
**Expiry and revocation.** Authentication (§8.5) checks that the owner's current identity still delegates to the agent's key at the event's time. An expired delegation is refused with a message naming `spectra agent renew NAME --expires …`; an overnight agent given `--expires 8h` stops being able to act in the morning without anyone remembering to switch it off. `spectra agent disable NAME` records a successor without the delegation and `agent.disabled`; `spectra admin verify` (§9.5) marks what the agent signed before as *valid but revoked*. The owner's successors are recorded as they are made; `spectra identity refresh` records any the ledger has missed, following `previous` back to the recorded owner. A successor must keep the key pair; a fresh key pair needs a rotation statement signed by both, which 0.1 does not accept yet.
281 302
282 303
An agent MAY belong to one project. An agent that works across several (a "release" agent) is given the `projects:all` scope rather than several projects, so attribution stays one name.
283 304
…
17 unchanged lines
301 322
302 323
### 8.3 The human
303 324
304 -
`spectra` has one human, the **owner**: an Oxygen identity. `spectra init` reads `oxy identity export` from the owner's own `OXY_HOME`, records it in the ledger (`identity.owner`), and names it in `spectra.toml` (`[owner] key`); the display name comes from the identity. The owner is a principal: messages and tasks the owner creates carry `actor = human`, and agents see them as from `@andy`.
325 +
`spectra` has one human, the **owner**: a Spectra identity (§8.0), made with `spectra identity create`. `spectra init` reads it from the owner's identity home, records it in the ledger (`identity.owner`) signed by itself, and names it in `spectra.toml` (`[owner] key`); the display name comes from the identity. The owner is a principal: messages and tasks the owner creates carry `actor = human`, and agents see them as from `@andy`.
305 326
306 327
The owner acts in two ways, with different assurance (§8.5):
307 328
308 -
- **From the CLI**, signing each request with the owner's own key from `OXY_HOME`, as `oxy` signs a change: level 2. Enough for chat and tasks.
309 -
- **From Radar**, signed in with a **passkey** (WebAuthn, user verification required): registered at `spectra init` through a one-time setup code printed to the terminal, as OxygenHub's first account is made; its credential id and public key are in the ledger (`identity.passkey`). Signing in asserts it once; each action that carries the human's authority (§8.5) asserts it again, and the assertion is the event's proof: level 3. A passkey lives in the platform authenticator or a hardware key, needs the person's presence, and cannot be read from disk, which is what makes G8 true on a machine where agents share the human's user.
329 +
- **From the CLI**, signing each request with the owner's own key from their identity home: level 2. Enough for chat and tasks.
330 +
- **From Radar**, signed in with a **passkey** (WebAuthn, user verification required): registered at `spectra init` through a one-time setup code printed to the terminal; its credential id and public key are in the ledger (`identity.passkey`). Signing in asserts it once; each action that carries the human's authority (§8.5) asserts it again, and the assertion is the event's proof: level 3. A passkey lives in the platform authenticator or a hardware key, needs the person's presence, and cannot be read from disk, which is what makes G8 true on a machine where agents share the human's user.
310 331
311 332
There is no owner token. A client that can neither sign nor assert a passkey is not the owner. Until the passkey exists (0.1 ships without it, §25), `spectra serve --open` signs the owner in to Radar with a one-time link that the CLI signs with the owner's key: a level-2 session, with a standing banner, in which nothing the policy puts at level 3 can be done.
312 333
…
5 unchanged lines
318 339
319 340
**Transports.** Local principals reach Spectra over the Unix socket `<data>/spectra.sock`: the shims (`spectra mcp`, `spectra channel`, `spectra hook`, `spectra trigger`) and the CLI connect there, and the daemon reads the peer's uid and pid (`SO_PEERCRED`) on every connection. The loopback TCP port serves Radar (browsers cannot open sockets) and, when tokens are allowed, clients that connect by HTTP alone. Both carry the same HTTP and the same MCP endpoint.
320 341
321 -
**Signed requests.** A shim signs every request it forwards with the agent's key; the CLI, with the owner's. The headers are `Spectra-Key` (the KeyId), `Spectra-Time` (milliseconds), `Spectra-Nonce` (16 random bytes, hex) and `Spectra-Signature` (an Oxygen composite signature, oxygen spec §14.3, over `"spectra1 request " || method || 0x00 || BLAKE3(body) || time || nonce`, made through `oxy-core`'s challenge signing so the signed target is `BLAKE3("oxy1 challenge " || message)` and can never be taken for a change, an attestation or a policy). The daemon refuses a time more than five minutes from its clock, a nonce it has seen in that window, a key it does not know, and a key whose owner's current identity does not delegate to it now (§8.1). The signature is kept as the resulting events' proof (§9.1), so an event can be checked later against the identity in the ledger without trusting the daemon that wrote it.
342 +
**Signed requests.** A shim signs every request it forwards with the agent's key; the CLI, with the owner's. The headers are `Spectra-Key` (the KeyId), `Spectra-Time` (milliseconds), `Spectra-Nonce` (16 random bytes, hex) and `Spectra-Signature` (a composite signature, §8.0, over `"spectra1 request " || method || 0x00 || BLAKE3(body) || time || nonce`). The daemon refuses a time more than five minutes from its clock, a nonce it has seen in that window, a key it does not know, and a key whose owner's current identity does not delegate to it now (§8.1). The signature is kept as the resulting events' proof (§9.1), so an event can be checked later against the identity in the ledger without trusting the daemon that wrote it.
322 343
323 344
**Passkey assertions** are the proof of the human's Radar actions (§8.3). Until the passkey exists, a Radar session opened by the CLI-signed link records its actions with the method `session` at level 2, the signed link request as their proof. **Tokens**, when allowed, are proof of nothing beyond possession and are recorded as such.
324 345
…
15 unchanged lines
340 361
341 362
**The server.** `[server] bind` names a non-loopback address and `[server] tls = "auto"` (the default once the bind is not loopback) makes the daemon serve HTTPS with a certificate of its own: a P-256 key and a self-signed certificate in `<data>/keys/tls.key` and `tls.crt`, made on first start, naming `[server] names` (default: the host name and its fully qualified name). The instance key vouches for the certificate with an `instance.tls` event, signed over its payload as CLI events are (§9.5), naming the SHA-256 of the certificate's SubjectPublicKeyInfo and its names; the daemon appends one whenever the certificate differs from the last recorded, and `spectra admin verify` checks it like any signed event. `tls = { cert = "…", key = "…" }` uses a certificate from elsewhere; the binding is signed the same way. A certificate authority is never needed: clients trust the instance key, not a CA. `GET /api/v1/instance` answers without authentication with the instance's name and identity, the owner's key, and the latest `instance.tls` payload with its proof.
342 363
343 -
**Connecting.** `spectra connect https://HOST:PORT` reads the presented certificate and `/api/v1/instance`, and accepts the server only when the binding is signed by the instance key and the owner's own identity (their `OXY_HOME`) delegates to that instance key now. Until the owner has delegated, it saves the instance's public identity and prints the `oxy identity delegate add` that would; it never asks the owner to trust a fingerprint by eye. On success it writes the client configuration, `$XDG_CONFIG_HOME/spectra/client.toml`:
364 +
**Connecting.** `spectra connect https://HOST:PORT` reads the presented certificate and `/api/v1/instance`, and accepts the server only when the binding is signed by the instance key and the owner's own identity delegates to that instance key. The first time, it shows the instance's name and KeyId, which `spectra init` printed on the server, and asks the owner to confirm they match (`--expect KEYID-PREFIX` checks it without asking); then it signs a successor of the owner's identity delegating to the instance key. On success it writes the client configuration, `client.toml` in the owner's identity home:
344 365
345 366
```toml
346 367
server = "https://earth.example:7350"
…
39 unchanged lines
386 407
}
387 408
```
388 409
389 -
`hlc` is a hybrid logical clock as Oxygen's changes carry (oxygen spec §6.4): advanced on every event the Spectra commits or receives, never more than five minutes ahead of the local clock. Wherever events from more than one Spectra meet (mirrored threads on a hub), the order is `hlc` then `id`, so an answer cannot sort before its question because one machine's clock is behind. On one machine `seq` is the order. The envelope (`seq`, `id`, `at_ms`, `hlc`, `prev`, `hash`, `auth`, `kind`, `actor`, `session`, `project`, `subject`, `payload`, `trace`) is **frozen at 0.1**: fields may be added later, never changed or removed, so a hub built later merges logs written from the first release.
410 +
`hlc` is a hybrid logical clock: advanced on every event the Spectra commits or receives, never more than five minutes ahead of the local clock. Wherever events from more than one Spectra meet (mirrored threads on a hub), the order is `hlc` then `id`, so an answer cannot sort before its question because one machine's clock is behind. On one machine `seq` is the order. The envelope (`seq`, `id`, `at_ms`, `hlc`, `prev`, `hash`, `auth`, `kind`, `actor`, `session`, `project`, `subject`, `payload`, `trace`) is **frozen at 0.1**: fields may be added later, never changed or removed, so a hub built later merges logs written from the first release.
390 411
391 412
Events are never updated or deleted. Corrections are new events (`message.redacted`, `task.updated`). The owner MAY run `spectra admin prune --before DATE` (a level-3 action), which removes the payloads and proofs of events older than the date, keeps their **stubs** (`seq`, `id`, `hlc`, `prev`, `hash`, `kind`, `actor`, `auth.key`) so the chain still verifies through them (§9.5), **and** rebuilds every projection from what remains, so the invariant "projections are a function of the log" holds after pruning too. Pruning is itself an event, `admin.pruned`, signed by the owner.
392 413
…
4 unchanged lines
397 418
| `identity.owner`, `identity.rotated`, `identity.passkey`, `identity.instance`, `identity.revoked` | an identity object, its predecessor, a passkey credential, a revoke attestation | `spectra init`, `spectra identity refresh`, Radar |
398 419
| `agent.added`, `agent.disabled`, `agent.presence`, `agent.status`, `agent.session_started`, `agent.session_ended` | agent id, identity, owner, delegation, state, text, task | owner, Tuner, presence timer |
399 420
| `ledger.checkpoint`, `ledger.verified` | seq, hash, hlc, count, the instance signature; a verification's findings | the ledger (§9.5) |
400 -
| `rules.committed` | layer, version, text hash, `oxy_change?` | owner (§12.4) |
421 +
| `rules.committed` | layer, version, text hash | owner (§12.4) |
401 422
| `ref.verified` | a referenced change's hash, signer, author, status | §23.5 |
402 423
| `policy.weakened`, `policy.restored` | class, level | owner |
403 424
| `project.added`, `project.updated`, `project.relation` | name, path, remote, depends_on | owner, CLI |
…
23 unchanged lines
427 448
428 449
### 9.5 The ledger: chain, proofs, checkpoints, verification
429 450
430 -
The log is tamper-evident the way Oxygen's history is: by hashing, not by trusting the database.
451 +
The log is tamper-evident by hashing, not by trusting the database.
431 452
432 -
- **Canonical encoding.** An event is encoded as deterministic CBOR with integer keys, exactly as Oxygen encodes its objects (oxygen spec §5.1, through `oxy-core`), and hashed as `BLAKE3("spectra1 event " || decimal(len) || 0x00 || body)`, the domain-separated form of oxygen spec §5.2, with `hash` left out of the body. JSON, in the API and the export, is a projection of that encoding, never the thing hashed.
453 +
- **Canonical encoding.** An event is encoded as deterministic CBOR (RFC 8949 §4.2: shortest integer forms, definite lengths, map keys sorted by their encoding) with integer keys, by Spectra's own encoder, and hashed as `BLAKE3("spectra1 event " || decimal(len) || 0x00 || body)`, with `hash` left out of the body. JSON, in the API and the export, is a projection of that encoding, never the thing hashed.
433 454
- **The chain.** `prev` is the previous event's `hash`; the first event's is zero. An edit to any field of any event changes its hash and breaks every later `prev`. Removing or reordering events does the same. A pruned event's stub keeps `prev` and `hash`, so the chain verifies across it.
434 455
- **Proofs.** `auth.proof` is the request signature (§8.5) or the passkey assertion that caused the event, with the hash of what was signed. An event the CLI writes itself is signed with `method = "cli"` over the event's canonical payload, so its proof binds its content too. A signed event's `actor` must be the principal its key is (`agent:NAME`, `human` for the owner, `system` for the instance): an agent's key never makes an event in another's name. Verifying it needs the signer's identity and, for an agent, the owner's identity that delegated to it at the event's `hlc`; both are themselves events in the ledger (`identity.*`, `agent.added`), so verification is closed over the ledger and needs nothing else.
435 -
- **Checkpoints.** Every 256 events, every ten minutes while events are written, and at a clean shutdown, the daemon appends `ledger.checkpoint { seq, hash, hlc, count }` signed by the **instance key**: an Oxygen identity made at `spectra init` (`keys/instance.secret`, 0600; its identity object is the `identity.instance` event), which the owner delegates to with `--scope changes` so a checkpoint is verifiably from the owner's machine. Checkpoints go up the uplink (§23.3) and, when `[oxygenhub] url` is set, into OxygenHub's transparency log (hub spec §13.5.3) as `spectra-checkpoint` entries, so the machine cannot later show a different history than the one it checkpointed.
436 -
- **Verification.** `spectra admin verify [--from SEQ] [--json]` recomputes every hash, checks every `prev`, verifies every proof against the identity and delegation valid at its `hlc`, verifies every checkpoint's signature, and reports: chain breaks, events without a valid proof, events signed by a since-revoked or expired delegate (*valid but revoked*, as `oxy verify` says), and events whose assurance was below the policy of their day. Exit 0 when clean, 1 otherwise. It runs nightly, records `ledger.verified`, and Radar's Log page shows the last result and the head's hash. `spectra admin export` writes the canonical encoding, so an export verifies anywhere `spectra` runs.
437 -
- **Signatures are outside the hash** (oxygen spec §5.4): a checkpoint's signature, and a proof, are carried beside the hashed body, so re-signing never changes a hash.
456 +
- **Checkpoints.** Every 256 events, every ten minutes while events are written, and at a clean shutdown, the daemon appends `ledger.checkpoint { seq, hash, hlc, count }` signed by the **instance key**: an identity made at `spectra init` (`keys/instance.secret`, 0600; its identity object is the `identity.instance` event), which the owner delegates to so a checkpoint is verifiably from the owner's machine. Checkpoints go up the uplink (§23.3) and, when `[witness] url` is set, to an append-only log of the owner's choosing, so the machine cannot later show a different history than the one it checkpointed.
457 +
- **Verification.** `spectra admin verify [--from SEQ] [--json]` recomputes every hash, checks every `prev`, verifies every proof against the identity and delegation valid at its `hlc`, verifies every checkpoint's signature, and reports: chain breaks, events without a valid proof, events signed by a since-revoked or expired delegate (*valid but revoked*), and events whose assurance was below the policy of their day. Exit 0 when clean, 1 otherwise. It runs nightly, records `ledger.verified`, and Radar's Log page shows the last result and the head's hash. `spectra admin export` writes the canonical encoding, so an export verifies anywhere `spectra` runs.
458 +
- **Signatures are outside the hash**: a checkpoint's signature, and a proof, are carried beside the hashed body, so re-signing never changes a hash.
438 459
439 -
What this gives: a message, task or decision in Spectra is attributable to a key, that key to an owner, and the whole record to a point in time witnessed outside the machine. What it does not give: protection against the holder of a secret key, which is Oxygen's limit too (§5.3).
460 +
What this gives: a message, task or decision in Spectra is attributable to a key, that key to an owner, and the whole record to a point in time witnessed outside the machine. What it does not give: protection against the holder of a secret key, which no signature scheme can give (§5.3).
440 461
441 462
## 10. Conversation: channels, threads and messages
442 463
…
30 unchanged lines
473 494
  body        text (Markdown), at most 64 KiB
474 495
  payload     JSON?, at most 64 KiB    // structured content for request, answer, handoff
475 496
  mentions    [principal]              // parsed from @name, plus explicit
476 -
  refs        [ref]                    // task:ID, decision:ID, oxy:change:HASH, hub URL, parsed and explicit
497 +
  refs        [ref]                    // task:ID, decision:ID, any URL, parsed and explicit
477 498
  attachments [{ name, blake3, bytes, media_type }]
478 499
  at_ms
479 500
}
…
101 unchanged lines
581 602
582 603
### 12.4 Provenance of rules
583 604
584 -
A rule file on disk can be edited by anything running as the owner's user, an agent included, and the file watcher cannot say who. So a layer takes effect only when the owner **commits** it: `spectra rules commit [LAYER]` from the CLI (level 2) or Save in Radar's editor (level 3) records `rules.committed { layer, version, text }` in the ledger, and the compiled rules an agent receives are built from the ledger's committed text, never from the file directly. A file that differs from its committed text is shown as *uncommitted* in Radar and in `spectra rules show`, with a diff, and changes nothing until committed. When the file is in an Oxygen checkout (`<project>/.spectra/rules.md`, the recommended place for project rules, and a `rules` repository for the site layer), the commit records the `oxy` change hash that holds the text, so a rule's provenance is a signed change as well as a ledger entry. The policy class for committing is `rules` (§8.5), level 3 by default.
605 +
A rule file on disk can be edited by anything running as the owner's user, an agent included, and the file watcher cannot say who. So a layer takes effect only when the owner **commits** it: `spectra rules commit [LAYER]` from the CLI (level 2) or Save in Radar's editor (level 3) records `rules.committed { layer, version, text }` in the ledger, and the compiled rules an agent receives are built from the ledger's committed text, never from the file directly. A file that differs from its committed text is shown as *uncommitted* in Radar and in `spectra rules show`, with a diff, and changes nothing until committed. The ledger entry, signed by the owner, is the rule's provenance; where the file also lives in version control, that history is a second record Spectra neither needs nor reads. The policy class for committing is `rules` (§8.5), level 3 by default.
585 606
586 607
### 12.5 What rules are for
587 608
…
97 unchanged lines
685 706
### 15.1 Transports and revisions
686 707
687 708
- **Streamable HTTP** at `POST /mcp`, protocol revisions `2026-07-28` (preferred), `2025-11-25` and `2025-06-18`. The 2026-07-28 rules apply: `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` headers are required and validated against the body (400 and `-32020` on mismatch); `server/discover` answers with versions, capabilities, identity and instructions; `subscriptions/listen` carries `tools/list_changed` and resource updates; there are no sessions and no `Mcp-Session-Id`; list results carry `ttlMs` and `cacheScope: "private"`; tools are listed in a fixed order so the client's prompt cache holds. For the older revisions the endpoint accepts `initialize` and ignores a session header it did not mint.
688 -
- **stdio**: `spectra mcp --agent NAME` is a shim that speaks stdio to the client (revisions `2025-11-25`, `2025-06-18`, `2025-03-26`, as `oxy mcp` does) and forwards each request to the daemon over the Unix socket, signed with the agent's key from its home (§8.5). It is the standard way in: every client starts stdio servers, the configuration is one line with nothing secret in it, and every event the agent causes carries its signature.
709 +
- **stdio**: `spectra mcp --agent NAME` is a shim that speaks stdio to the client (revisions `2025-11-25`, `2025-06-18`, `2025-03-26`) and forwards each request to the daemon over the Unix socket, signed with the agent's key from its home (§8.5). It is the standard way in: every client starts stdio servers, the configuration is one line with nothing secret in it, and every event the agent causes carries its signature.
689 710
- **Identity** is the request signature (§8.5), or a bearer token where `[security] allow_tokens` permits one. A request that authenticates as nobody is 401 with a JSON-RPC error naming the reason (unsigned, stale, replayed, an unknown key, an expired or missing delegation, a key bound to another Unix user): every reason concerns the caller's own key, and an agent whose delegation expired must be able to tell its owner so.
690 711
691 712
Adding Spectra to Claude Code, for one agent:
692 713
693 714
```sh
694 -
spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.oxy-agents/claude-oxygen
695 -
oxy identity delegate add ~/.oxy-agents/claude-oxygen/identity --scope changes --expires 8h   # as the owner
715 +
spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.spectra-agents/claude-oxygen --expires 8h
696 716
claude mcp add spectra -- spectra mcp --agent claude-oxygen
697 717
```
698 718
…
58 unchanged lines
757 777
758 778
```toml
759 779
[[hooks]]
760 -
name = "oxygenhub"
761 -
secret_env = "SPECTRA_HOOK_OXYGENHUB"          # keyed-BLAKE3 signature header, as OxygenHub sends it
780 +
name = "forge"
781 +
secret_env = "SPECTRA_HOOK_FORGE"              # HMAC-SHA256 of the body in X-Spectra-Signature: sha256=HEX
762 782
match = { event = "push", repo = "andymccall/oxygen" }
763 -
action = { say = { channel = "#oxygen", body = "Push to {{bookmark}} by {{pusher}}: {{summary}}" } }
783 +
action = { say = { channel = "#oxygen", body = "Push to {{branch}} by {{pusher}}: {{summary}}" } }
764 784
765 785
[[hooks]]
766 -
name = "oxygenhub-release"
767 -
secret_env = "SPECTRA_HOOK_OXYGENHUB"
768 -
match = { event = "release.published", repo = "andymccall/oxygen" }
769 -
action = { delegate = { to = "claude-oxygenhub", title = "Bump the oxygen pin to {{tag}}", kind = "task" } }
786 +
name = "editor-notes"
787 +
secret_env = "SPECTRA_HOOK_EDITOR"
788 +
match = { event = "comment" }
789 +
action = { delegate = { to = "claude-manuscript", title = "Address the editor's note on {{chapter}}", kind = "task" } }
770 790
```
771 791
772 -
Bodies are templated with the event's fields; unknown fields render empty. A route with no `secret_env` accepts only loopback. Every receipt is `hook.received` with a 4 KiB summary, so Radar shows what came in even when no route matched. The **relay** is a special route the other way: `[relay] url` and `secret_env` name an outside endpoint (a Telegram or Discord bridge, a phone app through SpectraHub) that human requests (§11.5) and permission prompts (§14.2) are POSTed to, and `POST /hooks/relay` takes the answers back.
792 +
Any system that can POST JSON and sign it with a shared secret can drive a route: a code forge, a document editor, a form, a calendar. Bodies are templated with the event's fields; unknown fields render empty. A route with no `secret_env` accepts only loopback. Every receipt is `hook.received` with a 4 KiB summary, so Radar shows what came in even when no route matched. The **relay** is a special route the other way: `[relay] url` and `secret_env` name an outside endpoint (a Telegram or Discord bridge, a phone app through SpectraHub) that human requests (§11.5) and permission prompts (§14.2) are POSTed to, and `POST /hooks/relay` takes the answers back.
773 793
774 794
## 17. Radar: the human interface
775 795
776 796
### 17.1 Theme and shell
777 797
778 -
Radar uses the same look as OxygenHub's default (Primer colours, light by default with `dark` and `auto` in a `theme` cookie, the system font stack, rounded boxes, underline navigation, sentence case), with Spectra's own mark: a prism splitting one line into a band of colours, as `assets/icons/spectra.svg`, shared with the VS Code extensions. The stylesheet is hand-written and compiled into the binary; fonts are system fonts, so nothing loads from the network. `header` (wordmark, search box, the human inbox count, theme switch) · `.band` (Live · Channels · Tasks · Agents · Decisions · Rules · Schedules · Log · Settings) · `main` at 1180px · `footer`.
798 +
Radar uses Primer colours (light by default with `dark` and `auto` in a `theme` cookie, the system font stack, rounded boxes, underline navigation, sentence case) and Spectra's own mark: a prism splitting one line into a band of colours, as `assets/icons/spectra.svg`, shared with the VS Code extensions. The stylesheet is hand-written and compiled into the binary; fonts are system fonts, so nothing loads from the network. `header` (wordmark, search box, the human inbox count, theme switch) · `.band` (Live · Channels · Tasks · Agents · Decisions · Rules · Schedules · Log · Settings) · `main` at 1180px · `footer`.
779 799
780 800
Every page has a `<title>` of the form `<thing> · Spectra`.
781 801
…
12 unchanged lines
794 814
| `/schedules` | Schedules with next and last firing; create, disable, delete |
795 815
| `/log` | The ledger, filterable by kind, actor, project, time; every event expandable to its payload, hash and proof; the head's hash and the last verification at the top; `?follow=1` tails it |
796 816
| `/search` | Echo's search as a page, with the same filters |
797 -
| `/settings` | The owner's identity and passkey, the instance key, projects (add from a path; relations read from `.oxymodules` and editable), hooks and the relay, retention, the policy (§8.5) |
817 +
| `/settings` | The owner's identity and passkey, the instance key, projects (name, description, optional folder, and links between them), hooks and the relay, retention, the policy (§8.5) |
798 818
799 819
### 17.3 Live updates
800 820
…
12 unchanged lines
813 833
## 19. Command-line interface
814 834
815 835
```
816 -
spectra serve [--data DIR] [--bind 127.0.0.1:7350] [--open]       the daemon; --open opens Radar
817 -
spectra mcp --agent NAME                                           stdio shim for one agent (§15.1)
836 +
spectra serve [--data DIR] [--bind ADDR] [--tls auto|on|off] [--open]   the daemon; --open opens Radar
837 +
spectra mcp --agent NAME [--home DIR]                              stdio shim for one agent (§15.1)
818 838
spectra channel --agent NAME [--relay-permissions]                 Claude Code channel shim (§14.2)
819 839
spectra hook brief --agent NAME | stop --agent NAME --wait S | install claude-code [--agent NAME]
820 840
spectra trigger --agent NAME -- CMD…                                 run CMD when work arrives and no session is live (§14.4)
821 -
spectra init [--owner-home DIR]                                     the owner's identity, the instance key, the passkey setup code
822 -
spectra identity show | refresh                                     the owner's identity as the ledger knows it; read a successor
823 -
spectra agent add NAME --kind KIND --project NAME --home DIR [--identity FILE] [--uid N] [--scope S..] | list | pause | unpause | disable
824 -
spectra project add PATH [--name N] [--remote URL] | list | relate A --depends-on B
841 +
spectra identity create --name NAME [--contact TEXT]               the owner's own identity (§8.0)
842 +
spectra init [--owner-home DIR] | --owner KEYID                     this machine's Spectra, or a server's for its owner to claim (§8.6)
843 +
spectra connect https://HOST:PORT [--expect KEYID-PREFIX] [--yes]  use a Spectra on another machine (§8.6)
844 +
spectra identity show | refresh                                     the owner's identity as the ledger knows it; record a missed successor
845 +
spectra agent add NAME --kind KIND --project NAME --home DIR [--expires 8h] [--yes] [--uid N] [--scope S..] | renew NAME --expires D | list | pause | unpause | disable
846 +
spectra project add NAME-OR-FOLDER [--path DIR] [-d TEXT] [--url URL] | link P --depends-on Q [--remove] | list
825 847
spectra say [-c CHANNEL] [--reply-to ID] BODY                      as the human
826 848
spectra inbox [--follow]                                           the human's inbox; --follow tails it
827 849
spectra task new|list|show|accept|complete|cancel|answer …         as the human
…
5 unchanged lines
833 855
spectra version [--json]
834 856
```
835 857
836 -
Every listing command takes `--json` and prints one JSON document on stdout; errors go to stderr with a non-zero status. Output uses colour and glyphs on a terminal and plain text when piped, with `--plain` and `NO_COLOR`, as `oxy` does. `spectra say` and `spectra inbox --follow` together are a usable terminal chat with the agents when Radar is not open.
858 +
Every listing command takes `--json` and prints one JSON document on stdout; errors go to stderr with a non-zero status. Output uses colour and glyphs on a terminal and plain text when piped, with `--plain` and `NO_COLOR`. `spectra say` and `spectra inbox --follow` together are a usable terminal chat with the agents when Radar is not open.
837 859
838 860
## 20. Guard rails: loops, budgets and injection
839 861
…
29 unchanged lines
869 891
870 892
### 21.3 Keys, passkeys and tokens
871 893
872 -
Every principal's secret key stays in its own home: the owner's in `OXY_HOME`, each agent's in the home `spectra agent add --home` named, the instance's in `keys/instance.secret`; `spectra serve` holds only the instance key, and signs only checkpoints and system events with it. Delegations, expiries and revocations are Oxygen's and are verified from the ledger's copies of the identities. The passkey's credential is in the ledger and its private half in the authenticator. Tokens, where allowed, are stored as BLAKE3, shown once, and recorded as level-1 proof; a token in a URL is refused. Webhook secrets are read from the environment variables the routes name, never from the configuration file.
894 +
Every principal's secret key stays in its own home: the owner's in their identity home (§8.0), each agent's in the home `spectra agent add --home` named, the instance's in `keys/instance.secret`; `spectra serve` holds only the instance key, and signs only checkpoints and system events with it. Delegations, expiries and revocations are Oxygen's and are verified from the ledger's copies of the identities. The passkey's credential is in the ledger and its private half in the authenticator. Tokens, where allowed, are stored as BLAKE3, shown once, and recorded as level-1 proof; a token in a URL is refused. Webhook secrets are read from the environment variables the routes name, never from the configuration file.
873 895
874 896
### 21.4 Rate limits
875 897
…
15 unchanged lines
891 913
892 914
```toml
893 915
[owner]
894 -
key = "KEYID"                         # the owner's Oxygen identity, recorded by `spectra init`; the name comes from it
895 -
home = "~/.config/oxy"                # the OXY_HOME the CLI signs with
916 +
key = "KEYID"                         # the owner's identity, recorded by `spectra init`; the name comes from it
917 +
home = "~/.config/spectra"            # the identity home the CLI signs with
896 918
897 919
[security]
898 920
allow_tokens = false                  # level-1 bearer tokens for clients that cannot run a shim (§8.5)
…
8 unchanged lines
907 929
identities = 3
908 930
admin = 3
909 931
910 -
[oxygenhub]
911 -
# url = "https://oxygenhub.io"        # where checkpoints are witnessed (§9.5)
932 +
[witness]
933 +
# url = "https://…"                   # an append-only log checkpoints are posted to (§9.5)
912 934
913 935
[server]
914 936
bind = "127.0.0.1:7350"               # "[::]:7350" to serve other machines (§8.6)
…
39 unchanged lines
954 976
### 22.4 Backup and restore
955 977
956 978
Stop the daemon or use `spectra admin export` (a consistent JSONL of the event log, from a read transaction, while running), copy the data directory, and restore by copying it back; `spectra admin import` rebuilds a database from an export, and `spectra admin check` verifies that projections match the log. A SpectraHub uplink (§23.3) is also a backup of the log.
957 -
958 -
## 23. Integration with Oxygen and OxygenHub
959 979
960 -
### 23.1 Projects are checkouts
980 +
## 23. Integrations and SpectraHub
961 981
962 -
`spectra project add PATH` records the path, reads `.oxy/` for the repository id and the `oxygenhub` remote URL when present, and reads `.oxymodules` to record `depends_on` relations to other registered projects. The project channel's purpose line links the hub page. Relations are what make "the agent in the project that depends on yours" a question Spectra can answer: `delegate` with `to: "downstream"` fans a task out to one agent per dependent project.
982 +
Spectra depends on no other program. Integrations are optional and none is built into the core.
963 983
964 -
### 23.2 Agents sign as themselves
984 +
### 23.1 Projects are any kind of work
965 985
966 -
An agent commits with `oxy` under the same delegated key it speaks to Spectra with (§8.1), from the same home (`OXY_HOME=<home> oxy …`, or `oxy mcp` started from it). So a push webhook from OxygenHub carrying `via` (the delegate that signed) maps straight to the agent, and a push shows in `#oxygen` as "claude-oxygen pushed 3 changes", with the same key on both sides of the story. Identities and delegations are Oxygen's; Spectra reads them and never makes its own. A project that is not in Oxygen still works: the agent's key is then known only to Spectra.
986 +
`spectra project add NAME [--path DIR] [-d TEXT] [--url URL]` (or `spectra project add FOLDER`, named after it) records a project: what a group of agents works on. The folder, if given, may hold code, a manuscript, a set of documents or nothing in particular; Spectra records where it is and reads nothing inside it. Projects are linked by the owner (`spectra project link P --depends-on Q`), and links are what make "the agent on the work that depends on yours" a question Spectra can answer: `delegate` with `to: "downstream"` fans a task out to one agent per dependent project.
967 987
968 -
### 23.5 Verifying what an agent claims
988 +
### 23.2 Integrations
969 989
970 -
When a message, a task result or a decision references a change (`oxy:change:HASH`), Spectra checks the claim rather than displaying it: `oxy verify --json HASH` in the project's checkout (or the hub's change API) says whether the change exists, who signed it and whether the signature verifies, and `ref.verified` records the answer. Radar shows a tick beside a reference whose change is signed by the agent that cited it, a mark beside one signed by someone else, and a warning beside one that does not verify or exist. "Done, see change X" is thereby a checkable statement, which is the point of giving agents Oxygen keys.
990 +
Outside systems reach Spectra through webhook routes (§16.2) and the JSON API (§18), and Spectra reaches them through the relay. That is enough for a code forge, a document editor, a chat bridge or a calendar to post into channels and create tasks. Deeper integrations, such as checking that a commit an agent cites exists and was signed by that agent, belong in separate, optional tools that use the API; the core never shells out to them or reads their files.
971 991
972 992
### 23.3 SpectraHub uplink
973 993
974 994
`[uplink] url`: when set, `spectra serve` authenticates to the hub with the instance key and streams every committed event, with its proof, and every checkpoint to the hub (`POST /api/v1/uplink/events`, batched, resumable from the hub's last acknowledged `seq`), and takes routed events back (messages and tasks addressed to this Spectra's agents from other machines or from the hub's web and Android clients). The hub is a superset: it stores many Spectras' logs, merges on event id, and renders the same Radar over all of them. See `../spectrahub/docs/spec.md`. Nothing in this document depends on the hub.
975 -
976 -
### 23.4 OxygenHub's assistants
977 -
978 -
OxygenHub can already ask a chat-completion endpoint to review a proposal (hub spec §13.4). That is a different thing from a Spectra agent: an assistant is a request-response call the hub makes; a Spectra agent is a live session that connects. The two meet when a review request is routed to Spectra as a task for the project's agent (a webhook route, §16.2) and the agent's answer is posted back through the hub's API. Not in 1.0; recorded here so the words stay distinct.
979 995
980 996
## 24. Performance targets
981 997
…
16 unchanged lines
998 1014
| **0.1 Talk** | Client and server over TLS (§8.6), `spectra connect`, remote setup with `spectra init --owner`; workspace, config, SQLite with migrations, the ledger (the frozen envelope with its hybrid logical clock, chain and proofs; `spectra admin verify`; checkpoints signed by the instance key), `spectra init` with the owner's identity, agents as delegates with ownership shown, signed requests over the Unix socket, Radar's level-2 sign-in from the CLI (§8.3), projects, channels, messages, threads, `brief`, `rules` (site layer), `say`, `read`, `inbox`, `wait`, `ack`, `agents`, `search`; Tuner over Streamable HTTP (2026-07-28 and 2025-11-25) and the stdio shim; Radar Live, Channels and Log pages with the SSE stream; `spectra say` and `spectra inbox --follow` | With the daemon on one machine and the owner and both agents on another, Claude Code in `oxygen` and Antigravity in `oxygenhub` each call `brief`, post in `#general`, and see each other's messages via `wait` within a second; the owner watches it on Live; `spectra admin verify` passes |
999 1015
| **0.2 Work** | The passkey and the assurance policy (§8.3, §8.5), since this is where the human's authority is first exercised; tasks with the full lifecycle, queues, `delegate`, `ask` and `task_update`, contracts with schema validation, claims, `human` and the human inbox, budgets, ping-pong detection, TTL expiry, the Tasks board and graph, agent pages with tool-call payloads, pause | A handoff with a `result_schema` goes from one agent to another and back with a validated result; a runaway thread is stopped by the guard and shown to the owner |
1000 1016
| **0.3 Rules and memory** | Rule layers (kind, project, agent), file watching, versions and diffs, the rule-change line on every result, `spectra rules export`, `spectra hook` with `install claude-code` and `stop --wait`, `spectra trigger`, `spectra rules commit` with the `oxy` change hash (§12.4), `ref.verified` (§23.5), decisions, the Rules and Decisions pages, resources and prompts | Editing `site.md` is noticed by every connected agent on its next call; `search` answers a question from last week's thread |
1001 -
| **0.4 Push and automations** | The Claude Code channel shim with permission relay, schedules, webhook routes with the OxygenHub signature, the relay, Settings page, `.oxymodules` relations, `to: "downstream"` | A push to `oxygen` on OxygenHub appears in `#oxygen` and creates a pin-bump task for the `oxygenhub` agent, who is told by channel event without polling |
1002 -
| **0.5 Uplink and API** | `spectra-api` complete for every page, `make ts`, the uplink to SpectraHub with proofs and checkpoints, checkpoints into OxygenHub's transparency log, export and import, prune and rebuild, retention | spectra-vscode lists agents, channels and tasks through the API; a Hub shows this machine's log |
1003 -
| **1.0** | Hardening (fuzzed JSON-RPC and hook bodies, projection equality tests, load test at §24), security review of §21, packaging (binary, `.deb` via the OxygenHub apt repository, systemd user unit), documentation | — |
1017 +
| **0.4 Push and automations** | The Claude Code channel shim with permission relay, schedules, webhook routes with HMAC signatures, the relay, Settings page, project links, `to: "downstream"` | A signed webhook from an outside system (a push on a code forge, a comment in a document editor) appears in the project's channel and creates a task for the agent on the dependent project, who is told by channel event without polling |
1018 +
| **0.5 Uplink and API** | `spectra-api` complete for every page, `make ts`, the uplink to SpectraHub with proofs and checkpoints, checkpoints to a witness log, export and import, prune and rebuild, retention | spectra-vscode lists agents, channels and tasks through the API; a Hub shows this machine's log |
1019 +
| **1.0** | Hardening (fuzzed JSON-RPC and hook bodies, projection equality tests, load test at §24), security review of §21, packaging (binary, `.deb`, systemd user unit), documentation | — |
1004 1020
1005 1021
## 26. Open questions
1006 1022
…
2 unchanged lines
1009 1025
3. **Channel shim lifetime.** The Claude Code channel feature is a research preview with an allowlist. If it ships generally, `spectra channel` becomes a plugin in an official marketplace; if it is withdrawn, Tier 3 hooks are the push path. Keep both until one wins.
1010 1026
4. **Tool count.** Twenty after Draft 0.2 folded the transitions. Measure which are called; `claim` and `schedule` could fold into `task_update`'s shape further, or `ask` into `delegate`, if the list still costs too much for small models.
1011 1027
5. **Encryption at rest.** Messages may carry secrets an agent pasted. A passphrase-sealed database (SQLCipher or application-level sealing of bodies) is possible; the cost is every search and page decrypting. Decide before a Hub holds other people's logs.
1012 -
6. **Agent-to-agent calls.** Should an agent be able to run a tool *on* another agent (ask Claude in `oxygen` to run `oxy log` and return it)? That is sampling-shaped and MCP has deprecated sampling. Tasks with a `result_schema` cover it with a human-visible step; keep it that way unless it proves too slow.
1028 +
6. **Agent-to-agent calls.** Should an agent be able to run a tool *on* another agent (ask the agent on one project to run a command in its folder and return the output)? That is sampling-shaped and MCP has deprecated sampling. Tasks with a `result_schema` cover it with a human-visible step; keep it that way unless it proves too slow.
1013 1029
7. **Kinds.** The agent `kind` list will date. Treat it as open text with known values for the rules layer.
1014 -
8. **A `spectra` delegation scope in Oxygen.** Today an agent is Spectra's because its owner delegated to it with any Oxygen scope (`changes`, usually). A scope that says "may act for me in Spectra" would let an owner give an agent Spectra without `oxy`, or `oxy` without Spectra, and would be the natural place for an expiry that Spectra alone honours. It is a one-line change to oxygen spec §14.2 and `oxy identity delegate add`; propose it to Oxygen once the shape here has settled.
1030 +
8. **Bringing an identity from elsewhere.** Spectra's identities are its own. Someone who already has keys (an SSH key, a signing key from another tool) might want to use them; a statement signed by both keys could link them. Not planned; recorded so the answer is not "impossible" by accident.
1015 1031
9. **The owner's CLI key.** Level 2 from the CLI means an agent under the owner's user could sign as the owner for chat and tasks. A hardware-backed key for the owner (a YubiKey through PKCS#11, or the platform keystore) would raise the CLI to level 3; so would running agents under their own users (§21.6). Decide after living with the passkey in 0.2.
1016 -
10. **Witnessing without OxygenHub.** Checkpoints are witnessed by OxygenHub's transparency log. A machine with no hub could publish them to a plain append-only file on a second machine, or to a public log. Not before 0.5.
1032 +
10. **Where checkpoints are witnessed.** `[witness] url` posts checkpoints to an append-only log, but which logs to support (a SpectraHub, a plain file on a second machine over SSH, a public transparency log) is open. Not before 0.5.
1017 1033
11. **How spectra-vscode signs.** The API takes signed requests (§8.5), and an extension in TypeScript has no ML-DSA-65 of its own. Recommended: the extension runs `spectra` as a signing proxy over the socket (a `spectra api METHOD PATH` command that signs with the owner's key and prints the JSON), so no key ever leaves Rust; the alternative, a WebAssembly build of `oxy-core`'s signing, is heavier. Decide before spectra-vscode 0.1.
1018 1034
1019 1035
## 27. Changelog
1020 1036
1037 +
- Draft 0.5 (2026-11-03), Spectra stands alone: identities, composite signatures and deterministic CBOR are Spectra's own (§8.0), designed after Oxygen's but sharing no code, file or command; `spectra identity create` makes the owner's identity, `spectra agent add` and `spectra connect` sign the delegation themselves after the owner confirms; a project is any kind of work with an optional folder that Spectra never reads, linked by the owner (§23.1); webhooks are signed with HMAC-SHA256 and checkpoints are witnessed by a log of the owner's choosing; nothing requires Oxygen, OxygenHub or version control (§2.2, §23).
1021 1038
- Draft 0.4 (2026-11-03), Sprint 2: client and server (§8.6): the daemon serves other machines over TLS with a certificate signed by its instance key, so no CA is needed; `spectra connect` accepts a server only when the owner's own identity delegates to its instance key, and pins its certificate; `spectra init --owner KEYID` sets a server up without the owner's secret key, which claims it from the client; a client has no ledger file and works through the JSON API; an agent bound to a uid is refused over TCP; Radar from a client goes through a loopback proxy; 0.1's acceptance runs across two machines (§25).
1022 1039
- Draft 0.3c (2026-11-02), while building #10: a Radar session's events carry the method `session` (assurance 2) and, as proof, the owner's signed request for the sign-in link, which `verify` checks against the owner (§8.3); every ledger write runs to completion in a task of its own, so a caller that goes away cannot leave a transaction open (§9.5); Radar's templates and assets live in the `radar` crate, so SpectraHub mounts it whole (§7.3).
1023 1040
- Draft 0.3b (2026-11-02), while building #7: a refused request names its reason (§15.1); requests to the daemon are signed with the owner identity the ledger already records, and a rotation travels in the same batch as the event that needs it (§8.5).
…
4 unchanged lines
modified src/cli/agent.rs +88 −36
1 1
//! `spectra agent`: agents as their owner's delegates (spec §8.1).
2 2
//!
3 -
//! `add` makes the agent's key pair in its own home, prints the one `oxy`
4 -
//! command that makes it the owner's, and records the agent only once the
5 -
//! owner's identity delegates to it. There is no agent without an owner.
3 +
//! `add` makes the agent's key pair in its own home, asks the owner to
4 +
//! confirm, signs the owner's successor that delegates to it, and records
5 +
//! both. There is no agent without an owner.
6 6
7 7
use super::identity::owner_home;
8 8
use super::{call, marks, ui, Ctx};
…
1 unchanged lines
10 10
use spectra_core::ids::now_ms;
11 11
use spectra_core::keys::{home_identity, KeyPair};
12 12
use spectra_core::ledger::Ledger;
13 -
use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DEFAULT_SCOPES, DELEGATION_SCOPE};
13 +
use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DEFAULT_SCOPES};
14 14
use std::path::PathBuf;
15 -
use std::time::Duration;
16 15
17 16
pub const KINDS: &[&str] = &["claude-code", "antigravity", "kimi-code", "codex", "other"];
18 17
…
4 unchanged lines
23 22
    pub home: PathBuf,
24 23
    pub uid: Option<u32>,
25 24
    pub learn_uid: bool,
26 -
    pub wait_secs: u64,
25 +
    pub expires: Option<String>,
26 +
    pub yes: bool,
27 27
    pub owner_home: Option<PathBuf>,
28 28
}
29 29
30 +
/// `30m`, `8h`, `7d` from now, in ms; `never` is none.
31 +
pub fn expiry(s: &str) -> Result<Option<u64>> {
32 +
    let s = s.trim();
33 +
    if s == "never" {
34 +
        return Ok(None);
35 +
    }
36 +
    let (n, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()));
37 +
    let n: u64 = n.parse().with_context(|| format!("{s} is not a length of time (30m, 8h, 7d)"))?;
38 +
    let ms = match unit {
39 +
        "s" => n * 1000,
40 +
        "m" => n * 60_000,
41 +
        "h" => n * 3_600_000,
42 +
        "d" | "" => n * 86_400_000,
43 +
        _ => bail!("{s} is not a length of time (30m, 8h, 7d)"),
44 +
    };
45 +
    Ok(Some(now_ms() + ms))
46 +
}
47 +
48 +
/// Ask the owner at the terminal; `yes` answers for scripts.
49 +
pub fn confirm(question: &str, yes: bool) -> Result<()> {
50 +
    use std::io::IsTerminal;
51 +
    if yes {
52 +
        return Ok(());
53 +
    }
54 +
    if !std::io::stdin().is_terminal() {
55 +
        bail!("{question}: not asked, since this is not a terminal (--yes answers for a script)");
56 +
    }
57 +
    print!("{} {} {} ", ui::glyph("?", "?"), question, ui::dim("[y/N]"));
58 +
    use std::io::Write;
59 +
    std::io::stdout().flush().ok();
60 +
    let mut line = String::new();
61 +
    std::io::stdin().read_line(&mut line)?;
62 +
    if !matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
63 +
        bail!("not delegated: nothing was changed");
64 +
    }
65 +
    Ok(())
66 +
}
67 +
30 68
pub async fn add(ctx: &Ctx, a: Add) -> Result<i32> {
31 69
    if !valid_name(&a.name) {
32 70
        bail!("an agent's name is lower-case letters, digits and dashes, starting with a letter or digit, at most 64");
…
31 unchanged lines
64 102
        println!("{} {}  {}", ui::done("Created"), ui::subject(format!("{}'s key", a.name)), ui::path(a.home.display()));
65 103
        pair
66 104
    };
67 -
    let delegated = |id: &oxy_core::identity::Identity| id.delegates_to(&agent.key, DELEGATION_SCOPE, now_ms());
68 -
    if !delegated(&home_identity(&home)?) {
69 -
        println!("{} {}", ui::caution(), ui::warn(format!("{} is not yours yet: delegate to its key, as yourself:", a.name)));
70 -
        println!(
71 -
            "    {}",
72 -
            ui::id(format!(
73 -
                "oxy identity delegate add {} --scope changes --expires 8h",
74 -
                a.home.join("keys").join(format!("{}.identity", agent.key.to_hex())).display()
75 -
            ))
76 -
        );
77 -
        let spin = ui::spinner(&format!(
78 -
            "waiting for your delegation to {} (up to {} s; Ctrl-C stops, and running this again resumes)",
79 -
            a.name, a.wait_secs
80 -
        ));
81 -
        let deadline = std::time::Instant::now() + Duration::from_secs(a.wait_secs);
82 -
        loop {
83 -
            if home_identity(&home).is_ok_and(|id| delegated(&id)) {
84 -
                spin.clear();
85 -
                break;
86 -
            }
87 -
            if std::time::Instant::now() >= deadline {
88 -
                spin.clear();
89 -
                bail!("{}'s key is not delegated yet: run the command above, then this again", a.name);
90 -
            }
91 -
            tokio::time::sleep(Duration::from_secs(2)).await;
92 -
        }
105 +
    let expires = a.expires.as_deref().map(expiry).transpose()?.flatten();
106 +
    let current = home_identity(&home)?;
107 +
    if current.delegate(&agent.key).map(|d| d.expires) != Some(expires) {
108 +
        let until = expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into());
109 +
        println!("{}", ui::kv("agent", format!("{}{}{}", ui::subject(&a.name), ui::sep(), ui::hash(agent.key.short(12)))));
110 +
        println!("{}", ui::kv("acts for", format!("{}{}{until}", ui::author(&current.name), ui::sep())));
111 +
        confirm(&format!("Let {} act for you", a.name), a.yes)?;
112 +
        spectra_core::keys::delegate(&home, agent.key, expires)?;
93 113
    }
94 114
    let w = ctx.owner_writer(&home).await?;
95 115
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
…
11 unchanged lines
107 127
    }
108 128
    p["learn_uid"] = a.learn_uid.into();
109 129
    let d = owner.identity.delegate(&agent.key).context("the delegation vanished")?;
110 -
    p["delegation"] = serde_json::json!({ "scope": d.scope.names(), "expires": d.expires });
130 +
    p["delegation"] = serde_json::json!({ "expires": d.expires });
111 131
    events.push(signed_event(&owner, "human", "agent.added", p));
112 132
    w.append(events).await?;
113 133
    println!("{} {}{}{}", ui::done("Added"), ui::subject(&a.name), ui::sep(), ui::author(format!("{}'s agent", owner.identity.name)));
114 134
    println!("{}", ui::kv("key", ui::hash(agent.key.to_hex())));
115 -
    let until = d.expires.map(|e| format!(" until {}", super::format::when(e))).unwrap_or_default();
116 -
    println!("{}", ui::kv("delegated", format!("{}{until}", d.scope.names().join(","))));
135 +
    let until = d.expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into());
136 +
    println!("{}", ui::kv("delegated", until));
117 137
    // With Spectra on another machine the shim cannot look the home up.
118 138
    let shim = if ctx.remote()?.is_some() {
119 139
        format!("spectra mcp --agent {} --home {}", a.name, a.home.display())
…
36 unchanged lines
156 176
    let now = now_ms();
157 177
    let mut out = Vec::new();
158 178
    for AgentRow { name, key, kind, project, disabled_ms: disabled, presence, status } in rows {
159 -
        let k = oxy_core::hash::ObjectRef::from_hex(&key).context("a stored key")?;
179 +
        let k = spectra_core::identity::KeyId::from_hex(&key).context("a stored key")?;
160 180
        let state = match p.principal(&k, now) {
161 181
            Ok(_) => "ok".to_string(),
162 182
            Err(why) => why,
…
61 unchanged lines
224 244
    };
225 245
    if !known {
226 246
        bail!("no agent named {name}");
247 +
    }
248 +
    // Stop delegating to its key too, where the owner's identity names it.
249 +
    if let Some(key) = agent_key(&w, name).await? {
250 +
        if home_identity(&home)?.delegate(&key).is_some() {
251 +
            spectra_core::keys::undelegate(&home, key)?;
252 +
        }
227 253
    }
228 254
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
229 255
    let owner = KeyPair::load_home(&home)?;
…
1 unchanged lines
231 257
    w.append(events).await?;
232 258
    println!("{} {}", ui::done("Disabled"), ui::subject(name));
233 259
    println!("  {} {}", ui::note(), ui::dim("its key is refused from now on; earlier events keep their signatures"));
260 +
    Ok(0)
261 +
}
262 +
263 +
/// An agent's key by name, from the daemon or the ledger here.
264 +
async fn agent_key(w: &super::Writer, name: &str) -> Result<Option<spectra_core::identity::KeyId>> {
265 +
    Ok(match (w.client(), w.ledger()) {
266 +
        (Some(c), _) => call(c, "GET", "/api/v1/agents", None).await?.as_array().and_then(|l| {
267 +
            l.iter().find(|x| x["name"] == name).and_then(|x| x["key"].as_str().and_then(spectra_core::identity::KeyId::from_hex))
268 +
        }),
269 +
        (None, Some(l)) => Principals::load(l).await?.agent_key(name),
270 +
        (None, None) => None,
271 +
    })
272 +
}
273 +
274 +
/// `spectra agent renew NAME --expires 8h`: the owner's successor with a
275 +
/// new expiry for the agent's key, recorded.
276 +
pub async fn renew(ctx: &Ctx, name: &str, expires: &str, owner_home_flag: Option<PathBuf>) -> Result<i32> {
277 +
    let home = owner_home(ctx, owner_home_flag);
278 +
    let w = ctx.owner_writer(&home).await?;
279 +
    let key = agent_key(&w, name).await?.with_context(|| format!("no agent named {name}"))?;
280 +
    let expires = expiry(expires)?;
281 +
    spectra_core::keys::delegate(&home, key, expires)?;
282 +
    let events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
283 +
    w.append(events).await?;
284 +
    let until = expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into());
285 +
    println!("{} {}{}{}", ui::done("Renewed"), ui::subject(name), ui::sep(), until);
234 286
    Ok(0)
235 287
}
236 288
…
6 unchanged lines
modified src/cli/connect.rs +23 −34
…
4 unchanged lines
5 5
6 6
use super::{identity, ui, Ctx};
7 7
use anyhow::{bail, Context, Result};
8 -
use oxy_core::identity::Identity;
9 8
use serde_json::Value as Json;
10 9
use spectra_core::daemon::WireEvent;
10 +
use spectra_core::identity::Identity;
11 11
use spectra_core::keys::KeyPair;
12 -
use spectra_core::principals::{identity_payload, signed_event, DELEGATION_SCOPE};
12 +
use spectra_core::principals::{identity_payload, signed_event};
13 13
use spectra_mcp::client::{host_port, Client, Endpoint};
14 14
use spectra_mcp::tls::Pinned;
15 15
use std::path::{Path, PathBuf};
…
12 unchanged lines
28 28
    }
29 29
}
30 30
31 -
/// `SPECTRA_SERVER`, else `$XDG_CONFIG_HOME/spectra/client.toml`, else
32 -
/// `~/.config/spectra/client.toml`.
31 +
/// `SPECTRA_SERVER`, else `client.toml` in the owner's identity home.
33 32
pub fn path() -> PathBuf {
34 33
    if let Some(p) = std::env::var_os("SPECTRA_SERVER").filter(|p| !p.is_empty()) {
35 34
        return PathBuf::from(p);
…
1 unchanged lines
37 36
    config_dir().join("client.toml")
38 37
}
39 38
39 +
/// The owner's identity home, where the client configuration lives too.
40 40
fn config_dir() -> PathBuf {
41 -
    let base = std::env::var_os("XDG_CONFIG_HOME")
42 -
        .filter(|p| !p.is_empty())
43 -
        .map(PathBuf::from)
44 -
        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
45 -
        .unwrap_or_else(|| PathBuf::from("."));
46 -
    base.join("spectra")
41 +
    spectra_core::keys::default_home()
47 42
}
48 43
49 44
/// The client configuration, if this machine uses a Spectra elsewhere.
…
33 unchanged lines
83 78
    KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity"))).ok()
84 79
}
85 80
86 -
pub async fn connect(_ctx: &Ctx, url: &str, owner_home: Option<PathBuf>) -> Result<i32> {
81 +
pub async fn connect(_ctx: &Ctx, url: &str, owner_home: Option<PathBuf>, expect: Option<String>, yes: bool) -> Result<i32> {
87 82
    let server = format!("https://{}", host_port(url)?);
88 -
    let home = owner_home.unwrap_or_else(oxy_core::repo::default_home);
89 -
    let owner = KeyPair::load_home(&home)
83 +
    let home = owner_home.unwrap_or_else(spectra_core::keys::default_home);
84 +
    let mut owner = KeyPair::load_home(&home)
90 85
        .with_context(|| format!("the owner connects with their own identity: none usable in {} (or --owner-home)", home.display()))?;
91 86
92 87
    // Who is there, before trusting anything.
…
16 unchanged lines
109 104
            bail!("{server} belongs to someone else (owner {})", &k[..12.min(k.len())]);
110 105
        }
111 106
        (None, Some(k)) if own(&home, k).is_none() => {
112 -
            bail!("{server} was set up for {}, which is not an identity in {}", &k[..12.min(k.len())], home.display());
107 +
            bail!(
108 +
                "{server} was set up for {}, which is not an identity in {} (spectra init --owner takes `spectra identity show`'s key)",
109 +
                &k[..12.min(k.len())],
110 +
                home.display()
111 +
            );
113 112
        }
114 113
        (None, None) => bail!("{server} has no owner and names none"),
115 114
        _ => {}
116 115
    }
117 116
118 117
    // Trust: the owner's own identity delegates to that instance key now.
119 -
    if !owner.identity.delegates_to(&instance_key, DELEGATION_SCOPE, spectra_core::ids::now_ms()) {
120 -
        let dir = config_dir();
121 -
        std::fs::create_dir_all(&dir)?;
122 -
        let file = dir.join(format!("{}-instance.identity", safe(&checked.name)));
123 -
        std::fs::write(&file, checked.instance.encode())?;
124 -
        println!("{} {} {}", ui::caution(), ui::subject(&server), ui::dim("is not yet yours to trust"));
125 -
        println!("{}", ui::kv("instance", format!("{}{}{}", checked.name, ui::sep(), ui::hash(instance_key.short(12)))));
126 -
        println!("  {} {}", ui::note(), ui::dim("your identity does not delegate to its instance key; if this is your server, run:"));
127 -
        println!("    {}", ui::id(format!("oxy identity delegate add {} --scope changes", file.display())));
128 -
        println!("  {} {}", ui::note(), ui::dim("then run spectra connect again"));
129 -
        return Ok(1);
118 +
    if !owner.identity.delegates_to(&instance_key, spectra_core::ids::now_ms()) {
119 +
        println!("{}", ui::kv("server", ui::path(&server)));
120 +
        println!("{}", ui::kv("instance", format!("{}{}{}", checked.name, ui::sep(), ui::hash(instance_key.to_hex()))));
121 +
        println!("  {} {}", ui::note(), ui::dim("`spectra init` printed this instance's key on the server: check it is the same"));
122 +
        match &expect {
123 +
            Some(prefix) if instance_key.to_hex().starts_with(&prefix.trim().to_ascii_lowercase()) => {}
124 +
            Some(prefix) => bail!("the server's instance key does not start with {prefix}: not trusted"),
125 +
            None => super::agent::confirm(&format!("Trust {} as your Spectra", checked.name), yes)?,
126 +
        }
127 +
        owner = spectra_core::keys::delegate(&home, instance_key, None)?;
130 128
    }
131 129
132 130
    let cfg = ClientCfg { server: server.clone(), instance: instance_key.to_hex(), tls_spki: seen };
…
39 unchanged lines
172 170
    );
173 171
    println!("{}", ui::kv("config", ui::path(saved.display())));
174 172
    Ok(0)
175 -
}
176 -
177 -
fn safe(name: &str) -> String {
178 -
    let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).collect();
179 -
    if s.is_empty() {
180 -
        "spectra".into()
181 -
    } else {
182 -
        s
183 -
    }
184 173
}
185 174
186 175
fn problem(body: &[u8]) -> String {
…
5 unchanged lines
modified src/cli/format.rs +1 −1
1 -
//! Times as `oxy` prints them: UTC, `YYYY-MM-DD HH:MM:SSZ`.
1 +
//! Times as Spectra prints them: UTC, `YYYY-MM-DD HH:MM:SSZ`.
2 2
3 3
pub fn when(ms: u64) -> String {
4 4
    let secs = (ms / 1000) as i64;
…
15 unchanged lines
modified src/cli/human.rs +30 −16
…
115 unchanged lines
116 116
pub async fn inbox(ctx: &Ctx, follow: bool, owner_home_flag: Option<PathBuf>, as_json: bool) -> Result<i32> {
117 117
    let home = owner_home(ctx, owner_home_flag);
118 118
    let client = owner_client(ctx, &home).await?;
119 +
    // Following: subscribe first, and read the inbox once the server says
120 +
    // the subscription holds, so nothing posted in between is missed.
121 +
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<bool>();
122 +
    let watcher = if follow {
123 +
        let stream_client = owner_client(ctx, &home).await?.context("--follow needs `spectra serve` running")?;
124 +
        let w = tokio::spawn(async move {
125 +
            stream_client
126 +
                .stream("/api/v1/events?follow=1&kind=message.posted", |l| {
127 +
                    if l.starts_with(':') {
128 +
                        let _ = tx.send(false);
129 +
                    } else if l.starts_with("data:") {
130 +
                        let _ = tx.send(true);
131 +
                    }
132 +
                    true
133 +
                })
134 +
                .await
135 +
        });
136 +
        match tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv()).await {
137 +
            Ok(Some(_)) => {}
138 +
            _ => bail!("the server did not start the stream of new messages"),
139 +
        }
140 +
        Some(w)
141 +
    } else {
142 +
        None
143 +
    };
119 144
    let items = inbox_items(ctx, client.as_ref()).await?;
120 145
    if as_json && !follow {
121 146
        println!("{}", Value::Array(items));
…
11 unchanged lines
133 158
            print_item(i);
134 159
        }
135 160
    }
136 -
    if !follow {
137 -
        return Ok(0);
138 -
    }
161 +
    let Some(watcher) = watcher else { return Ok(0) };
139 162
    let client = client.context("--follow needs `spectra serve` running")?;
140 163
    if !as_json {
141 164
        println!("{} {}", ui::note(), ui::dim("following; Ctrl-C stops"));
142 165
    }
143 166
    // Every new message is a cue to look at the inbox again.
144 -
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>();
145 -
    let stream_client = owner_client(ctx, &home).await?.context("--follow needs `spectra serve` running")?;
146 -
    let watcher = tokio::spawn(async move {
147 -
        stream_client
148 -
            .stream("/api/v1/events?follow=1&kind=message.posted", |l| {
149 -
                if l.starts_with("data:") {
150 -
                    let _ = tx.send(());
151 -
                }
152 -
                true
153 -
            })
154 -
            .await
155 -
    });
156 -
    while rx.recv().await.is_some() {
167 +
    while let Some(changed) = rx.recv().await {
168 +
        if !changed {
169 +
            continue;
170 +
        }
157 171
        for v in inbox_items(ctx, Some(&client)).await? {
158 172
            if seen.insert(v["id"].as_str().unwrap_or("").to_string()) {
159 173
                if as_json {
…
77 unchanged lines
modified src/cli/identity.rs +32 −24
…
3 unchanged lines
4 4
5 5
use super::{ui, Ctx};
6 6
use anyhow::{bail, Context, Result};
7 -
use oxy_core::identity::Identity;
7 +
use spectra_core::identity::Identity;
8 8
use spectra_core::keys::KeyPair;
9 9
use spectra_core::ledger::Ledger;
10 10
use spectra_core::principals::{identity_payload, signed_event, Principals};
11 11
use std::path::{Path, PathBuf};
12 12
13 13
/// The owner's `OXY_HOME`: `--owner-home`, else `[owner] home` in
14 -
/// spectra.toml, else oxy's own default.
14 +
/// spectra.toml, else the default identity home.
15 15
pub fn owner_home(ctx: &Ctx, flag: Option<PathBuf>) -> PathBuf {
16 -
    flag.or_else(|| ctx.config_owner_home()).unwrap_or_else(oxy_core::repo::default_home)
16 +
    flag.or_else(|| ctx.config_owner_home()).unwrap_or_else(spectra_core::keys::default_home)
17 17
}
18 18
19 19
pub async fn init(ctx: &Ctx, owner_home_flag: Option<PathBuf>, name: Option<String>) -> Result<i32> {
…
3 unchanged lines
23 23
        bail!("{} already holds a Spectra (spectra identity show)", data.display());
24 24
    }
25 25
    let home = owner_home(ctx, owner_home_flag);
26 -
    let owner = KeyPair::load_home(&home).with_context(|| {
27 -
        format!("the owner signs with their own identity: none usable in {} (oxy identity create, or --owner-home)", home.display())
26 +
    KeyPair::load_home(&home).with_context(|| {
27 +
        format!("the owner signs with their own identity: none usable in {} (spectra identity create, or --owner-home)", home.display())
28 28
    })?;
29 29
    std::fs::create_dir_all(data)?;
30 30
    #[cfg(unix)]
…
3 unchanged lines
34 34
    }
35 35
    let name = name.unwrap_or_else(hostname);
36 36
    let instance = KeyPair::instance(data, &name)?;
37 +
    // You are setting up your own machine: your identity delegates to its
38 +
    // instance key, so its checkpoints are verifiably yours (spec §9.5).
39 +
    let owner = spectra_core::keys::delegate(&home, instance.key, None)?;
37 40
    let ledger = Ledger::open(&db, Some(instance.clone())).await?;
38 41
    let mut first = vec![
39 42
        signed_event(&owner, "human", "identity.owner", identity_payload(&owner.identity)),
…
12 unchanged lines
52 55
    println!("{}", ui::kv("instance", format!("{}{}{}", name, ui::sep(), ui::hash(instance.key.short(12)))));
53 56
    println!("{}", ui::kv("ledger", format!("{}{}", ui::path(db.display()), ui::dim(" (verified with spectra admin verify)"))));
54 57
    println!();
55 -
    println!("  {} {}", ui::note(), ui::dim("so that checkpoints are verifiably from your machine, delegate to the instance key:"));
56 58
    println!(
57 -
        "    {}",
58 -
        ui::id(format!("oxy identity delegate add {} --scope changes", data.join("keys").join("instance.identity").display()))
59 -
    );
60 -
    println!(
61 59
        "  {} {} {}",
62 60
        ui::note(),
63 61
        ui::dim("then add agents with"),
…
10 unchanged lines
74 72
    let p = Principals::load(&ledger).await?;
75 73
    let owner = p.owner().context("no owner is recorded")?;
76 74
    let instance = p.instance();
77 -
    let delegated =
78 -
        instance.is_some_and(|i| owner.delegates_to(&i.hash(), spectra_core::principals::DELEGATION_SCOPE, spectra_core::ids::now_ms()));
75 +
    let delegated = instance.is_some_and(|i| owner.delegates_to(&i.hash(), spectra_core::ids::now_ms()));
79 76
    if json {
80 77
        println!(
81 78
            "{}",
…
5 unchanged lines
87 84
        return Ok(0);
88 85
    }
89 86
    println!("{}", ui::section("Owner"));
90 -
    println!("{}", ui::kv("name", ui::author(format!("{} <{}>", owner.name, owner.email))));
87 +
    println!("{}", ui::kv("name", ui::author(format!("{} <{}>", owner.name, owner.contact))));
91 88
    println!("{}", ui::kv("key", ui::hash(owner.hash().to_hex())));
92 89
    println!("{}", ui::kv("delegates", owner.delegates.len()));
93 90
    if let Some(i) = instance {
…
3 unchanged lines
97 94
        if delegated {
98 95
            println!("{}", ui::kv("status", ui::ok("delegated by the owner: checkpoints are verifiably yours")));
99 96
        } else {
100 -
            println!("{}", ui::kv("status", ui::warn("not delegated by the owner yet")));
101 -
            println!(
102 -
                "  {} {}",
103 -
                ui::note(),
104 -
                ui::id(format!("oxy identity delegate add {} --scope changes", ctx.data.join("keys").join("instance.identity").display()))
105 -
            );
97 +
            println!("{}", ui::kv("status", ui::warn("not delegated by the owner's current identity")));
106 98
        }
107 99
    }
108 100
    Ok(0)
…
79 unchanged lines
188 180
    let info = super::call(&spectra_mcp::client::Client::at(cfg.endpoint()?, None), "GET", "/api/v1/instance", None).await?;
189 181
    let owner_key = info["owner"].as_str().unwrap_or("").to_string();
190 182
    let instance_key = info["instance"]["key"].as_str().unwrap_or("").to_string();
191 -
    let home = oxy_core::repo::default_home();
183 +
    let home = spectra_core::keys::default_home();
192 184
    let me = KeyPair::load_home(&home).ok();
193 185
    let delegated = me.as_ref().is_some_and(|m| {
194 -
        oxy_core::hash::ObjectRef::from_hex(&instance_key)
195 -
            .is_some_and(|k| m.identity.delegates_to(&k, spectra_core::principals::DELEGATION_SCOPE, spectra_core::ids::now_ms()))
186 +
        spectra_core::identity::KeyId::from_hex(&instance_key).is_some_and(|k| m.identity.delegates_to(&k, spectra_core::ids::now_ms()))
196 187
    });
197 188
    if json {
198 189
        println!(
…
10 unchanged lines
209 200
    println!("{}", ui::kv("url", ui::path(&cfg.server)));
210 201
    println!("{}", ui::section("Owner"));
211 202
    if let Some(m) = &me {
212 -
        println!("{}", ui::kv("name", ui::author(format!("{} <{}>", m.identity.name, m.identity.email))));
203 +
        println!("{}", ui::kv("name", ui::author(format!("{} <{}>", m.identity.name, m.identity.contact))));
213 204
    }
214 205
    println!("{}", ui::kv("key", ui::hash(&owner_key)));
215 206
    println!("{}", ui::section("Instance"));
…
4 unchanged lines
220 211
    } else {
221 212
        println!("{}", ui::kv("status", ui::warn("not delegated by your current identity")));
222 213
    }
214 +
    Ok(0)
215 +
}
216 +
217 +
/// `spectra identity create`: the owner's own key pair (spec §8.0).
218 +
pub fn create(name: &str, contact: &str, home: Option<PathBuf>) -> Result<i32> {
219 +
    let home = home.unwrap_or_else(spectra_core::keys::default_home);
220 +
    let pair = KeyPair::create_home(&home, name.trim(), contact.trim())?;
221 +
    println!("{} {}  {}", ui::done("Created"), ui::subject("your identity"), ui::path(home.display()));
222 +
    println!("{}", ui::kv("name", ui::author(&pair.identity.name)));
223 +
    println!("{}", ui::kv("key", ui::hash(pair.key.to_hex())));
224 +
    println!("  {} {}", ui::note(), ui::dim("keep keys/ private: anyone with it can act as you"));
225 +
    println!(
226 +
        "  {} {} {}",
227 +
        ui::note(),
228 +
        ui::dim("next: spectra init here, or on a server"),
229 +
        ui::id(format!("spectra init --owner {}", pair.key.to_hex()))
230 +
    );
223 231
    Ok(0)
224 232
}
225 233
…
3 unchanged lines
229 237
pub async fn init_for(ctx: &Ctx, owner_key: &str, name: Option<String>) -> Result<i32> {
230 238
    let key = owner_key.trim().to_ascii_lowercase();
231 239
    if key.len() != 64 || hex::decode(&key).is_err() {
232 -
        bail!("{owner_key} is not a KeyId (64 hex digits; the owner's `oxy identity show` prints it)");
240 +
        bail!("{owner_key} is not a KeyId (64 hex digits; the owner's `spectra identity show` prints it)");
233 241
    }
234 242
    let data = &ctx.data;
235 243
    let db = data.join("spectra.db");
…
43 unchanged lines
modified src/cli/marks.rs +1 −2
1 -
//! Spectra's own marks, in the style of `oxy`'s (`ui.rs` stays exactly
2 -
//! `oxy`'s, so the two tools look the same): presence and agent health.
1 +
//! Spectra's own marks beside `ui.rs`: presence and agent health.
3 2
4 3
use super::ui::{dim, glyph, ok, warn};
5 4
use console::style;
…
28 unchanged lines
modified src/cli/mod.rs +76 −22
1 -
//! The `spectra` command line (spec §19). Output follows `oxy`: colour and
1 +
//! The `spectra` command line (spec §19). Output is modern: colour and
2 2
//! glyphs on a UTF-8 terminal, plain when piped, with `--plain` or with
3 3
//! `NO_COLOR`; `--json` prints one document on stdout and nothing else.
4 4
…
3 unchanged lines
8 8
pub mod format;
9 9
pub mod human;
10 10
pub mod identity;
11 -
pub mod json;
12 11
pub mod marks;
13 12
pub mod mcp;
14 13
pub mod open;
…
29 unchanged lines
44 43
    /// Make this machine's Spectra: record the owner's identity (read from
45 44
    /// their OXY_HOME and proven by signing) and make the instance key
46 45
    Init {
47 -
        /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy)
46 +
        /// The owner's identity home (default: $SPECTRA_HOME, else ~/.config/spectra)
48 47
        #[arg(long)]
49 48
        owner_home: Option<PathBuf>,
50 49
        /// This instance's name (default: the host name)
…
10 unchanged lines
61 60
    Connect {
62 61
        /// The server, https://HOST[:PORT]
63 62
        url: String,
64 -
        /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy)
63 +
        /// Trust the server if its instance key starts with this, without asking
64 +
        #[arg(long, value_name = "KEYID-PREFIX")]
65 +
        expect: Option<String>,
66 +
        /// Trust it without asking (for scripts)
67 +
        #[arg(long, short = 'y')]
68 +
        yes: bool,
69 +
        /// The owner's identity home (default: $SPECTRA_HOME, else ~/.config/spectra)
65 70
        #[arg(long)]
66 71
        owner_home: Option<PathBuf>,
67 72
    },
…
109 unchanged lines
177 182
178 183
#[derive(Subcommand)]
179 184
pub enum IdentityCmd {
185 +
    /// Make your own identity: a key pair that signs as you (spec §8.0)
186 +
    Create {
187 +
        /// Your name, as agents and Radar show it
188 +
        #[arg(long)]
189 +
        name: String,
190 +
        /// An email or URL, if you want one shown
191 +
        #[arg(long, default_value = "")]
192 +
        contact: String,
193 +
        /// Where to keep it (default: $SPECTRA_HOME, else ~/.config/spectra)
194 +
        #[arg(long)]
195 +
        home: Option<PathBuf>,
196 +
    },
180 197
    /// The owner and the instance as the ledger knows them
181 198
    Show {
182 199
        #[arg(long)]
183 200
        json: bool,
184 201
    },
185 -
    /// Record the owner's current identity after `oxy identity delegate add|remove`
202 +
    /// Record the owner's current identity if the ledger has missed a successor
186 203
    Refresh {
187 204
        #[arg(long)]
188 205
        owner_home: Option<PathBuf>,
…
2 unchanged lines
191 208
192 209
#[derive(Subcommand)]
193 210
pub enum ProjectCmd {
194 -
    /// Register a checkout: its repository id, remote and pinned subrepos
211 +
    /// Add a project: any kind of work, with a folder if it has one
195 212
    Add {
196 -
        path: PathBuf,
197 -
        /// Its name (default: the directory's)
213 +
        /// A folder (its name becomes the project's), or just a name
214 +
        target: String,
215 +
        /// Its name, when TARGET is a folder
198 216
        #[arg(long)]
199 217
        name: Option<String>,
200 -
        /// Its remote URL (default: the checkout's origin)
218 +
        /// The work's folder, when TARGET is a name; Spectra never reads it
219 +
        #[arg(long)]
220 +
        path: Option<PathBuf>,
221 +
        /// What the work is, for agents and Radar
222 +
        #[arg(long, short = 'd', default_value = "")]
223 +
        description: String,
224 +
        /// A link to it, if any
225 +
        #[arg(long)]
226 +
        url: Option<String>,
227 +
        #[arg(long)]
228 +
        owner_home: Option<PathBuf>,
229 +
    },
230 +
    /// Say that one project depends on another (`delegate to: downstream` follows links)
231 +
    Link {
232 +
        project: String,
233 +
        #[arg(long)]
234 +
        depends_on: String,
235 +
        /// Remove the link instead
201 236
        #[arg(long)]
202 -
        remote: Option<String>,
237 +
        remove: bool,
203 238
        #[arg(long)]
204 239
        owner_home: Option<PathBuf>,
205 240
    },
206 -
    /// Every project with what it pins, what pins it, and its agents
241 +
    /// Every project with its links and agents
207 242
    List {
208 243
        #[arg(long)]
209 244
        json: bool,
…
2 unchanged lines
212 247
213 248
#[derive(Subcommand)]
214 249
pub enum AgentCmd {
215 -
    /// Make an agent's key in its own home and record it once you delegate to it
250 +
    /// Make an agent's key in its own home, delegate to it, and record it
216 251
    Add {
217 252
        /// Lower-case letters, digits and dashes
218 253
        name: String,
…
3 unchanged lines
222 257
        /// The project it works in
223 258
        #[arg(long)]
224 259
        project: String,
225 -
        /// The agent's own OXY_HOME, where its key is made and kept
260 +
        /// The agent's own identity home, where its key is made and kept
226 261
        #[arg(long)]
227 262
        home: PathBuf,
228 263
        /// Bind the key to this Unix user: a request from another is refused
…
2 unchanged lines
231 266
        /// Bind the key to the Unix user of its first signed connection
232 267
        #[arg(long, conflicts_with = "uid")]
233 268
        learn_uid: bool,
234 -
        /// How long to wait for your delegation, in seconds
235 -
        #[arg(long, default_value_t = 600)]
236 -
        wait: u64,
237 -
        /// The owner's OXY_HOME
269 +
        /// How long the agent may act for you: 30m, 8h, 7d (default: until disabled)
270 +
        #[arg(long)]
271 +
        expires: Option<String>,
272 +
        /// Delegate without asking (for scripts)
273 +
        #[arg(long, short = 'y')]
274 +
        yes: bool,
275 +
        /// The owner's identity home
276 +
        #[arg(long)]
277 +
        owner_home: Option<PathBuf>,
278 +
    },
279 +
    /// Give an agent more time: delegate to its key again with a new expiry
280 +
    Renew {
281 +
        name: String,
282 +
        /// 30m, 8h, 7d, or `never`
283 +
        #[arg(long)]
284 +
        expires: String,
238 285
        #[arg(long)]
239 286
        owner_home: Option<PathBuf>,
240 287
    },
…
128 unchanged lines
369 416
    /// A Spectra on another machine; `recorded` is the owner identity it knows.
370 417
    Remote {
371 418
        client: Box<spectra_mcp::client::Client>,
372 -
        recorded: oxy_core::identity::Identity,
419 +
        recorded: spectra_core::identity::Identity,
373 420
    },
374 421
}
375 422
…
107 unchanged lines
483 530
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
484 531
    match cli.command {
485 532
        Command::Version { json } => version(json),
486 -
        Command::Connect { url, owner_home } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home)),
533 +
        Command::Connect { url, owner_home, expect, yes } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home, expect, yes)),
487 534
        Command::Init { owner_home, name, owner: Some(key) } => {
488 535
            let _ = owner_home;
489 536
            runtime()?.block_on(identity::init_for(&ctx, &key, name))
…
1 unchanged lines
491 538
        Command::Init { owner_home, name, owner: None } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
492 539
        Command::Identity { command } => runtime()?.block_on(async {
493 540
            match command {
541 +
                IdentityCmd::Create { name, contact, home } => identity::create(&name, &contact, home),
494 542
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
495 543
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
496 544
            }
…
20 unchanged lines
517 565
        Command::Api { method, path, body, owner_home } => runtime()?.block_on(human::raw(&ctx, &method, &path, body, owner_home)),
518 566
        Command::Project { command } => runtime()?.block_on(async {
519 567
            match command {
520 -
                ProjectCmd::Add { path, name, remote, owner_home } => project::add(&ctx, &path, name, remote, owner_home).await,
568 +
                ProjectCmd::Add { target, name, path, description, url, owner_home } => {
569 +
                    project::add(&ctx, project::Add { target, name, path, description, url }, owner_home).await
570 +
                }
571 +
                ProjectCmd::Link { project, depends_on, remove, owner_home } => {
572 +
                    project::link(&ctx, &project, &depends_on, remove, owner_home).await
573 +
                }
521 574
                ProjectCmd::List { json } => project::list(&ctx, json).await,
522 575
            }
523 576
        }),
524 577
        Command::Agent { command } => runtime()?.block_on(async {
525 578
            match command {
526 -
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, wait, owner_home } => {
527 -
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, wait_secs: wait, owner_home }).await
579 +
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home } => {
580 +
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home }).await
528 581
                }
582 +
                AgentCmd::Renew { name, expires, owner_home } => agent::renew(&ctx, &name, &expires, owner_home).await,
529 583
                AgentCmd::List { json } => agent::list(&ctx, json).await,
530 584
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
531 585
            }
…
27 unchanged lines
modified src/cli/project.rs +76 −37
1 -
//! `spectra project`: the checkouts agents work in (spec §23.1).
1 +
//! `spectra project`: the work agents share (spec §23.1), any kind of it.
2 2
3 3
use super::identity::owner_home;
4 4
use super::{call, ui, Ctx};
…
4 unchanged lines
9 9
use spectra_core::projects;
10 10
use std::path::{Path, PathBuf};
11 11
12 -
pub async fn add(ctx: &Ctx, path: &Path, name: Option<String>, remote: Option<String>, owner_home_flag: Option<PathBuf>) -> Result<i32> {
13 -
    let path = path.canonicalize().with_context(|| format!("{} does not exist", path.display()))?;
14 -
    let name = match name {
15 -
        Some(n) => n,
16 -
        None => projects::default_name(&path)?,
12 +
pub struct Add {
13 +
    pub target: String,
14 +
    pub name: Option<String>,
15 +
    pub path: Option<PathBuf>,
16 +
    pub description: String,
17 +
    pub url: Option<String>,
18 +
}
19 +
20 +
pub async fn add(ctx: &Ctx, a: Add, owner_home_flag: Option<PathBuf>) -> Result<i32> {
21 +
    // A folder that exists is the work's folder; anything else is a name.
22 +
    let as_dir = Path::new(&shellexpand(&a.target)).to_path_buf();
23 +
    let (name, path) = match (&a.path, as_dir.is_dir()) {
24 +
        (None, true) => {
25 +
            let path = as_dir.canonicalize()?;
26 +
            (a.name.clone().map(Ok).unwrap_or_else(|| projects::default_name(&path))?, Some(path))
27 +
        }
28 +
        (Some(p), _) => {
29 +
            let path = p.canonicalize().with_context(|| format!("{} does not exist", p.display()))?;
30 +
            (a.name.clone().unwrap_or_else(|| a.target.clone()), Some(path))
31 +
        }
32 +
        (None, false) => (a.name.clone().unwrap_or_else(|| a.target.clone()), None),
17 33
    };
18 34
    if !super::agent::valid_name(&name) {
19 -
        bail!("a project's name is lower-case letters, digits and dashes (--name)");
35 +
        bail!("a project's name is lower-case letters, digits and dashes, starting with a letter or digit (--name)");
20 36
    }
21 -
    let checkout = projects::inspect(&path)?;
22 37
    let home = owner_home(ctx, owner_home_flag);
23 38
    let w = ctx.owner_writer(&home).await?;
24 -
    let path_s = path.display().to_string();
39 +
    let path_s = path.as_ref().map(|p| p.display().to_string());
25 40
    let existing = all(&w).await?;
26 -
    if let Some(e) = existing.iter().find(|p| p["name"] == name.as_str() || p["path"] == path_s.as_str()) {
27 -
        bail!("{} is already a project ({})", path.display(), e["name"].as_str().unwrap_or(""));
41 +
    if let Some(e) = existing.iter().find(|p| p["name"] == name.as_str() || (path_s.is_some() && p["path"].as_str() == path_s.as_deref())) {
42 +
        bail!("{} is already a project", e["name"].as_str().unwrap_or(""));
28 43
    }
29 44
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
30 45
    let owner = KeyPair::load_home(&home)?;
31 -
    let remote = remote.or(checkout.remote.clone());
32 -
    let payload = serde_json::json!({
33 -
        "name": name, "path": path.display().to_string(), "repo_id": checkout.repo_id, "remote": remote, "modules": checkout.modules,
34 -
    });
46 +
    let payload = serde_json::json!({ "name": name, "path": path_s, "description": a.description, "url": a.url });
35 47
    events.push(signed_event(&owner, "human", "project.added", payload).project(name.clone()).subject(name.clone()));
36 48
    events.push(spectra_core::conversation::project_channel_event(&name));
37 49
    w.append(events).await?;
38 -
    println!("{} {}  {}", ui::done("Added"), ui::subject(&name), ui::path(path.display()));
39 -
    if let Some(r) = &remote {
40 -
        println!("{}", ui::kv("remote", r));
50 +
    println!("{} {}{}", ui::done("Added"), ui::subject(&name), path_s.map(|p| format!("  {}", ui::path(p))).unwrap_or_default());
51 +
    if !a.description.is_empty() {
52 +
        println!("{}", ui::kv("about", &a.description));
41 53
    }
42 -
    if let Some(id) = &checkout.repo_id {
43 -
        println!("{}", ui::kv("repo", ui::hash(id)));
54 +
    if let Some(u) = &a.url {
55 +
        println!("{}", ui::kv("link", u));
44 56
    }
45 -
    let now = all(&w).await?;
46 -
    let me = now.iter().find(|p| p["name"] == name.as_str()).cloned().unwrap_or_default();
47 -
    let names =
48 -
        |k: &str| me[k].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>()).unwrap_or_default();
49 -
    let (deps, users) = (names("depends_on"), names("depended_on_by"));
50 -
    if !deps.is_empty() {
51 -
        println!("{}", ui::kv("pins", deps.join(", ")));
57 +
    println!(
58 +
        "  {} {}",
59 +
        ui::note(),
60 +
        ui::dim(format!("agents talk about it in #{name}; link it with spectra project link {name} --depends-on OTHER"))
61 +
    );
62 +
    Ok(0)
63 +
}
64 +
65 +
/// `~/x` as the shell would have it.
66 +
fn shellexpand(s: &str) -> String {
67 +
    match (s.strip_prefix("~/"), std::env::var_os("HOME")) {
68 +
        (Some(rest), Some(h)) => Path::new(&h).join(rest).display().to_string(),
69 +
        _ => s.to_string(),
52 70
    }
53 -
    if !users.is_empty() {
54 -
        println!("{}", ui::kv("pinned by", users.join(", ")));
71 +
}
72 +
73 +
/// `spectra project link P --depends-on Q [--remove]`.
74 +
pub async fn link(ctx: &Ctx, project: &str, on: &str, remove: bool, owner_home_flag: Option<PathBuf>) -> Result<i32> {
75 +
    if project == on {
76 +
        bail!("a project cannot depend on itself");
55 77
    }
56 -
    if checkout.repo_id.is_none() {
57 -
        println!("  {} {}", ui::note(), ui::dim("not an Oxygen checkout: relations come from .oxymodules, so it has none"));
78 +
    let home = owner_home(ctx, owner_home_flag);
79 +
    let w = ctx.owner_writer(&home).await?;
80 +
    let existing = all(&w).await?;
81 +
    for n in [project, on] {
82 +
        if !existing.iter().any(|p| p["name"] == n) {
83 +
            bail!("no project named {n} (spectra project list)");
84 +
        }
58 85
    }
86 +
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
87 +
    let owner = KeyPair::load_home(&home)?;
88 +
    let kind = if remove { "project.unlinked" } else { "project.linked" };
89 +
    events.push(
90 +
        signed_event(&owner, "human", kind, serde_json::json!({ "project": project, "depends_on": on })).project(project.to_string()),
91 +
    );
92 +
    w.append(events).await?;
93 +
    let verb = if remove { "Unlinked" } else { "Linked" };
94 +
    println!("{} {} {} {}", ui::done(verb), ui::subject(project), ui::dim("depends on"), ui::subject(on));
59 95
    Ok(0)
60 96
}
61 97
…
20 unchanged lines
82 118
}
83 119
84 120
async fn local(ledger: &Ledger) -> Result<Vec<serde_json::Value>> {
85 -
    let rows: Vec<(String, String, Option<String>)> =
86 -
        sqlx::query_as("SELECT name, path, remote FROM projects ORDER BY name").fetch_all(ledger.pool()).await?;
121 +
    let rows: Vec<(String, Option<String>, String, Option<String>)> =
122 +
        sqlx::query_as("SELECT name, path, description, url FROM projects ORDER BY name").fetch_all(ledger.pool()).await?;
87 123
    let mut out = Vec::new();
88 -
    for (name, path, remote) in rows {
124 +
    for (name, path, description, url) in rows {
89 125
        let (deps, users) = relations(ledger, &name).await?;
90 126
        let agents: Vec<String> = sqlx::query_scalar("SELECT name FROM agents WHERE project = ? AND disabled_ms IS NULL ORDER BY name")
91 127
            .bind(&name)
92 128
            .fetch_all(ledger.pool())
93 129
            .await?;
94 -
        out.push(serde_json::json!({ "name": name, "path": path, "remote": remote, "depends_on": deps, "depended_on_by": users, "agents": agents }));
130 +
        out.push(serde_json::json!({ "name": name, "path": path, "description": description, "url": url, "depends_on": deps, "depended_on_by": users, "agents": agents }));
95 131
    }
96 132
    Ok(out)
97 133
}
…
8 unchanged lines
106 142
        return Ok(0);
107 143
    }
108 144
    if out.is_empty() {
109 -
        println!("{} {}", ui::note(), ui::dim("no projects yet: spectra project add PATH"));
145 +
        println!("{} {}", ui::note(), ui::dim("no projects yet: spectra project add NAME-OR-FOLDER"));
110 146
    }
111 147
    for p in out {
112 148
        let list =
…
4 unchanged lines
117 153
            ui::bookmark(p["name"].as_str().unwrap_or("")),
118 154
            ui::path(p["path"].as_str().unwrap_or(""))
119 155
        );
120 -
        for (k, label) in [("depends_on", "pins"), ("depended_on_by", "pinned by"), ("agents", "agents")] {
156 +
        if let Some(d) = p["description"].as_str().filter(|d| !d.is_empty()) {
157 +
            println!("{}", ui::kv("about", d));
158 +
        }
159 +
        for (k, label) in [("depends_on", "depends on"), ("depended_on_by", "needed by"), ("agents", "agents")] {
121 160
            let v = list(k);
122 161
            if !v.is_empty() {
123 162
                println!("{}", ui::kv(label, v));
…
5 unchanged lines
modified src/cli/ui.rs +1 −1
1 -
#![allow(dead_code)] // Shared with oxy: not every helper is used yet.
1 +
#![allow(dead_code)] // A toolkit: not every helper is used yet.
2 2
//! Terminal presentation: colour, emphasis, and a live spinner while a
3 3
//! command works. Everything degrades to plain text when stdout is not a
4 4
//! terminal, when `NO_COLOR` is set, or with `--plain`.
…
241 unchanged lines
modified tests/cli_admin.rs +2 −2
1 1
//! `spectra admin verify|check|rebuild` against a real ledger, run as the
2 2
//! built binary in a hermetic environment: a temporary data directory and
3 -
//! `OXY_HOME`, never the user's own.
3 +
//! `SPECTRA_HOME`, never the user's own.
4 4
5 5
use spectra_core::event::NewEvent;
6 6
use spectra_core::keys::KeyPair;
…
5 unchanged lines
12 12
fn hermetic(dir: &Path) -> Command {
13 13
    let mut c = Command::new(env!("CARGO_BIN_EXE_spectra"));
14 14
    c.env("SPECTRA_DATA", dir.join("data"))
15 -
        .env("OXY_HOME", dir.join("oxy"))
15 +
        .env("SPECTRA_HOME", dir.join("owner-home"))
16 16
        .env("HOME", dir)
17 17
        .env("XDG_DATA_HOME", dir.join("xdg"))
18 18
        .env("NO_COLOR", "1");
…
55 unchanged lines
modified tests/cli_identity.rs +29 −33
1 -
//! The owner and agents from the command line (spec §8.1, §8.3), as the
2 -
//! built binary in a hermetic environment: `init` proves the owner's
3 -
//! identity, `agent add` refuses until the owner delegates, ownership is
4 -
//! shown, and the ledger verifies throughout.
1 +
//! The owner and agents from the command line (spec §8.0, §8.1, §8.3), as
2 +
//! the built binary in a hermetic environment: `identity create` makes the
3 +
//! owner, `init` proves it, `agent add` delegates only when the owner
4 +
//! confirms, ownership is shown, and the ledger verifies throughout.
5 5
6 -
use oxy_core::durable::{write_atomic, write_atomic_private};
7 -
use oxy_core::identity::{Delegate, Identity, SecretKey};
8 6
use spectra_core::keys::KeyPair;
9 -
use spectra_core::principals::DELEGATION_SCOPE;
10 7
use std::path::Path;
11 8
use std::process::{Command, Output};
12 9
13 10
fn hermetic(dir: &Path) -> Command {
14 11
    let mut c = Command::new(env!("CARGO_BIN_EXE_spectra"));
15 12
    c.env("SPECTRA_DATA", dir.join("data"))
16 -
        .env("OXY_HOME", dir.join("owner"))
13 +
        .env("SPECTRA_HOME", dir.join("owner"))
17 14
        .env("HOME", dir)
18 15
        .env("XDG_DATA_HOME", dir.join("xdg"))
19 16
        .env("NO_COLOR", "1");
…
8 unchanged lines
28 25
    format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
29 26
}
30 27
31 -
/// `oxy identity delegate add IDENTITY --scope changes` in the owner's home.
32 -
fn delegate(owner_home: &Path, agent: &Identity) {
33 -
    let cur = KeyPair::load_home(owner_home).unwrap();
34 -
    let keys = owner_home.join("keys");
35 -
    let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap()).unwrap();
36 -
    let mut d = cur.identity.delegates.clone();
37 -
    d.push(Delegate { key: agent.hash(), scope: DELEGATION_SCOPE, expires: None });
38 -
    let next = cur.identity.successor(d, None, spectra_core::ids::now_ms());
39 -
    let k = next.hash();
40 -
    write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap();
41 -
    write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap();
42 -
    oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap();
43 -
}
44 -
45 28
fn verify_clean(dir: &Path) {
46 29
    let o = run(dir, &["admin", "verify", "--json"]);
47 30
    let v: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|_| panic!("{}", text(&o)));
…
4 unchanged lines
52 35
fn init_then_an_owned_agent_end_to_end() {
53 36
    let dir = tempfile::tempdir().unwrap();
54 37
    let d = dir.path();
55 -
    KeyPair::create_home(&d.join("owner"), "Andy McCall", "[email protected]").unwrap();
38 +
    let o = run(d, &["identity", "create", "--name", "Andy McCall", "--contact", "[email protected]"]);
39 +
    assert!(o.status.success(), "{}", text(&o));
40 +
    assert!(!run(d, &["identity", "create", "--name", "again"]).status.success(), "one identity per home");
56 41
57 42
    let o = run(d, &["init", "--name", "laptop"]);
58 43
    assert!(o.status.success(), "{}", text(&o));
59 -
    assert!(text(&o).contains("oxy identity delegate add"), "{}", text(&o));
60 44
    assert!(!run(d, &["init"]).status.success(), "a second init is refused");
45 +
    let owner = KeyPair::load_home(&d.join("owner")).unwrap();
46 +
    let instance = KeyPair::instance(&d.join("data"), "laptop").unwrap();
47 +
    assert!(owner.identity.delegates_to(&instance.key, 1), "init delegates to its own instance key");
61 48
    verify_clean(d);
62 49
63 -
    let checkout = d.join("oxygen");
64 -
    std::fs::create_dir_all(&checkout).unwrap();
65 -
    let o = run(d, &["project", "add", checkout.to_str().unwrap()]);
50 +
    // A project is any work; with no folder at all, too.
51 +
    let o = run(d, &["project", "add", "oxygen", "-d", "the version control system"]);
66 52
    assert!(o.status.success(), "{}", text(&o));
67 53
    let home = d.join("agents/claude-oxygen");
68 54
    let home_s = home.to_str().unwrap();
69 -
    let args = ["agent", "add", "claude-oxygen", "--kind", "claude-code", "--project", "oxygen", "--home", home_s, "--wait", "0"];
55 +
    let args = ["agent", "add", "claude-oxygen", "--kind", "claude-code", "--project", "oxygen", "--home", home_s, "--expires", "8h"];
70 56
    let o = run(d, &args);
71 -
    assert!(!o.status.success(), "refused until delegated");
72 -
    assert!(text(&o).contains("oxy identity delegate add") && text(&o).contains("not delegated yet"), "{}", text(&o));
57 +
    assert!(!o.status.success(), "not a terminal and no --yes: nothing delegated");
58 +
    assert!(text(&o).contains("--yes"), "{}", text(&o));
73 59
    let agent = KeyPair::load_home(&home).unwrap();
60 +
    assert!(!KeyPair::load_home(&d.join("owner")).unwrap().identity.delegates_to(&agent.key, 1));
74 61
75 -
    delegate(&d.join("owner"), &agent.identity);
76 -
    let o = run(d, &args);
62 +
    let mut yes = args.to_vec();
63 +
    yes.push("--yes");
64 +
    let o = run(d, &yes);
77 65
    assert!(o.status.success(), "{}", text(&o));
78 66
    assert!(text(&o).contains("Added claude-oxygen") && text(&o).contains("Andy McCall's agent"), "{}", text(&o));
79 67
    assert_eq!(KeyPair::load_home(&home).unwrap().key, agent.key, "the key made first is the key recorded");
68 +
    let d8 = KeyPair::load_home(&d.join("owner")).unwrap().identity.delegate(&agent.key).cloned().unwrap();
69 +
    assert!(d8.expires.is_some(), "the expiry was delegated");
80 70
81 71
    let list: serde_json::Value = serde_json::from_slice(&run(d, &["agent", "list", "--json"]).stdout).unwrap();
82 72
    assert_eq!(list[0]["display"], "claude-oxygen · Andy McCall's agent");
83 73
    assert_eq!(list[0]["state"], "ok");
74 +
    verify_clean(d);
75 +
76 +
    let o = run(d, &["agent", "renew", "claude-oxygen", "--expires", "never"]);
77 +
    assert!(o.status.success(), "{}", text(&o));
78 +
    assert_eq!(KeyPair::load_home(&d.join("owner")).unwrap().identity.delegate(&agent.key).unwrap().expires, None);
84 79
    verify_clean(d);
85 80
86 81
    assert!(run(d, &["agent", "disable", "claude-oxygen"]).status.success());
82 +
    assert!(KeyPair::load_home(&d.join("owner")).unwrap().identity.delegate(&agent.key).is_none(), "the delegation is removed");
87 83
    let list: serde_json::Value = serde_json::from_slice(&run(d, &["agent", "list", "--json"]).stdout).unwrap();
88 84
    assert!(list[0]["state"].as_str().unwrap().contains("disabled"), "{list}");
89 85
    verify_clean(d);
90 86
    assert!(run(d, &["admin", "check"]).status.success());
91 87
92 -
    // Piped, as here, output is plain like oxy's: no colour codes, no glyphs.
88 +
    // Piped, as here, output is plain: no colour codes, no glyphs.
93 89
    let o = run(d, &["agent", "list"]);
94 90
    let t = text(&o);
95 91
    assert!(!t.contains('\x1b') && !t.contains('✔'), "{t}");
…
23 unchanged lines
modified tests/cli_project.rs +35 −34
1 -
//! Projects from the command line (spec §23.1): relations come from
2 -
//! `.oxymodules`, matched by remote, whichever is registered first; an agent
3 -
//! needs a registered project.
1 +
//! Projects from the command line (spec §23.1): any kind of work, with or
2 +
//! without a folder that Spectra never reads; links drawn by the owner; an
3 +
//! agent needs a registered project.
4 4
5 5
use spectra_core::keys::KeyPair;
6 6
use std::path::Path;
…
2 unchanged lines
9 9
fn hermetic(dir: &Path) -> Command {
10 10
    let mut c = Command::new(env!("CARGO_BIN_EXE_spectra"));
11 11
    c.env("SPECTRA_DATA", dir.join("data"))
12 -
        .env("OXY_HOME", dir.join("owner"))
12 +
        .env("SPECTRA_HOME", dir.join("owner"))
13 13
        .env("HOME", dir)
14 14
        .env("XDG_DATA_HOME", dir.join("xdg"))
15 15
        .env("NO_COLOR", "1");
…
8 unchanged lines
24 24
    format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
25 25
}
26 26
27 -
/// A checkout as `oxy` leaves one: an id, a remote, and the subrepos it pins.
28 -
fn checkout(at: &Path, remote: &str, pins: &[&str]) {
29 -
    std::fs::create_dir_all(at.join(".oxy")).unwrap();
30 -
    std::fs::write(at.join(".oxy/id"), "0123456789abcdef0123456789abcdef").unwrap();
31 -
    std::fs::write(at.join(".oxy/config"), format!("remote.origin.url = \"{remote}\"\n")).unwrap();
32 -
    let modules: String = pins.iter().enumerate().map(|(i, u)| format!("[deps/{i}]\nurl = {u}\nbookmark = main\n")).collect();
33 -
    std::fs::write(at.join(".oxymodules"), modules).unwrap();
34 -
}
35 -
36 27
fn setup() -> tempfile::TempDir {
37 28
    let dir = tempfile::tempdir().unwrap();
38 29
    KeyPair::create_home(&dir.path().join("owner"), "Andy McCall", "").unwrap();
…
6 unchanged lines
45 36
}
46 37
47 38
#[test]
48 -
fn relations_are_found_whichever_project_comes_first() {
49 -
    for hub_first in [false, true] {
50 -
        let dir = setup();
51 -
        let d = dir.path();
52 -
        checkout(&d.join("oxygen"), "https://hub.example/andy/oxygen", &[]);
53 -
        checkout(&d.join("oxygenhub"), "https://hub.example/andy/oxygenhub", &["https://hub.example/andy/oxygen"]);
54 -
        let order = if hub_first { ["oxygenhub", "oxygen"] } else { ["oxygen", "oxygenhub"] };
55 -
        for p in order {
56 -
            let o = run(d, &["project", "add", d.join(p).to_str().unwrap()]);
57 -
            assert!(o.status.success(), "{}", text(&o));
58 -
        }
59 -
        let list = projects(d);
60 -
        let hub = list.as_array().unwrap().iter().find(|p| p["name"] == "oxygenhub").unwrap();
61 -
        let oxy = list.as_array().unwrap().iter().find(|p| p["name"] == "oxygen").unwrap();
62 -
        assert_eq!(hub["depends_on"], serde_json::json!(["oxygen"]), "hub first: {hub_first}");
63 -
        assert_eq!(oxy["depended_on_by"], serde_json::json!(["oxygenhub"]));
64 -
        assert!(run(d, &["admin", "check"]).status.success());
65 -
        assert!(run(d, &["admin", "verify"]).status.success());
66 -
    }
39 +
fn any_work_is_a_project_and_the_owner_links_them() {
40 +
    let dir = setup();
41 +
    let d = dir.path();
42 +
    // A book being restructured: a folder of chapters, and an index with none.
43 +
    std::fs::create_dir_all(d.join("Manuscript")).unwrap();
44 +
    std::fs::write(d.join("Manuscript/.oxymodules"), "this is not read").unwrap();
45 +
    let o = run(d, &["project", "add", d.join("Manuscript").to_str().unwrap(), "-d", "the book, chapter by chapter"]);
46 +
    assert!(o.status.success(), "{}", text(&o));
47 +
    let o = run(d, &["project", "add", "index", "-d", "the index and cross-references", "--url", "https://example.org/index"]);
48 +
    assert!(o.status.success(), "{}", text(&o));
49 +
    let o = run(d, &["project", "link", "index", "--depends-on", "manuscript"]);
50 +
    assert!(o.status.success(), "{}", text(&o));
51 +
52 +
    let list = projects(d);
53 +
    let book = list.as_array().unwrap().iter().find(|p| p["name"] == "manuscript").unwrap();
54 +
    let index = list.as_array().unwrap().iter().find(|p| p["name"] == "index").unwrap();
55 +
    assert_eq!(book["description"], "the book, chapter by chapter");
56 +
    assert!(book["path"].as_str().unwrap().ends_with("Manuscript"));
57 +
    assert!(index["path"].is_null(), "no folder");
58 +
    assert_eq!(index["url"], "https://example.org/index");
59 +
    assert_eq!(index["depends_on"], serde_json::json!(["manuscript"]));
60 +
    assert_eq!(book["depended_on_by"], serde_json::json!(["index"]));
61 +
62 +
    assert!(run(d, &["project", "link", "index", "--depends-on", "manuscript", "--remove"]).status.success());
63 +
    assert_eq!(projects(d)[0]["depends_on"], serde_json::json!([]));
64 +
    assert!(!run(d, &["project", "link", "index", "--depends-on", "nowhere"]).status.success());
65 +
    assert!(run(d, &["admin", "check"]).status.success());
66 +
    assert!(run(d, &["admin", "verify"]).status.success());
67 67
}
68 68
69 69
#[test]
70 70
fn a_project_is_registered_once_and_an_agent_needs_one() {
71 71
    let dir = setup();
72 72
    let d = dir.path();
73 -
    checkout(&d.join("oxygen"), "https://hub.example/andy/oxygen", &[]);
73 +
    std::fs::create_dir_all(d.join("oxygen")).unwrap();
74 74
    assert!(run(d, &["project", "add", d.join("oxygen").to_str().unwrap()]).status.success());
75 75
    let again = run(d, &["project", "add", d.join("oxygen").to_str().unwrap(), "--name", "other"]);
76 76
    assert!(!again.status.success() && text(&again).contains("already a project"), "{}", text(&again));
77 +
    assert!(!run(d, &["project", "add", "Not A Name"]).status.success());
77 78
    let o = run(
78 79
        d,
79 -
        &["agent", "add", "kimi", "--kind", "kimi-code", "--project", "nowhere", "--home", d.join("kimi").to_str().unwrap(), "--wait", "0"],
80 +
        &["agent", "add", "kimi", "--kind", "kimi-code", "--project", "nowhere", "--home", d.join("kimi").to_str().unwrap(), "--yes"],
80 81
    );
81 82
    assert!(!o.status.success() && text(&o).contains("no project named nowhere"), "{}", text(&o));
82 83
    assert!(!d.join("kimi").exists(), "no key is made for an agent that cannot be added");
…
1 unchanged lines
modified tests/client.rs +2 −4
…
3 unchanged lines
4 4
5 5
mod common;
6 6
7 -
use common::{add_agent, delegate, hermetic, run, text, Serving};
7 +
use common::{add_agent, hermetic, run, text, Serving};
8 8
use serde_json::{json, Value};
9 9
use spectra_core::keys::KeyPair;
10 10
use std::path::Path;
…
15 unchanged lines
26 26
    assert!(run(s, &["init", "--owner", &owner.key.to_hex(), "--name", "earth"]).status.success());
27 27
    let (srv, port) = Serving::start_tls(s);
28 28
    let url = format!("https://127.0.0.1:{port}");
29 -
    assert_eq!(run(c, &["connect", &url]).status.code(), Some(1));
30 -
    delegate(&c.join("owner"), &KeyPair::instance(&s.join("data"), "earth").unwrap());
31 -
    let o = run(c, &["connect", &url]);
29 +
    let o = run(c, &["connect", &url, "--yes"]);
32 30
    assert!(o.status.success(), "{}", text(&o));
33 31
    (srv, port)
34 32
}
…
159 unchanged lines
modified tests/common/mod.rs +8 −23
1 1
//! Helpers for end-to-end tests: a hermetic environment, the owner's
2 -
//! delegation as `oxy identity delegate add` makes it, agents added through
3 -
//! the CLI, and the daemon started and stopped as a real process.
2 +
//! delegation as Spectra makes it, agents added through the CLI, and the
3 +
//! daemon started and stopped as a real process.
4 4
#![allow(dead_code)]
5 5
6 -
use oxy_core::durable::{write_atomic, write_atomic_private};
7 -
use oxy_core::identity::{Delegate, SecretKey};
8 6
use spectra_core::keys::KeyPair;
9 -
use spectra_core::principals::DELEGATION_SCOPE;
10 7
use std::path::{Path, PathBuf};
11 8
use std::process::{Child, Command, Output};
12 9
13 10
pub fn hermetic(dir: &Path) -> Command {
14 11
    let mut c = Command::new(env!("CARGO_BIN_EXE_spectra"));
15 12
    c.env("SPECTRA_DATA", dir.join("data"))
16 -
        .env("OXY_HOME", dir.join("owner"))
13 +
        .env("SPECTRA_HOME", dir.join("owner"))
17 14
        .env("HOME", dir)
18 15
        .env("XDG_DATA_HOME", dir.join("xdg"))
19 16
        .env("NO_COLOR", "1");
…
8 unchanged lines
28 25
    format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
29 26
}
30 27
31 -
/// `oxy identity delegate add` in the owner's home.
28 +
/// The owner delegates to `key`, as `spectra agent add` and `connect` do.
32 29
pub fn delegate(owner_home: &Path, agent: &KeyPair) {
33 -
    let cur = KeyPair::load_home(owner_home).unwrap();
34 -
    let keys = owner_home.join("keys");
35 -
    let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap()).unwrap();
36 -
    let mut d = cur.identity.delegates.clone();
37 -
    d.push(Delegate { key: agent.key, scope: DELEGATION_SCOPE, expires: None });
38 -
    let next = cur.identity.successor(d, None, spectra_core::ids::now_ms());
39 -
    let k = next.hash();
40 -
    write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap();
41 -
    write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap();
42 -
    oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap();
30 +
    spectra_core::keys::delegate(owner_home, agent.key, None).unwrap();
43 31
}
44 32
45 -
/// Make an agent's home, delegate to it, and add it.
33 +
/// Add an agent through the CLI, confirming the delegation.
46 34
pub fn add_agent(d: &Path, name: &str, extra: &[&str]) -> KeyPair {
47 35
    let home = d.join("agents").join(name);
48 -
    let pair = KeyPair::create_home(&home, name, "").unwrap();
49 -
    delegate(&d.join("owner"), &pair);
50 -
    let mut args =
51 -
        vec!["agent", "add", name, "--kind", "claude-code", "--project", "oxygen", "--home", home.to_str().unwrap(), "--wait", "0"];
36 +
    let mut args = vec!["agent", "add", name, "--kind", "claude-code", "--project", "oxygen", "--home", home.to_str().unwrap(), "--yes"];
52 37
    args.extend_from_slice(extra);
53 38
    let o = run(d, &args);
54 39
    assert!(o.status.success(), "{}", text(&o));
55 -
    pair
40 +
    KeyPair::load_home(&home).unwrap()
56 41
}
57 42
58 43
pub struct Serving {
…
65 unchanged lines
modified tests/connect.rs +26 −22
1 -
//! `spectra connect` (spec §8.6, spectra#21): a workstation trusts a server
2 -
//! because the owner delegates to its instance key and that key vouches for
3 -
//! its certificate; it claims a server set up with `init --owner`, even
4 -
//! though delegating changed the owner's KeyId; a new certificate is
5 -
//! refused until connect runs again; someone else's server is refused.
1 +
//! `spectra connect` (spec §8.6): a workstation trusts a server once the
2 +
//! owner confirms its instance key and delegates to it, and that key
3 +
//! vouches for its certificate; it claims a server set up with `init
4 +
//! --owner`, even though delegating changed the owner's KeyId; a new
5 +
//! certificate is refused until connect runs again; someone else's server
6 +
//! is refused.
6 7
7 8
mod common;
8 9
9 -
use common::{delegate, hermetic, run, text, Serving};
10 +
use common::{hermetic, run, text, Serving};
10 11
use spectra_core::keys::KeyPair;
11 12
use spectra_mcp::client::{Client, Endpoint};
12 13
use spectra_mcp::tls::Pinned;
…
21 unchanged lines
34 35
    let (srv, port) = server_for(s, &c.join("owner"));
35 36
    let url = format!("https://127.0.0.1:{port}");
36 37
37 -
    // First contact: not yet delegated, so not trusted; told what to run.
38 +
    // First contact, not at a terminal and not confirmed: nothing trusted.
38 39
    let o = run(c, &["connect", &url]);
39 -
    assert_eq!(o.status.code(), Some(1), "{}", text(&o));
40 -
    assert!(text(&o).contains("oxy identity delegate add"), "{}", text(&o));
41 -
    let saved = c.join(".config/spectra/earth-instance.identity");
42 -
    assert!(saved.exists(), "the instance's public identity is saved to delegate to");
43 -
    assert!(!c.join(".config/spectra/client.toml").exists(), "nothing trusted yet");
40 +
    assert!(!o.status.success(), "{}", text(&o));
41 +
    assert!(text(&o).contains("--yes"), "{}", text(&o));
42 +
    assert!(!c.join("owner/client.toml").exists(), "nothing trusted yet");
43 +
    // A key that does not match what `init` printed is refused.
44 +
    let o = run(c, &["connect", &url, "--expect", "00ff"]);
45 +
    assert!(!o.status.success() && text(&o).contains("does not start with"), "{}", text(&o));
44 46
45 -
    // The owner delegates (which gives their identity a new KeyId) ...
47 +
    // The owner checks the instance key against what `init` printed (here,
48 +
    // by --expect) and connects: their identity now delegates to it, under
49 +
    // a new KeyId ...
46 50
    let instance = KeyPair::instance(&s.join("data"), "earth").unwrap();
47 -
    assert_eq!(oxy_core::identity::Identity::decode(&std::fs::read(&saved).unwrap()).unwrap().hash(), instance.key);
48 -
    delegate(&c.join("owner"), &instance);
49 -
    let current = KeyPair::load_home(&c.join("owner")).unwrap();
50 -
51 -
    // ... and connects again: trusted, pinned, and claimed as theirs.
52 -
    let o = run(c, &["connect", &url]);
51 +
    let before = KeyPair::load_home(&c.join("owner")).unwrap();
52 +
    // ... and the server is trusted, pinned, and claimed as theirs.
53 +
    let o = run(c, &["connect", &url, "--expect", &instance.key.short(16)]);
53 54
    assert!(o.status.success(), "{}", text(&o));
54 55
    assert!(text(&o).contains("Claimed"), "{}", text(&o));
55 -
    let cfg = std::fs::read_to_string(c.join(".config/spectra/client.toml")).unwrap();
56 +
    let current = KeyPair::load_home(&c.join("owner")).unwrap();
57 +
    assert_ne!(current.key, before.key);
58 +
    assert!(current.identity.delegates_to(&instance.key, 1));
59 +
    let cfg = std::fs::read_to_string(c.join("owner/client.toml")).unwrap();
56 60
    assert!(cfg.contains(&instance.key.to_hex()) && cfg.contains("tls_spki"), "{cfg}");
57 61
    assert_eq!(instance_owner(port).await, current.key.to_hex(), "recorded at the owner's current identity");
58 62
…
8 unchanged lines
67 71
    std::fs::remove_file(s.join("data/keys/tls.key")).unwrap();
68 72
    let (srv, port) = Serving::start_tls(s);
69 73
    let url = format!("https://127.0.0.1:{port}");
70 -
    let cfg = std::fs::read_to_string(c.join(".config/spectra/client.toml")).unwrap();
74 +
    let cfg = std::fs::read_to_string(c.join("owner/client.toml")).unwrap();
71 75
    let old_pin: [u8; 32] =
72 76
        hex::decode(cfg.lines().find(|l| l.starts_with("tls_spki")).unwrap().split('"').nth(1).unwrap()).unwrap().try_into().unwrap();
73 77
    let pinned = Client::at(Endpoint::tls(&url, old_pin).unwrap(), None);
…
1 unchanged lines
75 79
    // ... until connect checks that the same instance key vouches for it.
76 80
    let o = run(c, &["connect", &url]);
77 81
    assert!(o.status.success(), "{}", text(&o));
78 -
    let cfg2 = std::fs::read_to_string(c.join(".config/spectra/client.toml")).unwrap();
82 +
    let cfg2 = std::fs::read_to_string(c.join("owner/client.toml")).unwrap();
79 83
    assert_ne!(cfg, cfg2, "re-pinned");
80 84
    srv.stop();
81 85
…
16 unchanged lines