OXYGENHUB

The ledger: hash-chained events in the frozen envelope, signed checkpoints, admin verify, check and rebuild (#3) 36537112fcac

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 19ae205fcebb
When
· 2026-11-02 21:53 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
36537112fcacca5a7d148db09061afc9b8875ec6df78827c4fa7fee83c67f26b
Depends on
258bb3824813 ad258dc8cf5e
Depended on by
862f0db224aa fe6b29c66cd4 87f601ccce6d 8d803d858c8c c7161ec1a607 b044d90930d9 98a0fb9f0948 67bfa06a894f eca5af92279b 77df591d5a2f b576d05cf862 98a16830a3d3 da84e98f104e e071e91a14ef 39f5c913f617 eb675dfa5e18 037c45d8093c 571572f5fb2c 5eb57578f4ef 2fc88a7b3a5c 5d695d869fbf 09752034b5a8
In bookmarks
main
In tags
v0.1.0
Edits
137 atoms, 13 tree operations, 19 files

Browse the tree at this change

added crates/spectra-core/migrations/0001_ledger.sql +38 −0
1 +
-- The ledger (spec §9.1, §9.5): every event, in order, chained by hash.
2 +
-- Columns are the frozen envelope; `pruned` marks a stub whose payload and
3 +
-- proof were removed by `spectra admin prune` (its hash and prev stay).
4 +
CREATE TABLE events (
5 +
    seq             INTEGER PRIMARY KEY,
6 +
    id              TEXT NOT NULL UNIQUE,
7 +
    at_ms           INTEGER NOT NULL,
8 +
    hlc_wall        INTEGER NOT NULL,
9 +
    hlc_logical     INTEGER NOT NULL,
10 +
    prev            BLOB NOT NULL,
11 +
    hash            BLOB NOT NULL,
12 +
    auth_key        BLOB,
13 +
    auth_method     INTEGER NOT NULL,
14 +
    auth_assurance  INTEGER NOT NULL,
15 +
    auth_proof      BLOB,
16 +
    kind            TEXT NOT NULL,
17 +
    actor           TEXT NOT NULL,
18 +
    session         TEXT,
19 +
    project         TEXT,
20 +
    subject         TEXT,
21 +
    payload         BLOB,
22 +
    trace           TEXT,
23 +
    pruned          INTEGER NOT NULL DEFAULT 0
24 +
);
25 +
CREATE INDEX events_kind ON events(kind);
26 +
CREATE INDEX events_subject ON events(subject);
27 +
28 +
-- Facts about this database: the ledger format it was written with.
29 +
CREATE TABLE meta (
30 +
    key   TEXT PRIMARY KEY,
31 +
    value TEXT NOT NULL
32 +
);
33 +
34 +
-- Projection: how many events of each kind (Radar's Log header, `admin check`).
35 +
CREATE TABLE event_counts (
36 +
    kind  TEXT PRIMARY KEY,
37 +
    n     INTEGER NOT NULL
38 +
);
added crates/spectra-core/src/event.rs +282 −0
1 +
//! The event envelope (spec §9.1) and how it is encoded and hashed (§9.5).
2 +
//!
3 +
//! An event's body is deterministic CBOR with integer keys, through
4 +
//! `oxy-core`'s encoder, exactly as Oxygen encodes its objects (oxygen spec
5 +
//! §5.1); its hash is `BLAKE3("spectra1 event " || len || 0x00 || body)`, the
6 +
//! domain-separated form of oxygen spec §5.2. The hash and the proof are not
7 +
//! in the body: signatures sit outside what they sign (oxygen spec §5.4).
8 +
//!
9 +
//! The envelope is frozen from the first release: fields may be added at new
10 +
//! keys, never changed or removed, so a hub merges logs from any version.
11 +
12 +
use crate::hlc::Hlc;
13 +
use oxy_core::cbor::{self, Value};
14 +
15 +
/// The envelope's version, key 0 of the body.
16 +
pub const ENVELOPE_VERSION: u64 = 1;
17 +
18 +
/// The field names of the envelope, in order: the frozen set (spec §9.1).
19 +
pub const ENVELOPE_FIELDS: &[&str] =
20 +
    &["seq", "id", "at_ms", "hlc", "prev", "hash", "auth", "kind", "actor", "session", "project", "subject", "payload", "trace"];
21 +
22 +
/// How the event's cause was authenticated (spec §8.5).
23 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24 +
pub enum Method {
25 +
    /// Made by Spectra itself (presence timers, checkpoints of its own);
26 +
    /// covered by the next checkpoint's signature.
27 +
    System = 0,
28 +
    /// A bearer token: possession only.
29 +
    Token = 1,
30 +
    /// A request signed with a key on disk.
31 +
    Signature = 2,
32 +
    /// A passkey assertion with user presence.
33 +
    Passkey = 3,
34 +
}
35 +
36 +
impl Method {
37 +
    pub fn from_u8(n: u8) -> Option<Method> {
38 +
        Some(match n {
39 +
            0 => Method::System,
40 +
            1 => Method::Token,
41 +
            2 => Method::Signature,
42 +
            3 => Method::Passkey,
43 +
            _ => return None,
44 +
        })
45 +
    }
46 +
    pub fn name(self) -> &'static str {
47 +
        match self {
48 +
            Method::System => "system",
49 +
            Method::Token => "token",
50 +
            Method::Signature => "signature",
51 +
            Method::Passkey => "passkey",
52 +
        }
53 +
    }
54 +
}
55 +
56 +
/// Who caused an event and the proof of it. `key` and the assurance level
57 +
/// are hashed; `proof` is carried beside the hash.
58 +
#[derive(Clone, Debug, PartialEq, Eq)]
59 +
pub struct Auth {
60 +
    pub key: Option<[u8; 32]>,
61 +
    pub method: Method,
62 +
    /// 0 for system events, else 1 to 3 (spec §8.5).
63 +
    pub assurance: u8,
64 +
    pub proof: Option<Vec<u8>>,
65 +
}
66 +
67 +
impl Auth {
68 +
    /// An event Spectra makes itself.
69 +
    pub fn system() -> Auth {
70 +
        Auth { key: None, method: Method::System, assurance: 0, proof: None }
71 +
    }
72 +
}
73 +
74 +
/// An event before it is appended: what the caller decides.
75 +
#[derive(Clone, Debug)]
76 +
pub struct NewEvent {
77 +
    pub kind: String,
78 +
    pub actor: String,
79 +
    pub session: Option<String>,
80 +
    pub project: Option<String>,
81 +
    pub subject: Option<String>,
82 +
    pub payload: serde_json::Value,
83 +
    pub trace: Option<String>,
84 +
    pub auth: Auth,
85 +
}
86 +
87 +
impl NewEvent {
88 +
    /// A system event of `kind` with `payload`.
89 +
    pub fn system(kind: &str, payload: serde_json::Value) -> NewEvent {
90 +
        NewEvent {
91 +
            kind: kind.into(),
92 +
            actor: "system".into(),
93 +
            session: None,
94 +
            project: None,
95 +
            subject: None,
96 +
            payload,
97 +
            trace: None,
98 +
            auth: Auth::system(),
99 +
        }
100 +
    }
101 +
    pub fn subject(mut self, s: impl Into<String>) -> NewEvent {
102 +
        self.subject = Some(s.into());
103 +
        self
104 +
    }
105 +
    pub fn project(mut self, p: impl Into<String>) -> NewEvent {
106 +
        self.project = Some(p.into());
107 +
        self
108 +
    }
109 +
    pub fn session(mut self, s: impl Into<String>) -> NewEvent {
110 +
        self.session = Some(s.into());
111 +
        self
112 +
    }
113 +
    pub fn by(mut self, actor: impl Into<String>, auth: Auth) -> NewEvent {
114 +
        self.actor = actor.into();
115 +
        self.auth = auth;
116 +
        self
117 +
    }
118 +
}
119 +
120 +
/// An event in the ledger.
121 +
#[derive(Clone, Debug, PartialEq, Eq)]
122 +
pub struct Event {
123 +
    pub seq: u64,
124 +
    pub id: String,
125 +
    pub at_ms: u64,
126 +
    pub hlc: Hlc,
127 +
    pub prev: [u8; 32],
128 +
    pub hash: [u8; 32],
129 +
    pub auth: Auth,
130 +
    pub kind: String,
131 +
    pub actor: String,
132 +
    pub session: Option<String>,
133 +
    pub project: Option<String>,
134 +
    pub subject: Option<String>,
135 +
    /// Canonical JSON (sorted keys, no whitespace), hashed as stored;
136 +
    /// `None` once pruned to a stub.
137 +
    pub payload: Option<Vec<u8>>,
138 +
    pub trace: Option<String>,
139 +
}
140 +
141 +
impl Event {
142 +
    /// The payload as JSON (`null` for a stub).
143 +
    pub fn json(&self) -> serde_json::Value {
144 +
        self.payload.as_deref().and_then(|b| serde_json::from_slice(b).ok()).unwrap_or(serde_json::Value::Null)
145 +
    }
146 +
147 +
    /// The canonical body: every envelope field but `hash` and the proof.
148 +
    pub fn body(&self) -> Vec<u8> {
149 +
        let opt = |s: &Option<String>| s.as_ref().map(|t| Value::Text(t.clone())).unwrap_or(Value::Null);
150 +
        cbor::encode(&Value::map([
151 +
            (0, Value::Uint(ENVELOPE_VERSION)),
152 +
            (1, Value::Uint(self.seq)),
153 +
            (2, Value::Text(self.id.clone())),
154 +
            (3, Value::Uint(self.at_ms)),
155 +
            (4, Value::Array(vec![Value::Uint(self.hlc.wall_ms), Value::Uint(self.hlc.logical as u64)])),
156 +
            (5, Value::Bytes(self.prev.to_vec())),
157 +
            (
158 +
                6,
159 +
                Value::map([
160 +
                    (0, self.auth.key.map(|k| Value::Bytes(k.to_vec())).unwrap_or(Value::Null)),
161 +
                    (1, Value::Uint(self.auth.method as u64)),
162 +
                    (2, Value::Uint(self.auth.assurance as u64)),
163 +
                ]),
164 +
            ),
165 +
            (7, Value::Text(self.kind.clone())),
166 +
            (8, Value::Text(self.actor.clone())),
167 +
            (9, opt(&self.session)),
168 +
            (10, opt(&self.project)),
169 +
            (11, opt(&self.subject)),
170 +
            (12, self.payload.as_ref().map(|p| Value::Bytes(p.clone())).unwrap_or(Value::Null)),
171 +
            (13, opt(&self.trace)),
172 +
        ]))
173 +
    }
174 +
175 +
    /// The hash this event's fields give; equal to `hash` unless edited.
176 +
    pub fn computed_hash(&self) -> [u8; 32] {
177 +
        hash_body(&self.body())
178 +
    }
179 +
}
180 +
181 +
/// `BLAKE3("spectra1 event " || decimal(len) || 0x00 || body)`.
182 +
pub fn hash_body(body: &[u8]) -> [u8; 32] {
183 +
    let mut h = blake3::Hasher::new();
184 +
    h.update(b"spectra1 event ");
185 +
    h.update(body.len().to_string().as_bytes());
186 +
    h.update(&[0]);
187 +
    h.update(body);
188 +
    *h.finalize().as_bytes()
189 +
}
190 +
191 +
/// JSON with object keys sorted at every level and no whitespace, so equal
192 +
/// payloads are equal bytes whatever built them.
193 +
pub fn canonical_json(v: &serde_json::Value) -> Vec<u8> {
194 +
    fn sort(v: &serde_json::Value) -> serde_json::Value {
195 +
        match v {
196 +
            serde_json::Value::Object(m) => {
197 +
                let mut keys: Vec<&String> = m.keys().collect();
198 +
                keys.sort();
199 +
                let mut out = serde_json::Map::new();
200 +
                for k in keys {
201 +
                    out.insert(k.clone(), sort(&m[k]));
202 +
                }
203 +
                serde_json::Value::Object(out)
204 +
            }
205 +
            serde_json::Value::Array(a) => serde_json::Value::Array(a.iter().map(sort).collect()),
206 +
            other => other.clone(),
207 +
        }
208 +
    }
209 +
    serde_json::to_vec(&sort(v)).expect("JSON values always serialise")
210 +
}
211 +
212 +
#[cfg(test)]
213 +
mod tests {
214 +
    use super::*;
215 +
216 +
    fn sample() -> Event {
217 +
        Event {
218 +
            seq: 7,
219 +
            id: "01J0000000000000000000000Z".into(),
220 +
            at_ms: 1_700_000_000_000,
221 +
            hlc: Hlc { wall_ms: 1_700_000_000_000, logical: 2 },
222 +
            prev: [9; 32],
223 +
            hash: [0; 32],
224 +
            auth: Auth { key: Some([1; 32]), method: Method::Signature, assurance: 2, proof: Some(vec![5, 5]) },
225 +
            kind: "message.posted".into(),
226 +
            actor: "agent:claude-oxygen".into(),
227 +
            session: None,
228 +
            project: Some("oxygen".into()),
229 +
            subject: Some("m1".into()),
230 +
            payload: Some(canonical_json(&serde_json::json!({"b": 1, "a": [{"z": 1, "y": 2}]}))),
231 +
            trace: None,
232 +
        }
233 +
    }
234 +
235 +
    #[test]
236 +
    fn every_field_but_the_proof_changes_the_hash() {
237 +
        let e = sample();
238 +
        let h = e.computed_hash();
239 +
        let mut p = e.clone();
240 +
        p.auth.proof = None;
241 +
        assert_eq!(p.computed_hash(), h, "the proof is outside the hash");
242 +
        type Edit = Box<dyn Fn(&mut Event)>;
243 +
        let edits: Vec<Edit> = vec![
244 +
            Box::new(|e| e.seq += 1),
245 +
            Box::new(|e| e.id.push('X')),
246 +
            Box::new(|e| e.at_ms += 1),
247 +
            Box::new(|e| e.hlc.logical += 1),
248 +
            Box::new(|e| e.prev[0] ^= 1),
249 +
            Box::new(|e| e.auth.key = None),
250 +
            Box::new(|e| e.auth.assurance = 3),
251 +
            Box::new(|e| e.auth.method = Method::Token),
252 +
            Box::new(|e| e.kind.push('x')),
253 +
            Box::new(|e| e.actor.push('x')),
254 +
            Box::new(|e| e.session = Some("s".into())),
255 +
            Box::new(|e| e.project = None),
256 +
            Box::new(|e| e.subject = None),
257 +
            Box::new(|e| e.payload = Some(b"{}".to_vec())),
258 +
            Box::new(|e| e.trace = Some("t".into())),
259 +
        ];
260 +
        for edit in edits {
261 +
            let mut x = e.clone();
262 +
            edit(&mut x);
263 +
            assert_ne!(x.computed_hash(), h);
264 +
        }
265 +
    }
266 +
267 +
    #[test]
268 +
    fn canonical_json_sorts_keys_at_every_level() {
269 +
        let a = canonical_json(&serde_json::json!({"b": 1, "a": {"d": 1, "c": 2}}));
270 +
        assert_eq!(a, br#"{"a":{"c":2,"d":1},"b":1}"#);
271 +
    }
272 +
273 +
    #[test]
274 +
    fn the_envelope_is_frozen() {
275 +
        assert_eq!(
276 +
            ENVELOPE_FIELDS.join(","),
277 +
            "seq,id,at_ms,hlc,prev,hash,auth,kind,actor,session,project,subject,payload,trace",
278 +
            "the envelope is frozen at 0.1: add fields at new keys, never change these (spec §9.1)"
279 +
        );
280 +
        assert_eq!(ENVELOPE_VERSION, 1);
281 +
    }
282 +
}
added crates/spectra-core/src/hlc.rs +73 −0
1 +
//! The hybrid logical clock on every event (spec §9.1), with Oxygen's drift
2 +
//! bound (oxygen spec §6.4): observing a remote time never moves this clock
3 +
//! more than five minutes ahead of the local wall clock.
4 +
5 +
use std::sync::Mutex;
6 +
7 +
/// How far ahead of the local clock an observed time may move ours.
8 +
pub const MAX_DRIFT_MS: u64 = 5 * 60 * 1000;
9 +
10 +
/// A hybrid logical clock reading: wall milliseconds and a counter.
11 +
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
12 +
pub struct Hlc {
13 +
    pub wall_ms: u64,
14 +
    pub logical: u32,
15 +
}
16 +
17 +
/// The clock: monotonic across calls, whatever the wall clock does.
18 +
#[derive(Debug, Default)]
19 +
pub struct Clock {
20 +
    last: Mutex<Hlc>,
21 +
}
22 +
23 +
impl Clock {
24 +
    /// A clock that continues from `last` (the ledger's newest reading).
25 +
    pub fn starting_at(last: Hlc) -> Clock {
26 +
        Clock { last: Mutex::new(last) }
27 +
    }
28 +
29 +
    /// The next reading for a local event, at wall time `now_ms`.
30 +
    pub fn tick(&self, now_ms: u64) -> Hlc {
31 +
        let mut last = self.last.lock().unwrap();
32 +
        *last = if now_ms > last.wall_ms {
33 +
            Hlc { wall_ms: now_ms, logical: 0 }
34 +
        } else {
35 +
            Hlc { wall_ms: last.wall_ms, logical: last.logical.saturating_add(1) }
36 +
        };
37 +
        *last
38 +
    }
39 +
40 +
    /// Take a remote reading into account, as on receiving another
41 +
    /// Spectra's event; the remote reading is kept as it is, only this
42 +
    /// clock's own advance is bounded.
43 +
    pub fn observe(&self, remote: Hlc, now_ms: u64) {
44 +
        let mut last = self.last.lock().unwrap();
45 +
        let bound = now_ms.saturating_add(MAX_DRIFT_MS);
46 +
        let remote = if remote.wall_ms > bound { Hlc { wall_ms: bound, logical: 0 } } else { remote };
47 +
        if remote > *last {
48 +
            *last = remote;
49 +
        }
50 +
    }
51 +
}
52 +
53 +
#[cfg(test)]
54 +
mod tests {
55 +
    use super::*;
56 +
57 +
    #[test]
58 +
    fn ticks_are_monotonic_when_the_wall_clock_stalls_or_steps_back() {
59 +
        let c = Clock::default();
60 +
        let a = c.tick(1_000);
61 +
        let b = c.tick(1_000);
62 +
        let d = c.tick(500);
63 +
        assert!(a < b && b < d);
64 +
        assert_eq!(c.tick(2_000), Hlc { wall_ms: 2_000, logical: 0 });
65 +
    }
66 +
67 +
    #[test]
68 +
    fn a_far_future_remote_time_moves_the_clock_by_the_drift_bound_at_most() {
69 +
        let c = Clock::default();
70 +
        c.observe(Hlc { wall_ms: 10_000_000_000, logical: 7 }, 1_000);
71 +
        assert_eq!(c.tick(1_000).wall_ms, 1_000 + MAX_DRIFT_MS);
72 +
    }
73 +
}
added crates/spectra-core/src/ids.rs +40 −0
1 +
//! Identifiers (spec §9.4): ULIDs, 26 characters of Crockford base32, time
2 +
//! ordered and globally unique, so an id made on one machine merges into a
3 +
//! hub without renaming.
4 +
5 +
use rand::RngCore;
6 +
7 +
const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
8 +
9 +
/// A new ULID for the current time.
10 +
pub fn ulid() -> String {
11 +
    let mut r = [0u8; 16];
12 +
    rand::rngs::OsRng.fill_bytes(&mut r);
13 +
    ulid_from(now_ms(), u128::from_be_bytes(r))
14 +
}
15 +
16 +
/// A ULID from a millisecond time and 80 bits taken from `random`.
17 +
pub fn ulid_from(ms: u64, random: u128) -> String {
18 +
    let v: u128 = ((ms as u128 & ((1 << 48) - 1)) << 80) | (random & ((1u128 << 80) - 1));
19 +
    (0..26).map(|i| CROCKFORD[((v >> (125 - 5 * i)) & 31) as usize] as char).collect()
20 +
}
21 +
22 +
/// Milliseconds since the Unix epoch.
23 +
pub fn now_ms() -> u64 {
24 +
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
25 +
}
26 +
27 +
#[cfg(test)]
28 +
mod tests {
29 +
    use super::*;
30 +
31 +
    #[test]
32 +
    fn ulids_are_26_chars_and_sort_by_time() {
33 +
        let a = ulid_from(1_000, 5);
34 +
        let b = ulid_from(2_000, 0);
35 +
        assert_eq!(a.len(), 26);
36 +
        assert!(a < b);
37 +
        assert_eq!(ulid_from(0, 0), "00000000000000000000000000");
38 +
        assert_ne!(ulid(), ulid());
39 +
    }
40 +
}
added crates/spectra-core/src/keys.rs +158 −0
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.
6 +
//!
7 +
//! The instance key (spec §9.5) is kept in the data directory instead, at
8 +
//! `keys/instance.secret` and `keys/instance.identity`.
9 +
10 +
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 +
use std::fs;
15 +
use std::path::{Path, PathBuf};
16 +
17 +
/// A key pair with its identity: the instance, an agent, or the owner.
18 +
#[derive(Clone, Debug)]
19 +
pub struct KeyPair {
20 +
    pub key: KeyId,
21 +
    pub identity: Identity,
22 +
    secret: SecretKey,
23 +
}
24 +
25 +
impl KeyPair {
26 +
    /// A fresh key pair named `name`, made now.
27 +
    pub fn generate(name: &str, email: &str) -> KeyPair {
28 +
        let secret = SecretKey::generate();
29 +
        let identity = secret.identity(name, email, crate::ids::now_ms());
30 +
        KeyPair { key: identity.hash(), identity, secret }
31 +
    }
32 +
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.
37 +
    pub fn sign(&self, message: &[u8]) -> Signature {
38 +
        self.secret.sign_challenge(&self.key, message).expect("Spectra messages are longer than 16 bytes")
39 +
    }
40 +
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()))?;
47 +
        #[cfg(unix)]
48 +
        {
49 +
            use std::os::unix::fs::PermissionsExt;
50 +
            fs::set_permissions(home, fs::Permissions::from_mode(0o700))?;
51 +
        }
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())?;
58 +
        Ok(pair)
