Skip to main content

vex_core/
segment.rs

1//! # VEX Segments
2//!
3//! Provides the data structures and JCS canonicalization for the v0.1.0 "Hardened" Commitment model.
4
5use crate::merkle::Hash;
6use crate::zk::{ZkError, ZkVerifier};
7use serde::{Deserialize, Serialize};
8use sha2::Digest;
9use utoipa::ToSchema;
10
11/// Intent Data (VEX Pillar)
12/// Proves the proposed action before execution. It supports two variants:
13/// - Transparent: Standard human-readable reasoning (Standard).
14/// - Shadow: STARK-proofed hidden intent for privacy (High-Compliance).
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
16#[serde(untagged, rename_all = "snake_case")]
17pub enum IntentData {
18    Transparent {
19        request_sha256: String,
20        confidence: f64,
21        #[serde(default)]
22        capabilities: Vec<String>,
23        #[serde(skip_serializing_if = "Option::is_none")]
24        magpie_source: Option<String>,
25
26        /// Phase 6: Continuation authorization
27        #[serde(skip_serializing_if = "Option::is_none")]
28        continuation_token: Option<ContinuationToken>,
29
30        /// Catch-all for extra fields to preserve binary parity in JCS.
31        #[serde(flatten, default)]
32        #[schema(ignore)]
33        metadata: SchemaValue,
34    },
35    Shadow {
36        commitment_hash: String,
37        stark_proof_b64: String,
38        #[schema(ignore)]
39        public_inputs: SchemaValue,
40
41        /// New Phase 2: Plonky3 Circuit Identity
42        #[serde(skip_serializing_if = "Option::is_none")]
43        circuit_id: Option<String>,
44
45        /// Phase 6: Continuation authorization
46        #[serde(skip_serializing_if = "Option::is_none")]
47        continuation_token: Option<ContinuationToken>,
48
49        /// Catch-all for extra fields to preserve binary parity in JCS.
50        #[serde(flatten, default)]
51        #[schema(ignore)]
52        metadata: SchemaValue,
53    },
54}
55
56impl IntentData {
57    pub fn continuation_token(&self) -> Option<&ContinuationToken> {
58        match self {
59            IntentData::Transparent {
60                continuation_token, ..
61            } => continuation_token.as_ref(),
62            IntentData::Shadow {
63                continuation_token, ..
64            } => continuation_token.as_ref(),
65        }
66    }
67
68    pub fn circuit_id(&self) -> Option<String> {
69        match self {
70            IntentData::Transparent { .. } => None,
71            IntentData::Shadow { circuit_id, .. } => circuit_id.clone(),
72        }
73    }
74
75    pub fn metadata(&self) -> &SchemaValue {
76        match self {
77            IntentData::Transparent { metadata, .. } => metadata,
78            IntentData::Shadow { metadata, .. } => metadata,
79        }
80    }
81}
82
83/// A wrapper for serde_json::Value that implements utoipa::ToSchema.
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(transparent)]
86pub struct SchemaValue(pub serde_json::Value);
87
88impl std::ops::Deref for SchemaValue {
89    type Target = serde_json::Value;
90
91    fn deref(&self) -> &Self::Target {
92        &self.0
93    }
94}
95
96impl std::ops::DerefMut for SchemaValue {
97    fn deref_mut(&mut self) -> &mut Self::Target {
98        &mut self.0
99    }
100}
101
102impl Default for SchemaValue {
103    fn default() -> Self {
104        Self(serde_json::Value::Null)
105    }
106}
107
108impl SchemaValue {
109    pub fn is_null(&self) -> bool {
110        self.0.is_null()
111    }
112}
113
114impl utoipa::PartialSchema for SchemaValue {
115    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
116        utoipa::openapi::RefOr::T(utoipa::openapi::ObjectBuilder::new().into())
117    }
118}
119
120impl utoipa::ToSchema for SchemaValue {
121    fn name() -> std::borrow::Cow<'static, str> {
122        "SchemaValue".into()
123    }
124}
125
126impl IntentData {
127    pub fn to_jcs_hash(&self) -> Result<Hash, String> {
128        let jcs_bytes =
129            serde_jcs::to_vec(self).map_err(|e| format!("JCS serialization failed: {}", e))?;
130        Ok(Hash::digest(&jcs_bytes))
131    }
132
133    /// Verifies the Zero-Knowledge proof for Shadow intents.
134    /// For Transparent intents, this always returns Ok(true).
135    pub fn verify_shadow(&self, verifier: &dyn ZkVerifier) -> Result<bool, ZkError> {
136        match self {
137            IntentData::Transparent { .. } => Ok(true),
138            IntentData::Shadow {
139                commitment_hash,
140                stark_proof_b64,
141                public_inputs,
142                ..
143            } => verifier.verify_stark(commitment_hash, stark_proof_b64, &public_inputs.0),
144        }
145    }
146
147    /// Accesses the typed ShadowPublicInputs for a Shadow intent.
148    pub fn shadow_public_inputs(&self) -> Result<ShadowPublicInputs, ZkError> {
149        match self {
150            IntentData::Shadow { public_inputs, .. } => {
151                ShadowPublicInputs::from_schema_value(public_inputs).map_err(ZkError::ConfigError)
152            }
153            _ => Err(ZkError::ConfigError("Not a Shadow intent".to_string())),
154        }
155    }
156}
157
158/// Typed Public Inputs for Shadow Intents (Phase 2 Hardening)
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
160pub struct ShadowPublicInputs {
161    pub schema: String,
162    pub start_root: String,
163    pub commitment_hash: String,
164    pub circuit_id: String,
165    /// The salt used in the semantic commitment (Phase 3 Binding)
166    pub public_salt: String,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub verifier_params_hash: Option<String>,
169}
170
171impl ShadowPublicInputs {
172    pub const SCHEMA_V1: &'static str = "vex.shadow_intent.public_inputs.v1";
173
174    pub fn validate(&self) -> Result<(), String> {
175        if self.schema != Self::SCHEMA_V1 {
176            return Err("unsupported schema".to_string());
177        }
178        if self.start_root.len() != 64 || !self.start_root.chars().all(|c| c.is_ascii_hexdigit()) {
179            return Err("start_root must be 32-byte hex".to_string());
180        }
181        if self.commitment_hash.len() != 64
182            || !self.commitment_hash.chars().all(|c| c.is_ascii_hexdigit())
183        {
184            return Err("commitment_hash must be 32-byte hex".to_string());
185        }
186        if self.circuit_id.trim().is_empty() {
187            return Err("circuit_id must not be empty".to_string());
188        }
189        Ok(())
190    }
191
192    pub fn to_schema_value(&self) -> SchemaValue {
193        SchemaValue(serde_json::to_value(self).expect("serializes"))
194    }
195
196    pub fn from_schema_value(v: &SchemaValue) -> Result<Self, String> {
197        let decoded: Self = serde_json::from_value(v.0.clone()).map_err(|e| e.to_string())?;
198        decoded.validate()?;
199        Ok(decoded)
200    }
201
202    /// Verifies that the commitment hash matches the provided prompt and salt.
203    /// This is the "Commitment Opening" step for auditors (Phase 5).
204    pub fn verify_opening(&self, prompt: &str) -> bool {
205        let salt = hex::decode(&self.public_salt).unwrap_or_default();
206        let expected = semantic_commitment_hash(prompt, &salt);
207        self.commitment_hash == expected
208    }
209}
210
211pub const SHADOW_INTENT_DOMAIN_TAG: &[u8] = b"vex.shadow_intent.commitment.v1";
212
213pub const FULL_ROUNDS_START: usize = 4;
214pub const PARTIAL_ROUNDS: usize = 22;
215pub const TOTAL_ROUNDS: usize = 30;
216pub const WIDTH: usize = 8;
217pub const FULL_WIDTH: usize = 28;
218
219pub const GOLDILOCKS_PRIME: u64 = 0xFFFF_FFFF_0000_0001;
220
221pub const COL_STATE_START: usize = 0;
222pub const COL_AUX_START: usize = 8;
223pub const COL_CONST_START: usize = 16;
224pub const COL_IS_FULL: usize = 24;
225pub const COL_IS_ACTIVE: usize = 25;
226pub const COL_IS_LAST: usize = 26;
227pub const COL_IS_REAL: usize = 27;
228
229pub const ME_CIRC: [u64; 8] = [3, 1, 1, 1, 1, 1, 1, 2];
230pub const MU: [u64; 4] = [5, 6, 5, 6];
231
232pub fn get_round_constant(round: usize, element: usize) -> u64 {
233    let base = (round + 1) as u64 * 0x12345678;
234    let offset = (element + 1) as u64 * 0x87654321;
235    base.wrapping_add(offset) % GOLDILOCKS_PRIME
236}
237
238/// Computes the structural permutation of a state (reference implementation).
239pub fn structural_permute(state: &mut [u64; 8]) {
240    use p3_field::PrimeField64;
241    use p3_goldilocks::Goldilocks;
242
243    let mut g_state = [Goldilocks::default(); 8];
244    for i in 0..8 {
245        g_state[i] = Goldilocks::new(state[i] % GOLDILOCKS_PRIME);
246    }
247
248    for step in 0..TOTAL_ROUNDS {
249        let is_full = !(FULL_ROUNDS_START..FULL_ROUNDS_START + PARTIAL_ROUNDS).contains(&step);
250        let mut sbox_out = [Goldilocks::default(); 8];
251        for i in 0..8 {
252            let x = g_state[i];
253            sbox_out[i] = if is_full || i == 0 {
254                let x2 = x * x;
255                let x4 = x2 * x2;
256                x4 * x2 * x
257            } else {
258                x
259            };
260        }
261
262        let mut next_state = [Goldilocks::default(); 8];
263        if is_full {
264            for r in 0..8 {
265                let mut sum = Goldilocks::default();
266                for c in 0..8 {
267                    sum += Goldilocks::new(ME_CIRC[(8 + r - c) % 8]) * sbox_out[c];
268                }
269                next_state[r] = sum + Goldilocks::new(get_round_constant(step, r));
270            }
271        } else {
272            let mut sum_sbox = Goldilocks::default();
273            for x in sbox_out.iter() {
274                sum_sbox += *x;
275            }
276            for i in 0..8 {
277                let mu_m1 = MU[i % 4] - 1;
278                next_state[i] = Goldilocks::new(mu_m1) * sbox_out[i]
279                    + sum_sbox
280                    + Goldilocks::new(get_round_constant(step, i));
281            }
282        }
283        g_state = next_state;
284    }
285
286    for i in 0..8 {
287        state[i] = PrimeField64::as_canonical_u64(&g_state[i]);
288    }
289}
290
291/// Computes a canonical, length-prefixed encoding for ZK commitment preimages.
292/// Format: [DOMAIN_TAG] [PROMPT_LEN:4] [PROMPT] [SALT_LEN:4] [SALT]
293pub fn canonical_encode_shadow_intent(prompt: &str, salt: &[u8]) -> Vec<u8> {
294    let mut bytes = Vec::with_capacity(
295        SHADOW_INTENT_DOMAIN_TAG.len() + std::mem::size_of::<u64>() * 2 + prompt.len() + salt.len(),
296    );
297
298    bytes.extend_from_slice(SHADOW_INTENT_DOMAIN_TAG);
299    bytes.extend_from_slice(&(prompt.len() as u64).to_le_bytes());
300    bytes.extend_from_slice(prompt.as_bytes());
301    bytes.extend_from_slice(&(salt.len() as u64).to_le_bytes());
302    bytes.extend_from_slice(salt);
303    bytes
304}
305
306/// Computes the semantic commitment hash for a shadow intent.
307/// Currently uses the structural permutation for ZK consistency.
308pub fn semantic_commitment_hash(prompt: &str, salt: &[u8]) -> String {
309    let preimage = sha2::Sha256::digest(canonical_encode_shadow_intent(prompt, salt));
310
311    // Phase 3A: 64-bit binding. We use only the first 8 bytes as Lane 0.
312    // In Phase 3B/4, we will absorb the full 256 bits into state[0..4].
313    let initial_val = u64::from_le_bytes(preimage[0..8].try_into().unwrap());
314
315    let mut state = [0u64; 8];
316    state[0] = initial_val;
317
318    structural_permute(&mut state);
319
320    // Bind to the first 4 lanes (32 bytes / 256 bits)
321    let mut hash_bytes = Vec::with_capacity(32);
322    for lane in state.iter().take(4) {
323        hash_bytes.extend_from_slice(&lane.to_le_bytes());
324    }
325    hex::encode(hash_bytes)
326}
327
328/// Phase 4A: Canonical M31 Chunking Specification.
329/// Maps a 256-bit hash (32 bytes-LE) into 9 M31 field elements using the LE rule:
330/// chunk_i = (H >> (31 * i)) & ((1 << 31) - 1)
331/// This ensures injective and bit-exact binding for Circle STARKs.
332pub fn hash_to_m31_chunks(hash: &[u8; 32]) -> [u32; 9] {
333    let mut chunks = [0u32; 9];
334
335    // Convert the 32-byte hash to a bit-stream for exact 31-bit windowing.
336    // Index 8 (the 9th chunk) will contain the remaining 8 bits.
337    for (i, item) in chunks.iter_mut().enumerate() {
338        let bit_offset = i * 31;
339        let mut val = 0u64;
340
341        for b in 0..31 {
342            let total_bit = bit_offset + b;
343            if total_bit >= 256 {
344                break;
345            }
346
347            let byte_idx = total_bit / 8;
348            let bit_in_byte = total_bit % 8;
349
350            if (hash[byte_idx] & (1 << bit_in_byte)) != 0 {
351                val |= 1 << b;
352            }
353        }
354        *item = (val & 0x7FFFFFFF) as u32;
355    }
356    chunks
357}
358
359/// Authority Data (CHORA Pillar)
360/// Proves the governance decision.
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
362pub struct AuthorityData {
363    pub capsule_id: String,
364    pub outcome: String,
365    pub reason_code: String,
366    pub trace_root: String,
367    pub nonce: String,
368
369    /// New Phase 2: CHORA Binding Mode Fields
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub escalation_id: Option<String>,
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub binding_status: Option<String>,
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub continuation_token: Option<ContinuationToken>,
376
377    /// Decision classification for deterministic precedence (STRUCTURAL / POLICY / AMBIGUITY / ALLOW)
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub authority_class: Option<String>,
380
381    #[serde(
382        default = "default_sensor_value",
383        skip_serializing_if = "SchemaValue::is_null"
384    )]
385    pub gate_sensors: SchemaValue,
386
387    /// Catch-all for extra fields to preserve binary parity in JCS.
388    #[serde(flatten, default)]
389    pub metadata: SchemaValue,
390}
391
392impl AuthorityData {
393    pub const CLASS_STRUCTURAL: &'static str = "STRUCTURAL_TERMINAL";
394    pub const CLASS_POLICY: &'static str = "POLICY_TERMINAL";
395    pub const CLASS_AMBIGUITY: &'static str = "ESCALATABLE_AMBIGUITY";
396    pub const CLASS_ALLOW: &'static str = "ALLOW_PATH";
397}
398
399fn default_sensor_value() -> SchemaValue {
400    SchemaValue(serde_json::Value::Null)
401}
402
403/// Witness Data (CHORA Append-Only Log)
404/// Proves the receipt issuance parameters.
405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
406pub struct WitnessData {
407    pub chora_node_id: String,
408    pub receipt_hash: String,
409    pub timestamp: u64,
410    /// Diagnostic or display-only fields that are NOT part of the commitment surface.
411    #[serde(flatten, default)]
412    pub metadata: SchemaValue,
413}
414
415impl WitnessData {
416    /// Compute the "witness_hash" using the v0.3 Minimal Witness spec.
417    /// ONLY chora_node_id and timestamp are committed.
418    /// receipt_hash is post-seal metadata and is NOT part of the witness commitment surface.
419    /// Ref: CHORA_VERIFICATION_CONTRACT_v0.3.md
420    pub fn to_commitment_hash(&self) -> Result<Hash, String> {
421        #[derive(Serialize)]
422        struct MinimalWitness<'a> {
423            chora_node_id: &'a str,
424            timestamp: u64,
425        }
426
427        let minimal = MinimalWitness {
428            chora_node_id: &self.chora_node_id,
429            timestamp: self.timestamp,
430        };
431
432        let jcs_bytes = serde_jcs::to_vec(&minimal)
433            .map_err(|e| format!("JCS serialization of minimal witness failed: {}", e))?;
434
435        Ok(Hash::digest(&jcs_bytes))
436    }
437
438    pub fn to_jcs_hash(&self) -> Result<Hash, String> {
439        self.to_commitment_hash()
440    }
441}
442
443/// Identity Data (Attest Pillar)
444/// Proves the silicon source and its integrity state.
445#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
446pub struct IdentityData {
447    pub aid: String,
448    pub identity_type: String,
449    /// Platform Configuration Registers (PCRs) for hardware-rooted integrity.
450    /// Map of PCR index (e.g., 0, 7, 11) to SHA-256 hash (hex string).
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub pcrs: Option<std::collections::HashMap<u32, String>>,
453
454    /// Catch-all for extra fields to preserve binary parity in JCS.
455    #[serde(flatten, default)]
456    pub metadata: SchemaValue,
457}
458
459/// Continuation Token (Phase 2 Enforcement Primitive)
460/// A signed artifact that permits execution after an escalation.
461#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
462pub struct ContinuationToken {
463    pub payload: ContinuationPayload,
464    pub signature: String,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
468pub struct ExecutionTarget {
469    pub aid: String,
470    pub circuit_id: String,
471    pub intent_hash: String,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
475pub struct ContinuationPayload {
476    pub schema: String,
477    pub ledger_event_id: String,
478    pub source_capsule_root: String,
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub resolution_event_id: Option<String>,
481    #[serde(default)]
482    pub capabilities: Vec<String>,
483    pub nonce: String,
484    pub execution_target: ExecutionTarget,
485    pub iat: i64,
486    pub exp: i64,
487    pub issuer: String,
488}
489
490impl ContinuationPayload {
491    /// Validates the token's lifecycle (iat/exp) with a grace period.
492    pub fn validate_lifecycle(&self, now: chrono::DateTime<chrono::Utc>) -> Result<(), String> {
493        let now_unix = now.timestamp();
494        let leeway = 30; // 30 seconds
495
496        if now_unix < self.iat - leeway {
497            return Err("Token issued in the future (beyond leeway)".to_string());
498        }
499
500        if now_unix > self.exp + leeway {
501            return Err("Token expired (beyond leeway)".to_string());
502        }
503
504        Ok(())
505    }
506}
507
508impl ContinuationToken {
509    /// Computes the JCS hash of the payload for signature verification.
510    pub fn payload_hash(&self) -> Result<Vec<u8>, String> {
511        let jcs_bytes = serde_jcs::to_vec(&self.payload)
512            .map_err(|e| format!("JCS serialization failed: {}", e))?;
513        let mut hasher = sha2::Sha256::new();
514        hasher.update(&jcs_bytes);
515        Ok(hasher.finalize().to_vec())
516    }
517
518    /// Verifies the v3 token signature (Ed25519 over JCS payload hash).
519    pub fn verify_v3(&self, public_key_hex: &str) -> Result<bool, String> {
520        use ed25519_dalek::{Signature, Verifier, VerifyingKey};
521
522        let pub_key_bytes =
523            hex::decode(public_key_hex).map_err(|e| format!("Invalid public key hex: {}", e))?;
524        let public_key = VerifyingKey::from_bytes(
525            pub_key_bytes
526                .as_slice()
527                .try_into()
528                .map_err(|_| "Invalid Key Length")?,
529        )
530        .map_err(|e| format!("Invalid Ed25519 public key: {}", e))?;
531
532        let sig_bytes =
533            hex::decode(&self.signature).map_err(|e| format!("Invalid signature hex: {}", e))?;
534
535        let sig_array: [u8; 64] = sig_bytes
536            .as_slice()
537            .try_into()
538            .map_err(|_| "Signature must be 64 bytes".to_string())?;
539        let signature = Signature::from_bytes(&sig_array);
540
541        let hash = self.payload_hash()?;
542
543        Ok(public_key.verify(&hash, &signature).is_ok())
544    }
545}
546
547/// Crypto verification details.
548#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
549pub struct CryptoData {
550    pub algo: String,
551    pub public_key_endpoint: String,
552    pub signature_scope: String,
553    pub signature_b64: String,
554}
555
556/// Auditability metadata to link the raw payload to the intent.
557#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
558pub struct RequestCommitment {
559    pub canonicalization: String,
560    pub payload_sha256: String,
561    pub payload_encoding: String,
562}
563
564/// A Composite Evidence Capsule (The v0.1.0 "Zero-Trust Singularity" Root)
565/// Binds Intent, Authority, Identity, and Witness into a single commitment.
566#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
567pub struct Capsule {
568    pub capsule_id: String,
569    /// VEX Pillar: What was intended
570    pub intent: IntentData,
571    /// CHORA Pillar: Who authorized it
572    pub authority: AuthorityData,
573    /// ATTEST Pillar: Where it executed (Silicon)
574    pub identity: IdentityData,
575    /// CHORA Log Pillar: Where the receipt lives
576    pub witness: WitnessData,
577
578    // Derived hashes for transparency
579    pub intent_hash: String,
580    pub authority_hash: String,
581    pub identity_hash: String,
582    pub witness_hash: String,
583    pub capsule_root: String,
584
585    /// Ed25519 signature details
586    pub crypto: CryptoData,
587
588    /// Optional auditable link to raw payload (v0.2+)
589    #[serde(skip_serializing_if = "Option::is_none")]
590    pub request_commitment: Option<RequestCommitment>,
591}
592
593impl Capsule {
594    /// Compute the canonical "capsule_root" using the Binary Merkle Tree model.
595    /// This enables ZK-Explorer partial disclosure proofs for "Shadow Intents".
596    pub fn to_composite_hash(&self) -> Result<Hash, String> {
597        let intent_h = self.intent.to_jcs_hash()?;
598
599        // Authority and Identity are hashed as "Naked" leaves for byte-level interop with CHORA.
600        let authority_h = {
601            let jcs = serde_jcs::to_vec(&self.authority).map_err(|e| e.to_string())?;
602            Hash::digest(&jcs)
603        };
604
605        let identity_h = {
606            let jcs = serde_jcs::to_vec(&self.identity).map_err(|e| e.to_string())?;
607            Hash::digest(&jcs)
608        };
609
610        let witness_h = self.witness.to_jcs_hash()?;
611
612        // Build 4-leaf Merkle Tree (RFC 6962 compatible structure)
613        let leaves = vec![
614            ("intent".to_string(), intent_h),
615            ("authority".to_string(), authority_h),
616            ("identity".to_string(), identity_h),
617            ("witness".to_string(), witness_h),
618        ];
619
620        let tree = crate::merkle::MerkleTree::from_leaves(leaves);
621
622        tree.root_hash()
623            .cloned()
624            .ok_or_else(|| "Failed to calculate Merkle root".to_string())
625    }
626}
627
628/// A Spec-Grade Receipt (Phase 6 Hardening)
629/// Binds a validated Evidence Capsule to its proving context.
630#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
631pub struct VexReceipt {
632    pub schema: String,
633    pub capsule: Capsule,
634    pub proving_metadata: ProvingMetadata,
635}
636
637impl VexReceipt {
638    pub const SCHEMA_V1: &'static str = "vex.receipt.v1";
639
640    pub fn new(capsule: Capsule, proving_metadata: ProvingMetadata) -> Self {
641        Self {
642            schema: Self::SCHEMA_V1.to_string(),
643            capsule,
644            proving_metadata,
645        }
646    }
647}
648
649/// Metadata describing the Zero-Knowledge proving process.
650#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
651pub struct ProvingMetadata {
652    pub circuit_id: String,
653    pub proving_engine: String,
654    pub machine_id: String,
655    pub proving_duration_ms: u64,
656    pub timestamp: u64,
657}
658
659/// An Aggregate Proof for a sequence of authorized transitions.
660/// Used to prove the final state of a multi-step governed stream.
661#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
662pub struct StreamFinalProof {
663    pub stream_id: String,
664    pub receipts: Vec<VexReceipt>,
665    pub aggregate_proof_b64: String,
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn test_intent_segment_jcs_deterministic() {
674        let segment1 = IntentData::Transparent {
675            request_sha256: "8ee6010d905547c377c67e63559e989b8073b168f11a1ffefd092c7ca962076e"
676                .to_string(),
677            confidence: 0.95,
678            capabilities: vec![],
679            magpie_source: None,
680            continuation_token: None,
681            metadata: SchemaValue::default(),
682        };
683        let segment2 = segment1.clone();
684
685        let hash1 = segment1.to_jcs_hash().unwrap();
686        let hash2 = segment2.to_jcs_hash().unwrap();
687
688        assert_eq!(hash1, hash2, "JCS hashing must be deterministic");
689    }
690
691    #[test]
692    fn test_intent_segment_content_change() {
693        let segment1 = IntentData::Transparent {
694            request_sha256: "a".into(),
695            confidence: 0.5,
696            capabilities: vec![],
697            magpie_source: None,
698            continuation_token: None,
699            metadata: SchemaValue::default(),
700        };
701        let mut segment2 = segment1.clone();
702        if let IntentData::Transparent {
703            ref mut confidence, ..
704        } = segment2
705        {
706            *confidence = 0.9;
707        }
708
709        let hash1 = segment1.to_jcs_hash().unwrap();
710        let hash2 = segment2.to_jcs_hash().unwrap();
711
712        assert_ne!(hash1, hash2, "Hashes must change when content changes");
713    }
714
715    #[test]
716    fn test_shadow_intent_jcs_deterministic() {
717        let segment1 = IntentData::Shadow {
718            commitment_hash: "5555555555555555555555555555555555555555555555555555555555555555"
719                .to_string(),
720            stark_proof_b64: "c29tZS1zdGFyay1wcm9vZg==".to_string(),
721            public_inputs: SchemaValue(serde_json::json!({
722                "policy_id": "standard-v1",
723                "outcome_commitment": "ALLOW"
724            })),
725            circuit_id: None,
726            continuation_token: None,
727            metadata: SchemaValue::default(),
728        };
729        let segment2 = segment1.clone();
730
731        let hash1 = segment1.to_jcs_hash().unwrap();
732        let hash2 = segment2.to_jcs_hash().unwrap();
733
734        assert_eq!(hash1, hash2, "Shadow JCS hashing must be deterministic");
735
736        // Verify JCS serialization (untagged)
737        let jcs_bytes = serde_jcs::to_vec(&segment1).unwrap();
738        let jcs_str = String::from_utf8(jcs_bytes).unwrap();
739        assert!(
740            jcs_str.contains("\"commitment_hash\""),
741            "JCS must include the commitment_hash"
742        );
743    }
744
745    #[test]
746    fn test_witness_metadata_exclusion() {
747        let base_witness = WitnessData {
748            chora_node_id: "node-1".to_string(),
749            receipt_hash: "hash-1".to_string(),
750            timestamp: 1710396000,
751            metadata: SchemaValue(serde_json::Value::Null),
752        };
753
754        let hash_base = base_witness.to_commitment_hash().unwrap();
755
756        let mut metadata_witness = base_witness.clone();
757        metadata_witness.metadata = SchemaValue(serde_json::json!({
758            "witness_mode": "sentinel",
759            "diagnostics": {
760                "latency_ms": 42
761            }
762        }));
763
764        let hash_with_metadata = metadata_witness.to_commitment_hash().unwrap();
765
766        assert_eq!(
767            hash_base, hash_with_metadata,
768            "Witness hash must be invariant to extra metadata fields"
769        );
770    }
771
772    #[test]
773    fn test_witness_segment_minimal_interop() {
774        // v0.3 spec: witness commitment = {chora_node_id, timestamp} ONLY.
775        // receipt_hash is post-seal metadata and is excluded.
776        // Canonical JCS surface: {"chora_node_id":"chora-gate-v1","timestamp":1710396000}
777        let witness = WitnessData {
778            chora_node_id: "chora-gate-v1".to_string(),
779            receipt_hash: "ignored-in-v03".to_string(),
780            timestamp: 1710396000,
781            metadata: SchemaValue(serde_json::json!({
782                "witness_mode": "full",
783                "observational_only": false
784            })),
785        };
786
787        let hash_hex = witness.to_commitment_hash().expect("Hashing failed");
788
789        // SHA256(0x00 + {"chora_node_id":"chora-gate-v1","timestamp":1710396000})
790        assert_eq!(
791            hash_hex.to_hex(),
792            "87657d67389ca1a0e3e9bd4bccb5ab60a1cdcc59902d4cd67826d285dd98bff5",
793            "v0.3 witness hash must include 0x00 leaf prefix and exclude receipt_hash"
794        );
795    }
796
797    #[test]
798    fn test_authority_extra_fields_parity() {
799        // Specimen based on the CHORA example
800        let json_data = serde_json::json!({
801            "capsule_id": "example-capsule-001",
802            "outcome": "ALLOW",
803            "reason_code": "policy_ok",
804            "trace_root": "trace-001",
805            "nonce": "1234567890",
806            "gate_sensors": null,
807            "rule_set_owner": "chora-authority-node",
808            "fail_closed": true
809        });
810
811        let authority: AuthorityData = serde_json::from_value(json_data.clone()).unwrap();
812
813        // Ensure extra fields went into metadata
814        assert_eq!(authority.metadata["rule_set_owner"], "chora-authority-node");
815        assert_eq!(authority.metadata["fail_closed"], true);
816
817        // Verify JCS serialization includes the extra fields
818        let jcs_bytes = serde_jcs::to_vec(&authority).unwrap();
819        let jcs_str = String::from_utf8(jcs_bytes).unwrap();
820        assert!(jcs_str.contains("\"rule_set_owner\":\"chora-authority-node\""));
821    }
822}