Skip to content

Commit e4d3349

Browse files
committed
feat: implement Verity truth engine Phase 1 (kernel, outcomes, integrity)
Rust workspace with three foundational crates: - verity-kernel: Money (integer minor units, checked arithmetic, bps splits), BasisPoints (0-10000), type-safe IDs with format validation, CanonicalTimestamp (UTC-only), deterministic JSON serialization with sorted keys, SHA-256 canonical hashing - verity-outcomes: Outcome state machine (UNKNOWN → SUCCESS/FAIL/ PARTIAL/TIMEOUT/DISPUTED, DISPUTED → resolution, SUCCESS/FAIL/ PARTIAL → REVERSED, TIMEOUT → DISPUTED), DecisionType enum (7 types), transition history tracking - verity-integrity: Hash chains (append-only, tamper detection), replay hash computation and verification, Ed25519 signing with VeritySigner, Merkle tree for batch proofs 68 tests pass, zero clippy warnings.
1 parent 324e1f8 commit e4d3349

18 files changed

Lines changed: 1507 additions & 0 deletions

File tree

verity-engine/Cargo.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[workspace]
2+
resolver = "2"
3+
members = [
4+
"crates/verity-kernel",
5+
"crates/verity-outcomes",
6+
"crates/verity-integrity",
7+
]
8+
9+
[workspace.package]
10+
version = "0.2.0"
11+
edition = "2021"
12+
license = "MIT"
13+
repository = "https://github.com/agentralabs/verity-engine"
14+
15+
[workspace.dependencies]
16+
serde = { version = "1", features = ["derive"] }
17+
serde_json = "1"
18+
sha2 = "0.10"
19+
ed25519-dalek = { version = "2", features = ["serde"] }
20+
rand = "0.8"
21+
thiserror = "1"
22+
chrono = { version = "0.4", features = ["serde"] }
23+
hex = "0.4"
24+
base64 = "0.22"