59 +
    }
60 +
61 +
    /// The key pair an `OXY_HOME` signs with: `author.key` from its
62 +
    /// `config.toml`, else its only secret key.
63 +
    pub fn load_home(home: &Path) -> Result<KeyPair> {
64 +
        let keys = home.join("keys");
65 +
        let key = match author_key(home)? {
66 +
            Some(k) => k,
67 +
            None => {
68 +
                let secrets: Vec<PathBuf> = fs::read_dir(&keys)
69 +
                    .with_context(|| format!("reading {}", keys.display()))?
70 +
                    .filter_map(|e| e.ok().map(|e| e.path()))
71 +
                    .filter(|p| p.extension().is_some_and(|x| x == "secret"))
72 +
                    .collect();
73 +
                match secrets.as_slice() {
74 +
                    [one] => KeyId::from_hex(one.file_stem().and_then(|s| s.to_str()).unwrap_or(""))
75 +
                        .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()),
78 +
                }
79 +
            }
80 +
        };
81 +
        KeyPair::load(&keys.join(format!("{}.secret", key.to_hex())), &keys.join(format!("{}.identity", key.to_hex())))
82 +
    }
83 +
84 +
    /// Read a secret and its identity, checking that they belong together.
85 +
    pub fn load(secret: &Path, identity: &Path) -> Result<KeyPair> {
86 +
        let (key, secret_key) = SecretKey::decode(&fs::read(secret).with_context(|| format!("reading {}", secret.display()))?)
87 +
            .map_err(|e| anyhow::anyhow!("{}: {e}", secret.display()))?;
88 +
        let identity = Identity::decode(&fs::read(identity).with_context(|| format!("reading {}", identity.display()))?)
89 +
            .map_err(|e| anyhow::anyhow!("{}: {e}", identity.display()))?;
90 +
        if identity.hash() != key {
91 +
            bail!("{} is not the identity of {}", identity.name, secret.display());
92 +
        }
93 +
        if secret_key.identity(&identity.name, &identity.email, identity.created).ed25519_pk != identity.ed25519_pk {
94 +
            bail!("the secret key in {} does not match its identity", secret.display());
95 +
        }
96 +
        Ok(KeyPair { key, identity, secret: secret_key })
97 +
    }
98 +
99 +
    /// The data directory's instance key, made on first use (spec §9.5).
100 +
    pub fn instance(data: &Path, name: &str) -> Result<KeyPair> {
101 +
        let keys = data.join("keys");
102 +
        let (secret, identity) = (keys.join("instance.secret"), keys.join("instance.identity"));
103 +
        if secret.exists() {
104 +
            return KeyPair::load(&secret, &identity);
105 +
        }
106 +
        fs::create_dir_all(&keys)?;
107 +
        let pair = KeyPair::generate(name, "");
108 +
        write_atomic_private(&secret, &pair.secret.encode(&pair.key))?;
109 +
        write_atomic(&identity, &pair.identity.encode())?;
110 +
        Ok(pair)
111 +
    }
