Skip to main content

semantic_memory/
shadow_policy.rs

1//! Shadow-learned policy proposals with deterministic promotion and rollback.
2//!
3//! This module is deliberately separate from facts, authority lineages, and active runtime
4//! configuration. A proposal is an observation about a possible policy change. It is never a
5//! canonical memory write, and the only path that can make it active is the deterministic gate
6//! below. The SQLite proposal, version, and receipt tables are append-only audit metadata.
7
8use 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
21/// Compute the canonical digest used for a policy JSON value.
22pub 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
431/// Evaluate a proposal without persistence or serving effects.
432pub 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    /// Append a proposal to the shadow ledger. This method cannot write facts, authority state,
505    /// active policy, or any runtime configuration.
506    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    /// Gate and, if admitted, atomically version active policy and emit its receipt.
634    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            // Safety: proposal status observation, gate decision, active-version update, and the
656            // immutable receipt commit or roll back as one SQLite transaction.
657            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            // Safety: target lookup, active pointer restoration, and rollback receipt are one
833            // transaction, so a failed restoration cannot expose a partially rolled-back policy.
834            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        // Evaluation wall-clock time is trace metadata, not evidence identity. Excluding it
1347        // makes a semantically identical retry idempotent while retaining all metric inputs.
1348        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}