Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
872
vendor/ruvector/crates/prime-radiant/src/governance/lineage.rs
vendored
Normal file
872
vendor/ruvector/crates/prime-radiant/src/governance/lineage.rs
vendored
Normal file
@@ -0,0 +1,872 @@
|
||||
//! Lineage Record Entity
|
||||
//!
|
||||
//! Implements provenance tracking for all authoritative writes.
|
||||
//!
|
||||
//! # Core Invariant
|
||||
//!
|
||||
//! **No write without lineage**: Every authoritative write MUST have a lineage record
|
||||
//! that tracks:
|
||||
//!
|
||||
//! - What entity was modified
|
||||
//! - What operation was performed
|
||||
//! - What witness authorized the write
|
||||
//! - Who performed the write
|
||||
//! - What prior lineage records this depends on
|
||||
//!
|
||||
//! # Causal Dependencies
|
||||
//!
|
||||
//! Lineage records form a directed acyclic graph (DAG) of dependencies:
|
||||
//!
|
||||
//! ```text
|
||||
//! L1 ─────┐
|
||||
//! ├──► L4 ──► L5
|
||||
//! L2 ─────┤
|
||||
//! └──► L6
|
||||
//! L3 ──────────────► L7
|
||||
//! ```
|
||||
//!
|
||||
//! This enables:
|
||||
//! - Understanding the causal history of any entity
|
||||
//! - Detecting concurrent writes
|
||||
//! - Supporting deterministic replay
|
||||
|
||||
use super::{Hash, Timestamp, WitnessId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Unique identifier for a lineage record
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct LineageId(pub Uuid);
|
||||
|
||||
impl LineageId {
|
||||
/// Generate a new random ID
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Create from a UUID
|
||||
#[must_use]
|
||||
pub const fn from_uuid(uuid: Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
|
||||
/// Get as bytes
|
||||
#[must_use]
|
||||
pub fn as_bytes(&self) -> &[u8; 16] {
|
||||
self.0.as_bytes()
|
||||
}
|
||||
|
||||
/// Create a nil/sentinel ID
|
||||
#[must_use]
|
||||
pub const fn nil() -> Self {
|
||||
Self(Uuid::nil())
|
||||
}
|
||||
|
||||
/// Check if this is the nil ID
|
||||
#[must_use]
|
||||
pub fn is_nil(&self) -> bool {
|
||||
self.0.is_nil()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LineageId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LineageId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference to an entity in the system
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct EntityRef {
|
||||
/// Entity type (e.g., "node", "edge", "policy")
|
||||
pub entity_type: String,
|
||||
/// Entity identifier
|
||||
pub entity_id: String,
|
||||
/// Optional namespace/scope
|
||||
pub namespace: Option<String>,
|
||||
/// Version of the entity (if applicable)
|
||||
pub version: Option<u64>,
|
||||
}
|
||||
|
||||
impl EntityRef {
|
||||
/// Create a new entity reference
|
||||
#[must_use]
|
||||
pub fn new(entity_type: impl Into<String>, entity_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
entity_type: entity_type.into(),
|
||||
entity_id: entity_id.into(),
|
||||
namespace: None,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the namespace
|
||||
#[must_use]
|
||||
pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
|
||||
self.namespace = Some(namespace.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the version
|
||||
#[must_use]
|
||||
pub const fn with_version(mut self, version: u64) -> Self {
|
||||
self.version = Some(version);
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a node reference
|
||||
#[must_use]
|
||||
pub fn node(id: impl Into<String>) -> Self {
|
||||
Self::new("node", id)
|
||||
}
|
||||
|
||||
/// Create an edge reference
|
||||
#[must_use]
|
||||
pub fn edge(id: impl Into<String>) -> Self {
|
||||
Self::new("edge", id)
|
||||
}
|
||||
|
||||
/// Create a policy reference
|
||||
#[must_use]
|
||||
pub fn policy(id: impl Into<String>) -> Self {
|
||||
Self::new("policy", id)
|
||||
}
|
||||
|
||||
/// Get a canonical string representation
|
||||
#[must_use]
|
||||
pub fn canonical(&self) -> String {
|
||||
let mut s = format!("{}:{}", self.entity_type, self.entity_id);
|
||||
if let Some(ref ns) = self.namespace {
|
||||
s = format!("{ns}/{s}");
|
||||
}
|
||||
if let Some(v) = self.version {
|
||||
s = format!("{s}@{v}");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Compute content hash
|
||||
#[must_use]
|
||||
pub fn content_hash(&self) -> Hash {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(self.entity_type.as_bytes());
|
||||
hasher.update(self.entity_id.as_bytes());
|
||||
if let Some(ref ns) = self.namespace {
|
||||
hasher.update(ns.as_bytes());
|
||||
}
|
||||
if let Some(v) = self.version {
|
||||
hasher.update(&v.to_le_bytes());
|
||||
}
|
||||
Hash::from_blake3(hasher.finalize())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EntityRef {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.canonical())
|
||||
}
|
||||
}
|
||||
|
||||
/// Type of operation performed on an entity
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Operation {
|
||||
/// Create a new entity
|
||||
Create,
|
||||
/// Update an existing entity
|
||||
Update,
|
||||
/// Delete an entity
|
||||
Delete,
|
||||
/// Archive an entity (soft delete)
|
||||
Archive,
|
||||
/// Restore an archived entity
|
||||
Restore,
|
||||
/// Merge entities
|
||||
Merge,
|
||||
/// Split an entity
|
||||
Split,
|
||||
/// Transfer ownership
|
||||
Transfer,
|
||||
}
|
||||
|
||||
impl Operation {
|
||||
/// Check if this operation creates a new entity
|
||||
#[must_use]
|
||||
pub const fn is_create(&self) -> bool {
|
||||
matches!(self, Self::Create | Self::Split)
|
||||
}
|
||||
|
||||
/// Check if this operation removes an entity
|
||||
#[must_use]
|
||||
pub const fn is_destructive(&self) -> bool {
|
||||
matches!(self, Self::Delete | Self::Archive | Self::Merge)
|
||||
}
|
||||
|
||||
/// Check if this operation modifies an entity
|
||||
#[must_use]
|
||||
pub const fn is_mutation(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Update | Self::Transfer | Self::Restore | Self::Merge | Self::Split
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Operation {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Create => write!(f, "CREATE"),
|
||||
Self::Update => write!(f, "UPDATE"),
|
||||
Self::Delete => write!(f, "DELETE"),
|
||||
Self::Archive => write!(f, "ARCHIVE"),
|
||||
Self::Restore => write!(f, "RESTORE"),
|
||||
Self::Merge => write!(f, "MERGE"),
|
||||
Self::Split => write!(f, "SPLIT"),
|
||||
Self::Transfer => write!(f, "TRANSFER"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lineage-related errors
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LineageError {
|
||||
/// Missing authorizing witness
|
||||
#[error("Missing authorizing witness for lineage {0}")]
|
||||
MissingWitness(LineageId),
|
||||
|
||||
/// Dependency not found
|
||||
#[error("Dependency not found: {0}")]
|
||||
DependencyNotFound(LineageId),
|
||||
|
||||
/// Circular dependency detected
|
||||
#[error("Circular dependency detected involving {0}")]
|
||||
CircularDependency(LineageId),
|
||||
|
||||
/// Invalid operation for entity state
|
||||
#[error("Invalid operation {0} for entity {1}")]
|
||||
InvalidOperation(Operation, EntityRef),
|
||||
|
||||
/// Lineage not found
|
||||
#[error("Lineage not found: {0}")]
|
||||
NotFound(LineageId),
|
||||
|
||||
/// Lineage already exists
|
||||
#[error("Lineage already exists: {0}")]
|
||||
AlreadyExists(LineageId),
|
||||
|
||||
/// Content hash mismatch
|
||||
#[error("Content hash mismatch for lineage {0}")]
|
||||
HashMismatch(LineageId),
|
||||
}
|
||||
|
||||
/// Provenance tracking for an authoritative write
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct LineageRecord {
|
||||
/// Unique lineage identifier
|
||||
pub id: LineageId,
|
||||
/// Entity that was modified
|
||||
pub entity_ref: EntityRef,
|
||||
/// Operation performed
|
||||
pub operation: Operation,
|
||||
/// Causal dependencies (prior lineage records this depends on)
|
||||
pub dependencies: Vec<LineageId>,
|
||||
/// Witness that authorized this write
|
||||
pub authorizing_witness: WitnessId,
|
||||
/// Actor who performed the write
|
||||
pub actor: String,
|
||||
/// Creation timestamp
|
||||
pub timestamp: Timestamp,
|
||||
/// Content hash for integrity
|
||||
pub content_hash: Hash,
|
||||
/// Optional description of the change
|
||||
pub description: Option<String>,
|
||||
/// Optional previous state hash (for updates)
|
||||
pub previous_state_hash: Option<Hash>,
|
||||
/// Optional new state hash
|
||||
pub new_state_hash: Option<Hash>,
|
||||
/// Additional metadata
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl LineageRecord {
|
||||
/// Create a new lineage record
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
entity_ref: EntityRef,
|
||||
operation: Operation,
|
||||
dependencies: Vec<LineageId>,
|
||||
authorizing_witness: WitnessId,
|
||||
actor: impl Into<String>,
|
||||
) -> Self {
|
||||
let id = LineageId::new();
|
||||
let timestamp = Timestamp::now();
|
||||
|
||||
let mut record = Self {
|
||||
id,
|
||||
entity_ref,
|
||||
operation,
|
||||
dependencies,
|
||||
authorizing_witness,
|
||||
actor: actor.into(),
|
||||
timestamp,
|
||||
content_hash: Hash::zero(), // Placeholder
|
||||
description: None,
|
||||
previous_state_hash: None,
|
||||
new_state_hash: None,
|
||||
metadata: HashMap::new(),
|
||||
};
|
||||
|
||||
record.content_hash = record.compute_content_hash();
|
||||
record
|
||||
}
|
||||
|
||||
/// Create a lineage record for entity creation
|
||||
#[must_use]
|
||||
pub fn create(
|
||||
entity_ref: EntityRef,
|
||||
authorizing_witness: WitnessId,
|
||||
actor: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
entity_ref,
|
||||
Operation::Create,
|
||||
Vec::new(),
|
||||
authorizing_witness,
|
||||
actor,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a lineage record for entity update
|
||||
#[must_use]
|
||||
pub fn update(
|
||||
entity_ref: EntityRef,
|
||||
dependencies: Vec<LineageId>,
|
||||
authorizing_witness: WitnessId,
|
||||
actor: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
entity_ref,
|
||||
Operation::Update,
|
||||
dependencies,
|
||||
authorizing_witness,
|
||||
actor,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a lineage record for entity deletion
|
||||
#[must_use]
|
||||
pub fn delete(
|
||||
entity_ref: EntityRef,
|
||||
dependencies: Vec<LineageId>,
|
||||
authorizing_witness: WitnessId,
|
||||
actor: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
entity_ref,
|
||||
Operation::Delete,
|
||||
dependencies,
|
||||
authorizing_witness,
|
||||
actor,
|
||||
)
|
||||
}
|
||||
|
||||
/// Set description
|
||||
#[must_use]
|
||||
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
|
||||
self.description = Some(desc.into());
|
||||
self.content_hash = self.compute_content_hash();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set previous state hash
|
||||
#[must_use]
|
||||
pub fn with_previous_state(mut self, hash: Hash) -> Self {
|
||||
self.previous_state_hash = Some(hash);
|
||||
self.content_hash = self.compute_content_hash();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set new state hash
|
||||
#[must_use]
|
||||
pub fn with_new_state(mut self, hash: Hash) -> Self {
|
||||
self.new_state_hash = Some(hash);
|
||||
self.content_hash = self.compute_content_hash();
|
||||
self
|
||||
}
|
||||
|
||||
/// Add metadata
|
||||
#[must_use]
|
||||
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.metadata.insert(key.into(), value.into());
|
||||
self.content_hash = self.compute_content_hash();
|
||||
self
|
||||
}
|
||||
|
||||
/// Compute the content hash using Blake3
|
||||
#[must_use]
|
||||
pub fn compute_content_hash(&self) -> Hash {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
|
||||
// Core identifying fields
|
||||
hasher.update(self.id.as_bytes());
|
||||
hasher.update(self.entity_ref.content_hash().as_bytes());
|
||||
hasher.update(&[self.operation as u8]);
|
||||
|
||||
// Dependencies (sorted for determinism)
|
||||
let mut deps: Vec<_> = self.dependencies.iter().collect();
|
||||
deps.sort_by_key(|d| d.0);
|
||||
for dep in deps {
|
||||
hasher.update(dep.as_bytes());
|
||||
}
|
||||
|
||||
// Authorization
|
||||
hasher.update(self.authorizing_witness.as_bytes());
|
||||
hasher.update(self.actor.as_bytes());
|
||||
|
||||
// Timestamp
|
||||
hasher.update(&self.timestamp.secs.to_le_bytes());
|
||||
hasher.update(&self.timestamp.nanos.to_le_bytes());
|
||||
|
||||
// Optional fields
|
||||
if let Some(ref desc) = self.description {
|
||||
hasher.update(desc.as_bytes());
|
||||
}
|
||||
if let Some(ref prev) = self.previous_state_hash {
|
||||
hasher.update(prev.as_bytes());
|
||||
}
|
||||
if let Some(ref new) = self.new_state_hash {
|
||||
hasher.update(new.as_bytes());
|
||||
}
|
||||
|
||||
// Metadata (sorted for determinism)
|
||||
let mut meta_keys: Vec<_> = self.metadata.keys().collect();
|
||||
meta_keys.sort();
|
||||
for key in meta_keys {
|
||||
hasher.update(key.as_bytes());
|
||||
if let Some(value) = self.metadata.get(key) {
|
||||
hasher.update(value.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
Hash::from_blake3(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Verify the content hash is correct
|
||||
#[must_use]
|
||||
pub fn verify_content_hash(&self) -> bool {
|
||||
self.content_hash == self.compute_content_hash()
|
||||
}
|
||||
|
||||
/// Check if this lineage has no dependencies (root lineage)
|
||||
#[must_use]
|
||||
pub fn is_root(&self) -> bool {
|
||||
self.dependencies.is_empty()
|
||||
}
|
||||
|
||||
/// Check if this lineage depends on a specific lineage
|
||||
#[must_use]
|
||||
pub fn depends_on(&self, other: LineageId) -> bool {
|
||||
self.dependencies.contains(&other)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for LineageRecord {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for LineageRecord {}
|
||||
|
||||
impl std::hash::Hash for LineageRecord {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for lineage records with validation
|
||||
pub struct LineageBuilder {
|
||||
entity_ref: Option<EntityRef>,
|
||||
operation: Option<Operation>,
|
||||
dependencies: Vec<LineageId>,
|
||||
authorizing_witness: Option<WitnessId>,
|
||||
actor: Option<String>,
|
||||
description: Option<String>,
|
||||
previous_state_hash: Option<Hash>,
|
||||
new_state_hash: Option<Hash>,
|
||||
metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl LineageBuilder {
|
||||
/// Create a new builder
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entity_ref: None,
|
||||
operation: None,
|
||||
dependencies: Vec::new(),
|
||||
authorizing_witness: None,
|
||||
actor: None,
|
||||
description: None,
|
||||
previous_state_hash: None,
|
||||
new_state_hash: None,
|
||||
metadata: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the entity reference
|
||||
#[must_use]
|
||||
pub fn entity(mut self, entity_ref: EntityRef) -> Self {
|
||||
self.entity_ref = Some(entity_ref);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the operation
|
||||
#[must_use]
|
||||
pub fn operation(mut self, op: Operation) -> Self {
|
||||
self.operation = Some(op);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a dependency
|
||||
#[must_use]
|
||||
pub fn depends_on(mut self, dep: LineageId) -> Self {
|
||||
self.dependencies.push(dep);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set all dependencies
|
||||
#[must_use]
|
||||
pub fn dependencies(mut self, deps: Vec<LineageId>) -> Self {
|
||||
self.dependencies = deps;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the authorizing witness
|
||||
#[must_use]
|
||||
pub fn authorized_by(mut self, witness: WitnessId) -> Self {
|
||||
self.authorizing_witness = Some(witness);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the actor
|
||||
#[must_use]
|
||||
pub fn actor(mut self, actor: impl Into<String>) -> Self {
|
||||
self.actor = Some(actor.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set description
|
||||
#[must_use]
|
||||
pub fn description(mut self, desc: impl Into<String>) -> Self {
|
||||
self.description = Some(desc.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set previous state hash
|
||||
#[must_use]
|
||||
pub fn previous_state(mut self, hash: Hash) -> Self {
|
||||
self.previous_state_hash = Some(hash);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set new state hash
|
||||
#[must_use]
|
||||
pub fn new_state(mut self, hash: Hash) -> Self {
|
||||
self.new_state_hash = Some(hash);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add metadata
|
||||
#[must_use]
|
||||
pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.metadata.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the lineage record
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if required fields are missing
|
||||
pub fn build(self) -> Result<LineageRecord, LineageError> {
|
||||
let entity_ref = self.entity_ref.ok_or_else(|| {
|
||||
LineageError::InvalidOperation(
|
||||
self.operation.unwrap_or(Operation::Create),
|
||||
EntityRef::new("unknown", "unknown"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let operation = self.operation.unwrap_or(Operation::Create);
|
||||
|
||||
let authorizing_witness = self
|
||||
.authorizing_witness
|
||||
.ok_or_else(|| LineageError::MissingWitness(LineageId::nil()))?;
|
||||
|
||||
let actor = self.actor.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let mut record = LineageRecord::new(
|
||||
entity_ref,
|
||||
operation,
|
||||
self.dependencies,
|
||||
authorizing_witness,
|
||||
actor,
|
||||
);
|
||||
|
||||
if let Some(desc) = self.description {
|
||||
record = record.with_description(desc);
|
||||
}
|
||||
if let Some(prev) = self.previous_state_hash {
|
||||
record = record.with_previous_state(prev);
|
||||
}
|
||||
if let Some(new) = self.new_state_hash {
|
||||
record = record.with_new_state(new);
|
||||
}
|
||||
for (key, value) in self.metadata {
|
||||
record = record.with_metadata(key, value);
|
||||
}
|
||||
|
||||
Ok(record)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LineageBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks lineage for an entity across multiple operations
|
||||
pub struct EntityLineageTracker {
|
||||
/// Entity being tracked
|
||||
pub entity_ref: EntityRef,
|
||||
/// All lineage records for this entity (ordered by timestamp)
|
||||
pub lineage: Vec<LineageRecord>,
|
||||
/// Current state hash
|
||||
pub current_state_hash: Option<Hash>,
|
||||
}
|
||||
|
||||
impl EntityLineageTracker {
|
||||
/// Create a new tracker
|
||||
#[must_use]
|
||||
pub fn new(entity_ref: EntityRef) -> Self {
|
||||
Self {
|
||||
entity_ref,
|
||||
lineage: Vec::new(),
|
||||
current_state_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a lineage record
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if the record is for a different entity
|
||||
pub fn add(&mut self, record: LineageRecord) -> Result<(), LineageError> {
|
||||
if record.entity_ref != self.entity_ref {
|
||||
return Err(LineageError::InvalidOperation(
|
||||
record.operation,
|
||||
self.entity_ref.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
// Update current state hash
|
||||
if let Some(ref new_hash) = record.new_state_hash {
|
||||
self.current_state_hash = Some(*new_hash);
|
||||
}
|
||||
|
||||
// Insert in timestamp order
|
||||
let pos = self
|
||||
.lineage
|
||||
.iter()
|
||||
.position(|r| r.timestamp > record.timestamp)
|
||||
.unwrap_or(self.lineage.len());
|
||||
self.lineage.insert(pos, record);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the most recent lineage record
|
||||
#[must_use]
|
||||
pub fn latest(&self) -> Option<&LineageRecord> {
|
||||
self.lineage.last()
|
||||
}
|
||||
|
||||
/// Get all dependencies for this entity
|
||||
#[must_use]
|
||||
pub fn all_dependencies(&self) -> Vec<LineageId> {
|
||||
self.lineage
|
||||
.iter()
|
||||
.flat_map(|r| r.dependencies.iter().copied())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if the entity has been deleted
|
||||
#[must_use]
|
||||
pub fn is_deleted(&self) -> bool {
|
||||
self.lineage
|
||||
.last()
|
||||
.map_or(false, |r| r.operation == Operation::Delete)
|
||||
}
|
||||
|
||||
/// Get lineage records by operation type
|
||||
#[must_use]
|
||||
pub fn by_operation(&self, op: Operation) -> Vec<&LineageRecord> {
|
||||
self.lineage.iter().filter(|r| r.operation == op).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_witness_id() -> WitnessId {
|
||||
WitnessId::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entity_ref() {
|
||||
let entity = EntityRef::node("node-123")
|
||||
.with_namespace("test")
|
||||
.with_version(1);
|
||||
|
||||
assert_eq!(entity.entity_type, "node");
|
||||
assert_eq!(entity.entity_id, "node-123");
|
||||
assert_eq!(entity.namespace, Some("test".to_string()));
|
||||
assert_eq!(entity.version, Some(1));
|
||||
assert_eq!(entity.canonical(), "test/node:node-123@1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lineage_creation() {
|
||||
let entity = EntityRef::node("node-1");
|
||||
let witness = test_witness_id();
|
||||
|
||||
let lineage = LineageRecord::create(entity.clone(), witness, "alice");
|
||||
|
||||
assert_eq!(lineage.operation, Operation::Create);
|
||||
assert!(lineage.is_root());
|
||||
assert!(lineage.verify_content_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lineage_with_dependencies() {
|
||||
let entity = EntityRef::node("node-1");
|
||||
let witness = test_witness_id();
|
||||
|
||||
let dep1 = LineageId::new();
|
||||
let dep2 = LineageId::new();
|
||||
|
||||
let lineage = LineageRecord::update(entity, vec![dep1, dep2], witness, "bob");
|
||||
|
||||
assert!(!lineage.is_root());
|
||||
assert!(lineage.depends_on(dep1));
|
||||
assert!(lineage.depends_on(dep2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lineage_builder() -> Result<(), LineageError> {
|
||||
let lineage = LineageBuilder::new()
|
||||
.entity(EntityRef::edge("edge-1"))
|
||||
.operation(Operation::Update)
|
||||
.authorized_by(test_witness_id())
|
||||
.actor("charlie")
|
||||
.description("Updated edge weight")
|
||||
.previous_state(Hash::from_bytes([1u8; 32]))
|
||||
.new_state(Hash::from_bytes([2u8; 32]))
|
||||
.metadata("reason", "optimization")
|
||||
.build()?;
|
||||
|
||||
assert_eq!(lineage.operation, Operation::Update);
|
||||
assert!(lineage.description.is_some());
|
||||
assert!(lineage.previous_state_hash.is_some());
|
||||
assert!(lineage.new_state_hash.is_some());
|
||||
assert_eq!(
|
||||
lineage.metadata.get("reason"),
|
||||
Some(&"optimization".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entity_lineage_tracker() -> Result<(), LineageError> {
|
||||
let entity = EntityRef::node("node-1");
|
||||
let witness = test_witness_id();
|
||||
|
||||
let mut tracker = EntityLineageTracker::new(entity.clone());
|
||||
|
||||
// Create
|
||||
let create = LineageRecord::create(entity.clone(), witness, "alice")
|
||||
.with_new_state(Hash::from_bytes([1u8; 32]));
|
||||
tracker.add(create)?;
|
||||
|
||||
// Update
|
||||
let update = LineageRecord::update(
|
||||
entity.clone(),
|
||||
vec![tracker.latest().unwrap().id],
|
||||
witness,
|
||||
"bob",
|
||||
)
|
||||
.with_previous_state(Hash::from_bytes([1u8; 32]))
|
||||
.with_new_state(Hash::from_bytes([2u8; 32]));
|
||||
tracker.add(update)?;
|
||||
|
||||
assert_eq!(tracker.lineage.len(), 2);
|
||||
assert_eq!(
|
||||
tracker.current_state_hash,
|
||||
Some(Hash::from_bytes([2u8; 32]))
|
||||
);
|
||||
assert!(!tracker.is_deleted());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_hash_determinism() {
|
||||
let entity = EntityRef::node("node-1");
|
||||
let witness = test_witness_id();
|
||||
|
||||
let lineage = LineageRecord::create(entity, witness, "alice").with_description("test");
|
||||
|
||||
let hash1 = lineage.compute_content_hash();
|
||||
let hash2 = lineage.compute_content_hash();
|
||||
assert_eq!(hash1, hash2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tamper_detection() {
|
||||
let entity = EntityRef::node("node-1");
|
||||
let witness = test_witness_id();
|
||||
|
||||
let mut lineage = LineageRecord::create(entity, witness, "alice");
|
||||
|
||||
// Tamper with the record
|
||||
lineage.actor = "mallory".to_string();
|
||||
|
||||
// Hash should no longer match
|
||||
assert!(!lineage.verify_content_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_operation_classification() {
|
||||
assert!(Operation::Create.is_create());
|
||||
assert!(Operation::Split.is_create());
|
||||
assert!(!Operation::Update.is_create());
|
||||
|
||||
assert!(Operation::Delete.is_destructive());
|
||||
assert!(Operation::Archive.is_destructive());
|
||||
assert!(!Operation::Create.is_destructive());
|
||||
|
||||
assert!(Operation::Update.is_mutation());
|
||||
assert!(!Operation::Create.is_mutation());
|
||||
}
|
||||
}
|
||||
438
vendor/ruvector/crates/prime-radiant/src/governance/mod.rs
vendored
Normal file
438
vendor/ruvector/crates/prime-radiant/src/governance/mod.rs
vendored
Normal file
@@ -0,0 +1,438 @@
|
||||
//! Governance Layer
|
||||
//!
|
||||
//! First-class, immutable, addressable governance objects for the Coherence Engine.
|
||||
//!
|
||||
//! This module implements ADR-CE-005: "Governance objects are first-class, immutable, addressable"
|
||||
//!
|
||||
//! # Core Invariants
|
||||
//!
|
||||
//! 1. **No action without witness**: Every gate decision must produce a `WitnessRecord`
|
||||
//! 2. **No write without lineage**: Every authoritative write must have a `LineageRecord`
|
||||
//! 3. **Policy immutability**: Once activated, a `PolicyBundle` cannot be modified
|
||||
//! 4. **Multi-party approval**: Critical policies require multiple `ApprovalSignature`s
|
||||
//! 5. **Witness chain integrity**: Each witness references its predecessor via Blake3 hash
|
||||
|
||||
mod lineage;
|
||||
mod policy;
|
||||
mod repository;
|
||||
mod witness;
|
||||
|
||||
pub use policy::{
|
||||
ApprovalSignature, ApproverId, EscalationCondition, EscalationRule, PolicyBundle,
|
||||
PolicyBundleBuilder, PolicyBundleId, PolicyBundleRef, PolicyBundleStatus, PolicyError,
|
||||
ThresholdConfig,
|
||||
};
|
||||
|
||||
pub use witness::{
|
||||
ComputeLane as WitnessComputeLane, EnergySnapshot, GateDecision, WitnessChainError,
|
||||
WitnessError, WitnessId, WitnessRecord,
|
||||
};
|
||||
|
||||
pub use lineage::{EntityRef, LineageError, LineageId, LineageRecord, Operation};
|
||||
|
||||
pub use repository::{LineageRepository, PolicyRepository, WitnessRepository};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Blake3 content hash (32 bytes)
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct Hash(pub [u8; 32]);
|
||||
|
||||
impl Hash {
|
||||
/// Create a new hash from bytes
|
||||
#[must_use]
|
||||
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
/// Create a hash from a Blake3 hasher output
|
||||
#[must_use]
|
||||
pub fn from_blake3(hash: blake3::Hash) -> Self {
|
||||
Self(*hash.as_bytes())
|
||||
}
|
||||
|
||||
/// Get the hash as bytes
|
||||
#[must_use]
|
||||
pub const fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Create a zero hash (used as sentinel)
|
||||
#[must_use]
|
||||
pub const fn zero() -> Self {
|
||||
Self([0u8; 32])
|
||||
}
|
||||
|
||||
/// Check if this is the zero hash
|
||||
#[must_use]
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.0 == [0u8; 32]
|
||||
}
|
||||
|
||||
/// Convert to hex string
|
||||
#[must_use]
|
||||
pub fn to_hex(&self) -> String {
|
||||
hex::encode(self.0)
|
||||
}
|
||||
|
||||
/// Parse from hex string
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the hex string is invalid or wrong length
|
||||
pub fn from_hex(s: &str) -> Result<Self, hex::FromHexError> {
|
||||
let bytes = hex::decode(s)?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(hex::FromHexError::InvalidStringLength);
|
||||
}
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&bytes);
|
||||
Ok(Self(arr))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Hash {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Hash({})", &self.to_hex()[..16])
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Hash {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.to_hex())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Hash {
|
||||
fn default() -> Self {
|
||||
Self::zero()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<blake3::Hash> for Hash {
|
||||
fn from(hash: blake3::Hash) -> Self {
|
||||
Self::from_blake3(hash)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<[u8]> for Hash {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Timestamp with nanosecond precision
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct Timestamp {
|
||||
/// Seconds since Unix epoch
|
||||
pub secs: i64,
|
||||
/// Nanoseconds within the second
|
||||
pub nanos: u32,
|
||||
}
|
||||
|
||||
impl Timestamp {
|
||||
/// Create a new timestamp
|
||||
#[must_use]
|
||||
pub const fn new(secs: i64, nanos: u32) -> Self {
|
||||
Self { secs, nanos }
|
||||
}
|
||||
|
||||
/// Get the current timestamp
|
||||
#[must_use]
|
||||
pub fn now() -> Self {
|
||||
let dt = chrono::Utc::now();
|
||||
Self {
|
||||
secs: dt.timestamp(),
|
||||
nanos: dt.timestamp_subsec_nanos(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a timestamp from Unix epoch seconds
|
||||
#[must_use]
|
||||
pub const fn from_secs(secs: i64) -> Self {
|
||||
Self { secs, nanos: 0 }
|
||||
}
|
||||
|
||||
/// Convert to Unix epoch milliseconds
|
||||
#[must_use]
|
||||
pub const fn as_millis(&self) -> i64 {
|
||||
self.secs * 1000 + (self.nanos / 1_000_000) as i64
|
||||
}
|
||||
|
||||
/// Create from Unix epoch milliseconds
|
||||
#[must_use]
|
||||
pub const fn from_millis(millis: i64) -> Self {
|
||||
Self {
|
||||
secs: millis / 1000,
|
||||
nanos: ((millis % 1000) * 1_000_000) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to chrono DateTime
|
||||
#[must_use]
|
||||
pub fn to_datetime(&self) -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::DateTime::from_timestamp(self.secs, self.nanos).unwrap_or_else(chrono::Utc::now)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Timestamp {
|
||||
fn default() -> Self {
|
||||
Self::now()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Timestamp {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
self.to_datetime().format("%Y-%m-%d %H:%M:%S%.3f UTC")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chrono::DateTime<chrono::Utc>> for Timestamp {
|
||||
fn from(dt: chrono::DateTime<chrono::Utc>) -> Self {
|
||||
Self {
|
||||
secs: dt.timestamp(),
|
||||
nanos: dt.timestamp_subsec_nanos(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic version for policy bundles
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct Version {
|
||||
/// Major version (breaking changes)
|
||||
pub major: u32,
|
||||
/// Minor version (new features, backward compatible)
|
||||
pub minor: u32,
|
||||
/// Patch version (bug fixes)
|
||||
pub patch: u32,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
/// Create a new version
|
||||
#[must_use]
|
||||
pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
|
||||
Self {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initial version (1.0.0)
|
||||
#[must_use]
|
||||
pub const fn initial() -> Self {
|
||||
Self::new(1, 0, 0)
|
||||
}
|
||||
|
||||
/// Increment patch version
|
||||
#[must_use]
|
||||
pub const fn bump_patch(self) -> Self {
|
||||
Self {
|
||||
major: self.major,
|
||||
minor: self.minor,
|
||||
patch: self.patch + 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment minor version (resets patch)
|
||||
#[must_use]
|
||||
pub const fn bump_minor(self) -> Self {
|
||||
Self {
|
||||
major: self.major,
|
||||
minor: self.minor + 1,
|
||||
patch: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment major version (resets minor and patch)
|
||||
#[must_use]
|
||||
pub const fn bump_major(self) -> Self {
|
||||
Self {
|
||||
major: self.major + 1,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Version {
|
||||
fn default() -> Self {
|
||||
Self::initial()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Version {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for Version {
|
||||
type Err = GovernanceError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let parts: Vec<&str> = s.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(GovernanceError::InvalidVersion(s.to_string()));
|
||||
}
|
||||
|
||||
let major = parts[0]
|
||||
.parse()
|
||||
.map_err(|_| GovernanceError::InvalidVersion(s.to_string()))?;
|
||||
let minor = parts[1]
|
||||
.parse()
|
||||
.map_err(|_| GovernanceError::InvalidVersion(s.to_string()))?;
|
||||
let patch = parts[2]
|
||||
.parse()
|
||||
.map_err(|_| GovernanceError::InvalidVersion(s.to_string()))?;
|
||||
|
||||
Ok(Self {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level governance error
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GovernanceError {
|
||||
/// Policy-related error
|
||||
#[error("Policy error: {0}")]
|
||||
Policy(#[from] PolicyError),
|
||||
|
||||
/// Witness-related error
|
||||
#[error("Witness error: {0}")]
|
||||
Witness(#[from] WitnessError),
|
||||
|
||||
/// Lineage-related error
|
||||
#[error("Lineage error: {0}")]
|
||||
Lineage(#[from] LineageError),
|
||||
|
||||
/// Invalid version format
|
||||
#[error("Invalid version format: {0}")]
|
||||
InvalidVersion(String),
|
||||
|
||||
/// Serialization error
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
/// Repository error
|
||||
#[error("Repository error: {0}")]
|
||||
Repository(String),
|
||||
|
||||
/// Invariant violation
|
||||
#[error("Invariant violation: {0}")]
|
||||
InvariantViolation(String),
|
||||
}
|
||||
|
||||
// Hex encoding utilities (inline to avoid external dependency)
|
||||
mod hex {
|
||||
pub use std::fmt::Write;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FromHexError {
|
||||
InvalidStringLength,
|
||||
InvalidHexCharacter(char),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FromHexError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidStringLength => write!(f, "invalid hex string length"),
|
||||
Self::InvalidHexCharacter(c) => write!(f, "invalid hex character: {c}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for FromHexError {}
|
||||
|
||||
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
|
||||
let bytes = bytes.as_ref();
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
write!(s, "{b:02x}").unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn decode(s: &str) -> Result<Vec<u8>, FromHexError> {
|
||||
if s.len() % 2 != 0 {
|
||||
return Err(FromHexError::InvalidStringLength);
|
||||
}
|
||||
|
||||
let mut bytes = Vec::with_capacity(s.len() / 2);
|
||||
let mut chars = s.chars();
|
||||
|
||||
while let (Some(h), Some(l)) = (chars.next(), chars.next()) {
|
||||
let high = h.to_digit(16).ok_or(FromHexError::InvalidHexCharacter(h))? as u8;
|
||||
let low = l.to_digit(16).ok_or(FromHexError::InvalidHexCharacter(l))? as u8;
|
||||
bytes.push((high << 4) | low);
|
||||
}
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_creation_and_display() {
|
||||
let bytes = [1u8; 32];
|
||||
let hash = Hash::from_bytes(bytes);
|
||||
|
||||
assert_eq!(hash.as_bytes(), &bytes);
|
||||
assert!(!hash.is_zero());
|
||||
|
||||
let hex = hash.to_hex();
|
||||
let parsed = Hash::from_hex(&hex).unwrap();
|
||||
assert_eq!(hash, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_zero() {
|
||||
let zero = Hash::zero();
|
||||
assert!(zero.is_zero());
|
||||
assert_eq!(zero.as_bytes(), &[0u8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timestamp() {
|
||||
let ts = Timestamp::now();
|
||||
assert!(ts.secs > 0);
|
||||
|
||||
let from_secs = Timestamp::from_secs(1700000000);
|
||||
assert_eq!(from_secs.secs, 1700000000);
|
||||
assert_eq!(from_secs.nanos, 0);
|
||||
|
||||
let from_millis = Timestamp::from_millis(1700000000123);
|
||||
assert_eq!(from_millis.secs, 1700000000);
|
||||
assert_eq!(from_millis.nanos, 123_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version() {
|
||||
let v = Version::new(1, 2, 3);
|
||||
assert_eq!(v.to_string(), "1.2.3");
|
||||
|
||||
let parsed: Version = "2.3.4".parse().unwrap();
|
||||
assert_eq!(parsed, Version::new(2, 3, 4));
|
||||
|
||||
let bumped = Version::new(1, 2, 3).bump_patch();
|
||||
assert_eq!(bumped, Version::new(1, 2, 4));
|
||||
|
||||
let minor_bump = Version::new(1, 2, 3).bump_minor();
|
||||
assert_eq!(minor_bump, Version::new(1, 3, 0));
|
||||
|
||||
let major_bump = Version::new(1, 2, 3).bump_major();
|
||||
assert_eq!(major_bump, Version::new(2, 0, 0));
|
||||
}
|
||||
}
|
||||
969
vendor/ruvector/crates/prime-radiant/src/governance/policy.rs
vendored
Normal file
969
vendor/ruvector/crates/prime-radiant/src/governance/policy.rs
vendored
Normal file
@@ -0,0 +1,969 @@
|
||||
//! Policy Bundle Aggregate
|
||||
//!
|
||||
//! Implements versioned, signed policy bundles with multi-signature threshold configurations.
|
||||
//!
|
||||
//! # Lifecycle
|
||||
//!
|
||||
//! 1. **Draft**: Initial creation, can be modified
|
||||
//! 2. **Pending**: Awaiting required approvals
|
||||
//! 3. **Active**: Fully approved and immutable
|
||||
//! 4. **Superseded**: Replaced by a newer version
|
||||
//! 5. **Revoked**: Explicitly invalidated
|
||||
//!
|
||||
//! # Immutability Invariant
|
||||
//!
|
||||
//! Once a policy bundle reaches `Active` status, it becomes immutable.
|
||||
//! Any changes require creating a new version.
|
||||
|
||||
use super::{Hash, Timestamp, Version};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Unique identifier for a policy bundle
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct PolicyBundleId(pub Uuid);
|
||||
|
||||
impl PolicyBundleId {
|
||||
/// Generate a new random ID
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Create from a UUID
|
||||
#[must_use]
|
||||
pub const fn from_uuid(uuid: Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
|
||||
/// Get as bytes
|
||||
#[must_use]
|
||||
pub fn as_bytes(&self) -> &[u8; 16] {
|
||||
self.0.as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PolicyBundleId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PolicyBundleId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight reference to a policy bundle for embedding in other records
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct PolicyBundleRef {
|
||||
/// Bundle ID
|
||||
pub id: PolicyBundleId,
|
||||
/// Version at time of reference
|
||||
pub version: Version,
|
||||
/// Content hash for integrity verification
|
||||
pub content_hash: Hash,
|
||||
}
|
||||
|
||||
impl PolicyBundleRef {
|
||||
/// Create a reference from a policy bundle
|
||||
#[must_use]
|
||||
pub fn from_bundle(bundle: &PolicyBundle) -> Self {
|
||||
Self {
|
||||
id: bundle.id,
|
||||
version: bundle.version.clone(),
|
||||
content_hash: bundle.content_hash(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get as bytes for hashing
|
||||
#[must_use]
|
||||
pub fn as_bytes(&self) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity(48 + 12);
|
||||
bytes.extend_from_slice(self.id.as_bytes());
|
||||
bytes.extend_from_slice(&self.version.major.to_le_bytes());
|
||||
bytes.extend_from_slice(&self.version.minor.to_le_bytes());
|
||||
bytes.extend_from_slice(&self.version.patch.to_le_bytes());
|
||||
bytes.extend_from_slice(self.content_hash.as_bytes());
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of a policy bundle in its lifecycle
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum PolicyBundleStatus {
|
||||
/// Initial creation, can be modified
|
||||
Draft,
|
||||
/// Awaiting required approvals
|
||||
Pending,
|
||||
/// Fully approved and immutable
|
||||
Active,
|
||||
/// Replaced by a newer version
|
||||
Superseded,
|
||||
/// Explicitly invalidated
|
||||
Revoked,
|
||||
}
|
||||
|
||||
impl PolicyBundleStatus {
|
||||
/// Check if the policy is in an editable state
|
||||
#[must_use]
|
||||
pub const fn is_editable(&self) -> bool {
|
||||
matches!(self, Self::Draft)
|
||||
}
|
||||
|
||||
/// Check if the policy is currently enforceable
|
||||
#[must_use]
|
||||
pub const fn is_enforceable(&self) -> bool {
|
||||
matches!(self, Self::Active)
|
||||
}
|
||||
|
||||
/// Check if the policy is in a terminal state
|
||||
#[must_use]
|
||||
pub const fn is_terminal(&self) -> bool {
|
||||
matches!(self, Self::Superseded | Self::Revoked)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique identifier for an approver (could be a user, service, or key)
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ApproverId(pub String);
|
||||
|
||||
impl ApproverId {
|
||||
/// Create a new approver ID
|
||||
#[must_use]
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
|
||||
/// Get as string slice
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApproverId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ApproverId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ApproverId {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Digital signature for policy approval
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ApprovalSignature {
|
||||
/// The approver who signed
|
||||
pub approver_id: ApproverId,
|
||||
/// Timestamp of approval
|
||||
pub timestamp: Timestamp,
|
||||
/// Signature bytes (format depends on signing algorithm)
|
||||
pub signature: Vec<u8>,
|
||||
/// Algorithm used (e.g., "ed25519", "secp256k1")
|
||||
pub algorithm: String,
|
||||
/// Optional comment from approver
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
impl ApprovalSignature {
|
||||
/// Create a new approval signature
|
||||
#[must_use]
|
||||
pub fn new(approver_id: ApproverId, signature: Vec<u8>, algorithm: impl Into<String>) -> Self {
|
||||
Self {
|
||||
approver_id,
|
||||
timestamp: Timestamp::now(),
|
||||
signature,
|
||||
algorithm: algorithm.into(),
|
||||
comment: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a comment to the approval
|
||||
#[must_use]
|
||||
pub fn with_comment(mut self, comment: impl Into<String>) -> Self {
|
||||
self.comment = Some(comment.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a placeholder signature for testing (NOT for production)
|
||||
#[must_use]
|
||||
pub fn placeholder(approver_id: ApproverId) -> Self {
|
||||
Self {
|
||||
approver_id,
|
||||
timestamp: Timestamp::now(),
|
||||
signature: vec![0u8; 64],
|
||||
algorithm: "placeholder".to_string(),
|
||||
comment: Some("Test signature".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Threshold configuration for a scope
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ThresholdConfig {
|
||||
/// Energy threshold for Lane 0 (Reflex) - allow without additional checks
|
||||
pub reflex: f32,
|
||||
/// Energy threshold for Lane 1 (Retrieval) - require evidence fetching
|
||||
pub retrieval: f32,
|
||||
/// Energy threshold for Lane 2 (Heavy) - require deep reasoning
|
||||
pub heavy: f32,
|
||||
/// Duration for which incoherence must persist before escalation
|
||||
pub persistence_window: Duration,
|
||||
/// Optional custom thresholds for specific metrics
|
||||
pub custom_thresholds: HashMap<String, f32>,
|
||||
}
|
||||
|
||||
impl ThresholdConfig {
|
||||
/// Create a new threshold config with defaults
|
||||
#[must_use]
|
||||
pub fn new(reflex: f32, retrieval: f32, heavy: f32) -> Self {
|
||||
Self {
|
||||
reflex,
|
||||
retrieval,
|
||||
heavy,
|
||||
persistence_window: Duration::from_secs(30),
|
||||
custom_thresholds: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a strict threshold config (lower thresholds = more escalations)
|
||||
#[must_use]
|
||||
pub fn strict() -> Self {
|
||||
Self {
|
||||
reflex: 0.1,
|
||||
retrieval: 0.3,
|
||||
heavy: 0.6,
|
||||
persistence_window: Duration::from_secs(10),
|
||||
custom_thresholds: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a permissive threshold config (higher thresholds = fewer escalations)
|
||||
#[must_use]
|
||||
pub fn permissive() -> Self {
|
||||
Self {
|
||||
reflex: 0.5,
|
||||
retrieval: 0.8,
|
||||
heavy: 0.95,
|
||||
persistence_window: Duration::from_secs(60),
|
||||
custom_thresholds: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a custom threshold
|
||||
#[must_use]
|
||||
pub fn with_custom(mut self, name: impl Into<String>, value: f32) -> Self {
|
||||
self.custom_thresholds.insert(name.into(), value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set persistence window
|
||||
#[must_use]
|
||||
pub const fn with_persistence_window(mut self, window: Duration) -> Self {
|
||||
self.persistence_window = window;
|
||||
self
|
||||
}
|
||||
|
||||
/// Validate threshold ordering (reflex < retrieval < heavy)
|
||||
#[must_use]
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.reflex >= 0.0
|
||||
&& self.reflex <= self.retrieval
|
||||
&& self.retrieval <= self.heavy
|
||||
&& self.heavy <= 1.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ThresholdConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
reflex: 0.3,
|
||||
retrieval: 0.6,
|
||||
heavy: 0.9,
|
||||
persistence_window: Duration::from_secs(30),
|
||||
custom_thresholds: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule for automatic escalation under certain conditions
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EscalationRule {
|
||||
/// Unique name for this rule
|
||||
pub name: String,
|
||||
/// Condition expression (simplified DSL)
|
||||
pub condition: EscalationCondition,
|
||||
/// Target lane to escalate to
|
||||
pub target_lane: u8,
|
||||
/// Optional notification channels
|
||||
pub notify: Vec<String>,
|
||||
/// Whether this rule is enabled
|
||||
pub enabled: bool,
|
||||
/// Priority (lower = higher priority)
|
||||
pub priority: u32,
|
||||
}
|
||||
|
||||
impl EscalationRule {
|
||||
/// Create a new escalation rule
|
||||
#[must_use]
|
||||
pub fn new(name: impl Into<String>, condition: EscalationCondition, target_lane: u8) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
condition,
|
||||
target_lane,
|
||||
notify: Vec::new(),
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a notification channel
|
||||
#[must_use]
|
||||
pub fn with_notify(mut self, channel: impl Into<String>) -> Self {
|
||||
self.notify.push(channel.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the priority
|
||||
#[must_use]
|
||||
pub const fn with_priority(mut self, priority: u32) -> Self {
|
||||
self.priority = priority;
|
||||
self
|
||||
}
|
||||
|
||||
/// Disable the rule
|
||||
#[must_use]
|
||||
pub const fn disabled(mut self) -> Self {
|
||||
self.enabled = false;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Condition for triggering an escalation
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum EscalationCondition {
|
||||
/// Energy exceeds threshold
|
||||
EnergyAbove(f32),
|
||||
/// Energy persists above threshold for duration
|
||||
PersistentEnergy { threshold: f32, duration_secs: u64 },
|
||||
/// Spectral drift detected
|
||||
SpectralDrift { magnitude: f32 },
|
||||
/// Multiple consecutive rejections
|
||||
ConsecutiveRejections { count: u32 },
|
||||
/// Compound condition (all must be true)
|
||||
All(Vec<EscalationCondition>),
|
||||
/// Compound condition (any must be true)
|
||||
Any(Vec<EscalationCondition>),
|
||||
}
|
||||
|
||||
/// Policy error types
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PolicyError {
|
||||
/// Policy is not in an editable state
|
||||
#[error("Policy is not editable (status: {0:?})")]
|
||||
NotEditable(PolicyBundleStatus),
|
||||
|
||||
/// Policy is not active
|
||||
#[error("Policy is not active (status: {0:?})")]
|
||||
NotActive(PolicyBundleStatus),
|
||||
|
||||
/// Insufficient approvals
|
||||
#[error("Insufficient approvals: {current} of {required}")]
|
||||
InsufficientApprovals { current: usize, required: usize },
|
||||
|
||||
/// Duplicate approver
|
||||
#[error("Duplicate approval from: {0}")]
|
||||
DuplicateApprover(ApproverId),
|
||||
|
||||
/// Invalid threshold configuration
|
||||
#[error("Invalid threshold configuration: {0}")]
|
||||
InvalidThreshold(String),
|
||||
|
||||
/// Scope not found
|
||||
#[error("Scope not found: {0}")]
|
||||
ScopeNotFound(String),
|
||||
|
||||
/// Policy already exists
|
||||
#[error("Policy already exists: {0}")]
|
||||
AlreadyExists(PolicyBundleId),
|
||||
|
||||
/// Content hash mismatch
|
||||
#[error("Content hash mismatch")]
|
||||
HashMismatch,
|
||||
}
|
||||
|
||||
/// Versioned, signed policy bundle for threshold configuration
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PolicyBundle {
|
||||
/// Unique bundle identifier
|
||||
pub id: PolicyBundleId,
|
||||
/// Semantic version
|
||||
pub version: Version,
|
||||
/// Human-readable name
|
||||
pub name: String,
|
||||
/// Optional description
|
||||
pub description: Option<String>,
|
||||
/// Current lifecycle status
|
||||
pub status: PolicyBundleStatus,
|
||||
/// Threshold configurations by scope pattern
|
||||
pub thresholds: HashMap<String, ThresholdConfig>,
|
||||
/// Escalation rules
|
||||
pub escalation_rules: Vec<EscalationRule>,
|
||||
/// Approvals collected
|
||||
pub approvals: Vec<ApprovalSignature>,
|
||||
/// Minimum required approvals for activation
|
||||
pub required_approvals: usize,
|
||||
/// Allowed approvers (if empty, any approver is valid)
|
||||
pub allowed_approvers: Vec<ApproverId>,
|
||||
/// Creation timestamp
|
||||
pub created_at: Timestamp,
|
||||
/// Last modification timestamp
|
||||
pub updated_at: Timestamp,
|
||||
/// Optional reference to superseded bundle
|
||||
pub supersedes: Option<PolicyBundleId>,
|
||||
/// Activation timestamp (when status became Active)
|
||||
pub activated_at: Option<Timestamp>,
|
||||
/// Cached content hash (recomputed on access if None)
|
||||
#[serde(skip)]
|
||||
cached_hash: Option<Hash>,
|
||||
}
|
||||
|
||||
impl PolicyBundle {
|
||||
/// Create a new policy bundle in Draft status
|
||||
#[must_use]
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
let now = Timestamp::now();
|
||||
Self {
|
||||
id: PolicyBundleId::new(),
|
||||
version: Version::initial(),
|
||||
name: name.into(),
|
||||
description: None,
|
||||
status: PolicyBundleStatus::Draft,
|
||||
thresholds: HashMap::new(),
|
||||
escalation_rules: Vec::new(),
|
||||
approvals: Vec::new(),
|
||||
required_approvals: 1,
|
||||
allowed_approvers: Vec::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
supersedes: None,
|
||||
activated_at: None,
|
||||
cached_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the content hash of this bundle
|
||||
#[must_use]
|
||||
pub fn content_hash(&self) -> Hash {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
|
||||
// Hash identifying fields
|
||||
hasher.update(self.id.as_bytes());
|
||||
hasher.update(&self.version.major.to_le_bytes());
|
||||
hasher.update(&self.version.minor.to_le_bytes());
|
||||
hasher.update(&self.version.patch.to_le_bytes());
|
||||
hasher.update(self.name.as_bytes());
|
||||
|
||||
// Hash thresholds (sorted for determinism)
|
||||
let mut scope_keys: Vec<_> = self.thresholds.keys().collect();
|
||||
scope_keys.sort();
|
||||
for key in scope_keys {
|
||||
hasher.update(key.as_bytes());
|
||||
if let Some(config) = self.thresholds.get(key) {
|
||||
hasher.update(&config.reflex.to_le_bytes());
|
||||
hasher.update(&config.retrieval.to_le_bytes());
|
||||
hasher.update(&config.heavy.to_le_bytes());
|
||||
hasher.update(&config.persistence_window.as_secs().to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
// Hash escalation rules
|
||||
for rule in &self.escalation_rules {
|
||||
hasher.update(rule.name.as_bytes());
|
||||
hasher.update(&rule.target_lane.to_le_bytes());
|
||||
hasher.update(&rule.priority.to_le_bytes());
|
||||
}
|
||||
|
||||
// Hash governance params
|
||||
hasher.update(&self.required_approvals.to_le_bytes());
|
||||
|
||||
Hash::from_blake3(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Get a reference to this bundle
|
||||
#[must_use]
|
||||
pub fn reference(&self) -> PolicyBundleRef {
|
||||
PolicyBundleRef::from_bundle(self)
|
||||
}
|
||||
|
||||
/// Add a threshold configuration for a scope
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if policy is not editable or threshold is invalid
|
||||
pub fn add_threshold(
|
||||
&mut self,
|
||||
scope: impl Into<String>,
|
||||
config: ThresholdConfig,
|
||||
) -> Result<(), PolicyError> {
|
||||
if !self.status.is_editable() {
|
||||
return Err(PolicyError::NotEditable(self.status));
|
||||
}
|
||||
|
||||
if !config.is_valid() {
|
||||
return Err(PolicyError::InvalidThreshold(
|
||||
"Thresholds must be ordered: reflex <= retrieval <= heavy".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.thresholds.insert(scope.into(), config);
|
||||
self.updated_at = Timestamp::now();
|
||||
self.cached_hash = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an escalation rule
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if policy is not editable
|
||||
pub fn add_escalation_rule(&mut self, rule: EscalationRule) -> Result<(), PolicyError> {
|
||||
if !self.status.is_editable() {
|
||||
return Err(PolicyError::NotEditable(self.status));
|
||||
}
|
||||
|
||||
self.escalation_rules.push(rule);
|
||||
self.escalation_rules.sort_by_key(|r| r.priority);
|
||||
self.updated_at = Timestamp::now();
|
||||
self.cached_hash = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get threshold config for a scope (with fallback to "default")
|
||||
#[must_use]
|
||||
pub fn get_threshold(&self, scope: &str) -> Option<&ThresholdConfig> {
|
||||
self.thresholds
|
||||
.get(scope)
|
||||
.or_else(|| self.thresholds.get("default"))
|
||||
}
|
||||
|
||||
/// Set the number of required approvals
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if policy is not editable
|
||||
pub fn set_required_approvals(&mut self, count: usize) -> Result<(), PolicyError> {
|
||||
if !self.status.is_editable() {
|
||||
return Err(PolicyError::NotEditable(self.status));
|
||||
}
|
||||
|
||||
self.required_approvals = count;
|
||||
self.updated_at = Timestamp::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an allowed approver
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if policy is not editable
|
||||
pub fn add_allowed_approver(&mut self, approver: ApproverId) -> Result<(), PolicyError> {
|
||||
if !self.status.is_editable() {
|
||||
return Err(PolicyError::NotEditable(self.status));
|
||||
}
|
||||
|
||||
if !self.allowed_approvers.contains(&approver) {
|
||||
self.allowed_approvers.push(approver);
|
||||
self.updated_at = Timestamp::now();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Submit the bundle for approval (Draft -> Pending)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if not in Draft status
|
||||
pub fn submit_for_approval(&mut self) -> Result<(), PolicyError> {
|
||||
if self.status != PolicyBundleStatus::Draft {
|
||||
return Err(PolicyError::NotEditable(self.status));
|
||||
}
|
||||
|
||||
self.status = PolicyBundleStatus::Pending;
|
||||
self.updated_at = Timestamp::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an approval signature
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if:
|
||||
/// - Policy is not pending
|
||||
/// - Approver is not allowed
|
||||
/// - Approver has already signed
|
||||
pub fn add_approval(&mut self, approval: ApprovalSignature) -> Result<(), PolicyError> {
|
||||
if self.status != PolicyBundleStatus::Pending {
|
||||
return Err(PolicyError::NotEditable(self.status));
|
||||
}
|
||||
|
||||
// Check if approver is allowed (if list is not empty)
|
||||
if !self.allowed_approvers.is_empty()
|
||||
&& !self.allowed_approvers.contains(&approval.approver_id)
|
||||
{
|
||||
return Err(PolicyError::DuplicateApprover(approval.approver_id));
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
if self
|
||||
.approvals
|
||||
.iter()
|
||||
.any(|a| a.approver_id == approval.approver_id)
|
||||
{
|
||||
return Err(PolicyError::DuplicateApprover(approval.approver_id));
|
||||
}
|
||||
|
||||
self.approvals.push(approval);
|
||||
self.updated_at = Timestamp::now();
|
||||
|
||||
// Auto-activate if we have enough approvals
|
||||
if self.approvals.len() >= self.required_approvals {
|
||||
self.status = PolicyBundleStatus::Active;
|
||||
self.activated_at = Some(Timestamp::now());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if the bundle has sufficient approvals
|
||||
#[must_use]
|
||||
pub fn has_sufficient_approvals(&self) -> bool {
|
||||
self.approvals.len() >= self.required_approvals
|
||||
}
|
||||
|
||||
/// Force activation (for testing or emergency)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if already active or insufficient approvals
|
||||
pub fn activate(&mut self) -> Result<(), PolicyError> {
|
||||
if self.status == PolicyBundleStatus::Active {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !self.has_sufficient_approvals() {
|
||||
return Err(PolicyError::InsufficientApprovals {
|
||||
current: self.approvals.len(),
|
||||
required: self.required_approvals,
|
||||
});
|
||||
}
|
||||
|
||||
self.status = PolicyBundleStatus::Active;
|
||||
self.activated_at = Some(Timestamp::now());
|
||||
self.updated_at = Timestamp::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark this bundle as superseded by another
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if not active
|
||||
pub fn supersede(&mut self, successor_id: PolicyBundleId) -> Result<(), PolicyError> {
|
||||
if self.status != PolicyBundleStatus::Active {
|
||||
return Err(PolicyError::NotActive(self.status));
|
||||
}
|
||||
|
||||
self.status = PolicyBundleStatus::Superseded;
|
||||
self.updated_at = Timestamp::now();
|
||||
// Note: supersedes field is on the successor, not here
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke this bundle (emergency invalidation)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if already in terminal state
|
||||
pub fn revoke(&mut self) -> Result<(), PolicyError> {
|
||||
if self.status.is_terminal() {
|
||||
return Err(PolicyError::NotEditable(self.status));
|
||||
}
|
||||
|
||||
self.status = PolicyBundleStatus::Revoked;
|
||||
self.updated_at = Timestamp::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new version based on this bundle
|
||||
#[must_use]
|
||||
pub fn create_new_version(&self) -> Self {
|
||||
let now = Timestamp::now();
|
||||
Self {
|
||||
id: PolicyBundleId::new(),
|
||||
version: self.version.clone().bump_minor(),
|
||||
name: self.name.clone(),
|
||||
description: self.description.clone(),
|
||||
status: PolicyBundleStatus::Draft,
|
||||
thresholds: self.thresholds.clone(),
|
||||
escalation_rules: self.escalation_rules.clone(),
|
||||
approvals: Vec::new(),
|
||||
required_approvals: self.required_approvals,
|
||||
allowed_approvers: self.allowed_approvers.clone(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
supersedes: Some(self.id),
|
||||
activated_at: None,
|
||||
cached_hash: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for creating policy bundles
|
||||
#[derive(Default)]
|
||||
pub struct PolicyBundleBuilder {
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
thresholds: HashMap<String, ThresholdConfig>,
|
||||
escalation_rules: Vec<EscalationRule>,
|
||||
required_approvals: usize,
|
||||
allowed_approvers: Vec<ApproverId>,
|
||||
}
|
||||
|
||||
impl PolicyBundleBuilder {
|
||||
/// Create a new builder
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
name: None,
|
||||
description: None,
|
||||
thresholds: HashMap::new(),
|
||||
escalation_rules: Vec::new(),
|
||||
required_approvals: 1,
|
||||
allowed_approvers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the policy name
|
||||
#[must_use]
|
||||
pub fn name(mut self, name: impl Into<String>) -> Self {
|
||||
self.name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the description
|
||||
#[must_use]
|
||||
pub fn description(mut self, desc: impl Into<String>) -> Self {
|
||||
self.description = Some(desc.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a threshold configuration
|
||||
#[must_use]
|
||||
pub fn with_threshold(mut self, scope: impl Into<String>, config: ThresholdConfig) -> Self {
|
||||
self.thresholds.insert(scope.into(), config);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add an escalation rule
|
||||
#[must_use]
|
||||
pub fn with_escalation_rule(mut self, rule: EscalationRule) -> Self {
|
||||
self.escalation_rules.push(rule);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set required approvals
|
||||
#[must_use]
|
||||
pub const fn with_required_approvals(mut self, count: usize) -> Self {
|
||||
self.required_approvals = count;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add an allowed approver
|
||||
#[must_use]
|
||||
pub fn with_approver(mut self, approver: ApproverId) -> Self {
|
||||
self.allowed_approvers.push(approver);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the policy bundle
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if name is not set or thresholds are invalid
|
||||
pub fn build(self) -> Result<PolicyBundle, PolicyError> {
|
||||
let name = self
|
||||
.name
|
||||
.ok_or_else(|| PolicyError::InvalidThreshold("Policy name is required".to_string()))?;
|
||||
|
||||
// Validate all thresholds
|
||||
for (scope, config) in &self.thresholds {
|
||||
if !config.is_valid() {
|
||||
return Err(PolicyError::InvalidThreshold(format!(
|
||||
"Invalid threshold for scope '{scope}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let now = Timestamp::now();
|
||||
Ok(PolicyBundle {
|
||||
id: PolicyBundleId::new(),
|
||||
version: Version::initial(),
|
||||
name,
|
||||
description: self.description,
|
||||
status: PolicyBundleStatus::Draft,
|
||||
thresholds: self.thresholds,
|
||||
escalation_rules: self.escalation_rules,
|
||||
approvals: Vec::new(),
|
||||
required_approvals: self.required_approvals,
|
||||
allowed_approvers: self.allowed_approvers,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
supersedes: None,
|
||||
activated_at: None,
|
||||
cached_hash: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_policy_bundle_creation() {
|
||||
let policy = PolicyBundle::new("test-policy");
|
||||
assert_eq!(policy.name, "test-policy");
|
||||
assert_eq!(policy.status, PolicyBundleStatus::Draft);
|
||||
assert!(policy.status.is_editable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_threshold_config_validation() {
|
||||
let valid = ThresholdConfig::new(0.3, 0.6, 0.9);
|
||||
assert!(valid.is_valid());
|
||||
|
||||
let invalid = ThresholdConfig::new(0.9, 0.6, 0.3); // Wrong order
|
||||
assert!(!invalid.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_lifecycle() -> Result<(), PolicyError> {
|
||||
let mut policy = PolicyBundle::new("test");
|
||||
policy.add_threshold("default", ThresholdConfig::default())?;
|
||||
policy.set_required_approvals(2)?;
|
||||
|
||||
// Submit for approval
|
||||
policy.submit_for_approval()?;
|
||||
assert_eq!(policy.status, PolicyBundleStatus::Pending);
|
||||
|
||||
// Add approvals
|
||||
policy.add_approval(ApprovalSignature::placeholder(ApproverId::new("approver1")))?;
|
||||
assert_eq!(policy.status, PolicyBundleStatus::Pending); // Still pending
|
||||
|
||||
policy.add_approval(ApprovalSignature::placeholder(ApproverId::new("approver2")))?;
|
||||
assert_eq!(policy.status, PolicyBundleStatus::Active); // Auto-activated
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_approver_rejected() -> Result<(), PolicyError> {
|
||||
let mut policy = PolicyBundle::new("test");
|
||||
// Require 2 approvals so policy stays pending after first approval
|
||||
policy.set_required_approvals(2)?;
|
||||
policy.submit_for_approval()?;
|
||||
|
||||
let approver = ApproverId::new("same-approver");
|
||||
policy.add_approval(ApprovalSignature::placeholder(approver.clone()))?;
|
||||
|
||||
// Second approval from same approver should fail
|
||||
let result = policy.add_approval(ApprovalSignature::placeholder(approver));
|
||||
assert!(matches!(result, Err(PolicyError::DuplicateApprover(_))));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_immutability_after_activation() -> Result<(), PolicyError> {
|
||||
let mut policy = PolicyBundle::new("test");
|
||||
policy.submit_for_approval()?;
|
||||
policy.add_approval(ApprovalSignature::placeholder(ApproverId::new("approver")))?;
|
||||
|
||||
assert_eq!(policy.status, PolicyBundleStatus::Active);
|
||||
|
||||
// Trying to modify should fail
|
||||
let result = policy.add_threshold("new-scope", ThresholdConfig::default());
|
||||
assert!(matches!(result, Err(PolicyError::NotEditable(_))));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_hash_determinism() {
|
||||
let mut policy1 = PolicyBundle::new("test");
|
||||
let _ = policy1.add_threshold("scope1", ThresholdConfig::default());
|
||||
|
||||
let mut policy2 = PolicyBundle::new("test");
|
||||
let _ = policy2.add_threshold("scope1", ThresholdConfig::default());
|
||||
|
||||
// Same content should produce same hash (ignoring ID)
|
||||
// Note: IDs are different, so hashes will differ
|
||||
// But hashing the same bundle twice should be deterministic
|
||||
let hash1 = policy1.content_hash();
|
||||
let hash2 = policy1.content_hash();
|
||||
assert_eq!(hash1, hash2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builder() -> Result<(), PolicyError> {
|
||||
let policy = PolicyBundleBuilder::new()
|
||||
.name("my-policy")
|
||||
.description("A test policy")
|
||||
.with_threshold("default", ThresholdConfig::default())
|
||||
.with_threshold("strict", ThresholdConfig::strict())
|
||||
.with_required_approvals(2)
|
||||
.with_approver(ApproverId::new("admin1"))
|
||||
.with_approver(ApproverId::new("admin2"))
|
||||
.build()?;
|
||||
|
||||
assert_eq!(policy.name, "my-policy");
|
||||
assert_eq!(policy.thresholds.len(), 2);
|
||||
assert_eq!(policy.required_approvals, 2);
|
||||
assert_eq!(policy.allowed_approvers.len(), 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_version_creation() -> Result<(), PolicyError> {
|
||||
let mut original = PolicyBundle::new("test");
|
||||
original.add_threshold("default", ThresholdConfig::default())?;
|
||||
original.submit_for_approval()?;
|
||||
original.add_approval(ApprovalSignature::placeholder(ApproverId::new("approver")))?;
|
||||
|
||||
let new_version = original.create_new_version();
|
||||
|
||||
assert_ne!(new_version.id, original.id);
|
||||
assert_eq!(new_version.supersedes, Some(original.id));
|
||||
assert_eq!(new_version.version, Version::new(1, 1, 0));
|
||||
assert_eq!(new_version.status, PolicyBundleStatus::Draft);
|
||||
assert!(new_version.approvals.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
1061
vendor/ruvector/crates/prime-radiant/src/governance/repository.rs
vendored
Normal file
1061
vendor/ruvector/crates/prime-radiant/src/governance/repository.rs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
722
vendor/ruvector/crates/prime-radiant/src/governance/witness.rs
vendored
Normal file
722
vendor/ruvector/crates/prime-radiant/src/governance/witness.rs
vendored
Normal file
@@ -0,0 +1,722 @@
|
||||
//! Witness Record Entity
|
||||
//!
|
||||
//! Implements immutable proof of every gate decision with content hashing.
|
||||
//!
|
||||
//! # Witness Chain
|
||||
//!
|
||||
//! Each witness record references its predecessor, forming a linked chain:
|
||||
//!
|
||||
//! ```text
|
||||
//! Witness N-2 <-- Witness N-1 <-- Witness N
|
||||
//! ^ ^ ^
|
||||
//! | | |
|
||||
//! hash(N-2) hash(N-1) hash(N)
|
||||
//! ```
|
||||
//!
|
||||
//! This provides:
|
||||
//! - Temporal ordering guarantee
|
||||
//! - Tamper detection (any modification breaks the chain)
|
||||
//! - Deterministic replay capability
|
||||
//!
|
||||
//! # Core Invariant
|
||||
//!
|
||||
//! **No action without witness**: Every gate decision MUST produce a witness record.
|
||||
|
||||
use super::{Hash, PolicyBundleRef, Timestamp};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Unique identifier for a witness record
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct WitnessId(pub Uuid);
|
||||
|
||||
impl WitnessId {
|
||||
/// Generate a new random ID
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Create from a UUID
|
||||
#[must_use]
|
||||
pub const fn from_uuid(uuid: Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
|
||||
/// Get as bytes
|
||||
#[must_use]
|
||||
pub fn as_bytes(&self) -> &[u8; 16] {
|
||||
self.0.as_bytes()
|
||||
}
|
||||
|
||||
/// Create a nil/sentinel ID
|
||||
#[must_use]
|
||||
pub const fn nil() -> Self {
|
||||
Self(Uuid::nil())
|
||||
}
|
||||
|
||||
/// Check if this is the nil ID
|
||||
#[must_use]
|
||||
pub fn is_nil(&self) -> bool {
|
||||
self.0.is_nil()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WitnessId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WitnessId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute lane levels (from ADR-014)
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum ComputeLane {
|
||||
/// Lane 0: Local residual updates, simple aggregates (<1ms)
|
||||
Reflex = 0,
|
||||
/// Lane 1: Evidence fetching, lightweight reasoning (~10ms)
|
||||
Retrieval = 1,
|
||||
/// Lane 2: Multi-step planning, spectral analysis (~100ms)
|
||||
Heavy = 2,
|
||||
/// Lane 3: Human escalation for sustained incoherence
|
||||
Human = 3,
|
||||
}
|
||||
|
||||
impl ComputeLane {
|
||||
/// Get the numeric value
|
||||
#[must_use]
|
||||
pub const fn as_u8(&self) -> u8 {
|
||||
*self as u8
|
||||
}
|
||||
|
||||
/// Create from numeric value
|
||||
#[must_use]
|
||||
pub const fn from_u8(value: u8) -> Option<Self> {
|
||||
match value {
|
||||
0 => Some(Self::Reflex),
|
||||
1 => Some(Self::Retrieval),
|
||||
2 => Some(Self::Heavy),
|
||||
3 => Some(Self::Human),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this lane requires human intervention
|
||||
#[must_use]
|
||||
pub const fn requires_human(&self) -> bool {
|
||||
matches!(self, Self::Human)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ComputeLane {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Reflex => write!(f, "Reflex"),
|
||||
Self::Retrieval => write!(f, "Retrieval"),
|
||||
Self::Heavy => write!(f, "Heavy"),
|
||||
Self::Human => write!(f, "Human"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gate decision result
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GateDecision {
|
||||
/// Whether the action was allowed
|
||||
pub allow: bool,
|
||||
/// Required compute lane
|
||||
pub lane: ComputeLane,
|
||||
/// Reason for the decision (especially if denied)
|
||||
pub reason: Option<String>,
|
||||
/// Confidence in the decision (0.0 to 1.0)
|
||||
pub confidence: f32,
|
||||
/// Additional decision metadata
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl GateDecision {
|
||||
/// Create an allow decision
|
||||
#[must_use]
|
||||
pub fn allow(lane: ComputeLane) -> Self {
|
||||
Self {
|
||||
allow: true,
|
||||
lane,
|
||||
reason: None,
|
||||
confidence: 1.0,
|
||||
metadata: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a deny decision
|
||||
#[must_use]
|
||||
pub fn deny(lane: ComputeLane, reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
allow: false,
|
||||
lane,
|
||||
reason: Some(reason.into()),
|
||||
confidence: 1.0,
|
||||
metadata: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set confidence level
|
||||
#[must_use]
|
||||
pub const fn with_confidence(mut self, confidence: f32) -> Self {
|
||||
self.confidence = confidence;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add metadata
|
||||
#[must_use]
|
||||
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.metadata.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of coherence energy at decision time
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EnergySnapshot {
|
||||
/// Total system energy (lower = more coherent)
|
||||
pub total_energy: f32,
|
||||
/// Energy for the specific scope being evaluated
|
||||
pub scope_energy: f32,
|
||||
/// Scope identifier
|
||||
pub scope: String,
|
||||
/// Number of edges contributing to this energy
|
||||
pub edge_count: u32,
|
||||
/// Timestamp when energy was computed
|
||||
pub computed_at: Timestamp,
|
||||
/// Fingerprint for change detection
|
||||
pub fingerprint: Hash,
|
||||
/// Per-scope breakdown (optional)
|
||||
pub scope_breakdown: Option<HashMap<String, f32>>,
|
||||
}
|
||||
|
||||
impl EnergySnapshot {
|
||||
/// Create a new energy snapshot
|
||||
#[must_use]
|
||||
pub fn new(total_energy: f32, scope_energy: f32, scope: impl Into<String>) -> Self {
|
||||
Self {
|
||||
total_energy,
|
||||
scope_energy,
|
||||
scope: scope.into(),
|
||||
edge_count: 0,
|
||||
computed_at: Timestamp::now(),
|
||||
fingerprint: Hash::zero(),
|
||||
scope_breakdown: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set edge count
|
||||
#[must_use]
|
||||
pub const fn with_edge_count(mut self, count: u32) -> Self {
|
||||
self.edge_count = count;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set fingerprint
|
||||
#[must_use]
|
||||
pub const fn with_fingerprint(mut self, fingerprint: Hash) -> Self {
|
||||
self.fingerprint = fingerprint;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add scope breakdown
|
||||
#[must_use]
|
||||
pub fn with_breakdown(mut self, breakdown: HashMap<String, f32>) -> Self {
|
||||
self.scope_breakdown = Some(breakdown);
|
||||
self
|
||||
}
|
||||
|
||||
/// Compute content hash for this snapshot
|
||||
#[must_use]
|
||||
pub fn content_hash(&self) -> Hash {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(&self.total_energy.to_le_bytes());
|
||||
hasher.update(&self.scope_energy.to_le_bytes());
|
||||
hasher.update(self.scope.as_bytes());
|
||||
hasher.update(&self.edge_count.to_le_bytes());
|
||||
hasher.update(&self.computed_at.secs.to_le_bytes());
|
||||
hasher.update(&self.computed_at.nanos.to_le_bytes());
|
||||
hasher.update(self.fingerprint.as_bytes());
|
||||
Hash::from_blake3(hasher.finalize())
|
||||
}
|
||||
}
|
||||
|
||||
/// Witness chain integrity errors
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WitnessChainError {
|
||||
/// Previous witness not found
|
||||
#[error("Previous witness not found: {0}")]
|
||||
PreviousNotFound(WitnessId),
|
||||
|
||||
/// Chain hash mismatch
|
||||
#[error("Chain hash mismatch at witness {0}")]
|
||||
HashMismatch(WitnessId),
|
||||
|
||||
/// Temporal ordering violation
|
||||
#[error("Temporal ordering violation: {0} should be before {1}")]
|
||||
TemporalViolation(WitnessId, WitnessId),
|
||||
|
||||
/// Gap in sequence
|
||||
#[error("Gap in witness sequence at {0}")]
|
||||
SequenceGap(u64),
|
||||
}
|
||||
|
||||
/// Witness-related errors
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WitnessError {
|
||||
/// Chain integrity error
|
||||
#[error("Chain integrity error: {0}")]
|
||||
ChainError(#[from] WitnessChainError),
|
||||
|
||||
/// Invalid witness data
|
||||
#[error("Invalid witness data: {0}")]
|
||||
InvalidData(String),
|
||||
|
||||
/// Witness not found
|
||||
#[error("Witness not found: {0}")]
|
||||
NotFound(WitnessId),
|
||||
|
||||
/// Witness already exists
|
||||
#[error("Witness already exists: {0}")]
|
||||
AlreadyExists(WitnessId),
|
||||
}
|
||||
|
||||
/// Immutable proof of a gate decision
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct WitnessRecord {
|
||||
/// Unique witness identifier
|
||||
pub id: WitnessId,
|
||||
/// Sequence number within the chain
|
||||
pub sequence: u64,
|
||||
/// Hash of the action that was evaluated
|
||||
pub action_hash: Hash,
|
||||
/// Energy state at time of evaluation
|
||||
pub energy_snapshot: EnergySnapshot,
|
||||
/// Gate decision made
|
||||
pub decision: GateDecision,
|
||||
/// Policy bundle used for evaluation
|
||||
pub policy_bundle_ref: PolicyBundleRef,
|
||||
/// Creation timestamp
|
||||
pub timestamp: Timestamp,
|
||||
/// Reference to previous witness in chain (None for genesis)
|
||||
pub previous_witness: Option<WitnessId>,
|
||||
/// Hash of previous witness content (for chain integrity)
|
||||
pub previous_hash: Option<Hash>,
|
||||
/// Content hash of this witness (computed on creation)
|
||||
pub content_hash: Hash,
|
||||
/// Optional actor who triggered the action
|
||||
pub actor: Option<String>,
|
||||
/// Optional correlation ID for request tracing
|
||||
pub correlation_id: Option<String>,
|
||||
}
|
||||
|
||||
impl WitnessRecord {
|
||||
/// Create a new witness record
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `action_hash` - Hash of the action being witnessed
|
||||
/// * `energy_snapshot` - Energy state at decision time
|
||||
/// * `decision` - The gate decision
|
||||
/// * `policy_bundle_ref` - Reference to the policy used
|
||||
/// * `previous` - Previous witness in chain (None for genesis)
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
action_hash: Hash,
|
||||
energy_snapshot: EnergySnapshot,
|
||||
decision: GateDecision,
|
||||
policy_bundle_ref: PolicyBundleRef,
|
||||
previous: Option<&WitnessRecord>,
|
||||
) -> Self {
|
||||
let id = WitnessId::new();
|
||||
let timestamp = Timestamp::now();
|
||||
|
||||
let (previous_witness, previous_hash, sequence) = match previous {
|
||||
Some(prev) => (Some(prev.id), Some(prev.content_hash), prev.sequence + 1),
|
||||
None => (None, None, 0),
|
||||
};
|
||||
|
||||
let mut witness = Self {
|
||||
id,
|
||||
sequence,
|
||||
action_hash,
|
||||
energy_snapshot,
|
||||
decision,
|
||||
policy_bundle_ref,
|
||||
timestamp,
|
||||
previous_witness,
|
||||
previous_hash,
|
||||
content_hash: Hash::zero(), // Placeholder, computed below
|
||||
actor: None,
|
||||
correlation_id: None,
|
||||
};
|
||||
|
||||
// Compute and set content hash
|
||||
witness.content_hash = witness.compute_content_hash();
|
||||
witness
|
||||
}
|
||||
|
||||
/// Create a genesis witness (first in chain)
|
||||
#[must_use]
|
||||
pub fn genesis(
|
||||
action_hash: Hash,
|
||||
energy_snapshot: EnergySnapshot,
|
||||
decision: GateDecision,
|
||||
policy_bundle_ref: PolicyBundleRef,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
action_hash,
|
||||
energy_snapshot,
|
||||
decision,
|
||||
policy_bundle_ref,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Set the actor
|
||||
#[must_use]
|
||||
pub fn with_actor(mut self, actor: impl Into<String>) -> Self {
|
||||
self.actor = Some(actor.into());
|
||||
// Recompute hash since we changed content
|
||||
self.content_hash = self.compute_content_hash();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set correlation ID
|
||||
#[must_use]
|
||||
pub fn with_correlation_id(mut self, id: impl Into<String>) -> Self {
|
||||
self.correlation_id = Some(id.into());
|
||||
// Recompute hash since we changed content
|
||||
self.content_hash = self.compute_content_hash();
|
||||
self
|
||||
}
|
||||
|
||||
/// Compute the content hash using Blake3
|
||||
#[must_use]
|
||||
pub fn compute_content_hash(&self) -> Hash {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
|
||||
// Core identifying fields
|
||||
hasher.update(self.id.as_bytes());
|
||||
hasher.update(&self.sequence.to_le_bytes());
|
||||
hasher.update(self.action_hash.as_bytes());
|
||||
|
||||
// Energy snapshot hash
|
||||
hasher.update(self.energy_snapshot.content_hash().as_bytes());
|
||||
|
||||
// Decision
|
||||
hasher.update(&[self.decision.allow as u8]);
|
||||
hasher.update(&[self.decision.lane.as_u8()]);
|
||||
hasher.update(&self.decision.confidence.to_le_bytes());
|
||||
if let Some(ref reason) = self.decision.reason {
|
||||
hasher.update(reason.as_bytes());
|
||||
}
|
||||
|
||||
// Policy reference
|
||||
hasher.update(&self.policy_bundle_ref.as_bytes());
|
||||
|
||||
// Timestamp
|
||||
hasher.update(&self.timestamp.secs.to_le_bytes());
|
||||
hasher.update(&self.timestamp.nanos.to_le_bytes());
|
||||
|
||||
// Chain linkage
|
||||
if let Some(ref prev_id) = self.previous_witness {
|
||||
hasher.update(prev_id.as_bytes());
|
||||
}
|
||||
if let Some(ref prev_hash) = self.previous_hash {
|
||||
hasher.update(prev_hash.as_bytes());
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
if let Some(ref actor) = self.actor {
|
||||
hasher.update(actor.as_bytes());
|
||||
}
|
||||
if let Some(ref corr_id) = self.correlation_id {
|
||||
hasher.update(corr_id.as_bytes());
|
||||
}
|
||||
|
||||
Hash::from_blake3(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Verify the content hash is correct
|
||||
#[must_use]
|
||||
pub fn verify_content_hash(&self) -> bool {
|
||||
self.content_hash == self.compute_content_hash()
|
||||
}
|
||||
|
||||
/// Verify the chain linkage to a previous witness
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if:
|
||||
/// - Previous witness hash doesn't match
|
||||
/// - Sequence numbers are not consecutive
|
||||
/// - Timestamp ordering is violated
|
||||
pub fn verify_chain_link(&self, previous: &WitnessRecord) -> Result<(), WitnessChainError> {
|
||||
// Check ID reference
|
||||
if self.previous_witness != Some(previous.id) {
|
||||
return Err(WitnessChainError::PreviousNotFound(previous.id));
|
||||
}
|
||||
|
||||
// Check hash linkage
|
||||
if self.previous_hash != Some(previous.content_hash) {
|
||||
return Err(WitnessChainError::HashMismatch(self.id));
|
||||
}
|
||||
|
||||
// Check sequence continuity
|
||||
if self.sequence != previous.sequence + 1 {
|
||||
return Err(WitnessChainError::SequenceGap(self.sequence));
|
||||
}
|
||||
|
||||
// Check temporal ordering
|
||||
if self.timestamp < previous.timestamp {
|
||||
return Err(WitnessChainError::TemporalViolation(previous.id, self.id));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if this is a genesis witness
|
||||
#[must_use]
|
||||
pub fn is_genesis(&self) -> bool {
|
||||
self.previous_witness.is_none() && self.sequence == 0
|
||||
}
|
||||
|
||||
/// Get the decision outcome
|
||||
#[must_use]
|
||||
pub const fn was_allowed(&self) -> bool {
|
||||
self.decision.allow
|
||||
}
|
||||
|
||||
/// Get the compute lane
|
||||
#[must_use]
|
||||
pub const fn lane(&self) -> ComputeLane {
|
||||
self.decision.lane
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for WitnessRecord {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for WitnessRecord {}
|
||||
|
||||
impl std::hash::Hash for WitnessRecord {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for creating witness chains
|
||||
pub struct WitnessChainBuilder {
|
||||
head: Option<WitnessRecord>,
|
||||
policy_ref: PolicyBundleRef,
|
||||
}
|
||||
|
||||
impl WitnessChainBuilder {
|
||||
/// Create a new chain builder
|
||||
#[must_use]
|
||||
pub fn new(policy_ref: PolicyBundleRef) -> Self {
|
||||
Self {
|
||||
head: None,
|
||||
policy_ref,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new chain builder starting from an existing witness
|
||||
#[must_use]
|
||||
pub fn from_head(head: WitnessRecord) -> Self {
|
||||
let policy_ref = head.policy_bundle_ref.clone();
|
||||
Self {
|
||||
head: Some(head),
|
||||
policy_ref,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a witness to the chain
|
||||
pub fn add_witness(
|
||||
&mut self,
|
||||
action_hash: Hash,
|
||||
energy_snapshot: EnergySnapshot,
|
||||
decision: GateDecision,
|
||||
) -> &WitnessRecord {
|
||||
let witness = WitnessRecord::new(
|
||||
action_hash,
|
||||
energy_snapshot,
|
||||
decision,
|
||||
self.policy_ref.clone(),
|
||||
self.head.as_ref(),
|
||||
);
|
||||
self.head = Some(witness);
|
||||
self.head.as_ref().unwrap()
|
||||
}
|
||||
|
||||
/// Get the current head of the chain
|
||||
#[must_use]
|
||||
pub fn head(&self) -> Option<&WitnessRecord> {
|
||||
self.head.as_ref()
|
||||
}
|
||||
|
||||
/// Get the current sequence number
|
||||
#[must_use]
|
||||
pub fn current_sequence(&self) -> u64 {
|
||||
self.head.as_ref().map_or(0, |w| w.sequence)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::governance::{PolicyBundleId, Version};
|
||||
|
||||
fn test_policy_ref() -> PolicyBundleRef {
|
||||
PolicyBundleRef {
|
||||
id: PolicyBundleId::new(),
|
||||
version: Version::initial(),
|
||||
content_hash: Hash::zero(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_energy_snapshot() -> EnergySnapshot {
|
||||
EnergySnapshot::new(0.5, 0.3, "test-scope")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_witness_creation() {
|
||||
let action_hash = Hash::from_bytes([1u8; 32]);
|
||||
let energy = test_energy_snapshot();
|
||||
let decision = GateDecision::allow(ComputeLane::Reflex);
|
||||
let policy_ref = test_policy_ref();
|
||||
|
||||
let witness = WitnessRecord::genesis(action_hash, energy, decision, policy_ref);
|
||||
|
||||
assert!(witness.is_genesis());
|
||||
assert!(witness.was_allowed());
|
||||
assert_eq!(witness.lane(), ComputeLane::Reflex);
|
||||
assert_eq!(witness.sequence, 0);
|
||||
assert!(witness.verify_content_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_witness_chain() {
|
||||
let policy_ref = test_policy_ref();
|
||||
let mut builder = WitnessChainBuilder::new(policy_ref);
|
||||
|
||||
// Genesis
|
||||
let action1 = Hash::from_bytes([1u8; 32]);
|
||||
let witness1 = builder.add_witness(
|
||||
action1,
|
||||
test_energy_snapshot(),
|
||||
GateDecision::allow(ComputeLane::Reflex),
|
||||
);
|
||||
assert!(witness1.is_genesis());
|
||||
let witness1_id = witness1.id.clone();
|
||||
|
||||
// Second witness
|
||||
let action2 = Hash::from_bytes([2u8; 32]);
|
||||
let witness2 = builder.add_witness(
|
||||
action2,
|
||||
test_energy_snapshot(),
|
||||
GateDecision::deny(ComputeLane::Heavy, "High energy"),
|
||||
);
|
||||
assert!(!witness2.is_genesis());
|
||||
assert_eq!(witness2.sequence, 1);
|
||||
assert_eq!(witness2.previous_witness, Some(witness1_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chain_verification() {
|
||||
let policy_ref = test_policy_ref();
|
||||
|
||||
// Create genesis
|
||||
let genesis = WitnessRecord::genesis(
|
||||
Hash::from_bytes([1u8; 32]),
|
||||
test_energy_snapshot(),
|
||||
GateDecision::allow(ComputeLane::Reflex),
|
||||
policy_ref.clone(),
|
||||
);
|
||||
|
||||
// Create next witness
|
||||
let next = WitnessRecord::new(
|
||||
Hash::from_bytes([2u8; 32]),
|
||||
test_energy_snapshot(),
|
||||
GateDecision::allow(ComputeLane::Retrieval),
|
||||
policy_ref,
|
||||
Some(&genesis),
|
||||
);
|
||||
|
||||
// Verify chain link
|
||||
assert!(next.verify_chain_link(&genesis).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_hash_determinism() {
|
||||
let action = Hash::from_bytes([1u8; 32]);
|
||||
let energy = test_energy_snapshot();
|
||||
let decision = GateDecision::allow(ComputeLane::Reflex);
|
||||
let policy_ref = test_policy_ref();
|
||||
|
||||
let witness =
|
||||
WitnessRecord::genesis(action, energy.clone(), decision.clone(), policy_ref.clone());
|
||||
|
||||
// Verify hash is consistent
|
||||
let hash1 = witness.compute_content_hash();
|
||||
let hash2 = witness.compute_content_hash();
|
||||
assert_eq!(hash1, hash2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tamper_detection() {
|
||||
let action = Hash::from_bytes([1u8; 32]);
|
||||
let energy = test_energy_snapshot();
|
||||
let decision = GateDecision::allow(ComputeLane::Reflex);
|
||||
let policy_ref = test_policy_ref();
|
||||
|
||||
let mut witness = WitnessRecord::genesis(action, energy, decision, policy_ref);
|
||||
|
||||
// Tamper with the witness
|
||||
witness.decision.confidence = 0.5;
|
||||
|
||||
// Content hash should no longer match
|
||||
assert!(!witness.verify_content_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gate_decision() {
|
||||
let allow = GateDecision::allow(ComputeLane::Reflex)
|
||||
.with_confidence(0.95)
|
||||
.with_metadata("source", "test");
|
||||
|
||||
assert!(allow.allow);
|
||||
assert_eq!(allow.lane, ComputeLane::Reflex);
|
||||
assert!((allow.confidence - 0.95).abs() < f32::EPSILON);
|
||||
assert_eq!(allow.metadata.get("source"), Some(&"test".to_string()));
|
||||
|
||||
let deny = GateDecision::deny(ComputeLane::Human, "High energy detected");
|
||||
assert!(!deny.allow);
|
||||
assert_eq!(deny.reason, Some("High energy detected".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_lane() {
|
||||
assert_eq!(ComputeLane::from_u8(0), Some(ComputeLane::Reflex));
|
||||
assert_eq!(ComputeLane::from_u8(3), Some(ComputeLane::Human));
|
||||
assert_eq!(ComputeLane::from_u8(4), None);
|
||||
|
||||
assert!(!ComputeLane::Reflex.requires_human());
|
||||
assert!(ComputeLane::Human.requires_human());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user