112 +
}
113 +
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`.
116 +
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()))?;
118 +
    let path = home.join("keys").join(format!("{}.identity", key.to_hex()));
119 +
    Identity::decode(&fs::read(&path).with_context(|| format!("reading {}", path.display()))?)
120 +
        .map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))
121 +
}
122 +
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 +
    })
132 +
}
133 +
134 +
#[cfg(test)]
135 +
mod tests {
136 +
    use super::*;
137 +
138 +
    #[test]
139 +
    fn a_home_made_here_reads_back_and_signs_verifiably() {
140 +
        let dir = tempfile::tempdir().unwrap();
141 +
        let made = KeyPair::create_home(dir.path(), "claude-oxygen", "").unwrap();
142 +
        let read = KeyPair::load_home(dir.path()).unwrap();
143 +
        assert_eq!(made.key, read.key);
144 +
        assert_eq!(home_identity(dir.path()).unwrap().hash(), made.key);
145 +
        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());
149 +
    }
150 +
151 +
    #[test]
152 +
    fn the_instance_key_is_made_once() {
153 +
        let dir = tempfile::tempdir().unwrap();
154 +
        let a = KeyPair::instance(dir.path(), "laptop").unwrap();
155 +
        let b = KeyPair::instance(dir.path(), "laptop").unwrap();
156 +
        assert_eq!(a.key, b.key);
157 +
    }
158 +
}
added crates/spectra-core/src/ledger.rs +506 −0
1 +
//! The ledger (spec §9, §9.5): the append-only, hash-chained event log in
2 +
//! SQLite, its checkpoints signed by the instance key, its verification,
3 +
//! and the bus that tells everything else what was appended.
4 +
//!
5 +
//! One writer at a time (a lock around the chain head and SQLite's own),
6 +
//! so `seq` is gap-free and every `prev` is the hash of the event before.
7 +
//! Readers never wait for it (WAL).
8 +
9 +
use crate::event::{canonical_json, Auth, Event, Method, NewEvent};
10 +
use crate::hlc::{Clock, Hlc};
11 +
use crate::keys::KeyPair;
12 +
use crate::{ids, projections};
13 +
use anyhow::{bail, Context, Result};
14 +
use oxy_core::hash::KeyId;
15 +
use oxy_core::identity::{Identity, Signature};
16 +
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteRow, SqliteSynchronous};
17 +
use sqlx::{Row, SqlitePool};
18 +
use std::path::Path;
19 +
use std::sync::Arc;
20 +
use tokio::sync::{broadcast, Mutex};
21 +
22 +
/// Events between checkpoints (spec §9.5).
23 +
pub const CHECKPOINT_EVERY: u64 = 256;
24 +
/// The longest a written event waits for a checkpoint (spec §9.5).
25 +
pub const CHECKPOINT_INTERVAL_MS: u64 = 10 * 60 * 1000;
26 +
27 +
const CHECKPOINT_DOMAIN: &[u8] = b"spectra1 checkpoint ";
28 +
29 +
/// The ledger: the database, the chain head, the clock and the bus.
30 +
pub struct Ledger {
31 +
    pool: SqlitePool,
32 +
    head: Mutex<Head>,
33 +
    clock: Clock,
34 +
    bus: broadcast::Sender<Arc<Event>>,
35 +
    instance: Option<KeyPair>,
36 +
}
37 +
38 +
#[derive(Clone, Copy, Debug)]
39 +
struct Head {
40 +
    seq: u64,
41 +
    hash: [u8; 32],
42 +
    since_checkpoint: u64,
43 +
    oldest_unchecked_ms: Option<u64>,
44 +
}
45 +
46 +
impl Ledger {
47 +
    /// Open (or create) `path`, apply migrations, and refuse a ledger
48 +
    /// written by a newer format. Checkpoints are signed with `instance`;
49 +
    /// without one (tests, read-only tools) none are made.
50 +
    pub async fn open(path: &Path, instance: Option<KeyPair>) -> Result<Ledger> {
51 +
        let opts = SqliteConnectOptions::new()
52 +
            .filename(path)
53 +
            .create_if_missing(true)
54 +
            .journal_mode(SqliteJournalMode::Wal)
55 +
            .synchronous(SqliteSynchronous::Normal)
56 +
            .foreign_keys(true)
57 +
            .busy_timeout(std::time::Duration::from_secs(10));
58 +
        let pool =
59 +
            SqlitePoolOptions::new().max_connections(8).connect_with(opts).await.with_context(|| format!("opening {}", path.display()))?;
60 +
        sqlx::migrate!("./migrations").run(&pool).await.context("applying migrations")?;
61 +
        let format: Option<String> = sqlx::query_scalar("SELECT value FROM meta WHERE key = 'ledger_format'").fetch_optional(&pool).await?;
62 +
        match format.as_deref().map(str::parse::<u32>) {
63 +
            None => {
64 +
                sqlx::query("INSERT INTO meta (key, value) VALUES ('ledger_format', ?)")
65 +
                    .bind(crate::LEDGER_FORMAT.to_string())
66 +
                    .execute(&pool)
67 +
                    .await?;
68 +
            }
69 +
            Some(Ok(f)) if f <= crate::LEDGER_FORMAT => {}
70 +
            Some(_) => {
71 +
                bail!("{} was written by a newer Spectra (ledger format {}): upgrade spectra", path.display(), format.unwrap_or_default())
72 +
            }
73 +
        }
74 +
        let last =
75 +
            sqlx::query("SELECT seq, hash, hlc_wall, hlc_logical FROM events ORDER BY seq DESC LIMIT 1").fetch_optional(&pool).await?;
76 +
        let (seq, hash, hlc) = match last {
77 +
            Some(r) => (
78 +
                r.get::<i64, _>(0) as u64,
79 +
                to32(r.get::<Vec<u8>, _>(1))?,
80 +
                Hlc { wall_ms: r.get::<i64, _>(2) as u64, logical: r.get::<i64, _>(3) as u32 },
81 +
            ),
82 +
            None => (0, [0; 32], Hlc::default()),
83 +
        };
84 +
        let checkpoint_seq: i64 =
85 +
            sqlx::query_scalar("SELECT COALESCE(MAX(seq), 0) FROM events WHERE kind = 'ledger.checkpoint'").fetch_one(&pool).await?;
86 +
        let since = seq.saturating_sub(checkpoint_seq as u64);
87 +
        let (bus, _) = broadcast::channel(4096);
88 +
        Ok(Ledger {
89 +
            pool,
90 +
            head: Mutex::new(Head { seq, hash, since_checkpoint: since, oldest_unchecked_ms: (since > 0).then(ids::now_ms) }),
91 +
            clock: Clock::starting_at(hlc),
92 +
            bus,
93 +
            instance,
94 +
        })
95 +
    }
96 +
97 +
    /// The database, for projections' readers.
98 +
    pub fn pool(&self) -> &SqlitePool {
99 +
        &self.pool
100 +
    }
101 +
102 +
    /// Every appended event, as it commits.
103 +
    pub fn subscribe(&self) -> broadcast::Receiver<Arc<Event>> {
104 +
        self.bus.subscribe()
105 +
    }
106 +
107 +
    /// The instance key, when this ledger signs checkpoints.
108 +
    pub fn instance(&self) -> Option<&KeyPair> {
109 +
        self.instance.as_ref()
110 +
    }
111 +
112 +
    /// The newest event's sequence number and hash.
113 +
    pub async fn head(&self) -> (u64, [u8; 32]) {
114 +
        let h = self.head.lock().await;
115 +
        (h.seq, h.hash)
116 +
    }
117 +
118 +
    /// Append `events` in one transaction, chained and projected, then
119 +
    /// publish them; a checkpoint follows when one is due.
120 +
    pub async fn append(&self, events: Vec<NewEvent>) -> Result<Vec<Arc<Event>>> {
121 +
        let mut head = self.head.lock().await;
122 +
        let mut tx = self.pool.begin().await?;
123 +
        let mut out = Vec::with_capacity(events.len() + 1);
124 +
        let mut h = *head;
125 +
        for n in events {
126 +
            let e = self.chain(&mut h, n);
127 +
            insert(&mut tx, &e).await?;
128 +
            projections::apply(&mut tx, &e).await?;
129 +
            h.since_checkpoint += 1;
130 +
            h.oldest_unchecked_ms.get_or_insert(e.at_ms);
131 +
            out.push(e);
132 +
            // Every 256 events exactly, even inside a large batch.
133 +
            if h.since_checkpoint >= CHECKPOINT_EVERY && self.instance.is_some() {
134 +
                let cp = self.checkpoint_event(&mut h);
135 +
                insert(&mut tx, &cp).await?;
136 +
                projections::apply(&mut tx, &cp).await?;
137 +
                out.push(cp);
138 +
            }
139 +
        }
140 +
        let due = h.oldest_unchecked_ms.is_some_and(|t| ids::now_ms().saturating_sub(t) >= CHECKPOINT_INTERVAL_MS);
141 +
        if due && self.instance.is_some() {
142 +
            let cp = self.checkpoint_event(&mut h);
143 +
            insert(&mut tx, &cp).await?;
144 +
            projections::apply(&mut tx, &cp).await?;
145 +
            out.push(cp);
146 +
        }
147 +
        tx.commit().await?;
148 +
        *head = h;
149 +
        drop(head);
150 +
        let out: Vec<Arc<Event>> = out.into_iter().map(Arc::new).collect();
151 +
        for e in &out {
152 +
            let _ = self.bus.send(e.clone());
153 +
        }
154 +
        Ok(out)
155 +
    }
156 +
157 +
    /// Append one event.
158 +
    pub async fn append_one(&self, e: NewEvent) -> Result<Arc<Event>> {
159 +
        Ok(self.append(vec![e]).await?.remove(0))
160 +
    }
161 +
162 +
    /// Write a checkpoint now if any event is not covered by one (at
163 +
    /// shutdown, and from the ten-minute timer).
164 +
    pub async fn checkpoint(&self) -> Result<Option<Arc<Event>>> {
165 +
        let Some(_) = &self.instance else { return Ok(None) };
166 +
        let mut head = self.head.lock().await;
167 +
        if head.since_checkpoint == 0 {
168 +
            return Ok(None);
169 +
        }
170 +
        let mut h = *head;
171 +
        let cp = self.checkpoint_event(&mut h);
172 +
        let mut tx = self.pool.begin().await?;
173 +
        insert(&mut tx, &cp).await?;
174 +
        projections::apply(&mut tx, &cp).await?;
175 +
        tx.commit().await?;
176 +
        *head = h;
177 +
        drop(head);
178 +
        let cp = Arc::new(cp);
179 +
        let _ = self.bus.send(cp.clone());
180 +
        Ok(Some(cp))
181 +
    }
182 +
183 +
    fn chain(&self, h: &mut Head, n: NewEvent) -> Event {
184 +
        let at_ms = ids::now_ms();
185 +
        let mut e = Event {
186 +
            seq: h.seq + 1,
187 +
            id: ids::ulid(),
188 +
            at_ms,
189 +
            hlc: self.clock.tick(at_ms),
190 +
            prev: h.hash,
191 +
            hash: [0; 32],
192 +
            auth: n.auth,
193 +
            kind: n.kind,
194 +
            actor: n.actor,
195 +
            session: n.session,
196 +
            project: n.project,
197 +
            subject: n.subject,
198 +
            payload: Some(canonical_json(&n.payload)),
199 +
            trace: n.trace,
200 +
        };
201 +
        e.hash = e.computed_hash();
202 +
        h.seq = e.seq;
203 +
        h.hash = e.hash;
204 +
        e
205 +
    }
206 +
207 +
    /// A checkpoint naming the head `h` holds, signed by the instance key
208 +
    /// over `"spectra1 checkpoint " || hash || seq` (spec §9.5).
209 +
    fn checkpoint_event(&self, h: &mut Head) -> Event {
210 +
        let instance = self.instance.as_ref().expect("checked by callers");
211 +
        let (seq, hash, count) = (h.seq, h.hash, h.since_checkpoint);
212 +
        let sig = instance.sign(&checkpoint_message(seq, &hash));
213 +
        let n = NewEvent::system("ledger.checkpoint", serde_json::json!({ "seq": seq, "hash": hex::encode(hash), "count": count })).by(
214 +
            "system",
215 +
            Auth {
216 +
                key: Some(instance.key.0),
217 +
                method: Method::Signature,
218 +
                assurance: 2,
219 +
                proof: Some(oxy_core::cbor::encode(&sig.to_value())),
220 +
            },
221 +
        );
222 +
        let e = self.chain(h, n);
223 +
        h.since_checkpoint = 0;
224 +
        h.oldest_unchecked_ms = None;
225 +
        e
226 +
    }
227 +
228 +
    /// Events from `from` (inclusive), oldest first, at most `limit`.
229 +
    pub async fn events(&self, from: u64, limit: u32) -> Result<Vec<Event>> {
230 +
        let rows = sqlx::query(&format!("{SELECT_EVENT} WHERE seq >= ? ORDER BY seq LIMIT ?"))
231 +
            .bind(from as i64)
232 +
            .bind(limit as i64)
233 +
            .fetch_all(&self.pool)
234 +
            .await?;
235 +
        rows.iter().map(row_to_event).collect()
236 +
    }
237 +
238 +
    /// Recompute the chain and check every checkpoint and every proof it
239 +
    /// can, from the ledger alone (spec §9.5, `spectra admin verify`).
240 +
    pub async fn verify(&self, keys: &dyn KeyResolver) -> Result<Verification> {
241 +
        let mut v = Verification::default();
242 +
        let mut prev = [0u8; 32];
243 +
        let mut expect = 1u64;
244 +
        let mut instance: Option<Identity> = None;
245 +
        let mut from = 1;
246 +
        loop {
247 +
            let batch = self.events(from, 2_000).await?;
248 +
            if batch.is_empty() {
249 +
                break;
250 +
            }
251 +
            for e in &batch {
252 +
                v.events += 1;
253 +
                if e.seq != expect {
254 +
                    v.finding(e.seq, Problem::Gap, format!("expected event {expect}"));
255 +
                    expect = e.seq;
256 +
                }
257 +
                if e.prev != prev {
258 +
                    v.finding(e.seq, Problem::ChainBreak, "prev is not the hash of the event before".into());
259 +
                }
260 +
                if e.payload.is_some() && e.computed_hash() != e.hash {
261 +
                    v.finding(e.seq, Problem::HashMismatch, "the event's fields do not give its hash: it was edited".into());
262 +
                }
263 +
                if e.kind == "identity.instance" {
264 +
                    instance = e
265 +
                        .json()
266 +
                        .get("identity")
267 +
                        .and_then(|h| h.as_str())
268 +
                        .and_then(|h| hex::decode(h).ok())
269 +
                        .and_then(|b| Identity::decode(&b).ok());
270 +
                }
271 +
                if e.kind == "ledger.checkpoint" {
272 +
                    v.checkpoints += 1;
273 +
                    match check_checkpoint(e, prev, instance.as_ref()) {
274 +
                        Ok(()) => v.last_checkpoint = Some(e.seq),
275 +
                        Err(why) => v.finding(e.seq, Problem::BadCheckpoint, why),
276 +
                    }
277 +
                } else if e.auth.method == Method::Signature || e.auth.method == Method::Passkey {
278 +
                    if let Err(why) = keys.check(e) {
279 +
                        v.finding(e.seq, Problem::BadProof, why);
280 +
                    }
281 +
                }
282 +
                prev = e.hash;
283 +
                expect += 1;
284 +
            }
285 +
            from = batch.last().map(|e| e.seq + 1).unwrap_or(from);
286 +
        }
287 +
        v.head = prev;
288 +
        v.uncovered = v.events.saturating_sub(v.last_checkpoint.unwrap_or(0));
289 +
        Ok(v)
290 +
    }
291 +
292 +
    /// Clear every projection and replay the ledger into them.
293 +
    pub async fn rebuild(&self) -> Result<()> {
294 +
        let _head = self.head.lock().await;
295 +
        let mut tx = self.pool.begin().await?;
296 +
        replay(&mut tx).await?;
297 +
        tx.commit().await?;
298 +
        Ok(())
299 +
    }
300 +
301 +
    /// Whether the projections equal a rebuild from the ledger: rebuild in
302 +
    /// a transaction, compare, and roll back. Returns the tables that differ.
303 +
    pub async fn check(&self) -> Result<Vec<String>> {
304 +
        let _head = self.head.lock().await;
305 +
        let mut tx = self.pool.begin().await?;
306 +
        let mut before = Vec::new();
307 +
        for t in projections::TABLES {
308 +
            before.push(dump(&mut tx, t).await?);
309 +
        }
310 +
        replay(&mut tx).await?;
311 +
        let mut differ = Vec::new();
312 +
        for (t, b) in projections::TABLES.iter().zip(before) {
313 +
            if dump(&mut tx, t).await? != b {
314 +
                differ.push(t.to_string());
315 +
            }
316 +
        }
317 +
        tx.rollback().await?;
318 +
        Ok(differ)
319 +
    }
320 +
}
321 +
322 +
/// `"spectra1 checkpoint " || hash || seq (8 bytes, big-endian)`.
323 +
pub fn checkpoint_message(seq: u64, hash: &[u8; 32]) -> Vec<u8> {
324 +
    let mut m = CHECKPOINT_DOMAIN.to_vec();
325 +
    m.extend_from_slice(hash);
326 +
    m.extend_from_slice(&seq.to_be_bytes());
327 +
    m
328 +
}
329 +
330 +
fn check_checkpoint(e: &Event, prev: [u8; 32], instance: Option<&Identity>) -> std::result::Result<(), String> {
331 +
    let p = e.json();
332 +
    let seq = p.get("seq").and_then(|s| s.as_u64()).ok_or("no seq")?;
333 +
    let hash: [u8; 32] =
334 +
        p.get("hash").and_then(|h| h.as_str()).and_then(|h| hex::decode(h).ok()).and_then(|b| b.try_into().ok()).ok_or("no hash")?;
335 +
    if seq + 1 != e.seq || hash != prev {
336 +
        return Err("names a head other than the event before it".into());
337 +
    }
338 +
    let instance = instance.ok_or("no instance identity precedes it")?;
339 +
    if e.auth.key != Some(instance.hash().0) {
340 +
        return Err("signed by a key other than the instance's".into());
341 +
    }
342 +
    let proof = e.auth.proof.as_deref().ok_or("unsigned")?;
343 +
    let sig = oxy_core::cbor::decode(proof).ok().and_then(|v| Signature::from_value(&v).ok()).ok_or("malformed signature")?;
344 +
    oxy_core::identity::verify_challenge(instance, &sig, &checkpoint_message(seq, &hash))
345 +
        .map_err(|e| format!("signature does not verify: {e}"))
346 +
}
347 +
348 +
/// Checks the proof of a signed event: the identities and delegations that
349 +
/// say whose key it was (spec §8.5). Principals supply it (`principals`).
350 +
pub trait KeyResolver: Send + Sync {
351 +
    fn check(&self, e: &Event) -> std::result::Result<(), String>;
352 +
}
353 +
354 +
/// A resolver that knows no keys: every signed event is reported.
355 +
pub struct NoKeys;
356 +
impl KeyResolver for NoKeys {
357 +
    fn check(&self, _: &Event) -> std::result::Result<(), String> {
358 +
        Err("no identity is known for its key".into())
359 +
    }
360 +
}
361 +
362 +
/// What `verify` found.
363 +
#[derive(Clone, Debug, Default, serde::Serialize)]
364 +
pub struct Verification {
365 +
    pub events: u64,
366 +
    pub checkpoints: u64,
367 +
    pub last_checkpoint: Option<u64>,
368 +
    /// Events after the last good checkpoint: chained, not yet signed.
369 +
    pub uncovered: u64,
370 +
    #[serde(serialize_with = "ser_hex")]
371 +
    pub head: [u8; 32],
372 +
    pub findings: Vec<Finding>,
373 +
}
374 +
375 +
fn ser_hex<S: serde::Serializer>(h: &[u8; 32], s: S) -> std::result::Result<S::Ok, S::Error> {
376 +
    s.serialize_str(&hex::encode(h))
377 +
}
378 +
379 +
impl Verification {
380 +
    pub fn clean(&self) -> bool {
381 +
        self.findings.is_empty()
382 +
    }
383 +
    fn finding(&mut self, seq: u64, problem: Problem, detail: String) {
384 +
        self.findings.push(Finding { seq, problem, detail });
385 +
    }
386 +
}
387 +
388 +
/// One thing wrong with the ledger.
389 +
#[derive(Clone, Debug, serde::Serialize)]
390 +
pub struct Finding {
391 +
    pub seq: u64,
392 +
    pub problem: Problem,
393 +
    pub detail: String,
394 +
}
395 +
396 +
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
397 +
#[serde(rename_all = "snake_case")]
398 +
pub enum Problem {
399 +
    Gap,
400 +
    ChainBreak,
401 +
    HashMismatch,
402 +
    BadCheckpoint,
403 +
    BadProof,
404 +
}
405 +
406 +
const SELECT_EVENT: &str = "SELECT seq, id, at_ms, hlc_wall, hlc_logical, prev, hash, auth_key, auth_method, auth_assurance, auth_proof, kind, actor, session, project, subject, payload, trace FROM events";
407 +
408 +
async fn insert(conn: &mut sqlx::SqliteConnection, e: &Event) -> sqlx::Result<()> {
409 +
    sqlx::query(
410 +
        "INSERT INTO events (seq, id, at_ms, hlc_wall, hlc_logical, prev, hash, auth_key, auth_method, auth_assurance, auth_proof, kind, actor, session, project, subject, payload, trace) \
411 +
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
412 +
    )
413 +
    .bind(e.seq as i64)
414 +
    .bind(&e.id)
415 +
    .bind(e.at_ms as i64)
416 +
    .bind(e.hlc.wall_ms as i64)
417 +
    .bind(e.hlc.logical as i64)
418 +
    .bind(e.prev.to_vec())
419 +
    .bind(e.hash.to_vec())
420 +
    .bind(e.auth.key.map(|k| k.to_vec()))
421 +
    .bind(e.auth.method as i64)
422 +
    .bind(e.auth.assurance as i64)
423 +
    .bind(e.auth.proof.clone())
424 +
    .bind(&e.kind)
425 +
    .bind(&e.actor)
426 +
    .bind(&e.session)
427 +
    .bind(&e.project)
428 +
    .bind(&e.subject)
429 +
    .bind(e.payload.clone())
430 +
    .bind(&e.trace)
431 +
    .execute(conn)
432 +
    .await?;
433 +
    Ok(())
434 +
}
435 +
436 +
fn row_to_event(r: &SqliteRow) -> Result<Event> {
437 +
    Ok(Event {
438 +
        seq: r.get::<i64, _>(0) as u64,
439 +
        id: r.get(1),
440 +
        at_ms: r.get::<i64, _>(2) as u64,
441 +
        hlc: Hlc { wall_ms: r.get::<i64, _>(3) as u64, logical: r.get::<i64, _>(4) as u32 },
442 +
        prev: to32(r.get(5))?,
443 +
        hash: to32(r.get(6))?,
444 +
        auth: Auth {
445 +
            key: r.get::<Option<Vec<u8>>, _>(7).map(to32).transpose()?,
446 +
            method: Method::from_u8(r.get::<i64, _>(8) as u8).context("unknown auth method")?,
447 +
            assurance: r.get::<i64, _>(9) as u8,
448 +
            proof: r.get(10),
449 +
        },
450 +
        kind: r.get(11),
451 +
        actor: r.get(12),
452 +
        session: r.get(13),
453 +
        project: r.get(14),
454 +
        subject: r.get(15),
455 +
        payload: r.get(16),
456 +
        trace: r.get(17),
457 +
    })
458 +
}
459 +
460 +
fn to32(v: Vec<u8>) -> Result<[u8; 32]> {
461 +
    v.try_into().map_err(|_| anyhow::anyhow!("a stored hash is not 32 bytes"))
462 +
}
463 +
464 +
async fn replay(conn: &mut sqlx::SqliteConnection) -> Result<()> {
465 +
    for t in projections::TABLES {
466 +
        sqlx::query(&format!("DELETE FROM {t}")).execute(&mut *conn).await?;
467 +
    }
468 +
    let mut from = 1i64;
469 +
    loop {
470 +
        let rows = sqlx::query(&format!("{SELECT_EVENT} WHERE seq >= ? ORDER BY seq LIMIT 2000")).bind(from).fetch_all(&mut *conn).await?;
471 +
        if rows.is_empty() {
472 +
            break;
473 +
        }
474 +
        for r in &rows {
475 +
            let e = row_to_event(r)?;
476 +
            projections::apply(conn, &e).await?;
477 +
            from = e.seq as i64 + 1;
478 +
        }
479 +
    }
480 +
    Ok(())
481 +
}
482 +
483 +
async fn dump(conn: &mut sqlx::SqliteConnection, table: &str) -> Result<Vec<String>> {
484 +
    // Every column as text, rows in a fixed order: enough to compare.
485 +
    let rows = sqlx::query(&format!("SELECT * FROM {table} ORDER BY 1, 2")).fetch_all(&mut *conn).await?;
486 +
    Ok(rows
487 +
        .iter()
488 +
        .map(|r| {
489 +
            (0..r.columns().len())
490 +
                .map(|i| match r.try_get::<Option<String>, _>(i) {
491 +
                    Ok(v) => format!("{v:?}"),
492 +
                    Err(_) => match r.try_get::<Option<i64>, _>(i) {
493 +
                        Ok(n) => format!("{n:?}"),
494 +
                        Err(_) => format!("{:?}", r.try_get::<Option<Vec<u8>>, _>(i).ok().flatten()),
495 +
                    },
496 +
                })
497 +
                .collect::<Vec<_>>()
498 +
                .join("|")
499 +
        })
500 +
        .collect())
501 +
}
502 +
503 +
/// The identity of `key` among `identities`, by KeyId.
504 +
pub fn find_identity<'a>(identities: &'a [Identity], key: &KeyId) -> Option<&'a Identity> {
505 +
    identities.iter().find(|i| &i.hash() == key)
506 +
}
added crates/spectra-core/src/projections.rs +19 −0
1 +
//! Projections (spec §9.3): tables kept from the ledger in the same
2 +
//! transaction as each append, and rebuilt from it by `spectra admin
3 +
//! rebuild`. Every table here must be a function of the events alone; the
4 +
//! test harness and `spectra admin check` hold them to it.
5 +
6 +
use crate::event::Event;
7 +
use sqlx::SqliteConnection;
8 +
9 +
/// The projection tables, in the order they are cleared and compared.
10 +
pub const TABLES: &[&str] = &["event_counts"];
11 +
12 +
/// Apply one event to every projection.
13 +
pub async fn apply(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> {
14 +
    sqlx::query("INSERT INTO event_counts (kind, n) VALUES (?, 1) ON CONFLICT(kind) DO UPDATE SET n = n + 1")
15 +
        .bind(&e.kind)
16 +
        .execute(&mut *conn)
17 +
        .await?;
18 +
    Ok(())
19 +
}
added crates/spectra-core/tests/ledger.rs +194 −0
1 +
//! The ledger's promises (spec §9.5, goal G6): an untouched ledger verifies,
2 +
//! with checkpoints signed by the instance key; an edited, removed or
3 +
//! re-signed event is caught; projections always equal a rebuild.
4 +
5 +
use rand::{Rng, SeedableRng};
6 +
use spectra_core::event::NewEvent;
7 +
use spectra_core::keys::KeyPair;
8 +
use spectra_core::ledger::{Ledger, NoKeys, Problem, CHECKPOINT_EVERY};
9 +
use std::path::Path;
10 +
11 +
async fn open(dir: &Path) -> Ledger {
12 +
    let instance = KeyPair::instance(dir, "test-instance").unwrap();
13 +
    let ledger = Ledger::open(&dir.join("spectra.db"), Some(instance.clone())).await.unwrap();
14 +
    if ledger.head().await.0 == 0 {
15 +
        ledger
16 +
            .append_one(NewEvent::system(
17 +
                "identity.instance",
18 +
                serde_json::json!({ "key": instance.key.to_hex(), "identity": hex::encode(instance.identity.encode()) }),
19 +
            ))
20 +
            .await
21 +
            .unwrap();
22 +
    }
23 +
    ledger
24 +
}
25 +
26 +
fn msg(i: usize) -> NewEvent {
27 +
    NewEvent::system("test.message", serde_json::json!({ "n": i, "body": format!("message {i}") })).subject(format!("m{i}"))
28 +
}
29 +
30 +
async fn raw(dir: &Path, sql: &str) {
31 +
    let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", dir.join("spectra.db").display())).await.unwrap();
32 +
    sqlx::query(sql).execute(&pool).await.unwrap();
33 +
    pool.close().await;
34 +
}
35 +
36 +
#[tokio::test]
37 +
async fn an_untouched_ledger_verifies_with_signed_checkpoints() {
38 +
    let dir = tempfile::tempdir().unwrap();
39 +
    let ledger = open(dir.path()).await;
40 +
    for i in 0..(CHECKPOINT_EVERY as usize + 10) {
41 +
        ledger.append_one(msg(i)).await.unwrap();
42 +
    }
43 +
    let cp = ledger.checkpoint().await.unwrap().expect("events since the last checkpoint");
44 +
    assert_eq!(cp.kind, "ledger.checkpoint");
45 +
    let v = ledger.verify(&NoKeys).await.unwrap();
46 +
    assert!(v.clean(), "{:?}", v.findings);
47 +
    assert_eq!(v.checkpoints, 2, "one at 256 events, one on demand");
48 +
    assert_eq!(v.uncovered, 0);
49 +
    assert_eq!(v.head, ledger.head().await.1);
50 +
    assert!(ledger.checkpoint().await.unwrap().is_none(), "nothing new to cover");
51 +
}
52 +
53 +
#[tokio::test]
54 +
async fn an_edited_event_is_caught() {
55 +
    let dir = tempfile::tempdir().unwrap();
56 +
    let ledger = open(dir.path()).await;
57 +
    for i in 0..20 {
58 +
        ledger.append_one(msg(i)).await.unwrap();
59 +
    }
60 +
    ledger.checkpoint().await.unwrap();
61 +
    drop(ledger);
62 +
    raw(dir.path(), r#"UPDATE events SET payload = CAST('{"body":"message 5, edited","n":5}' AS BLOB) WHERE subject = 'm5'"#).await;
63 +
    let v = open(dir.path()).await.verify(&NoKeys).await.unwrap();
64 +
    assert_eq!(v.findings.len(), 1, "{:?}", v.findings);
65 +
    assert_eq!(v.findings[0].problem, Problem::HashMismatch);
66 +
}
67 +
68 +
#[tokio::test]
69 +
async fn an_edit_with_its_hash_recomputed_breaks_the_chain_or_the_checkpoint() {
70 +
    let dir = tempfile::tempdir().unwrap();
71 +
    let ledger = open(dir.path()).await;
72 +
    for i in 0..20 {
73 +
        ledger.append_one(msg(i)).await.unwrap();
74 +
    }
75 +
    ledger.checkpoint().await.unwrap();
76 +
    // A careful forger recomputes the edited event's hash: the next event's
77 +
    // prev no longer matches, and the checkpoint names the old head.
78 +
    let mut e = ledger.events(1, 100).await.unwrap().into_iter().find(|e| e.subject.as_deref() == Some("m19")).unwrap();
79 +
    e.payload = Some(br#"{"body":"forged","n":19}"#.to_vec());
80 +
    let forged = hex::encode(e.computed_hash());
81 +
    drop(ledger);
82 +
    raw(
83 +
        dir.path(),
84 +
        &format!(r#"UPDATE events SET payload = CAST('{{"body":"forged","n":19}}' AS BLOB), hash = X'{forged}' WHERE seq = {}"#, e.seq),
85 +
    )
86 +
    .await;
87 +
    let v = open(dir.path()).await.verify(&NoKeys).await.unwrap();
88 +
    assert!(v.findings.iter().any(|f| f.problem == Problem::ChainBreak || f.problem == Problem::BadCheckpoint), "{:?}", v.findings);
89 +
}
90 +
91 +
#[tokio::test]
92 +
async fn a_removed_event_is_caught() {
93 +
    let dir = tempfile::tempdir().unwrap();
94 +
    let ledger = open(dir.path()).await;
95 +
    for i in 0..10 {
96 +
        ledger.append_one(msg(i)).await.unwrap();
97 +
    }
98 +
    drop(ledger);
99 +
    raw(dir.path(), "DELETE FROM events WHERE subject = 'm4'").await;
100 +
    let v = open(dir.path()).await.verify(&NoKeys).await.unwrap();
101 +
    assert!(v.findings.iter().any(|f| f.problem == Problem::Gap), "{:?}", v.findings);
102 +
    assert!(v.findings.iter().any(|f| f.problem == Problem::ChainBreak), "{:?}", v.findings);
103 +
}
104 +
105 +
#[tokio::test]
106 +
async fn a_checkpoint_signed_by_another_key_is_caught() {
107 +
    let dir = tempfile::tempdir().unwrap();
108 +
    let ledger = open(dir.path()).await;
109 +
    ledger.append_one(msg(1)).await.unwrap();
110 +
    let cp = ledger.checkpoint().await.unwrap().unwrap();
111 +
    drop(ledger);
112 +
    let mallory = KeyPair::generate("mallory", "");
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()));
115 +
    raw(dir.path(), &format!("UPDATE events SET auth_proof = X'{proof}' WHERE seq = {}", cp.seq)).await;
116 +
    let v = open(dir.path()).await.verify(&NoKeys).await.unwrap();
117 +
    assert_eq!(v.findings.len(), 1, "{:?}", v.findings);
118 +
    assert_eq!(v.findings[0].problem, Problem::BadCheckpoint);
119 +
}
120 +
121 +
#[tokio::test]
122 +
async fn projections_equal_a_rebuild_over_ten_thousand_random_events() {
123 +
    let dir = tempfile::tempdir().unwrap();
124 +
    let ledger = open(dir.path()).await;
125 +
    let mut rng = rand::rngs::StdRng::seed_from_u64(7);
126 +
    let kinds = ["message.posted", "agent.presence", "task.created", "claim.taken", "tool.called"];
127 +
    let started = std::time::Instant::now();
128 +
    let mut appended = 0;
129 +
    while appended < 10_000 {
130 +
        let n = rng.gen_range(1..50);
131 +
        let batch = (0..n)
132 +
            .map(|i| {
133 +
                NewEvent::system(kinds[rng.gen_range(0..kinds.len())], serde_json::json!({ "i": appended + i, "r": rng.gen::<u32>() }))
134 +
            })
135 +
            .collect();
136 +
        ledger.append(batch).await.unwrap();
137 +
        appended += n;
138 +
    }
139 +
    let per_batch_ms = started.elapsed().as_millis() as f64 / (10_000.0 / 25.0);
140 +
    assert!(ledger.check().await.unwrap().is_empty(), "projections drifted from the ledger");
141 +
    ledger.rebuild().await.unwrap();
142 +
    assert!(ledger.check().await.unwrap().is_empty());
143 +
    let v = ledger.verify(&NoKeys).await.unwrap();
144 +
    assert!(v.clean(), "{:?}", &v.findings[..v.findings.len().min(3)]);
145 +
    assert!(v.checkpoints >= 10_000 / CHECKPOINT_EVERY);
146 +
    // Generous in a debug build; the release number is in docs/plan.md.
147 +
    assert!(per_batch_ms < 200.0, "{per_batch_ms} ms a batch");
148 +
}
149 +
150 +
#[tokio::test]
151 +
async fn a_ledger_from_a_newer_spectra_is_refused() {
152 +
    let dir = tempfile::tempdir().unwrap();
153 +
    drop(open(dir.path()).await);
154 +
    raw(dir.path(), "UPDATE meta SET value = '99' WHERE key = 'ledger_format'").await;
155 +
    let err = Ledger::open(&dir.path().join("spectra.db"), None).await.err().expect("refused");
156 +
    assert!(err.to_string().contains("newer Spectra"), "{err}");
157 +
}
158 +
159 +
#[tokio::test]
160 +
async fn the_bus_delivers_what_was_appended_in_order() {
161 +
    let dir = tempfile::tempdir().unwrap();
162 +
    let ledger = open(dir.path()).await;
163 +
    let mut rx = ledger.subscribe();
164 +
    ledger.append(vec![msg(1), msg(2)]).await.unwrap();
165 +
    assert_eq!(rx.recv().await.unwrap().subject.as_deref(), Some("m1"));
166 +
    assert_eq!(rx.recv().await.unwrap().subject.as_deref(), Some("m2"));
167 +
}
168 +
169 +
#[tokio::test]
170 +
async fn reopening_continues_the_chain() {
171 +
    let dir = tempfile::tempdir().unwrap();
172 +
    let ledger = open(dir.path()).await;
173 +
    ledger.append_one(msg(1)).await.unwrap();
174 +
    let (seq, hash) = ledger.head().await;
175 +
    drop(ledger);
176 +
    let ledger = open(dir.path()).await;
177 +
    let e = ledger.append_one(msg(2)).await.unwrap();
178 +
    assert_eq!((e.seq, e.prev), (seq + 1, hash));
179 +
    assert!(ledger.verify(&NoKeys).await.unwrap().clean());
180 +
}
181 +
182 +
#[tokio::test]
183 +
async fn a_single_append_is_fast() {
184 +
    let dir = tempfile::tempdir().unwrap();
185 +
    let ledger = open(dir.path()).await;
186 +
    let started = std::time::Instant::now();
187 +
    for i in 0..1_000 {
188 +
        ledger.append_one(msg(i)).await.unwrap();
189 +
    }
190 +
    let each_us = started.elapsed().as_micros() / 1_000;
191 +
    eprintln!("append: {each_us} µs an event over 1,000 single appends");
192 +
    // The target is 10 ms with fsync (spec §24); debug builds get headroom.
193 +
    assert!(each_us < 50_000, "{each_us} µs");
194 +
}
added src/cli/admin.rs +55 −0
1 +
//! `spectra admin`: the ledger's own checks (spec §9.5, §19).
2 +
3 +
use super::{ui, Ctx};
4 +
use anyhow::Result;
5 +
use spectra_core::ledger::{Ledger, NoKeys};
6 +
7 +
/// `spectra admin verify`: recompute the chain, check every checkpoint and
8 +
/// proof; exit 1 when anything is wrong.
9 +
pub async fn verify(ctx: &Ctx, json: bool) -> Result<i32> {
10 +
    let ledger = Ledger::open(&ctx.db()?, None).await?;
11 +
    let v = ledger.verify(&NoKeys).await?;
12 +
    if json {
13 +
        println!("{}", serde_json::to_string(&v)?);
14 +
    } else if v.clean() {
15 +
        println!("{} ledger verified", ui::done(ui::glyph("✔", "ok")));
16 +
        println!("{}", ui::kv("events", v.events));
17 +
        println!(
18 +
            "{}",
19 +
            ui::kv(
20 +
                "checkpoints",
21 +
                format!("{} (last at {})", v.checkpoints, v.last_checkpoint.map(|s| s.to_string()).unwrap_or("none".into()))
22 +
            )
23 +
        );
24 +
        println!("{}", ui::kv("uncovered", format!("{} event(s) after the last checkpoint", v.uncovered)));
25 +
        println!("{}", ui::kv("head", ui::hash(hex::encode(v.head))));
26 +
    } else {
27 +
        println!("{} ledger does not verify: {} finding(s)", ui::err(ui::glyph("✘", "x")), v.findings.len());
28 +
        for f in &v.findings {
29 +
            println!("  {} {:?}: {}", ui::id(format!("#{}", f.seq)), f.problem, f.detail);
30 +
        }
31 +
    }
32 +
    Ok(if v.clean() { 0 } else { 1 })
33 +
}
34 +
35 +
/// `spectra admin check`: do the projections equal a rebuild?
36 +
pub async fn check(ctx: &Ctx) -> Result<i32> {
37 +
    let ledger = Ledger::open(&ctx.db()?, None).await?;
38 +
    let differ = ledger.check().await?;
39 +
    if differ.is_empty() {
40 +
        println!("{} projections match the ledger", ui::done(ui::glyph("✔", "ok")));
41 +
        Ok(0)
42 +
    } else {
43 +
        println!("{} projections differ from the ledger: {}", ui::err(ui::glyph("✘", "x")), differ.join(", "));
44 +
        println!("  run {} to rebuild them", ui::id("spectra admin rebuild"));
45 +
        Ok(1)
46 +
    }
47 +
}
48 +
49 +
/// `spectra admin rebuild`: replay the ledger into every projection.
50 +
pub async fn rebuild(ctx: &Ctx) -> Result<i32> {
51 +
    let ledger = Ledger::open(&ctx.db()?, None).await?;
52 +
    ledger.rebuild().await?;
53 +
    println!("{} projections rebuilt from {} event(s)", ui::done(ui::glyph("✔", "ok")), ledger.head().await.0);
54 +
    Ok(0)
55 +
}
added tests/cli_admin.rs +67 −0
1 +
//! `spectra admin verify|check|rebuild` against a real ledger, run as the
2 +
//! built binary in a hermetic environment: a temporary data directory and
3 +
//! `OXY_HOME`, never the user's own.
4 +
5 +
use spectra_core::event::NewEvent;
6 +
use spectra_core::keys::KeyPair;
7 +
use spectra_core::ledger::Ledger;
8 +
use std::path::Path;
9 +
use std::process::Command;
10 +
11 +
/// The binary with every directory it might read pointed at `dir`.
12 +
fn hermetic(dir: &Path) -> Command {
13 +
    let mut c = Command::new(env!("CARGO_BIN_EXE_spectra"));
14 +
    c.env("SPECTRA_DATA", dir.join("data"))
15 +
        .env("OXY_HOME", dir.join("oxy"))
16 +
        .env("HOME", dir)
17 +
        .env("XDG_DATA_HOME", dir.join("xdg"))
18 +
        .env("NO_COLOR", "1");
19 +
    c
20 +
}
21 +
22 +
async fn ledger_with_events(data: &Path, n: usize) {
23 +
    std::fs::create_dir_all(data).unwrap();
24 +
    let instance = KeyPair::instance(data, "test").unwrap();
25 +
    let ledger = Ledger::open(&data.join("spectra.db"), Some(instance.clone())).await.unwrap();
26 +
    ledger
27 +
        .append_one(NewEvent::system("identity.instance", serde_json::json!({ "identity": hex::encode(instance.identity.encode()) })))
28 +
        .await
29 +
        .unwrap();
30 +
    for i in 0..n {
31 +
        ledger.append_one(NewEvent::system("test.message", serde_json::json!({ "n": i }))).await.unwrap();
32 +
    }
33 +
    ledger.checkpoint().await.unwrap();
34 +
}
35 +
36 +
#[tokio::test]
37 +
async fn verify_check_and_rebuild_on_a_clean_and_a_tampered_ledger() {
38 +
    let dir = tempfile::tempdir().unwrap();
39 +
    let data = dir.path().join("data");
40 +
    ledger_with_events(&data, 40).await;
41 +
42 +
    let out = hermetic(dir.path()).args(["admin", "verify", "--json"]).output().unwrap();
43 +
    assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
44 +
    let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("one JSON document on stdout");
45 +
    assert_eq!(v["events"], 42);
46 +
    assert_eq!(v["findings"].as_array().unwrap().len(), 0);
47 +
    assert_eq!(v["uncovered"], 0);
48 +
49 +
    assert!(hermetic(dir.path()).args(["admin", "check"]).status().unwrap().success());
50 +
    assert!(hermetic(dir.path()).args(["admin", "rebuild"]).status().unwrap().success());
51 +
52 +
    let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", data.join("spectra.db").display())).await.unwrap();
53 +
    sqlx::query("UPDATE events SET actor = 'agent:mallory' WHERE seq = 10").execute(&pool).await.unwrap();
54 +
    pool.close().await;
55 +
    let out = hermetic(dir.path()).args(["admin", "verify"]).output().unwrap();
56 +
    assert_eq!(out.status.code(), Some(1));
57 +
    let text = String::from_utf8_lossy(&out.stdout);
58 +
    assert!(text.contains("does not verify") && text.contains("#10") && text.contains("HashMismatch"), "{text}");
59 +
}
60 +
61 +
#[test]
62 +
fn a_missing_data_directory_says_what_to_do() {
63 +
    let dir = tempfile::tempdir().unwrap();
64 +
    let out = hermetic(dir.path()).args(["admin", "verify"]).output().unwrap();
65 +
    assert_eq!(out.status.code(), Some(1));
66 +
    assert!(String::from_utf8_lossy(&out.stderr).contains("spectra init"));
67 +
}
modified .flow/pipeline.kdl +5 −4
…
32 unchanged lines
33 33
    network "isolated"
34 34
}
35 35
36 -
// Spectra's own packages only: `--all` would reach the pinned Oxygen in
37 -
// deps/, which has its own formatting (as in oxygenhub).
36 +
// Spectra's own packages only, here and in clippy and test: `--all` and
37 +
// `--workspace` would reach the pinned Oxygen in deps/, which has its own
38 +
// formatting, lints and tests (as in oxygenhub).
38 39
job "fmt" {
39 40
    sandbox "rust-small"
40 41
    exec "cargo" "fmt" "-p" "spectra" "-p" "spectra-core" "-p" "spectra-mcp" "-p" "spectra-api" "-p" "radar" "--check"
…
16 unchanged lines
57 58
    env {
58 59
        SPECTRA_BUILD "ci"
59 60
    }
60 -
    exec "cargo" "clippy" "--workspace" "--all-targets" "--locked" "--" "-D" "warnings"
61 +
    exec "cargo" "clippy" "-p" "spectra" "-p" "spectra-core" "-p" "spectra-mcp" "-p" "spectra-api" "-p" "radar" "--all-targets" "--locked" "--" "-D" "warnings"
61 62
}
62 63
63 64
// The tests, run under cargo-nextest and cargo-llvm-cov: a test report
…
12 unchanged lines
76 77
    env {
77 78
        SPECTRA_BUILD "ci"
78 79
    }
79 -
    exec "cargo" "llvm-cov" "nextest" "--workspace" "--locked" "--profile" "ci" "--lcov" "--output-path" "/src/target/lcov.info"
80 +
    exec "cargo" "llvm-cov" "nextest" "-p" "spectra" "-p" "spectra-core" "-p" "spectra-mcp" "-p" "spectra-api" "-p" "radar" "--locked" "--profile" "ci" "--lcov" "--output-path" "/src/target/lcov.info"
80 81
    output {
81 82
        artifact "junit" {
82 83
            path "/src/target/nextest/ci/junit.xml"
…
27 unchanged lines
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Added: the ledger (#3).** Every event is deterministic CBOR (through `oxy-core`) hashed with a domain-separated BLAKE3 and chained to the one before; the envelope, with its hybrid logical clock, is frozen. The instance key signs a checkpoint every 256 events, every ten minutes while writing, and on demand. `spectra admin verify [--json]` recomputes the chain and checks every checkpoint, catching an edited, removed or re-signed event; `spectra admin check` and `rebuild` keep projections equal to the ledger. A single append takes 0.15 ms.
5 6
- **Added: CI on OxygenFlow (#2).** `.flow/pipeline.kdl` in the shape of Oxygen's: fmt, clippy, test (nextest under llvm-cov, with JUnit and coverage for the hub) and build, in the vendored image with no network.
6 7
- **Added: the project skeleton (#1).** A Cargo workspace in Oxygen's shape (`spectra-core`, `spectra-mcp`, `spectra-api`, `radar` and the `spectra` binary), Oxygen pinned under `deps/oxygen` for identities and signatures, `make check`, the changelog check, `spectra version [--json]` with the build identity, and `scripts/deps-check.sh` to keep Spectra buildable by the vendored OxygenFlow image.
modified Cargo.lock +1016 −35
…
17 unchanged lines
18 18
]
19 19
20 20
[[package]]
21 +
name = "allocator-api2"
22 +
version = "0.2.21"
23 +
source = "registry+https://github.com/rust-lang/crates.io-index"
24 +
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
25 +
26 +
[[package]]
21 27
name = "anstream"
22 28
version = "1.0.0"
23 29
source = "registry+https://github.com/rust-lang/crates.io-index"
…
54 unchanged lines
78 84
version = "0.7.8"
79 85
source = "registry+https://github.com/rust-lang/crates.io-index"
80 86
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
87 +
88 +
[[package]]
89 +
name = "atoi"
90 +
version = "2.0.0"
91 +
source = "registry+https://github.com/rust-lang/crates.io-index"
92 +
checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
93 +
dependencies = [
94 +
 "num-traits",
95 +
]
81 96
82 97
[[package]]
83 98
name = "autocfg"
…
33 unchanged lines
117 132
version = "2.13.2"
118 133
source = "registry+https://github.com/rust-lang/crates.io-index"
119 134
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
135 +
dependencies = [
136 +
 "serde_core",
137 +
]
120 138
121 139
[[package]]
122 140
name = "blake3"
…
5 unchanged lines
128 146
 "cc",
129 147
 "cfg-if",
130 148
 "constant_time_eq",
131 -
 "cpufeatures",
149 +
 "cpufeatures 0.3.1",
132 150
 "rayon-core",
151 +
]
152 +
153 +
[[package]]
154 +
name = "block-buffer"
155 +
version = "0.10.4"
156 +
source = "registry+https://github.com/rust-lang/crates.io-index"
157 +
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
158 +
dependencies = [
159 +
 "generic-array",
133 160
]
134 161
135 162
[[package]]
…
22 unchanged lines
158 185
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
159 186
160 187
[[package]]
188 +
name = "byteorder"
189 +
version = "1.5.0"
190 +
source = "registry+https://github.com/rust-lang/crates.io-index"
191 +
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
192 +
193 +
[[package]]
161 194
name = "bytes"
162 195
version = "1.12.1"
163 196
source = "registry+https://github.com/rust-lang/crates.io-index"
…
81 unchanged lines
245 278
 "unicode-width",
246 279
 "windows-sys 0.59.0",
247 280
]
281 +
282 +
[[package]]
283 +
name = "const-oid"
284 +
version = "0.9.6"
285 +
source = "registry+https://github.com/rust-lang/crates.io-index"
286 +
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
248 287
249 288
[[package]]
250 289
name = "const-oid"
…
9 unchanged lines
260 299
261 300
[[package]]
262 301
name = "cpufeatures"
302 +
version = "0.2.17"
303 +
source = "registry+https://github.com/rust-lang/crates.io-index"
304 +
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
305 +
dependencies = [
306 +
 "libc",
307 +
]
308 +
309 +
[[package]]
310 +
name = "cpufeatures"
263 311
version = "0.3.1"
264 312
source = "registry+https://github.com/rust-lang/crates.io-index"
265 313
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
…
2 unchanged lines
268 316
]
269 317
270 318
[[package]]
319 +
name = "crc"
320 +
version = "3.4.0"
321 +
source = "registry+https://github.com/rust-lang/crates.io-index"
322 +
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
323 +
dependencies = [
324 +
 "crc-catalog",
325 +
]
326 +
327 +
[[package]]
328 +
name = "crc-catalog"
329 +
version = "2.5.0"
330 +
source = "registry+https://github.com/rust-lang/crates.io-index"
331 +
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
332 +
333 +
[[package]]
271 334
name = "crc32fast"
272 335
version = "1.5.2"
273 336
source = "registry+https://github.com/rust-lang/crates.io-index"
…
22 unchanged lines
296 359
]
297 360
298 361
[[package]]
362 +
name = "crossbeam-queue"
363 +
version = "0.3.14"
364 +
source = "registry+https://github.com/rust-lang/crates.io-index"
365 +
checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
366 +
dependencies = [
367 +
 "crossbeam-utils",
368 +
]
369 +
370 +
[[package]]
299 371
name = "crossbeam-utils"
300 372
version = "0.8.23"
301 373
source = "registry+https://github.com/rust-lang/crates.io-index"
302 374
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
375 +
376 +
[[package]]
377 +
name = "crypto-common"
378 +
version = "0.1.7"
379 +
source = "registry+https://github.com/rust-lang/crates.io-index"
380 +
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
381 +
dependencies = [
382 +
 "generic-array",
383 +
 "typenum",
384 +
]
303 385
304 386
[[package]]
305 387
name = "crypto-common"
…
22 unchanged lines
328 410
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
329 411
dependencies = [
330 412
 "cfg-if",
331 -
 "cpufeatures",
413 +
 "cpufeatures 0.3.1",
332 414
 "curve25519-dalek-derive",
333 -
 "digest",
415 +
 "digest 0.11.3",
334 416
 "fiat-crypto",
335 417
 "rustc_version",
336 418
 "subtle",
…
13 unchanged lines
350 432
351 433
[[package]]
352 434
name = "der"
435 +
version = "0.7.10"
436 +
source = "registry+https://github.com/rust-lang/crates.io-index"
437 +
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
438 +
dependencies = [
439 +
 "const-oid 0.9.6",
440 +
 "pem-rfc7468",
441 +
 "zeroize",
442 +
]
443 +
444 +
[[package]]
445 +
name = "der"
353 446
version = "0.8.2"
354 447
source = "registry+https://github.com/rust-lang/crates.io-index"
355 448
checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
356 449
dependencies = [
357 -
 "const-oid",
450 +
 "const-oid 0.10.2",
358 451
 "zeroize",
452 +
]
453 +
454 +
[[package]]
455 +
name = "digest"
456 +
version = "0.10.7"
457 +
source = "registry+https://github.com/rust-lang/crates.io-index"
458 +
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
459 +
dependencies = [
460 +
 "block-buffer 0.10.4",
461 +
 "const-oid 0.9.6",
462 +
 "crypto-common 0.1.7",
463 +
 "subtle",
359 464
]
360 465
361 466
[[package]]
…
2 unchanged lines
364 469
source = "registry+https://github.com/rust-lang/crates.io-index"
365 470
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
366 471
dependencies = [
367 -
 "block-buffer",
368 -
 "const-oid",
369 -
 "crypto-common",
472 +
 "block-buffer 0.12.1",
473 +
 "const-oid 0.10.2",
474 +
 "crypto-common 0.2.2",
370 475
]
371 476
372 477
[[package]]
…
8 unchanged lines
381 486
]
382 487
383 488
[[package]]
489 +
name = "dotenvy"
490 +
version = "0.15.7"
491 +
source = "registry+https://github.com/rust-lang/crates.io-index"
492 +
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
493 +
494 +
[[package]]
384 495
name = "ed25519"
385 496
version = "3.0.0"
386 497
source = "registry+https://github.com/rust-lang/crates.io-index"
387 498
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
388 499
dependencies = [
389 -
 "signature",
500 +
 "signature 3.0.0",
390 501
]
391 502
392 503
[[package]]
…
4 unchanged lines
397 508
dependencies = [
398 509
 "curve25519-dalek",
399 510
 "ed25519",
400 -
 "sha2",
511 +
 "sha2 0.11.0",
401 512
 "subtle",
402 513
 "zeroize",
403 514
]
…
3 unchanged lines
407 518
version = "1.18.0"
408 519
source = "registry+https://github.com/rust-lang/crates.io-index"
409 520
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
521 +
dependencies = [
522 +
 "serde",
523 +
]
410 524
411 525
[[package]]
412 526
name = "encode_unicode"
413 527
version = "1.0.0"
414 528
source = "registry+https://github.com/rust-lang/crates.io-index"
415 529
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
530 +
531 +
[[package]]
532 +
name = "equivalent"
533 +
version = "1.0.2"
534 +
source = "registry+https://github.com/rust-lang/crates.io-index"
535 +
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
416 536
417 537
[[package]]
418 538
name = "errno"
…
2 unchanged lines
421 541
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
422 542
dependencies = [
423 543
 "libc",
424 -
 "windows-sys 0.52.0",
544 +
 "windows-sys 0.61.2",
425 545
]
426 546
427 547
[[package]]
548 +
name = "etcetera"
549 +
version = "0.8.0"
550 +
source = "registry+https://github.com/rust-lang/crates.io-index"
551 +
checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
552 +
dependencies = [
553 +
 "cfg-if",
554 +
 "home",
555 +
 "windows-sys 0.48.0",
556 +
]
557 +
558 +
[[package]]
559 +
name = "event-listener"
560 +
version = "5.4.2"
561 +
source = "registry+https://github.com/rust-lang/crates.io-index"
562 +
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
563 +
dependencies = [
564 +
 "parking",
565 +
 "pin-project-lite",
566 +
]
567 +
568 +
[[package]]
428 569
name = "fastrand"
429 570
version = "2.5.0"
430 571
source = "registry+https://github.com/rust-lang/crates.io-index"
…
23 unchanged lines
454 595
]
455 596
456 597
[[package]]
598 +
name = "flume"
599 +
version = "0.11.1"
600 +
source = "registry+https://github.com/rust-lang/crates.io-index"
601 +
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
602 +
dependencies = [
603 +
 "futures-core",
604 +
 "futures-sink",
605 +
 "spin",
606 +
]
607 +
608 +
[[package]]
457 609
name = "fnv"
458 610
version = "1.0.7"
459 611
source = "registry+https://github.com/rust-lang/crates.io-index"
460 612
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
613 +
614 +
[[package]]
615 +
name = "foldhash"
616 +
version = "0.1.5"
617 +
source = "registry+https://github.com/rust-lang/crates.io-index"
618 +
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
461 619
462 620
[[package]]
463 621
name = "form_urlencoded"
…
2 unchanged lines
466 624
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
467 625
dependencies = [
468 626
 "percent-encoding",
627 +
]
628 +
629 +
[[package]]
630 +
name = "futures-channel"
631 +
version = "0.3.34"
632 +
source = "registry+https://github.com/rust-lang/crates.io-index"
633 +
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
634 +
dependencies = [
635 +
 "futures-core",
636 +
 "futures-sink",
469 637
]
470 638
471 639
[[package]]
…
3 unchanged lines
475 643
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
476 644
477 645
[[package]]
646 +
name = "futures-executor"
647 +
version = "0.3.34"
648 +
source = "registry+https://github.com/rust-lang/crates.io-index"
649 +
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
650 +
dependencies = [
651 +
 "futures-core",
652 +
 "futures-task",
653 +
 "futures-util",
654 +
]
655 +
656 +
[[package]]
657 +
name = "futures-intrusive"
658 +
version = "0.5.0"
659 +
source = "registry+https://github.com/rust-lang/crates.io-index"
660 +
checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
661 +
dependencies = [
662 +
 "futures-core",
663 +
 "lock_api",
664 +
 "parking_lot",
665 +
]
666 +
667 +
[[package]]
668 +
name = "futures-io"
669 +
version = "0.3.34"
670 +
source = "registry+https://github.com/rust-lang/crates.io-index"
671 +
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
672 +
673 +
[[package]]
674 +
name = "futures-sink"
675 +
version = "0.3.34"
676 +
source = "registry+https://github.com/rust-lang/crates.io-index"
677 +
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
678 +
679 +
[[package]]
478 680
name = "futures-task"
479 681
version = "0.3.34"
480 682
source = "registry+https://github.com/rust-lang/crates.io-index"
…
6 unchanged lines
487 689
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
488 690
dependencies = [
489 691
 "futures-core",
692 +
 "futures-io",
693 +
 "futures-sink",
490 694
 "futures-task",
695 +
 "memchr",
491 696
 "pin-project-lite",
492 697
 "slab",
698 +
]
699 +
700 +
[[package]]
701 +
name = "generic-array"
702 +
version = "0.14.7"
703 +
source = "registry+https://github.com/rust-lang/crates.io-index"
704 +
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
705 +
dependencies = [
706 +
 "typenum",
707 +
 "version_check",
493 708
]
494 709
495 710
[[package]]
…
42 unchanged lines
538 753
 "log",