verity-engine/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Agentra Labs
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "verity-integrity"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
repository.workspace = true
7+
description = "Hash chains, replay proofs, and cryptographic signing for Verity"
8+
9+
[dependencies]
10+
verity-kernel = { path = "../verity-kernel" }
11+
serde.workspace = true
12+
serde_json.workspace = true
13+
sha2.workspace = true
14+
ed25519-dalek.workspace = true
15+
rand.workspace = true
16+
thiserror.workspace = true
17+
hex.workspace = true
18+
base64.workspace = true
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
use serde::Serialize;
2+
use verity_kernel::{VerityId, SettlementId, VerityError, canonical_hash};
3+
4+
/// A chain of VerityReceipts for a single settlement.
5+
/// Each receipt's hash includes the previous receipt's hash.
6+
/// Invariant 8: Truth history is append-only.
7+
pub struct VerityChain {
8+
settlement_id: SettlementId,
9+
entries: Vec<ChainEntry>,
10+
}
11+
12+
#[derive(Debug, Clone)]
13+
pub struct ChainEntry {
14+
pub position: u32,
15+
pub verity_id: VerityId,
16+
pub content_hash: String,
17+
pub previous_hash: Option<String>,
18+
pub chain_hash: String,
19+
}
20+
21+
impl VerityChain {
22+
pub fn new(settlement_id: SettlementId) -> Self {
23+
Self {
24+
settlement_id,
25+
entries: Vec::new(),
26+
}
27+
}
28+
29+
/// Append a new entry to the chain. Returns the chain_hash.
30+
pub fn append(
31+
&mut self,
32+
verity_id: VerityId,
33+
content: &impl Serialize,
34+
) -> Result<String, VerityError> {
35+
let content_hash = canonical_hash(content)?;
36+
let previous_hash = self.entries.last().map(|e| e.chain_hash.clone());
37+
let position = (self.entries.len() as u32) + 1;
38+
39+
let chain_input = ChainHashInput {
40+
content_hash: &content_hash,
41+
previous_hash: previous_hash.as_deref(),
42+
};
43+
let chain_hash = canonical_hash(&chain_input)?;
44+
45+
let entry = ChainEntry {
46+
position,
47+
verity_id,
48+
content_hash,
49+
previous_hash,
50+
chain_hash: chain_hash.clone(),
51+
};
52+
self.entries.push(entry);
53+
Ok(chain_hash)
54+
}
55+
56+
/// Verify the entire chain is intact (no gaps, no tampering).
57+
pub fn verify(&self) -> Result<bool, VerityError> {
58+
for (i, entry) in self.entries.iter().enumerate() {
59+
// Check position is sequential
60+
if entry.position != (i as u32) + 1 {
61+
return Ok(false);
62+
}
63+
64+
// Check previous_hash linkage
65+
if i == 0 {
66+
if entry.previous_hash.is_some() {
67+
return Ok(false);
68+
}
69+
} else if entry.previous_hash.as_ref() != Some(&self.entries[i - 1].chain_hash) {
70+
return Ok(false);
71+
}
72+
73+
// Recompute chain_hash and verify
74+
let chain_input = ChainHashInput {
75+
content_hash: &entry.content_hash,
76+
previous_hash: entry.previous_hash.as_deref(),
77+
};
78+
let expected = canonical_hash(&chain_input)?;
79+
if entry.chain_hash != expected {
80+
return Ok(false);
81+
}
82+
}
83+
Ok(true)
84+
}
85+
86+
pub fn len(&self) -> usize {
87+
self.entries.len()
88+
}
89+
90+
pub fn is_empty(&self) -> bool {
91+
self.entries.is_empty()
92+
}
93+
94+
pub fn latest_hash(&self) -> Option<&str> {
95+
self.entries.last().map(|e| e.chain_hash.as_str())
96+
}
97+
98+
pub fn settlement_id(&self) -> &SettlementId {
99+
&self.settlement_id
100+
}
101+
102+
pub fn entries(&self) -> &[ChainEntry] {
103+
&self.entries
104+
}
105+
}
106+
107+
#[derive(Serialize)]
108+
struct ChainHashInput<'a> {
109+
content_hash: &'a str,
110+
previous_hash: Option<&'a str>,
111+
}
112+
113+
#[cfg(test)]
114+
mod tests {
115+
use super::*;
116+
use serde_json::json;
117+
118+
fn test_settlement_id() -> SettlementId {
119+
SettlementId::new("stl_4b7c9e2f").unwrap()
120+
}
121+
122+
#[test]
123+
fn test_append_and_verify() {
124+
let mut chain = VerityChain::new(test_settlement_id());
125+
let v1 = VerityId::new("vrt_a1b2c3d4").unwrap();
126+
let v2 = VerityId::new("vrt_e5f6a7b8").unwrap();
127+
128+
chain.append(v1, &json!({"decision": "release"})).unwrap();
129+
chain.append(v2, &json!({"decision": "split"})).unwrap();
130+
131+
assert_eq!(chain.len(), 2);
132+
assert!(chain.verify().unwrap());
133+
}
134+
135+
#[test]
136+
fn test_first_entry_no_previous() {
137+
let mut chain = VerityChain::new(test_settlement_id());
138+
let v1 = VerityId::new("vrt_a1b2c3d4").unwrap();
139+
chain.append(v1, &json!({"test": true})).unwrap();
140+
141+
assert!(chain.entries()[0].previous_hash.is_none());
142+
}
143+
144+
#[test]
145+
fn test_subsequent_entries_link() {
146+
let mut chain = VerityChain::new(test_settlement_id());
147+
let v1 = VerityId::new("vrt_a1b2c3d4").unwrap();
148+
let v2 = VerityId::new("vrt_e5f6a7b8").unwrap();
149+
150+
chain.append(v1, &json!({"step": 1})).unwrap();
151+
chain.append(v2, &json!({"step": 2})).unwrap();
152+
153+
assert_eq!(
154+
chain.entries()[1].previous_hash.as_ref().unwrap(),
155+
&chain.entries()[0].chain_hash
156+
);
157+
}
158+
159+
#[test]
160+
fn test_tamper_detection() {
161+
let mut chain = VerityChain::new(test_settlement_id());
162+
let v1 = VerityId::new("vrt_a1b2c3d4").unwrap();
163+
let v2 = VerityId::new("vrt_e5f6a7b8").unwrap();
164+
165+
chain.append(v1, &json!({"step": 1})).unwrap();
166+
chain.append(v2, &json!({"step": 2})).unwrap();
167+
168+
// Tamper with first entry's content_hash
169+
chain.entries[0].content_hash = "sha256:0000000000000000000000000000000000000000000000000000000000000000".to_string();
170+
171+
assert!(!chain.verify().unwrap());
172+
}
173+
174+
#[test]
175+
fn test_latest_hash() {
176+
let mut chain = VerityChain::new(test_settlement_id());
177+
assert!(chain.latest_hash().is_none());
178+
179+
let v1 = VerityId::new("vrt_a1b2c3d4").unwrap();
180+
let hash = chain.append(v1, &json!({"test": true})).unwrap();
181+
assert_eq!(chain.latest_hash().unwrap(), hash);
182+
}
183+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
mod chain;
2+
mod replay;
3+
mod signing;
4+
mod merkle;
5+
6+
pub use chain::{VerityChain, ChainEntry};
7+
pub use replay::{compute_replay_hash, verify_replay_hash};
8+
pub use signing::{VeritySigner, verify_signature};
9+
pub use merkle::MerkleTree;
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
use sha2::{Sha256, Digest};
2+
use verity_kernel::VerityError;
3+
4+
/// Merkle tree for batch inclusion proofs.
5+
/// In v1.0, we support single-entry proof. Full batch proofs in v1.1.
6+
pub struct MerkleTree {
7+
leaves: Vec<String>,
8+
root: Option<String>,
9+
}
10+
11+
impl MerkleTree {
12+
pub fn new() -> Self {
13+
Self {
14+
leaves: Vec::new(),
15+
root: None,
16+
}
17+
}
18+
19+
pub fn add_leaf(&mut self, hash: String) {
20+
self.leaves.push(hash);
21+
self.root = None; // invalidate cached root
22+
}
23+
24+
pub fn compute_root(&mut self) -> Result<String, VerityError> {
25+
if self.leaves.is_empty() {
26+
return Err(VerityError::ChainIntegrityError(
27+
"cannot compute root of empty tree".to_string(),
28+
));
29+
}
30+
31+
let mut current_level: Vec<String> = self.leaves.clone();
32+
33+
while current_level.len() > 1 {
34+
let mut next_level = Vec::new();
35+
for chunk in current_level.chunks(2) {
36+
let combined = if chunk.len() == 2 {
37+
format!("{}{}", chunk[0], chunk[1])
38+
} else {
39+
// Odd node: duplicate it
40+
format!("{}{}", chunk[0], chunk[0])
41+
};
42+
let mut hasher = Sha256::new();
43+
hasher.update(combined.as_bytes());
44+
let hash = hasher.finalize();
45+
next_level.push(format!("sha256:{}", hex::encode(hash)));
46+
}
47+
current_level = next_level;
48+
}
49+
50+
let root = current_level.into_iter().next().unwrap();
51+
self.root = Some(root.clone());
52+
Ok(root)
53+
}
54+
55+
pub fn root(&self) -> Option<&str> {
56+
self.root.as_deref()
57+
}
58+
59+
pub fn len(&self) -> usize {
60+
self.leaves.len()
61+
}
62+
63+
pub fn is_empty(&self) -> bool {
64+
self.leaves.is_empty()
65+
}
66+
}
67+
68+
impl Default for MerkleTree {
69+
fn default() -> Self {
70+
Self::new()
71+
}
72+
}
73+
74+
#[cfg(test)]
75+
mod tests {
76+
use super::*;
77+
78+
#[test]
79+
fn test_single_leaf() {
80+
let mut tree = MerkleTree::new();
81+
tree.add_leaf("sha256:abc123".to_string());
82+
let root = tree.compute_root().unwrap();
83+
assert!(root.starts_with("sha256:"));
84+
}
85+
86+
#[test]
87+
fn test_multiple_leaves() {
88+
let mut tree = MerkleTree::new();
89+
tree.add_leaf("sha256:aaa".to_string());
90+
tree.add_leaf("sha256:bbb".to_string());
91+
tree.add_leaf("sha256:ccc".to_string());
92+
let root = tree.compute_root().unwrap();
93+
assert!(root.starts_with("sha256:"));
94+
}
95+
96+
#[test]
97+
fn test_root_changes_with_leaf() {
98+
let mut tree1 = MerkleTree::new();
99+
tree1.add_leaf("sha256:aaa".to_string());
100+
tree1.add_leaf("sha256:bbb".to_string());
101+
let root1 = tree1.compute_root().unwrap();
102+
103+
let mut tree2 = MerkleTree::new();
104+
tree2.add_leaf("sha256:aaa".to_string());
105+
tree2.add_leaf("sha256:ccc".to_string());
106+
let root2 = tree2.compute_root().unwrap();
107+
108+
assert_ne!(root1, root2);
109+
}
110+
111+
#[test]
112+
fn test_empty_tree_errors() {
113+
let mut tree = MerkleTree::new();
114+
assert!(tree.compute_root().is_err());
115+
}
116+
117+
#[test]
118+
fn test_root_cached() {
119+
let mut tree = MerkleTree::new();
120+
tree.add_leaf("sha256:aaa".to_string());
121+
tree.compute_root().unwrap();
122+
assert!(tree.root().is_some());
123+
}
124+
125+
#[test]
126+
fn test_root_invalidated_on_add() {
127+
let mut tree = MerkleTree::new();
128+
tree.add_leaf("sha256:aaa".to_string());
129+
tree.compute_root().unwrap();
130+
tree.add_leaf("sha256:bbb".to_string());
131+
assert!(tree.root().is_none());
132+
}
133+
}

0 commit comments

Comments
 (0)