1use crate::authority_contracts::AuthorityFaultStage;
9use crate::db::with_transaction;
10use crate::{MemoryError, MemoryStore};
11use chrono::{DateTime, Duration, Utc};
12use rusqlite::{params, OptionalExtension, Transaction};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16pub const SHADOW_POLICY_PROPOSAL_V1: &str = "shadow_policy_proposal_v1";
17pub const PROMOTION_DECISION_RECEIPT_V1: &str = "promotion_decision_receipt_v1";
18const SHADOW_PROPOSAL_MAX_RISK: f64 = 0.75;
19const SHADOW_MAX_DELTA: f64 = 1.0;
20
21pub fn shadow_policy_digest(value: &Value) -> String {
23 digest_json(value)
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ShadowPolicyKindV1 {
29 Routing,
30 WriteAdmission,
31 Retention,
32 RerankWeights,
33}
34
35impl ShadowPolicyKindV1 {
36 pub fn as_str(self) -> &'static str {
37 match self {
38 Self::Routing => "routing",
39 Self::WriteAdmission => "write_admission",
40 Self::Retention => "retention",
41 Self::RerankWeights => "rerank_weights",
42 }
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum ShadowPolicyStatusV1 {
49 Proposed,
50 Promoted,
51 Rejected,
52 Quarantined,
53 Deferred,
54 Expired,
55 RolledBack,
56}
57
58impl ShadowPolicyStatusV1 {
59 fn as_str(self) -> &'static str {
60 match self {
61 Self::Proposed => "proposed",
62 Self::Promoted => "promoted",
63 Self::Rejected => "rejected",
64 Self::Quarantined => "quarantined",
65 Self::Deferred => "deferred",
66 Self::Expired => "expired",
67 Self::RolledBack => "rolled_back",
68 }
69 }
70
71 fn parse(value: &str) -> Result<Self, MemoryError> {
72 match value {
73 "proposed" => Ok(Self::Proposed),
74 "promoted" => Ok(Self::Promoted),
75 "rejected" => Ok(Self::Rejected),
76 "quarantined" => Ok(Self::Quarantined),
77 "deferred" => Ok(Self::Deferred),
78 "expired" => Ok(Self::Expired),
79 "rolled_back" => Ok(Self::RolledBack),
80 other => Err(MemoryError::CorruptData {
81 table: "shadow_policy_proposals",
82 row_id: value.to_string(),
83 detail: format!("unknown proposal status '{other}'"),
84 }),
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct ShadowPolicyProvenanceV1 {
91 pub origin: String,
92 pub source: String,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub model_id: Option<String>,
95}
96
97impl ShadowPolicyProvenanceV1 {
98 pub fn new(origin: impl Into<String>, source: impl Into<String>) -> Self {
99 Self {
100 origin: origin.into(),
101 source: source.into(),
102 model_id: None,
103 }
104 }
105
106 pub fn with_model(mut self, model_id: impl Into<String>) -> Self {
107 self.model_id = Some(model_id.into());
108 self
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct ShadowEvaluationWindowV1 {
114 pub training_start: String,
115 pub training_end: String,
116 pub evaluation_start: String,
117 pub evaluation_end: String,
118 pub held_out_input_digest: String,
119}
120
121impl ShadowEvaluationWindowV1 {
122 pub fn new(
123 training_start: impl Into<String>,
124 training_end: impl Into<String>,
125 evaluation_start: impl Into<String>,
126 evaluation_end: impl Into<String>,
127 held_out_input_digest: impl Into<String>,
128 ) -> Self {
129 Self {
130 training_start: training_start.into(),
131 training_end: training_end.into(),
132 evaluation_start: evaluation_start.into(),
133 evaluation_end: evaluation_end.into(),
134 held_out_input_digest: held_out_input_digest.into(),
135 }
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140pub struct ShadowPolicyRiskV1 {
141 pub score: f64,
142 pub categories: Vec<String>,
143}
144
145impl ShadowPolicyRiskV1 {
146 pub fn new(score: f64, categories: Vec<impl Into<String>>) -> Self {
147 Self {
148 score,
149 categories: categories.into_iter().map(Into::into).collect(),
150 }
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155pub struct ShadowPolicyProposalV1 {
156 pub schema_version: String,
157 pub proposal_id: String,
158 pub idempotency_key: String,
159 pub principal: String,
160 pub policy_kind: ShadowPolicyKindV1,
161 pub provenance: ShadowPolicyProvenanceV1,
162 pub training_window: ShadowEvaluationWindowV1,
163 pub feature_digest: String,
164 pub baseline_policy: Value,
165 pub baseline_policy_digest: String,
166 pub proposed_delta: Value,
167 pub risk: ShadowPolicyRiskV1,
168 pub expires_at: String,
169 pub status: ShadowPolicyStatusV1,
170 pub proposal_digest: String,
171 pub created_at: String,
172}
173
174impl ShadowPolicyProposalV1 {
175 #[allow(clippy::too_many_arguments)]
176 pub fn new(
177 policy_kind: ShadowPolicyKindV1,
178 principal: impl Into<String>,
179 provenance: ShadowPolicyProvenanceV1,
180 training_window: ShadowEvaluationWindowV1,
181 feature_digest: impl Into<String>,
182 baseline_policy: Value,
183 proposed_delta: Value,
184 risk: ShadowPolicyRiskV1,
185 expires_at: impl Into<String>,
186 idempotency_key: impl Into<String>,
187 ) -> Self {
188 let principal = principal.into();
189 let idempotency_key = idempotency_key.into();
190 let feature_digest = feature_digest.into();
191 let expires_at = expires_at.into();
192 let baseline_policy_digest = digest_json(&baseline_policy);
193 let created_at = Utc::now().to_rfc3339();
194 let proposal_id = format!(
195 "shadow:{}",
196 digest_json(&serde_json::json!({
197 "idempotency_key": idempotency_key.clone(),
198 "principal": principal.clone(),
199 "policy_kind": policy_kind,
200 "provenance": provenance.clone(),
201 "training_window": training_window.clone(),
202 "feature_digest": feature_digest.clone(),
203 "baseline_policy": baseline_policy.clone(),
204 "proposed_delta": proposed_delta.clone(),
205 "risk": risk.clone(),
206 "expires_at": expires_at.clone(),
207 }))
208 );
209 let mut proposal = Self {
210 schema_version: SHADOW_POLICY_PROPOSAL_V1.into(),
211 proposal_id,
212 idempotency_key,
213 principal,
214 policy_kind,
215 provenance,
216 training_window,
217 feature_digest,
218 baseline_policy,
219 baseline_policy_digest,
220 proposed_delta,
221 risk,
222 expires_at,
223 status: ShadowPolicyStatusV1::Proposed,
224 proposal_digest: String::new(),
225 created_at,
226 };
227 proposal.proposal_digest = proposal.compute_digest();
228 proposal
229 }
230
231 pub fn compute_digest(&self) -> String {
232 digest_json(&serde_json::json!({
233 "schema_version": self.schema_version,
234 "proposal_id": self.proposal_id,
235 "idempotency_key": self.idempotency_key,
236 "principal": self.principal,
237 "policy_kind": self.policy_kind,
238 "provenance": self.provenance,
239 "training_window": self.training_window,
240 "feature_digest": self.feature_digest,
241 "baseline_policy": self.baseline_policy,
242 "baseline_policy_digest": self.baseline_policy_digest,
243 "proposed_delta": self.proposed_delta,
244 "risk": self.risk,
245 "expires_at": self.expires_at,
246 }))
247 }
248}
249
250#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
251pub struct PromotionEvidenceV1 {
252 pub schema_version: String,
253 pub proposal_id: String,
254 pub held_out_improvement: f64,
255 pub safety_regression: bool,
256 pub integrity_regression: bool,
257 pub metrics: Value,
258 pub evaluation_input_hashes: Vec<String>,
259 pub metrics_digest: String,
260 pub reproducibility_digest: String,
261 pub rollback_target: String,
262 pub evaluated_at: String,
263}
264
265impl PromotionEvidenceV1 {
266 pub fn new(
267 proposal: &ShadowPolicyProposalV1,
268 held_out_improvement: f64,
269 safety_regression: bool,
270 integrity_regression: bool,
271 metrics: Value,
272 evaluation_input_hashes: Vec<String>,
273 rollback_target: impl Into<String>,
274 ) -> Self {
275 let metrics_digest = digest_json(&metrics);
276 let reproducibility_digest = digest_json(&serde_json::json!({
277 "proposal": proposal.proposal_digest,
278 "held_out_improvement": held_out_improvement,
279 "safety_regression": safety_regression,
280 "integrity_regression": integrity_regression,
281 "metrics_digest": metrics_digest,
282 "evaluation_input_hashes": evaluation_input_hashes,
283 }));
284 Self {
285 schema_version: "promotion_evidence_v1".into(),
286 proposal_id: proposal.proposal_id.clone(),
287 held_out_improvement,
288 safety_regression,
289 integrity_regression,
290 metrics,
291 evaluation_input_hashes,
292 metrics_digest,
293 reproducibility_digest,
294 rollback_target: rollback_target.into(),
295 evaluated_at: Utc::now().to_rfc3339(),
296 }
297 }
298
299 fn verify_integrity(&self, proposal: &ShadowPolicyProposalV1) -> Result<(), String> {
300 if self.schema_version != "promotion_evidence_v1"
301 || self.proposal_id != proposal.proposal_id
302 {
303 return Err("evidence schema or proposal identity mismatch".into());
304 }
305 if !self.held_out_improvement.is_finite() {
306 return Err("held-out improvement is not finite".into());
307 }
308 if self.metrics_digest != digest_json(&self.metrics) {
309 return Err("metrics digest does not match metrics payload".into());
310 }
311 let expected = digest_json(&serde_json::json!({
312 "proposal": proposal.proposal_digest,
313 "held_out_improvement": self.held_out_improvement,
314 "safety_regression": self.safety_regression,
315 "integrity_regression": self.integrity_regression,
316 "metrics_digest": self.metrics_digest,
317 "evaluation_input_hashes": self.evaluation_input_hashes,
318 }));
319 if self.reproducibility_digest != expected {
320 return Err("reproducibility digest does not match evaluation inputs".into());
321 }
322 if self.evaluation_input_hashes.is_empty()
323 || self
324 .evaluation_input_hashes
325 .iter()
326 .any(|hash| hash.trim().is_empty())
327 {
328 return Err("held-out evaluation input hashes are required".into());
329 }
330 if self.rollback_target.trim().is_empty() {
331 return Err("explicit rollback target is required".into());
332 }
333 Ok(())
334 }
335}
336
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338pub struct ShadowPolicyPromotionPermitV1 {
339 pub principal: String,
340 pub caller_id: String,
341 pub capability: String,
342 pub elevation: String,
343}
344
345impl ShadowPolicyPromotionPermitV1 {
346 pub const CAPABILITY: &'static str = "memory.shadow_policy.promote";
347
348 pub fn elevated(
349 principal: impl Into<String>,
350 caller_id: impl Into<String>,
351 elevation: impl Into<String>,
352 ) -> Self {
353 Self {
354 principal: principal.into(),
355 caller_id: caller_id.into(),
356 capability: Self::CAPABILITY.into(),
357 elevation: elevation.into(),
358 }
359 }
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "snake_case")]
364pub enum PromotionDispositionV1 {
365 Promoted,
366 Rejected,
367 Quarantined,
368 Deferred,
369 Expired,
370 RolledBack,
371}
372
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374pub struct PromotionDecisionReceiptV1 {
375 pub schema_version: String,
376 pub receipt_id: String,
377 pub caller_idempotency_key: String,
378 pub proposal_id: String,
379 pub principal: String,
380 pub policy_kind: ShadowPolicyKindV1,
381 pub disposition: PromotionDispositionV1,
382 pub status: ShadowPolicyStatusV1,
383 pub reason_codes: Vec<String>,
384 pub evidence_digest: String,
385 pub before_version: Option<u64>,
386 pub after_version: Option<u64>,
387 pub before_policy_digest: Option<String>,
388 pub after_policy_digest: Option<String>,
389 pub rollback_target: Option<String>,
390 pub receipt_digest: String,
391 pub committed_at: String,
392}
393
394impl PromotionDecisionReceiptV1 {
395 pub const SCHEMA_VERSION: &'static str = PROMOTION_DECISION_RECEIPT_V1;
396}
397
398#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
399pub struct ActiveShadowPolicyV1 {
400 pub principal: String,
401 pub policy_kind: ShadowPolicyKindV1,
402 pub version: u64,
403 pub policy: Value,
404 pub policy_digest: String,
405 pub source_proposal_id: String,
406 pub activated_by: String,
407 pub activated_at: String,
408}
409
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411pub struct ShadowExecutionComparisonV1 {
412 pub schema_version: String,
413 pub principal: String,
414 pub policy_kind: ShadowPolicyKindV1,
415 pub input_digest: String,
416 pub cases_compared: usize,
417 pub changed_cases: usize,
418 pub baseline_output_digest: String,
419 pub shadow_output_digest: String,
420 pub served: bool,
421 pub canonical_mutation: bool,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425pub struct PromotionGateDecisionV1 {
426 pub admissible: bool,
427 pub disposition: PromotionDispositionV1,
428 pub reason_codes: Vec<String>,
429}
430
431pub fn evaluate_shadow_policy_promotion_v1(
433 proposal: &ShadowPolicyProposalV1,
434 evidence: &PromotionEvidenceV1,
435 permit: &ShadowPolicyPromotionPermitV1,
436 active: Option<&ActiveShadowPolicyV1>,
437) -> PromotionGateDecisionV1 {
438 match evaluate_gate(proposal, evidence, permit, active) {
439 Ok(()) => PromotionGateDecisionV1 {
440 admissible: true,
441 disposition: PromotionDispositionV1::Promoted,
442 reason_codes: Vec::new(),
443 },
444 Err(GateFailure::Expired(reason)) => PromotionGateDecisionV1 {
445 admissible: false,
446 disposition: PromotionDispositionV1::Expired,
447 reason_codes: vec![reason],
448 },
449 Err(GateFailure::Quarantine(reason)) => PromotionGateDecisionV1 {
450 admissible: false,
451 disposition: PromotionDispositionV1::Quarantined,
452 reason_codes: vec![reason],
453 },
454 Err(GateFailure::Defer(reason)) => PromotionGateDecisionV1 {
455 admissible: false,
456 disposition: PromotionDispositionV1::Deferred,
457 reason_codes: vec![reason],
458 },
459 Err(GateFailure::Reject(reason)) => PromotionGateDecisionV1 {
460 admissible: false,
461 disposition: PromotionDispositionV1::Rejected,
462 reason_codes: vec![reason],
463 },
464 }
465}
466
467pub fn compare_shadow_execution_v1(
468 principal: impl Into<String>,
469 policy_kind: ShadowPolicyKindV1,
470 input_digest: impl Into<String>,
471 cases: Vec<(&str, Value, Value)>,
472) -> Result<ShadowExecutionComparisonV1, MemoryError> {
473 let principal = principal.into();
474 let input_digest = input_digest.into();
475 if principal.trim().is_empty() || input_digest.trim().is_empty() {
476 return Err(MemoryError::ShadowPolicyRejected {
477 reason: "shadow execution requires principal and input digest".into(),
478 });
479 }
480 let changed_cases = cases
481 .iter()
482 .filter(|(_, baseline, shadow)| baseline != shadow)
483 .count();
484 let baseline_outputs: Vec<_> = cases
485 .iter()
486 .map(|(id, baseline, _)| (*id, baseline))
487 .collect();
488 let shadow_outputs: Vec<_> = cases.iter().map(|(id, _, shadow)| (*id, shadow)).collect();
489 Ok(ShadowExecutionComparisonV1 {
490 schema_version: "shadow_execution_comparison_v1".into(),
491 principal,
492 policy_kind,
493 input_digest,
494 cases_compared: cases.len(),
495 changed_cases,
496 baseline_output_digest: digest_json(&serde_json::json!(baseline_outputs)),
497 shadow_output_digest: digest_json(&serde_json::json!(shadow_outputs)),
498 served: false,
499 canonical_mutation: false,
500 })
501}
502
503impl MemoryStore {
504 pub async fn submit_shadow_policy_proposal(
507 &self,
508 proposal: ShadowPolicyProposalV1,
509 ) -> Result<ShadowPolicyProposalV1, MemoryError> {
510 validate_proposal(&proposal)?;
511 let proposal_json =
512 serde_json::to_string(&proposal).map_err(|e| MemoryError::ShadowPolicyRejected {
513 reason: e.to_string(),
514 })?;
515 let proposal_digest = proposal.proposal_digest.clone();
516 self.with_write_conn(move |conn| {
517 with_transaction(conn, |tx| {
518 let existing: Option<(String, String)> = tx
519 .query_row(
520 "SELECT proposal_json, proposal_digest FROM shadow_policy_proposals
521 WHERE proposal_id = ?1 OR idempotency_key = ?2",
522 params![proposal.proposal_id, proposal.idempotency_key],
523 |row| Ok((row.get(0)?, row.get(1)?)),
524 )
525 .optional()?;
526 if let Some((json, digest)) = existing {
527 if digest != proposal_digest {
528 return Err(MemoryError::ShadowPolicyConflict {
529 key: proposal.idempotency_key.clone(),
530 });
531 }
532 return serde_json::from_str(&json).map_err(|error| MemoryError::CorruptData {
533 table: "shadow_policy_proposals",
534 row_id: proposal.proposal_id.clone(),
535 detail: error.to_string(),
536 });
537 }
538 tx.execute(
539 "INSERT INTO shadow_policy_proposals
540 (proposal_id, idempotency_key, principal, policy_kind, proposal_digest,
541 proposal_json, status, created_at)
542 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
543 params![
544 proposal.proposal_id,
545 proposal.idempotency_key,
546 proposal.principal,
547 proposal.policy_kind.as_str(),
548 proposal_digest,
549 proposal_json,
550 proposal.status.as_str(),
551 proposal.created_at,
552 ],
553 )?;
554 Ok(proposal)
555 })
556 })
557 .await
558 }
559
560 pub async fn get_shadow_policy_proposal(
561 &self,
562 proposal_id: &str,
563 principal: &str,
564 ) -> Result<Option<ShadowPolicyProposalV1>, MemoryError> {
565 let proposal_id = proposal_id.to_string();
566 let principal = principal.to_string();
567 self.with_read_conn(move |conn| load_proposal(conn, &proposal_id, &principal))
568 .await
569 }
570
571 pub async fn get_shadow_policy_promotion_receipt(
572 &self,
573 caller_idempotency_key: &str,
574 principal: &str,
575 ) -> Result<Option<PromotionDecisionReceiptV1>, MemoryError> {
576 let key = caller_idempotency_key.to_string();
577 let principal = principal.to_string();
578 self.with_read_conn(move |conn| {
579 let raw: Option<String> = conn
580 .query_row(
581 "SELECT receipt_json FROM shadow_policy_receipts
582 WHERE caller_idempotency_key = ?1 AND principal = ?2",
583 params![key, principal],
584 |row| row.get(0),
585 )
586 .optional()?;
587 raw.map(|json| {
588 serde_json::from_str(&json).map_err(|error| MemoryError::CorruptData {
589 table: "shadow_policy_receipts",
590 row_id: key.clone(),
591 detail: error.to_string(),
592 })
593 })
594 .transpose()
595 })
596 .await
597 }
598
599 pub async fn list_shadow_policy_proposals(
600 &self,
601 principal: &str,
602 policy_kind: Option<ShadowPolicyKindV1>,
603 ) -> Result<Vec<ShadowPolicyProposalV1>, MemoryError> {
604 let principal = principal.to_string();
605 let kind = policy_kind.map(|kind| kind.as_str().to_string());
606 self.with_read_conn(move |conn| {
607 let mut statement = conn.prepare(
608 "SELECT proposal_id, proposal_json FROM shadow_policy_proposals
609 WHERE principal = ?1 AND (?2 IS NULL OR policy_kind = ?2)
610 ORDER BY created_at ASC, proposal_id ASC",
611 )?;
612 let rows = statement.query_map(params![principal, kind], |row| {
613 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
614 })?;
615 let mut proposals = Vec::new();
616 for row in rows {
617 let (id, json) = row?;
618 let proposal: ShadowPolicyProposalV1 =
619 serde_json::from_str(&json).map_err(|e| {
620 rusqlite::Error::FromSqlConversionFailure(
621 1,
622 rusqlite::types::Type::Text,
623 Box::new(e),
624 )
625 })?;
626 proposals.push(with_current_status(conn, proposal, &id)?);
627 }
628 Ok(proposals)
629 })
630 .await
631 }
632
633 pub async fn promote_shadow_policy(
635 &self,
636 permit: ShadowPolicyPromotionPermitV1,
637 caller_idempotency_key: impl Into<String>,
638 proposal_id: impl Into<String>,
639 evidence: PromotionEvidenceV1,
640 ) -> Result<PromotionDecisionReceiptV1, MemoryError> {
641 let caller_idempotency_key = caller_idempotency_key.into();
642 let proposal_id = proposal_id.into();
643 if permit.capability != ShadowPolicyPromotionPermitV1::CAPABILITY
644 || permit.principal.trim().is_empty()
645 || permit.caller_id.trim().is_empty()
646 || permit.elevation.trim().is_empty()
647 || caller_idempotency_key.trim().is_empty()
648 {
649 return Err(MemoryError::ShadowPolicyUnauthorized {
650 principal: permit.principal,
651 });
652 }
653 let fault = self.inner.authority_fault.clone();
654 self.with_write_conn(move |conn| {
655 with_transaction(conn, |tx| {
658 let proposal =
659 load_proposal_tx(tx, &proposal_id, &permit.principal)?.ok_or_else(|| {
660 MemoryError::ShadowPolicyNotFound {
661 proposal_id: proposal_id.clone(),
662 }
663 })?;
664 let evidence_digest = evidence_digest(&evidence)?;
665 if let Some((stored_digest, receipt_json)) = tx
666 .query_row(
667 "SELECT evidence_digest, receipt_json FROM shadow_policy_receipts
668 WHERE caller_idempotency_key = ?1",
669 params![caller_idempotency_key],
670 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
671 )
672 .optional()?
673 {
674 if stored_digest != evidence_digest {
675 return Err(MemoryError::ShadowPolicyConflict {
676 key: caller_idempotency_key.clone(),
677 });
678 }
679 return serde_json::from_str(&receipt_json).map_err(|e| {
680 MemoryError::CorruptData {
681 table: "shadow_policy_receipts",
682 row_id: caller_idempotency_key.clone(),
683 detail: e.to_string(),
684 }
685 });
686 }
687 let active = load_active_tx(tx, &proposal.principal, proposal.policy_kind)?;
688 let gate = evaluate_gate(&proposal, &evidence, &permit, active.as_ref());
689 let (disposition, status, reasons) = match gate {
690 Ok(()) => (
691 PromotionDispositionV1::Promoted,
692 ShadowPolicyStatusV1::Promoted,
693 Vec::new(),
694 ),
695 Err(GateFailure::Expired(reason)) => (
696 PromotionDispositionV1::Expired,
697 ShadowPolicyStatusV1::Expired,
698 vec![reason],
699 ),
700 Err(GateFailure::Quarantine(reason)) => (
701 PromotionDispositionV1::Quarantined,
702 ShadowPolicyStatusV1::Quarantined,
703 vec![reason],
704 ),
705 Err(GateFailure::Defer(reason)) => (
706 PromotionDispositionV1::Deferred,
707 ShadowPolicyStatusV1::Deferred,
708 vec![reason],
709 ),
710 Err(GateFailure::Reject(reason)) => (
711 PromotionDispositionV1::Rejected,
712 ShadowPolicyStatusV1::Rejected,
713 vec![reason],
714 ),
715 };
716 if disposition == PromotionDispositionV1::Promoted {
717 fault_gate(&fault, AuthorityFaultStage::BeforeShadowPromotion)?;
718 let next_policy =
719 apply_delta(&proposal.baseline_policy, &proposal.proposed_delta)?;
720 let next_version = active.as_ref().map_or(1, |current| current.version + 1);
721 let next_digest = digest_json(&next_policy);
722 let now = Utc::now().to_rfc3339();
723 tx.execute(
724 "INSERT INTO shadow_policy_versions
725 (principal, policy_kind, version, policy_json, policy_digest,
726 proposal_id, activated_by, activated_at)
727 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
728 params![
729 proposal.principal,
730 proposal.policy_kind.as_str(),
731 next_version,
732 serde_json::to_string(&next_policy).map_err(|e| {
733 MemoryError::ShadowPolicyRejected {
734 reason: e.to_string(),
735 }
736 })?,
737 next_digest,
738 proposal.proposal_id,
739 permit.caller_id,
740 now,
741 ],
742 )?;
743 tx.execute(
744 "INSERT INTO shadow_active_policies
745 (principal, policy_kind, version, policy_json, policy_digest,
746 source_proposal_id, activated_by, activated_at)
747 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
748 ON CONFLICT(principal, policy_kind) DO UPDATE SET
749 version = excluded.version, policy_json = excluded.policy_json,
750 policy_digest = excluded.policy_digest,
751 source_proposal_id = excluded.source_proposal_id,
752 activated_by = excluded.activated_by,
753 activated_at = excluded.activated_at",
754 params![
755 proposal.principal,
756 proposal.policy_kind.as_str(),
757 next_version,
758 serde_json::to_string(&next_policy).map_err(|e| {
759 MemoryError::ShadowPolicyRejected {
760 reason: e.to_string(),
761 }
762 })?,
763 next_digest,
764 proposal.proposal_id,
765 permit.caller_id,
766 now,
767 ],
768 )?;
769 fault_gate(&fault, AuthorityFaultStage::AfterShadowPromotion)?;
770 let receipt = build_receipt(
771 &proposal,
772 &caller_idempotency_key,
773 &evidence_digest,
774 disposition,
775 status,
776 reasons,
777 active.as_ref(),
778 Some(next_version),
779 Some(next_digest),
780 Some(evidence.rollback_target.clone()),
781 );
782 insert_receipt(tx, &receipt)?;
783 return Ok(receipt);
784 }
785 let receipt = build_receipt(
786 &proposal,
787 &caller_idempotency_key,
788 &evidence_digest,
789 disposition,
790 status,
791 reasons,
792 active.as_ref(),
793 active.as_ref().map(|a| a.version),
794 active.as_ref().map(|a| a.policy_digest.clone()),
795 Some(evidence.rollback_target),
796 );
797 insert_receipt(tx, &receipt)?;
798 Ok(receipt)
799 })
800 })
801 .await
802 }
803
804 pub async fn get_active_shadow_policy(
805 &self,
806 principal: &str,
807 policy_kind: ShadowPolicyKindV1,
808 ) -> Result<Option<ActiveShadowPolicyV1>, MemoryError> {
809 let principal = principal.to_string();
810 self.with_read_conn(move |conn| load_active(conn, &principal, policy_kind))
811 .await
812 }
813
814 pub async fn rollback_shadow_policy(
815 &self,
816 permit: ShadowPolicyPromotionPermitV1,
817 caller_idempotency_key: impl Into<String>,
818 principal: impl Into<String>,
819 policy_kind: ShadowPolicyKindV1,
820 target_version: u64,
821 ) -> Result<PromotionDecisionReceiptV1, MemoryError> {
822 let key = caller_idempotency_key.into();
823 let principal = principal.into();
824 if permit.capability != ShadowPolicyPromotionPermitV1::CAPABILITY
825 || permit.principal != principal
826 || permit.elevation.trim().is_empty()
827 || key.trim().is_empty()
828 {
829 return Err(MemoryError::ShadowPolicyUnauthorized { principal });
830 }
831 self.with_write_conn(move |conn| {
832 with_transaction(conn, |tx| {
835 let rollback_identity =
836 digest_json(&serde_json::json!({"target_version": target_version}));
837 if let Some((stored_digest, receipt_json)) = tx
838 .query_row(
839 "SELECT evidence_digest, receipt_json FROM shadow_policy_receipts
840 WHERE caller_idempotency_key = ?1 AND principal = ?2",
841 params![key, principal],
842 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
843 )
844 .optional()?
845 {
846 if stored_digest != rollback_identity {
847 return Err(MemoryError::ShadowPolicyConflict { key: key.clone() });
848 }
849 return serde_json::from_str(&receipt_json).map_err(|e| {
850 MemoryError::CorruptData {
851 table: "shadow_policy_receipts",
852 row_id: key.clone(),
853 detail: e.to_string(),
854 }
855 });
856 }
857 let active = load_active_tx(tx, &principal, policy_kind)?.ok_or_else(|| {
858 MemoryError::ShadowPolicyRejected {
859 reason: "no active policy".into(),
860 }
861 })?;
862 let target = tx
863 .query_row(
864 "SELECT version, policy_json, policy_digest, proposal_id, activated_at
865 FROM shadow_policy_versions
866 WHERE principal = ?1 AND policy_kind = ?2 AND version = ?3",
867 params![principal, policy_kind.as_str(), target_version],
868 |row| {
869 Ok((
870 row.get::<_, u64>(0)?,
871 row.get::<_, String>(1)?,
872 row.get::<_, String>(2)?,
873 row.get::<_, String>(3)?,
874 row.get::<_, String>(4)?,
875 ))
876 },
877 )
878 .optional()?
879 .ok_or_else(|| MemoryError::ShadowPolicyRejected {
880 reason: "rollback target not found".into(),
881 })?;
882 let evidence_digest = rollback_identity;
883 if target.0 >= active.version {
884 return Err(MemoryError::ShadowPolicyRejected {
885 reason: "rollback target must be an earlier active version".into(),
886 });
887 }
888 tx.execute(
889 "INSERT INTO shadow_active_policies
890 (principal, policy_kind, version, policy_json, policy_digest,
891 source_proposal_id, activated_by, activated_at)
892 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
893 ON CONFLICT(principal, policy_kind) DO UPDATE SET
894 version = excluded.version, policy_json = excluded.policy_json,
895 policy_digest = excluded.policy_digest,
896 source_proposal_id = excluded.source_proposal_id,
897 activated_by = excluded.activated_by,
898 activated_at = excluded.activated_at",
899 params![
900 principal,
901 policy_kind.as_str(),
902 target.0,
903 target.1,
904 target.2,
905 target.3,
906 permit.caller_id,
907 Utc::now().to_rfc3339(),
908 ],
909 )?;
910 let proposal_id =
911 format!("rollback:{principal}:{:?}:{target_version}", policy_kind);
912 let mut receipt = PromotionDecisionReceiptV1 {
913 schema_version: PROMOTION_DECISION_RECEIPT_V1.into(),
914 receipt_id: uuid::Uuid::new_v4().to_string(),
915 caller_idempotency_key: key.clone(),
916 proposal_id,
917 principal: principal.clone(),
918 policy_kind,
919 disposition: PromotionDispositionV1::RolledBack,
920 status: ShadowPolicyStatusV1::RolledBack,
921 reason_codes: vec!["explicit_rollback".into()],
922 evidence_digest,
923 before_version: Some(active.version),
924 after_version: Some(target.0),
925 before_policy_digest: Some(active.policy_digest),
926 after_policy_digest: Some(target.2),
927 rollback_target: Some(target_version.to_string()),
928 receipt_digest: String::new(),
929 committed_at: Utc::now().to_rfc3339(),
930 };
931 receipt.receipt_digest = digest_json(&receipt_without_digest(&receipt));
932 insert_receipt(tx, &receipt)?;
933 Ok(receipt)
934 })
935 })
936 .await
937 }
938}
939
940#[derive(Debug)]
941enum GateFailure {
942 Expired(String),
943 Quarantine(String),
944 Defer(String),
945 Reject(String),
946}
947
948fn validate_proposal(proposal: &ShadowPolicyProposalV1) -> Result<(), MemoryError> {
949 if proposal.schema_version != SHADOW_POLICY_PROPOSAL_V1
950 || proposal.status != ShadowPolicyStatusV1::Proposed
951 || proposal.proposal_id.trim().is_empty()
952 || proposal.idempotency_key.trim().is_empty()
953 || proposal.principal.trim().is_empty()
954 || proposal.provenance.origin.trim().is_empty()
955 || proposal.provenance.source.trim().is_empty()
956 || proposal.feature_digest.trim().is_empty()
957 || proposal
958 .training_window
959 .held_out_input_digest
960 .trim()
961 .is_empty()
962 || proposal.expires_at.trim().is_empty()
963 || proposal.proposal_digest != proposal.compute_digest()
964 || proposal.baseline_policy_digest != digest_json(&proposal.baseline_policy)
965 || !proposal.risk.score.is_finite()
966 || !(0.0..=1.0).contains(&proposal.risk.score)
967 {
968 return Err(MemoryError::ShadowPolicyRejected {
969 reason: "proposal failed structural or digest validation".into(),
970 });
971 }
972 for field in [
973 &proposal.training_window.training_start,
974 &proposal.training_window.training_end,
975 &proposal.training_window.evaluation_start,
976 &proposal.training_window.evaluation_end,
977 ] {
978 if DateTime::parse_from_rfc3339(field).is_err() {
979 return Err(MemoryError::ShadowPolicyRejected {
980 reason: format!("invalid evaluation window timestamp '{field}'"),
981 });
982 }
983 }
984 let training_start = DateTime::parse_from_rfc3339(&proposal.training_window.training_start)
985 .map_err(|_| MemoryError::ShadowPolicyRejected {
986 reason: "invalid training window".into(),
987 })?;
988 let training_end = DateTime::parse_from_rfc3339(&proposal.training_window.training_end)
989 .map_err(|_| MemoryError::ShadowPolicyRejected {
990 reason: "invalid training window".into(),
991 })?;
992 let evaluation_start = DateTime::parse_from_rfc3339(&proposal.training_window.evaluation_start)
993 .map_err(|_| MemoryError::ShadowPolicyRejected {
994 reason: "invalid evaluation window".into(),
995 })?;
996 let evaluation_end = DateTime::parse_from_rfc3339(&proposal.training_window.evaluation_end)
997 .map_err(|_| MemoryError::ShadowPolicyRejected {
998 reason: "invalid evaluation window".into(),
999 })?;
1000 if training_start >= training_end
1001 || evaluation_start >= evaluation_end
1002 || training_end > evaluation_start
1003 {
1004 return Err(MemoryError::ShadowPolicyRejected {
1005 reason: "training and evaluation windows must be ordered and non-overlapping".into(),
1006 });
1007 }
1008 validate_delta_shape(&proposal.proposed_delta)?;
1009 Ok(())
1010}
1011
1012fn evaluate_gate(
1013 proposal: &ShadowPolicyProposalV1,
1014 evidence: &PromotionEvidenceV1,
1015 permit: &ShadowPolicyPromotionPermitV1,
1016 active: Option<&ActiveShadowPolicyV1>,
1017) -> Result<(), GateFailure> {
1018 if permit.principal != proposal.principal {
1019 return Err(GateFailure::Reject("principal isolation violation".into()));
1020 }
1021 if let Ok(expires) = DateTime::parse_from_rfc3339(&proposal.expires_at) {
1022 if expires.with_timezone(&Utc) <= Utc::now() {
1023 return Err(GateFailure::Expired("proposal expired".into()));
1024 }
1025 } else {
1026 return Err(GateFailure::Reject("proposal expiry is not RFC3339".into()));
1027 }
1028 if let Err(reason) = evidence.verify_integrity(proposal) {
1029 return Err(GateFailure::Quarantine(reason));
1030 }
1031 let evaluated_at = match DateTime::parse_from_rfc3339(&evidence.evaluated_at) {
1032 Ok(value) => value.with_timezone(&Utc),
1033 Err(_) => {
1034 return Err(GateFailure::Quarantine(
1035 "evidence timestamp is not RFC3339".into(),
1036 ))
1037 }
1038 };
1039 let now = Utc::now();
1040 if evaluated_at > now + Duration::minutes(5) {
1041 return Err(GateFailure::Reject(
1042 "evidence timestamp is in the future".into(),
1043 ));
1044 }
1045 if now.signed_duration_since(evaluated_at) > Duration::days(30) {
1046 return Err(GateFailure::Defer("evaluation evidence is stale".into()));
1047 }
1048 if proposal.risk.score > SHADOW_PROPOSAL_MAX_RISK {
1049 return Err(GateFailure::Reject(
1050 "proposal risk exceeds promotion bound".into(),
1051 ));
1052 }
1053 if evidence.safety_regression || evidence.integrity_regression {
1054 return Err(GateFailure::Reject("safety or integrity regression".into()));
1055 }
1056 if evidence.held_out_improvement <= 0.0 {
1057 return Err(GateFailure::Defer(
1058 "held-out improvement is not positive".into(),
1059 ));
1060 }
1061 if evidence
1062 .evaluation_input_hashes
1063 .iter()
1064 .all(|hash| hash != &proposal.training_window.held_out_input_digest)
1065 {
1066 return Err(GateFailure::Reject(
1067 "held-out input digest is not reproducible".into(),
1068 ));
1069 }
1070 match active {
1071 Some(active) => {
1072 if proposal.baseline_policy_digest != active.policy_digest {
1073 return Err(GateFailure::Defer("baseline policy is stale".into()));
1074 }
1075 if evidence.rollback_target != active.version.to_string()
1076 && evidence.rollback_target != active.policy_digest
1077 {
1078 return Err(GateFailure::Reject(
1079 "rollback target does not identify active version".into(),
1080 ));
1081 }
1082 }
1083 None if evidence.rollback_target != "none" => {
1084 return Err(GateFailure::Reject(
1085 "initial promotion rollback target must be none".into(),
1086 ));
1087 }
1088 None => {}
1089 }
1090 if max_numeric_delta(&proposal.proposed_delta) > SHADOW_MAX_DELTA {
1091 return Err(GateFailure::Reject(
1092 "proposed delta exceeds deterministic bound".into(),
1093 ));
1094 }
1095 Ok(())
1096}
1097
1098fn max_numeric_delta(value: &Value) -> f64 {
1099 match value {
1100 Value::Number(number) => number.as_f64().map_or(f64::INFINITY, f64::abs),
1101 Value::Array(values) => values.iter().map(max_numeric_delta).fold(0.0, f64::max),
1102 Value::Object(values) => values.values().map(max_numeric_delta).fold(0.0, f64::max),
1103 _ => 0.0,
1104 }
1105}
1106
1107fn validate_delta_shape(value: &Value) -> Result<(), MemoryError> {
1108 match value {
1109 Value::Null | Value::String(_) => Err(MemoryError::ShadowPolicyRejected {
1110 reason: "policy deltas must not contain null or string values".into(),
1111 }),
1112 Value::Number(number) if number.as_f64().is_none() => {
1113 Err(MemoryError::ShadowPolicyRejected {
1114 reason: "policy delta contains an invalid number".into(),
1115 })
1116 }
1117 Value::Number(_) | Value::Bool(_) => Ok(()),
1118 Value::Array(values) => values.iter().try_for_each(validate_delta_shape),
1119 Value::Object(values) => values.values().try_for_each(validate_delta_shape),
1120 }
1121}
1122
1123fn apply_delta(baseline: &Value, delta: &Value) -> Result<Value, MemoryError> {
1124 match (baseline, delta) {
1125 (Value::Object(base), Value::Object(delta)) => {
1126 let mut result = base.clone();
1127 for (key, change) in delta {
1128 let next = match (result.get(key), change) {
1129 (Some(Value::Number(old)), Value::Number(change)) => {
1130 let old =
1131 old.as_f64()
1132 .ok_or_else(|| MemoryError::ShadowPolicyRejected {
1133 reason: "baseline number is invalid".into(),
1134 })?;
1135 let change =
1136 change
1137 .as_f64()
1138 .ok_or_else(|| MemoryError::ShadowPolicyRejected {
1139 reason: "delta number is invalid".into(),
1140 })?;
1141 serde_json::Number::from_f64(old + change)
1142 .map(Value::Number)
1143 .ok_or_else(|| MemoryError::ShadowPolicyRejected {
1144 reason: "delta produced non-finite policy".into(),
1145 })?
1146 }
1147 (Some(Value::Object(old)), Value::Object(change)) => {
1148 apply_delta(&Value::Object(old.clone()), &Value::Object(change.clone()))?
1149 }
1150 (_, replacement) => replacement.clone(),
1151 };
1152 result.insert(key.clone(), next);
1153 }
1154 Ok(Value::Object(result))
1155 }
1156 (_, replacement) => Ok(replacement.clone()),
1157 }
1158}
1159
1160fn load_proposal(
1161 conn: &rusqlite::Connection,
1162 id: &str,
1163 principal: &str,
1164) -> Result<Option<ShadowPolicyProposalV1>, MemoryError> {
1165 let raw: Option<(String, String)> = conn
1166 .query_row(
1167 "SELECT proposal_json, proposal_id FROM shadow_policy_proposals
1168 WHERE proposal_id = ?1 AND principal = ?2",
1169 params![id, principal],
1170 |row| Ok((row.get(0)?, row.get(1)?)),
1171 )
1172 .optional()?;
1173 raw.map(|(json, id)| {
1174 let proposal: ShadowPolicyProposalV1 =
1175 serde_json::from_str(&json).map_err(|e| MemoryError::CorruptData {
1176 table: "shadow_policy_proposals",
1177 row_id: id.clone(),
1178 detail: e.to_string(),
1179 })?;
1180 with_current_status(conn, proposal, &id)
1181 })
1182 .transpose()
1183}
1184
1185fn load_proposal_tx(
1186 tx: &Transaction<'_>,
1187 id: &str,
1188 principal: &str,
1189) -> Result<Option<ShadowPolicyProposalV1>, MemoryError> {
1190 load_proposal(tx, id, principal)
1191}
1192
1193fn with_current_status(
1194 conn: &rusqlite::Connection,
1195 mut proposal: ShadowPolicyProposalV1,
1196 id: &str,
1197) -> Result<ShadowPolicyProposalV1, MemoryError> {
1198 let status: Option<String> = conn.query_row(
1199 "SELECT status FROM shadow_policy_receipts WHERE proposal_id = ?1 ORDER BY created_at DESC, receipt_id DESC LIMIT 1",
1200 params![id],
1201 |row| row.get(0),
1202 ).optional()?;
1203 if let Some(status) = status {
1204 proposal.status = ShadowPolicyStatusV1::parse(&status)?;
1205 }
1206 Ok(proposal)
1207}
1208
1209fn load_active(
1210 conn: &rusqlite::Connection,
1211 principal: &str,
1212 kind: ShadowPolicyKindV1,
1213) -> Result<Option<ActiveShadowPolicyV1>, MemoryError> {
1214 load_active_tx(conn, principal, kind)
1215}
1216
1217fn load_active_tx(
1218 conn: &rusqlite::Connection,
1219 principal: &str,
1220 kind: ShadowPolicyKindV1,
1221) -> Result<Option<ActiveShadowPolicyV1>, MemoryError> {
1222 let row: Option<(u64, String, String, String, String, String)> = conn.query_row(
1223 "SELECT version, policy_json, policy_digest, source_proposal_id, activated_by, activated_at
1224 FROM shadow_active_policies WHERE principal = ?1 AND policy_kind = ?2",
1225 params![principal, kind.as_str()],
1226 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?)),
1227 ).optional()?;
1228 row.map(
1229 |(version, policy_json, policy_digest, source_proposal_id, activated_by, activated_at)| {
1230 let policy =
1231 serde_json::from_str(&policy_json).map_err(|e| MemoryError::CorruptData {
1232 table: "shadow_active_policies",
1233 row_id: format!("{principal}:{}", kind.as_str()),
1234 detail: e.to_string(),
1235 })?;
1236 Ok(ActiveShadowPolicyV1 {
1237 principal: principal.into(),
1238 policy_kind: kind,
1239 version,
1240 policy,
1241 policy_digest,
1242 source_proposal_id,
1243 activated_by,
1244 activated_at,
1245 })
1246 },
1247 )
1248 .transpose()
1249}
1250
1251fn build_receipt(
1252 proposal: &ShadowPolicyProposalV1,
1253 key: &str,
1254 evidence_digest: &str,
1255 disposition: PromotionDispositionV1,
1256 status: ShadowPolicyStatusV1,
1257 reason_codes: Vec<String>,
1258 before: Option<&ActiveShadowPolicyV1>,
1259 after_version: Option<u64>,
1260 after_policy_digest: Option<String>,
1261 rollback_target: Option<String>,
1262) -> PromotionDecisionReceiptV1 {
1263 let mut receipt = PromotionDecisionReceiptV1 {
1264 schema_version: PROMOTION_DECISION_RECEIPT_V1.into(),
1265 receipt_id: uuid::Uuid::new_v4().to_string(),
1266 caller_idempotency_key: key.into(),
1267 proposal_id: proposal.proposal_id.clone(),
1268 principal: proposal.principal.clone(),
1269 policy_kind: proposal.policy_kind,
1270 disposition,
1271 status,
1272 reason_codes,
1273 evidence_digest: evidence_digest.into(),
1274 before_version: before.map(|policy| policy.version),
1275 after_version,
1276 before_policy_digest: before.map(|policy| policy.policy_digest.clone()),
1277 after_policy_digest,
1278 rollback_target,
1279 receipt_digest: String::new(),
1280 committed_at: Utc::now().to_rfc3339(),
1281 };
1282 receipt.receipt_digest = digest_json(&receipt_without_digest(&receipt));
1283 receipt
1284}
1285
1286fn receipt_without_digest(receipt: &PromotionDecisionReceiptV1) -> Value {
1287 let mut value = serde_json::to_value(receipt).unwrap_or(Value::Null);
1288 if let Value::Object(ref mut object) = value {
1289 object.insert("receipt_digest".into(), Value::String(String::new()));
1290 }
1291 value
1292}
1293
1294fn insert_receipt(
1295 tx: &Transaction<'_>,
1296 receipt: &PromotionDecisionReceiptV1,
1297) -> Result<(), MemoryError> {
1298 tx.execute(
1299 "INSERT INTO shadow_policy_receipts
1300 (receipt_id, caller_idempotency_key, proposal_id, principal, policy_kind,
1301 evidence_digest, status, receipt_json, receipt_digest, created_at)
1302 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1303 params![
1304 receipt.receipt_id,
1305 receipt.caller_idempotency_key,
1306 receipt.proposal_id,
1307 receipt.principal,
1308 receipt.policy_kind.as_str(),
1309 receipt.evidence_digest,
1310 receipt.status.as_str(),
1311 serde_json::to_string(receipt).map_err(|e| MemoryError::ShadowPolicyRejected {
1312 reason: e.to_string()
1313 })?,
1314 receipt.receipt_digest,
1315 receipt.committed_at,
1316 ],
1317 )?;
1318 Ok(())
1319}
1320
1321fn fault_gate(
1322 fault: &std::sync::Arc<std::sync::Mutex<Option<AuthorityFaultStage>>>,
1323 stage: AuthorityFaultStage,
1324) -> Result<(), MemoryError> {
1325 let mut guard = fault
1326 .lock()
1327 .map_err(|_| MemoryError::Other("authority fault lock poisoned".into()))?;
1328 if guard.as_ref() == Some(&stage) {
1329 *guard = None;
1330 return Err(MemoryError::AuthorityFaultInjected { stage });
1331 }
1332 Ok(())
1333}
1334
1335fn digest_json(value: &Value) -> String {
1336 let canonical = canonical_json(value);
1337 format!("blake3:{}", blake3::hash(canonical.as_bytes()).to_hex())
1338}
1339
1340fn evidence_digest(evidence: &PromotionEvidenceV1) -> Result<String, MemoryError> {
1341 let value = serde_json::to_value(evidence).map_err(|e| MemoryError::ShadowPolicyRejected {
1342 reason: e.to_string(),
1343 })?;
1344 let mut object = value;
1345 if let Value::Object(ref mut fields) = object {
1346 fields.remove("evaluated_at");
1349 }
1350 Ok(digest_json(&object))
1351}
1352
1353fn canonical_json(value: &Value) -> String {
1354 match value {
1355 Value::Null => "null".into(),
1356 Value::Bool(value) => value.to_string(),
1357 Value::Number(value) => value.to_string(),
1358 Value::String(value) => serde_json::to_string(value).unwrap_or_else(|_| "\"\"".into()),
1359 Value::Array(values) => format!(
1360 "[{}]",
1361 values
1362 .iter()
1363 .map(canonical_json)
1364 .collect::<Vec<_>>()
1365 .join(",")
1366 ),
1367 Value::Object(values) => {
1368 let mut keys: Vec<_> = values.keys().collect();
1369 keys.sort();
1370 let entries = keys
1371 .into_iter()
1372 .map(|key| {
1373 format!(
1374 "{}:{}",
1375 serde_json::to_string(key).unwrap_or_else(|_| "\"\"".into()),
1376 canonical_json(&values[key])
1377 )
1378 })
1379 .collect::<Vec<_>>();
1380 format!("{{{}}}", entries.join(","))
1381 }
1382 }
1383}