539 754
 "regex-automata",
540 755
 "regex-syntax",
756 +
]
757 +
758 +
[[package]]
759 +
name = "hashbrown"
760 +
version = "0.15.5"
761 +
source = "registry+https://github.com/rust-lang/crates.io-index"
762 +
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
763 +
dependencies = [
764 +
 "allocator-api2",
765 +
 "equivalent",
766 +
 "foldhash",
767 +
]
768 +
769 +
[[package]]
770 +
name = "hashbrown"
771 +
version = "0.17.1"
772 +
source = "registry+https://github.com/rust-lang/crates.io-index"
773 +
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
774 +
775 +
[[package]]
776 +
name = "hashlink"
777 +
version = "0.10.0"
778 +
source = "registry+https://github.com/rust-lang/crates.io-index"
779 +
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
780 +
dependencies = [
781 +
 "hashbrown 0.15.5",
541 782
]
542 783
543 784
[[package]]
…
7 unchanged lines
551 792
version = "0.4.3"
552 793
source = "registry+https://github.com/rust-lang/crates.io-index"
553 794
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
795 +
796 +
[[package]]
797 +
name = "hkdf"
798 +
version = "0.12.4"
799 +
source = "registry+https://github.com/rust-lang/crates.io-index"
800 +
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
801 +
dependencies = [
802 +
 "hmac",
803 +
]
804 +
805 +
[[package]]
806 +
name = "hmac"
807 +
version = "0.12.1"
808 +
source = "registry+https://github.com/rust-lang/crates.io-index"
809 +
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
810 +
dependencies = [
811 +
 "digest 0.10.7",
812 +
]
813 +
814 +
[[package]]
815 +
name = "home"
816 +
version = "0.5.12"
817 +
source = "registry+https://github.com/rust-lang/crates.io-index"
818 +
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
819 +
dependencies = [
820 +
 "windows-sys 0.61.2",
821 +
]
554 822
555 823
[[package]]
556 824
name = "hybrid-array"
…
123 unchanged lines
680 948
 "same-file",
