1use crate::merkle::Hash;
6use crate::zk::{ZkError, ZkVerifier};
7use serde::{Deserialize, Serialize};
8use sha2::Digest;
9use utoipa::ToSchema;
10
11#[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 #[serde(skip_serializing_if = "Option::is_none")]
28 continuation_token: Option<ContinuationToken>,
29
30 #[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 #[serde(skip_serializing_if = "Option::is_none")]
43 circuit_id: Option<String>,
44
45 #[serde(skip_serializing_if = "Option::is_none")]
47 continuation_token: Option<ContinuationToken>,
48
49 #[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#[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 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 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#[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 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 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
238pub 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
291pub 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
306pub fn semantic_commitment_hash(prompt: &str, salt: &[u8]) -> String {
309 let preimage = sha2::Sha256::digest(canonical_encode_shadow_intent(prompt, salt));
310
311 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 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
328pub fn hash_to_m31_chunks(hash: &[u8; 32]) -> [u32; 9] {
333 let mut chunks = [0u32; 9];
334
335 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#[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 #[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 #[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 #[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#[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 #[serde(flatten, default)]
412 pub metadata: SchemaValue,
413}
414
415impl WitnessData {
416 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
446pub struct IdentityData {
447 pub aid: String,
448 pub identity_type: String,
449 #[serde(skip_serializing_if = "Option::is_none")]
452 pub pcrs: Option<std::collections::HashMap<u32, String>>,
453
454 #[serde(flatten, default)]
456 pub metadata: SchemaValue,
457}
458
459#[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 pub fn validate_lifecycle(&self, now: chrono::DateTime<chrono::Utc>) -> Result<(), String> {
493 let now_unix = now.timestamp();
494 let leeway = 30; 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 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 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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
567pub struct Capsule {
568 pub capsule_id: String,
569 pub intent: IntentData,
571 pub authority: AuthorityData,
573 pub identity: IdentityData,
575 pub witness: WitnessData,
577
578 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 pub crypto: CryptoData,
587
588 #[serde(skip_serializing_if = "Option::is_none")]
590 pub request_commitment: Option<RequestCommitment>,
591}
592
593impl Capsule {
594 pub fn to_composite_hash(&self) -> Result<Hash, String> {
597 let intent_h = self.intent.to_jcs_hash()?;
598
599 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 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#[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#[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#[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 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 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 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 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 assert_eq!(authority.metadata["rule_set_owner"], "chora-authority-node");
815 assert_eq!(authority.metadata["fail_closed"], true);
816
817 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}