681 949
 "walkdir",
682 950
 "winapi-util",
951 +
]
952 +
953 +
[[package]]
954 +
name = "indexmap"
955 +
version = "2.14.2"
956 +
source = "registry+https://github.com/rust-lang/crates.io-index"
957 +
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
958 +
dependencies = [
959 +
 "equivalent",
960 +
 "hashbrown 0.17.1",
683 961
]
684 962
685 963
[[package]]
…
49 unchanged lines
735 1013
checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598"
736 1014
dependencies = [
737 1015
 "cfg-if",
738 -
 "cpufeatures",
1016 +
 "cpufeatures 0.3.1",
739 1017
]
740 1018
741 1019
[[package]]
1020 +
name = "lazy_static"
1021 +
version = "1.5.1"
1022 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1023 +
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
1024 +
dependencies = [
1025 +
 "spin",
1026 +
]
1027 +
1028 +
[[package]]
742 1029
name = "libc"
743 1030
version = "0.2.189"
744 1031
source = "registry+https://github.com/rust-lang/crates.io-index"
745 1032
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
1033 +
1034 +
[[package]]
1035 +
name = "libm"
1036 +
version = "0.2.16"
1037 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1038 +
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
746 1039
747 1040
[[package]]
748 1041
name = "libmimalloc-sys"
…
5 unchanged lines
754 1047
]
755 1048
756 1049
[[package]]
1050 +
name = "libredox"
1051 +
version = "0.1.25"
1052 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1053 +
checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
1054 +
dependencies = [
1055 +
 "bitflags",
1056 +
 "libc",
1057 +
 "plain",
1058 +
 "redox_syscall 0.9.4",
1059 +
]
1060 +
1061 +
[[package]]
1062 +
name = "libsqlite3-sys"
1063 +
version = "0.30.1"
1064 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1065 +
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
1066 +
dependencies = [
1067 +
 "cc",
1068 +
 "pkg-config",
1069 +
 "vcpkg",
1070 +
]
1071 +
1072 +
[[package]]
757 1073
name = "linux-raw-sys"
758 1074
version = "0.12.1"
759 1075
source = "registry+https://github.com/rust-lang/crates.io-index"
…
4 unchanged lines
764 1080
version = "0.8.3"
765 1081
source = "registry+https://github.com/rust-lang/crates.io-index"
766 1082
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
1083 +
1084 +
[[package]]
1085 +
name = "lock_api"
1086 +
version = "0.4.14"
1087 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1088 +
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
1089 +
dependencies = [
1090 +
 "scopeguard",
1091 +
]
767 1092
768 1093
[[package]]
769 1094
name = "log"
…
2 unchanged lines
772 1097
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
773 1098
774 1099
[[package]]
1100 +
name = "md-5"
1101 +
version = "0.10.6"
1102 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1103 +
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
1104 +
dependencies = [
1105 +
 "cfg-if",
1106 +
 "digest 0.10.7",
1107 +
]
1108 +
1109 +
[[package]]
775 1110
name = "memchr"
776 1111
version = "2.8.3"
777 1112
source = "registry+https://github.com/rust-lang/crates.io-index"
…
35 unchanged lines
813 1148
source = "registry+https://github.com/rust-lang/crates.io-index"
814 1149
checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d"
815 1150
dependencies = [
816 -
 "const-oid",
817 -
 "crypto-common",
1151 +
 "const-oid 0.10.2",
1152 +
 "crypto-common 0.2.2",
818 1153
 "ctutils",
819 1154
 "hybrid-array",
820 1155
 "module-lattice",
821 -
 "pkcs8",
1156 +
 "pkcs8 0.11.0",
822 1157
 "shake",
823 -
 "signature",
1158 +
 "signature 3.0.0",
824 1159
]
825 1160
826 1161
[[package]]
…
4 unchanged lines
831 1166
dependencies = [
832 1167
 "ctutils",
833 1168
 "hybrid-array",
1169 +
 "num-traits",
1170 +
]
1171 +
1172 +
[[package]]
1173 +
name = "num-bigint-dig"
1174 +
version = "0.8.6"
1175 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1176 +
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
1177 +
dependencies = [
1178 +
 "lazy_static",
1179 +
 "libm",
1180 +
 "num-integer",
1181 +
 "num-iter",
834 1182
 "num-traits",
1183 +
 "rand 0.8.8",
1184 +
 "smallvec",
1185 +
 "zeroize",
835 1186
]
836 1187
837 1188
[[package]]
1189 +
name = "num-integer"
1190 +
version = "0.1.47"
1191 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1192 +
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
1193 +
dependencies = [
1194 +
 "num-traits",
1195 +
]
1196 +
1197 +
[[package]]
1198 +
name = "num-iter"
1199 +
version = "0.1.46"
1200 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1201 +
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
1202 +
dependencies = [
1203 +
 "num-integer",
1204 +
 "num-traits",
1205 +
]
1206 +
1207 +
[[package]]
838 1208
name = "num-traits"
839 1209
version = "0.2.19"
840 1210
source = "registry+https://github.com/rust-lang/crates.io-index"
841 1211
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
842 1212
dependencies = [
843 1213
 "autocfg",
1214 +
 "libm",
844 1215
]
845 1216
846 1217
[[package]]
…
32 unchanged lines
879 1250
 "rustls",
880 1251
 "rustls-pemfile",
881 1252
 "serde_json",
882 -
 "sha2",
1253 +
 "sha2 0.11.0",
883 1254
 "similar",
884 1255
 "tempfile",
885 1256
 "thiserror 1.0.69",
…
3 unchanged lines
889 1260
]
890 1261
891 1262
[[package]]
1263 +
name = "parking"
1264 +
version = "2.2.1"
1265 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1266 +
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
1267 +
1268 +
[[package]]
1269 +
name = "parking_lot"
1270 +
version = "0.12.5"
1271 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1272 +
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
1273 +
dependencies = [
1274 +
 "lock_api",
1275 +
 "parking_lot_core",
1276 +
]
1277 +
1278 +
[[package]]
1279 +
name = "parking_lot_core"
1280 +
version = "0.9.12"
1281 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1282 +
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
1283 +
dependencies = [
1284 +
 "cfg-if",
1285 +
 "libc",
1286 +
 "redox_syscall 0.5.18",
1287 +
 "smallvec",
1288 +
 "windows-link",
1289 +
]
1290 +
1291 +
[[package]]
1292 +
name = "pem-rfc7468"
1293 +
version = "0.7.0"
1294 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1295 +
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
1296 +
dependencies = [
1297 +
 "base64ct",
1298 +
]
1299 +
1300 +
[[package]]
892 1301
name = "percent-encoding"
893 1302
version = "2.3.2"
894 1303
source = "registry+https://github.com/rust-lang/crates.io-index"
…
4 unchanged lines
899 1308
version = "0.2.17"
900 1309
source = "registry+https://github.com/rust-lang/crates.io-index"
901 1310
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1311 +
1312 +
[[package]]
1313 +
name = "pkcs1"
1314 +
version = "0.7.5"
1315 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1316 +
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
1317 +
dependencies = [
1318 +
 "der 0.7.10",
1319 +
 "pkcs8 0.10.2",
1320 +
 "spki 0.7.3",
1321 +
]
902 1322
903 1323
[[package]]
904 1324
name = "pkcs8"
1325 +
version = "0.10.2"
1326 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1327 +
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
1328 +
dependencies = [
1329 +
 "der 0.7.10",
1330 +
 "spki 0.7.3",
1331 +
]
1332 +
1333 +
[[package]]
1334 +
name = "pkcs8"
905 1335
version = "0.11.0"
906 1336
source = "registry+https://github.com/rust-lang/crates.io-index"
907 1337
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
908 1338
dependencies = [
909 -
 "der",
910 -
 "spki",
1339 +
 "der 0.8.2",
1340 +
 "spki 0.8.0",
911 1341
]
912 1342
913 1343
[[package]]
…
3 unchanged lines
917 1347
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
918 1348
919 1349
[[package]]
1350 +
name = "plain"
1351 +
version = "0.2.3"
1352 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1353 +
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
1354 +
1355 +
[[package]]
920 1356
name = "portable-atomic"
921 1357
version = "1.15.0"
922 1358
source = "registry+https://github.com/rust-lang/crates.io-index"
…
174 unchanged lines
1097 1533
]
1098 1534
1099 1535
[[package]]
1536 +
name = "redox_syscall"
1537 +
version = "0.5.18"
1538 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1539 +
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1540 +
dependencies = [
1541 +
 "bitflags",
1542 +
]
1543 +
1544 +
[[package]]
1545 +
name = "redox_syscall"
1546 +
version = "0.9.4"
1547 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1548 +
checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
1549 +
dependencies = [
1550 +
 "bitflags",
1551 +
]
1552 +
1553 +
[[package]]
1100 1554
name = "regex-automata"
1101 1555
version = "0.4.18"
1102 1556
source = "registry+https://github.com/rust-lang/crates.io-index"
…
22 unchanged lines
1125 1579
 "libc",
1126 1580
 "untrusted",
1127 1581
 "windows-sys 0.52.0",
1582 +
]
1583 +
1584 +
[[package]]
1585 +
name = "rsa"
1586 +
version = "0.9.10"
1587 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1588 +
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
1589 +
dependencies = [
1590 +
 "const-oid 0.9.6",
1591 +
 "digest 0.10.7",
1592 +
 "num-bigint-dig",
1593 +
 "num-integer",
1594 +
 "num-traits",
1595 +
 "pkcs1",
1596 +
 "pkcs8 0.10.2",
1597 +
 "rand_core 0.6.4",
1598 +
 "signature 2.2.0",
1599 +
 "spki 0.7.3",
1600 +
 "subtle",
1601 +
 "zeroize",
1128 1602
]
1129 1603
1130 1604
[[package]]
…
15 unchanged lines
1146 1620
 "errno",
1147 1621
 "libc",
1148 1622
 "linux-raw-sys",
1149 -
 "windows-sys 0.52.0",
1623 +
 "windows-sys 0.61.2",
1150 1624
]
1151 1625
1152 1626
[[package]]
…
59 unchanged lines
1212 1686
]
1213 1687
1214 1688
[[package]]
1689 +
name = "ryu"
1690 +
version = "1.0.23"
1691 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1692 +
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
1693 +
1694 +
[[package]]
1215 1695
name = "same-file"
1216 1696
version = "1.0.6"
1217 1697
source = "registry+https://github.com/rust-lang/crates.io-index"
…
3 unchanged lines
1221 1701
]
1222 1702
1223 1703
[[package]]
1704 +
name = "scopeguard"
1705 +
version = "1.2.0"
1706 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1707 +
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1708 +
1709 +
[[package]]
1224 1710
name = "semver"
1225 1711
version = "1.0.28"
1226 1712
source = "registry+https://github.com/rust-lang/crates.io-index"
…
40 unchanged lines
1267 1753
 "serde",
1268 1754
 "serde_core",
1269 1755
 "zmij",
1756 +
]
1757 +
1758 +
[[package]]
1759 +
name = "serde_spanned"
1760 +
version = "0.6.9"
1761 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1762 +
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
1763 +
dependencies = [
1764 +
 "serde",
1765 +
]
1766 +
1767 +
[[package]]
1768 +
name = "serde_urlencoded"
1769 +
version = "0.7.1"
1770 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1771 +
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
1772 +
dependencies = [
1773 +
 "form_urlencoded",
1774 +
 "itoa",
1775 +
 "ryu",
1776 +
 "serde",
1777 +
]
1778 +
1779 +
[[package]]
1780 +
name = "sha1"
1781 +
version = "0.10.7"
1782 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1783 +
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
1784 +
dependencies = [
1785 +
 "cfg-if",
1786 +
 "cpufeatures 0.2.17",
1787 +
 "digest 0.10.7",
1270 1788
]
1271 1789
1272 1790
[[package]]
1273 1791
name = "sha2"
1792 +
version = "0.10.9"
1793 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1794 +
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1795 +
dependencies = [
1796 +
 "cfg-if",
1797 +
 "cpufeatures 0.2.17",
1798 +
 "digest 0.10.7",
1799 +
]
1800 +
1801 +
[[package]]
1802 +
name = "sha2"
1274 1803
version = "0.11.0"
1275 1804
source = "registry+https://github.com/rust-lang/crates.io-index"
1276 1805
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
1277 1806
dependencies = [
1278 1807
 "cfg-if",
1279 -
 "cpufeatures",
1280 -
 "digest",
1808 +
 "cpufeatures 0.3.1",
1809 +
 "digest 0.11.3",
1281 1810
]
1282 1811
1283 1812
[[package]]
…
2 unchanged lines
1286 1815
source = "registry+https://github.com/rust-lang/crates.io-index"
1287 1816
checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a"
1288 1817
dependencies = [
1289 -
 "digest",
1818 +
 "digest 0.11.3",
1290 1819
 "keccak",
1291 1820
 "sponge-cursor",
1292 1821
]
…
16 unchanged lines
1309 1838
1310 1839
[[package]]
1311 1840
name = "signature"
1841 +
version = "2.2.0"
1842 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1843 +
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
1844 +
dependencies = [
1845 +
 "digest 0.10.7",
1846 +
 "rand_core 0.6.4",
1847 +
]
1848 +
1849 +
[[package]]
1850 +
name = "signature"
1312 1851
version = "3.0.0"
1313 1852
source = "registry+https://github.com/rust-lang/crates.io-index"
1314 1853
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
1315 1854
dependencies = [
1316 -
 "digest",
1855 +
 "digest 0.11.3",
1317 1856
 "rand_core 0.10.1",
1318 1857
]
1319 1858
…
20 unchanged lines
1340 1879
version = "1.16.2"
1341 1880
source = "registry+https://github.com/rust-lang/crates.io-index"
1342 1881
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
1882 +
dependencies = [
1883 +
 "serde",
1884 +
]
1343 1885
1344 1886
[[package]]
1345 1887
name = "socket2"
…
12 unchanged lines
1358 1900
 "anyhow",
1359 1901
 "clap",
1360 1902
 "console",
1903 +
 "hex",
1361 1904
 "indicatif",
1362 1905
 "libc",
1363 1906
 "mimalloc",
…
3 unchanged lines
1367 1910
 "spectra-api",
1368 1911
 "spectra-core",
1369 1912
 "spectra-mcp",
1913 +
 "sqlx",
1370 1914
 "tempfile",
1371 1915
 "tokio",
1372 1916
]
…
11 unchanged lines
1384 1928
version = "0.1.0"
1385 1929
dependencies = [
1386 1930
 "anyhow",
1931 +
 "blake3",
1932 +
 "hex",
1387 1933
 "oxy-core",
1934 +
 "rand 0.8.8",
1388 1935
 "serde",
1389 1936
 "serde_json",
1937 +
 "sqlx",
1938 +
 "tempfile",
1390 1939
 "thiserror 2.0.21",
1940 +
 "tokio",
1941 +
 "toml",
1942 +
 "tracing",
1391 1943
]
1392 1944
1393 1945
[[package]]
…
5 unchanged lines
1399 1951
]
1400 1952
1401 1953
[[package]]
1954 +
name = "spin"
1955 +
version = "0.9.9"
1956 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1957 +
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
1958 +
dependencies = [
1959 +
 "lock_api",
1960 +
]
1961 +
1962 +
[[package]]
1402 1963
name = "spki"
1964 +
version = "0.7.3"
1965 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1966 +
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
1967 +
dependencies = [
1968 +
 "base64ct",
1969 +
 "der 0.7.10",
1970 +
]
1971 +
1972 +
[[package]]
1973 +
name = "spki"
1403 1974
version = "0.8.0"
1404 1975
source = "registry+https://github.com/rust-lang/crates.io-index"
1405 1976
checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
1406 1977
dependencies = [
1407 1978
 "base64ct",
1408 -
 "der",
1979 +
 "der 0.8.2",
1409 1980
]
1410 1981
1411 1982
[[package]]
…
3 unchanged lines
1415 1986
checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
1416 1987
1417 1988
[[package]]
1989 +
name = "sqlx"
1990 +
version = "0.8.6"
1991 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1992 +
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
1993 +
dependencies = [
1994 +
 "sqlx-core",
1995 +
 "sqlx-macros",
1996 +
 "sqlx-mysql",
1997 +
 "sqlx-postgres",
1998 +
 "sqlx-sqlite",
1999 +
]
2000 +
2001 +
[[package]]
2002 +
name = "sqlx-core"
2003 +
version = "0.8.6"
2004 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2005 +
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
2006 +
dependencies = [
2007 +
 "base64",
2008 +
 "bytes",
2009 +
 "crc",
2010 +
 "crossbeam-queue",
2011 +
 "either",
2012 +
 "event-listener",
2013 +
 "futures-core",
2014 +
 "futures-intrusive",
2015 +
 "futures-io",
2016 +
 "futures-util",
2017 +
 "hashbrown 0.15.5",
2018 +
 "hashlink",
2019 +
 "indexmap",
2020 +
 "log",
2021 +
 "memchr",
2022 +
 "once_cell",
2023 +
 "percent-encoding",
2024 +
 "serde",
2025 +
 "serde_json",
2026 +
 "sha2 0.10.9",
2027 +
 "smallvec",
2028 +
 "thiserror 2.0.21",
2029 +
 "tokio",
2030 +
 "tokio-stream",
2031 +
 "tracing",
2032 +
 "url",
2033 +
]
2034 +
2035 +
[[package]]
2036 +
name = "sqlx-macros"
2037 +
version = "0.8.6"
2038 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2039 +
checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
2040 +
dependencies = [
2041 +
 "proc-macro2",
2042 +
 "quote",
2043 +
 "sqlx-core",
2044 +
 "sqlx-macros-core",
2045 +
 "syn 2.0.119",
2046 +
]
2047 +
2048 +
[[package]]
2049 +
name = "sqlx-macros-core"
2050 +
version = "0.8.6"
2051 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2052 +
checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
2053 +
dependencies = [
2054 +
 "dotenvy",
2055 +
 "either",
2056 +
 "heck",
2057 +
 "hex",
2058 +
 "once_cell",
2059 +
 "proc-macro2",
2060 +
 "quote",
2061 +
 "serde",
2062 +
 "serde_json",
2063 +
 "sha2 0.10.9",
2064 +
 "sqlx-core",
2065 +
 "sqlx-sqlite",
2066 +
 "syn 2.0.119",
2067 +
 "tokio",
2068 +
 "url",
2069 +
]
2070 +
2071 +
[[package]]
2072 +
name = "sqlx-mysql"
2073 +
version = "0.8.6"
2074 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2075 +
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
2076 +
dependencies = [
2077 +
 "atoi",
2078 +
 "base64",
2079 +
 "bitflags",
2080 +
 "byteorder",
2081 +
 "bytes",
2082 +
 "crc",
2083 +
 "digest 0.10.7",
2084 +
 "dotenvy",
2085 +
 "either",
2086 +
 "futures-channel",
2087 +
 "futures-core",
2088 +
 "futures-io",
2089 +
 "futures-util",
2090 +
 "generic-array",
2091 +
 "hex",
2092 +
 "hkdf",
2093 +
 "hmac",
2094 +
 "itoa",
2095 +
 "log",
2096 +
 "md-5",
2097 +
 "memchr",
2098 +
 "once_cell",
2099 +
 "percent-encoding",
2100 +
 "rand 0.8.8",
2101 +
 "rsa",
2102 +
 "sha1",
2103 +
 "sha2 0.10.9",
2104 +
 "smallvec",
2105 +
 "sqlx-core",
2106 +
 "stringprep",
2107 +
 "thiserror 2.0.21",
2108 +
 "tracing",
2109 +
 "whoami",
2110 +
]
2111 +
2112 +
[[package]]
2113 +
name = "sqlx-postgres"
2114 +
version = "0.8.6"
2115 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2116 +
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
2117 +
dependencies = [
2118 +
 "atoi",
2119 +
 "base64",
2120 +
 "bitflags",
2121 +
 "byteorder",
2122 +
 "crc",
2123 +
 "dotenvy",
2124 +
 "etcetera",
2125 +
 "futures-channel",
2126 +
 "futures-core",
2127 +
 "futures-util",
2128 +
 "hex",
2129 +
 "hkdf",
2130 +
 "hmac",
2131 +
 "home",
2132 +
 "itoa",
2133 +
 "log",
2134 +
 "md-5",
2135 +
 "memchr",
2136 +
 "once_cell",
2137 +
 "rand 0.8.8",
2138 +
 "serde",
2139 +
 "serde_json",
2140 +
 "sha2 0.10.9",
2141 +
 "smallvec",
2142 +
 "sqlx-core",
2143 +
 "stringprep",
2144 +
 "thiserror 2.0.21",
2145 +
 "tracing",
2146 +
 "whoami",
2147 +
]
2148 +
2149 +
[[package]]
2150 +
name = "sqlx-sqlite"
2151 +
version = "0.8.6"
2152 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2153 +
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
2154 +
dependencies = [
2155 +
 "atoi",
2156 +
 "flume",
2157 +
 "futures-channel",
2158 +
 "futures-core",
2159 +
 "futures-executor",
2160 +
 "futures-intrusive",
2161 +
 "futures-util",
2162 +
 "libsqlite3-sys",
2163 +
 "log",
2164 +
 "percent-encoding",
2165 +
 "serde",
2166 +
 "serde_urlencoded",
2167 +
 "sqlx-core",
2168 +
 "thiserror 2.0.21",
2169 +
 "tracing",
2170 +
 "url",
2171 +
]
2172 +
2173 +
[[package]]
1418 2174
name = "stable_deref_trait"
1419 2175
version = "1.2.1"
1420 2176
source = "registry+https://github.com/rust-lang/crates.io-index"
1421 2177
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1422 2178
1423 2179
[[package]]
2180 +
name = "stringprep"
2181 +
version = "0.1.5"
2182 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2183 +
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
2184 +
dependencies = [
2185 +
 "unicode-bidi",
2186 +
 "unicode-normalization",
2187 +
 "unicode-properties",
2188 +
]
2189 +
2190 +
[[package]]
1424 2191
name = "strsim"
1425 2192
version = "0.11.1"
1426 2193
source = "registry+https://github.com/rust-lang/crates.io-index"
…
48 unchanged lines
1475 2242
 "getrandom 0.4.3",
1476 2243
 "once_cell",
1477 2244
 "rustix",
1478 -
 "windows-sys 0.52.0",
2245 +
 "windows-sys 0.61.2",
1479 2246
]
1480 2247
1481 2248
[[package]]
…
47 unchanged lines
1529 2296
]
1530 2297
1531 2298
[[package]]
2299 +
name = "tinyvec"
2300 +
version = "1.13.3"
2301 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2302 +
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
2303 +
2304 +
[[package]]
1532 2305
name = "tokio"
1533 2306
version = "1.53.1"
1534 2307
source = "registry+https://github.com/rust-lang/crates.io-index"
…
18 unchanged lines
1553 2326
 "proc-macro2",
1554 2327
 "quote",
1555 2328
 "syn 3.0.6",
2329 +
]
2330 +
2331 +
[[package]]
2332 +
name = "tokio-stream"
2333 +
version = "0.1.19"
2334 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2335 +
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
2336 +
dependencies = [
2337 +
 "futures-core",
2338 +
 "pin-project-lite",
2339 +
 "tokio",
2340 +
]
2341 +
2342 +
[[package]]
2343 +
name = "toml"
2344 +
version = "0.8.23"
2345 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2346 +
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
2347 +
dependencies = [
2348 +
 "serde",
2349 +
 "serde_spanned",
2350 +
 "toml_datetime",
2351 +
 "toml_edit",
2352 +
]
2353 +
2354 +
[[package]]
2355 +
name = "toml_datetime"
2356 +
version = "0.6.11"
2357 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2358 +
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
2359 +
dependencies = [
2360 +
 "serde",
1556 2361
]
1557 2362
1558 2363
[[package]]
2364 +
name = "toml_edit"
2365 +
version = "0.22.27"
2366 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2367 +
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
2368 +
dependencies = [
2369 +
 "indexmap",
2370 +
 "serde",
2371 +
 "serde_spanned",
2372 +
 "toml_datetime",
2373 +
 "toml_write",
2374 +
 "winnow",
2375 +
]
2376 +
2377 +
[[package]]
2378 +
name = "toml_write"
2379 +
version = "0.1.2"
2380 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2381 +
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
2382 +
2383 +
[[package]]
2384 +
name = "tracing"
2385 +
version = "0.1.44"
2386 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2387 +
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2388 +
dependencies = [
2389 +
 "log",
2390 +
 "pin-project-lite",
2391 +
 "tracing-attributes",
2392 +
 "tracing-core",
2393 +
]
2394 +
2395 +
[[package]]
2396 +
name = "tracing-attributes"
2397 +
version = "0.1.31"
2398 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2399 +
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2400 +
dependencies = [
2401 +
 "proc-macro2",
2402 +
 "quote",
2403 +
 "syn 2.0.119",
2404 +
]
2405 +
2406 +
[[package]]
2407 +
name = "tracing-core"
2408 +
version = "0.1.36"
2409 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2410 +
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2411 +
dependencies = [
2412 +
 "once_cell",
2413 +
]
2414 +
2415 +
[[package]]
1559 2416
name = "typenum"
1560 2417
version = "1.20.1"
1561 2418
source = "registry+https://github.com/rust-lang/crates.io-index"
…
4 unchanged lines
1566 2423
version = "0.1.4"
1567 2424
source = "registry+https://github.com/rust-lang/crates.io-index"
1568 2425
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
2426 +
2427 +
[[package]]
2428 +
name = "unicode-bidi"
2429 +
version = "0.3.18"
2430 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2431 +
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
1569 2432
1570 2433
[[package]]
1571 2434
name = "unicode-ident"
1572 2435
version = "1.0.26"
1573 2436
source = "registry+https://github.com/rust-lang/crates.io-index"
1574 2437
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
2438 +
2439 +
[[package]]
2440 +
name = "unicode-normalization"
2441 +
version = "0.1.25"
2442 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2443 +
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
2444 +
dependencies = [
2445 +
 "tinyvec",
2446 +
]
2447 +
2448 +
[[package]]
2449 +
name = "unicode-properties"
2450 +
version = "0.1.4"
2451 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2452 +
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
1575 2453
1576 2454
[[package]]
1577 2455
name = "unicode-width"
…
48 unchanged lines
1626 2504
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1627 2505
1628 2506
[[package]]
2507 +
name = "vcpkg"
2508 +
version = "0.2.15"
2509 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2510 +
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
2511 +
2512 +
[[package]]
2513 +
name = "version_check"
2514 +
version = "0.9.5"
2515 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2516 +
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2517 +
2518 +
[[package]]
1629 2519
name = "wait-timeout"
1630 2520
version = "0.2.1"
1631 2521
source = "registry+https://github.com/rust-lang/crates.io-index"
…
28 unchanged lines
1660 2550
]
1661 2551
1662 2552
[[package]]
2553 +
name = "wasite"
2554 +
version = "0.1.0"
2555 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2556 +
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
2557 +
2558 +
[[package]]
1663 2559
name = "wasm-bindgen"
1664 2560
version = "0.2.129"
1665 2561
source = "registry+https://github.com/rust-lang/crates.io-index"
…
67 unchanged lines
1733 2629
]
1734 2630
1735 2631
[[package]]
2632 +
name = "whoami"
2633 +
version = "1.6.1"
2634 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2635 +
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
2636 +
dependencies = [
2637 +
 "libredox",
2638 +
 "wasite",
2639 +
]
2640 +
2641 +
[[package]]
1736 2642
name = "winapi-util"
1737 2643
version = "0.1.11"
1738 2644
source = "registry+https://github.com/rust-lang/crates.io-index"
1739 2645
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
1740 2646
dependencies = [
1741 -
 "windows-sys 0.52.0",
2647 +
 "windows-sys 0.61.2",
1742 2648
]
1743 2649
1744 2650
[[package]]
…
4 unchanged lines
1749 2655
1750 2656
[[package]]
1751 2657
name = "windows-sys"
2658 +
version = "0.48.0"
2659 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2660 +
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
2661 +
dependencies = [
2662 +
 "windows-targets 0.48.5",
2663 +
]
2664 +
2665 +
[[package]]
2666 +
name = "windows-sys"
1752 2667
version = "0.52.0"
1753 2668
source = "registry+https://github.com/rust-lang/crates.io-index"
1754 2669
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
1755 2670
dependencies = [
1756 -
 "windows-targets",
2671 +
 "windows-targets 0.52.6",
1757 2672
]
1758 2673
1759 2674
[[package]]
…
2 unchanged lines
1762 2677
source = "registry+https://github.com/rust-lang/crates.io-index"
1763 2678
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
1764 2679
dependencies = [
1765 -
 "windows-targets",
2680 +
 "windows-targets 0.52.6",
1766 2681
]
1767 2682
1768 2683
[[package]]
…
3 unchanged lines
1772 2687
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
1773 2688
dependencies = [
1774 2689
 "windows-link",
2690 +
]
2691 +
2692 +
[[package]]
2693 +
name = "windows-targets"
2694 +
version = "0.48.5"
2695 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2696 +
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
2697 +
dependencies = [
2698 +
 "windows_aarch64_gnullvm 0.48.5",
2699 +
 "windows_aarch64_msvc 0.48.5",
2700 +
 "windows_i686_gnu 0.48.5",
2701 +
 "windows_i686_msvc 0.48.5",
2702 +
 "windows_x86_64_gnu 0.48.5",
2703 +
 "windows_x86_64_gnullvm 0.48.5",
2704 +
 "windows_x86_64_msvc 0.48.5",
1775 2705
]
1776 2706
1777 2707
[[package]]
…
2 unchanged lines
1780 2710
source = "registry+https://github.com/rust-lang/crates.io-index"
1781 2711
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1782 2712
dependencies = [
1783 -
 "windows_aarch64_gnullvm",
1784 -
 "windows_aarch64_msvc",
1785 -
 "windows_i686_gnu",
2713 +
 "windows_aarch64_gnullvm 0.52.6",
2714 +
 "windows_aarch64_msvc 0.52.6",
2715 +
 "windows_i686_gnu 0.52.6",
1786 2716
 "windows_i686_gnullvm",
1787 -
 "windows_i686_msvc",
1788 -
 "windows_x86_64_gnu",
1789 -
 "windows_x86_64_gnullvm",
1790 -
 "windows_x86_64_msvc",
2717 +
 "windows_i686_msvc 0.52.6",
2718 +
 "windows_x86_64_gnu 0.52.6",
2719 +
 "windows_x86_64_gnullvm 0.52.6",
2720 +
 "windows_x86_64_msvc 0.52.6",
1791 2721
]
2722 +
2723 +
[[package]]
2724 +
name = "windows_aarch64_gnullvm"
2725 +
version = "0.48.5"
2726 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2727 +
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
1792 2728
1793 2729
[[package]]
1794 2730
name = "windows_aarch64_gnullvm"
…
3 unchanged lines
1798 2734
1799 2735
[[package]]
1800 2736
name = "windows_aarch64_msvc"
2737 +
version = "0.48.5"
2738 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2739 +
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
2740 +
2741 +
[[package]]
2742 +
name = "windows_aarch64_msvc"
1801 2743
version = "0.52.6"
1802 2744
source = "registry+https://github.com/rust-lang/crates.io-index"
1803 2745
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
2746 +
2747 +
[[package]]
2748 +
name = "windows_i686_gnu"
2749 +
version = "0.48.5"
2750 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2751 +
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
1804 2752
1805 2753
[[package]]
1806 2754
name = "windows_i686_gnu"
…
6 unchanged lines
1813 2761
version = "0.52.6"
1814 2762
source = "registry+https://github.com/rust-lang/crates.io-index"
1815 2763
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
2764 +
2765 +
[[package]]
2766 +
name = "windows_i686_msvc"
2767 +
version = "0.48.5"
2768 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2769 +
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
1816 2770
1817 2771
[[package]]
1818 2772
name = "windows_i686_msvc"
…
3 unchanged lines
1822 2776
1823 2777
[[package]]
1824 2778
name = "windows_x86_64_gnu"
2779 +
version = "0.48.5"
2780 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2781 +
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
2782 +
2783 +
[[package]]
2784 +
name = "windows_x86_64_gnu"
1825 2785
version = "0.52.6"
1826 2786
source = "registry+https://github.com/rust-lang/crates.io-index"
1827 2787
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
1828 2788
1829 2789
[[package]]
1830 2790
name = "windows_x86_64_gnullvm"
2791 +
version = "0.48.5"
2792 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2793 +
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
2794 +
2795 +
[[package]]
2796 +
name = "windows_x86_64_gnullvm"
1831 2797
version = "0.52.6"
1832 2798
source = "registry+https://github.com/rust-lang/crates.io-index"
1833 2799
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
2800 +
2801 +
[[package]]
2802 +
name = "windows_x86_64_msvc"
2803 +
version = "0.48.5"
2804 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2805 +
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
1834 2806
1835 2807
[[package]]
1836 2808
name = "windows_x86_64_msvc"
1837 2809
version = "0.52.6"
1838 2810
source = "registry+https://github.com/rust-lang/crates.io-index"
1839 2811
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2812 +
2813 +
[[package]]
2814 +
name = "winnow"
2815 +
version = "0.7.15"
2816 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2817 +
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
2818 +
dependencies = [
2819 +
 "memchr",
2820 +
]
1840 2821
1841 2822
[[package]]
1842 2823
name = "wit-bindgen"
…
152 unchanged lines
modified Cargo.toml +2 −0
…
66 unchanged lines
67 67
clap.workspace = true
68 68
serde_json.workspace = true
69 69
tokio.workspace = true
70 +
hex.workspace = true
70 71
console = "0.15"
71 72
indicatif = "0.17"
72 73
libc = "0.2"
…
1 unchanged lines
74 75
75 76
[dev-dependencies]
76 77
tempfile.workspace = true
78 +
sqlx.workspace = true
77 79
78 80
[profile.release]
79 81
debug = 1
modified Makefile +7 −3
1 1
.PHONY: build check test fmt clippy changelog run ts install deps-check
2 2
3 +
# Spectra's own packages. `--workspace` and `--all` also reach the pinned
4 +
# Oxygen in deps/ (a path dependency), whose tests and lints are its own.
5 +
PKGS = -p spectra -p spectra-core -p spectra-mcp -p spectra-api -p radar
6 +
3 7
build:
4 8
	cargo build --release
5 9
…
4 unchanged lines
10 14
	scripts/changelog-check.sh
11 15
12 16
fmt:
13 -
	cargo fmt -p spectra -p spectra-core -p spectra-mcp -p spectra-api -p radar -- --check
17 +
	cargo fmt $(PKGS) -- --check
14 18
15 19
clippy:
16 -
	cargo clippy --workspace --all-targets -- -D warnings
20 +
	cargo clippy $(PKGS) --all-targets -- -D warnings
17 21
18 22
test:
19 -
	cargo test --workspace
23 +
	cargo test $(PKGS)
20 24
21 25
run:
22 26
	cargo run -- --data data serve
…
16 unchanged lines
modified crates/spectra-core/Cargo.toml +11 −0
…
10 unchanged lines
11 11
thiserror.workspace = true
12 12
serde.workspace = true
13 13
serde_json.workspace = true
14 +
sqlx.workspace = true
15 +
tokio.workspace = true
16 +
blake3.workspace = true
17 +
rand.workspace = true
18 +
hex.workspace = true
19 +
tracing.workspace = true
20 +
toml.workspace = true
21 +
22 +
[dev-dependencies]
23 +
tempfile.workspace = true
24 +
rand.workspace = true
modified crates/spectra-core/src/lib.rs +7 −0
…
4 unchanged lines
5 5
//! Identities, delegation, composite signatures, deterministic CBOR and
6 6
//! hashing come from `oxy-core`; nothing here reimplements them.
7 7
8 +
pub mod event;
9 +
pub mod hlc;
10 +
pub mod ids;
11 +
pub mod keys;
12 +
pub mod ledger;
13 +
pub mod projections;
14 +
8 15
/// The ledger's format version: the frozen event envelope (spec §9.1). A
9 16
/// database written with a newer format is refused, never guessed at.
10 17
pub const LEDGER_FORMAT: u32 = 1;
modified docs/plan.md +5 −3
…
8 unchanged lines
9 9
- Gate every commit on `make check` (changelog, fmt, clippy with warnings as errors, tests).
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 +
- Never commit or push anything under `docs/prompts/` (Andy's prompts; ignored in `.oxyignore`). Name the paths in every commit.
12 13
- Never act as Andy: owner-only steps (delegating to an agent key, the acceptance run) wait for him.
13 14
14 15
## Items
15 16
16 -
- [ ] #1 Project skeleton in Oxygen's shape
17 -
- [ ] #2 CI on OxygenFlow
18 -
- [ ] #3 The ledger
17 +
- [x] #1 Project skeleton in Oxygen's shape — done 2026-11-02: workspace, deps/oxygen pinned, make check, deps-check (212 crates, all vendored).
18 +
- [x] #2 CI on OxygenFlow — done 2026-11-02: run #1 passed fmt, clippy, test, build with no image rebuild.
19 +
- [x] #3 The ledger — done 2026-11-02: chain, frozen envelope, HLC, signed checkpoints every 256 events, verify/check/rebuild; append 0.15 ms (release), 10,000 random events with rebuild and verify 1.3 s.
19 20
- [ ] #4 Identities instead of tokens
20 21
- [ ] #5 Projects, presence and sessions
21 22
- [ ] #6 Channels, threads, messages, inbox and search
…
8 unchanged lines
30 31
## Log
31 32
32 33
- 2026-11-02 plan created; sprint adjusted to spec Draft 0.3 (identities and the ledger).
34 +
- 2026-11-02 #1 and #2 done (CI green on run #1); #3 done. Root disk was full: build trees go to /mnt/development/spectra-target (CARGO_TARGET_DIR).
modified src/cli/mod.rs +61 −1
…
1 unchanged lines
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
5 +
pub mod admin;
5 6
pub mod format;
6 7
pub mod json;
7 8
pub mod ui;
8 9
9 -
use anyhow::Result;
10 +
use anyhow::{bail, Result};
10 11
use clap::{Parser, Subcommand};
12 +
use std::path::PathBuf;
11 13
12 14
#[derive(Parser)]
13 15
#[command(name = "spectra", version = env!("SPECTRA_VERSION_LONG"), about = "A control plane for AI coding agents")]
…
1 unchanged lines
15 17
    /// Plain output: no colour or glyphs (also NO_COLOR, or when not a terminal)
16 18
    #[arg(long, global = true)]
17 19
    pub plain: bool,
20 +
    /// The data directory (default: $XDG_DATA_HOME/spectra, else ~/.local/share/spectra)
21 +
    #[arg(long, global = true, env = "SPECTRA_DATA")]
22 +
    pub data: Option<PathBuf>,
18 23
    #[command(subcommand)]
19 24
    pub command: Command,
20 25
}
…
6 unchanged lines
27 32
        #[arg(long)]
28 33
        json: bool,
29 34
    },
35 +
    /// The ledger's own checks: verify, check, rebuild
36 +
    Admin {
37 +
        #[command(subcommand)]
38 +
        command: Admin,
39 +
    },
40 +
}
41 +
42 +
#[derive(Subcommand)]
43 +
pub enum Admin {
44 +
    /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding)
45 +
    Verify {
46 +
        /// Machine-readable output
47 +
        #[arg(long)]
48 +
        json: bool,
49 +
    },
50 +
    /// Whether the projections equal a rebuild from the ledger
51 +
    Check,
52 +
    /// Replay the ledger into every projection
53 +
    Rebuild,
54 +
}
55 +
56 +
/// What every command needs: where the data lives.
57 +
pub struct Ctx {
58 +
    pub data: PathBuf,
59 +
}
60 +
61 +
impl Ctx {
62 +
    /// The ledger's database, which must exist.
63 +
    pub fn db(&self) -> Result<PathBuf> {
64 +
        let db = self.data.join("spectra.db");
65 +
        if !db.exists() {
66 +
            bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display());
67 +
        }
68 +
        Ok(db)
69 +
    }
70 +
}
71 +
72 +
fn default_data() -> PathBuf {
73 +
    if let Ok(x) = std::env::var("XDG_DATA_HOME") {
74 +
        return PathBuf::from(x).join("spectra");
75 +
    }
76 +
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
77 +
    PathBuf::from(home).join(".local").join("share").join("spectra")
78 +
}
79 +
80 +
fn runtime() -> Result<tokio::runtime::Runtime> {
81 +
    Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?)
30 82
}
31 83
32 84
pub fn run() -> Result<i32> {
33 85
    let cli = Cli::parse();
34 86
    ui::init(cli.plain);
87 +
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
35 88
    match cli.command {
36 89
        Command::Version { json } => version(json),
90 +
        Command::Admin { command } => runtime()?.block_on(async {
91 +
            match command {
92 +
                Admin::Verify { json } => admin::verify(&ctx, json).await,
93 +
                Admin::Check => admin::check(&ctx).await,
94 +
                Admin::Rebuild => admin::rebuild(&ctx).await,
95 +
            }
96 +
        }),
37 97
    }
38 98
}
39 99
…
16 unchanged lines