Skip to main content

semantic_memory/
procedural_memory.rs

1//! Governed procedural memory.
2//!
3//! Procedures are immutable, tested action candidates. They are intentionally stored outside
4//! facts, claims, FTS, embeddings, and factual authority lineages. This module exposes no command
5//! executor: even a promoted procedure is only returned with a fit/authority decision.
6
7use crate::db::with_transaction;
8use crate::origin_authority::{
9    evaluate_governed_access_v1, AudienceV1, CallerPrincipalV1, GovernedAccessPurposeV1,
10    GovernedAccessRequestV1, NamespaceScopeV1, OriginAuthorityDecisionV1, OriginAuthorityLabelV1,
11    SubjectPrincipalV1,
12};
13use crate::{MemoryError, MemoryStore};
14use chrono::{DateTime, Utc};
15use rusqlite::{params, OptionalExtension, Transaction};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::collections::{BTreeMap, BTreeSet};
19
20pub const PROCEDURAL_MEMORY_ARTIFACT_V1: &str = "procedural_memory_artifact_v1";
21pub const PROCEDURE_TEST_RECEIPT_V1: &str = "procedure_test_receipt_v1";
22pub const PROCEDURE_LIFECYCLE_RECEIPT_V1: &str = "procedure_lifecycle_receipt_v1";
23const PROCEDURE_LIFECYCLE_POLICY_V1: &str = "procedure_lifecycle_policy_v1";
24const PROCEDURE_ACTION_POLICY_V1: &str = "procedure_action_policy_v1";
25
26#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
27pub struct ProcedureCapabilityV1 {
28    pub domain: String,
29    pub name: String,
30}
31
32impl ProcedureCapabilityV1 {
33    pub fn new(domain: impl Into<String>, name: impl Into<String>) -> Self {
34        Self {
35            domain: domain.into(),
36            name: name.into(),
37        }
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
42pub struct ProcedureActionV1 {
43    pub kind: String,
44    pub description: String,
45}
46
47impl ProcedureActionV1 {
48    pub fn new(kind: impl Into<String>, description: impl Into<String>) -> Self {
49        Self {
50            kind: kind.into(),
51            description: description.into(),
52        }
53    }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum ApplicabilityOperatorV1 {
59    Equals,
60    NotEquals,
61    Present,
62    Absent,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct ApplicabilityPredicateV1 {
67    pub field: String,
68    pub operator: ApplicabilityOperatorV1,
69    pub value: Option<Value>,
70}
71
72impl ApplicabilityPredicateV1 {
73    pub fn equals(field: impl Into<String>, value: Value) -> Self {
74        Self {
75            field: field.into(),
76            operator: ApplicabilityOperatorV1::Equals,
77            value: Some(value),
78        }
79    }
80
81    pub fn not_equals(field: impl Into<String>, value: Value) -> Self {
82        Self {
83            field: field.into(),
84            operator: ApplicabilityOperatorV1::NotEquals,
85            value: Some(value),
86        }
87    }
88
89    pub fn present(field: impl Into<String>) -> Self {
90        Self {
91            field: field.into(),
92            operator: ApplicabilityOperatorV1::Present,
93            value: None,
94        }
95    }
96
97    fn matches(&self, context: &Value) -> bool {
98        let actual = value_at_path(context, &self.field);
99        match self.operator {
100            ApplicabilityOperatorV1::Equals => actual == self.value.as_ref(),
101            ApplicabilityOperatorV1::NotEquals => actual.is_some() && actual != self.value.as_ref(),
102            ApplicabilityOperatorV1::Present => actual.is_some(),
103            ApplicabilityOperatorV1::Absent => actual.is_none(),
104        }
105    }
106}
107
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct ProcedurePreconditionV1 {
110    pub predicate: ApplicabilityPredicateV1,
111    pub failure_code: String,
112}
113
114impl ProcedurePreconditionV1 {
115    pub fn equals(field: impl Into<String>, value: Value) -> Self {
116        let field = field.into();
117        Self {
118            predicate: ApplicabilityPredicateV1::equals(field.clone(), value),
119            failure_code: format!("precondition:{field}"),
120        }
121    }
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct ProcedureStepV1 {
126    pub step_id: String,
127    pub sequence: u32,
128    pub action_kind: String,
129    pub tool: String,
130    pub arguments: Value,
131    pub rollback: Option<String>,
132}
133
134impl ProcedureStepV1 {
135    pub fn tool(
136        step_id: impl Into<String>,
137        tool: impl Into<String>,
138        arguments: Value,
139        rollback: Option<String>,
140    ) -> Self {
141        Self {
142            step_id: step_id.into(),
143            sequence: 1,
144            action_kind: "tool_call".into(),
145            tool: tool.into(),
146            arguments,
147            rollback,
148        }
149    }
150
151    pub fn at_sequence(mut self, sequence: u32) -> Self {
152        self.sequence = sequence;
153        self
154    }
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158pub struct AllowedProcedureToolV1 {
159    pub tool: String,
160    pub argument_schema: Value,
161    pub schema_digest: String,
162}
163
164impl AllowedProcedureToolV1 {
165    pub fn new(tool: impl Into<String>, argument_schema: Value) -> Self {
166        let tool = tool.into();
167        let schema_digest = digest_value(&argument_schema);
168        Self {
169            tool,
170            argument_schema,
171            schema_digest,
172        }
173    }
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct ProcedureEffectV1 {
178    pub kind: String,
179    pub value: Value,
180}
181
182impl ProcedureEffectV1 {
183    pub fn new(kind: impl Into<String>, value: Value) -> Self {
184        Self {
185            kind: kind.into(),
186            value,
187        }
188    }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum ProcedureRiskV1 {
194    Low,
195    Medium,
196    High,
197    Critical,
198}
199
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct ProcedureFixtureV1 {
202    pub fixture_id: String,
203    pub context: Value,
204    pub available_tools: Vec<String>,
205    pub expected_effects: Vec<ProcedureEffectV1>,
206    pub forbidden_effects: Vec<ProcedureEffectV1>,
207}
208
209impl ProcedureFixtureV1 {
210    pub fn new(
211        fixture_id: impl Into<String>,
212        context: Value,
213        mut available_tools: Vec<String>,
214        expected_effects: Vec<ProcedureEffectV1>,
215        forbidden_effects: Vec<ProcedureEffectV1>,
216    ) -> Self {
217        available_tools.sort();
218        available_tools.dedup();
219        Self {
220            fixture_id: fixture_id.into(),
221            context,
222            available_tools,
223            expected_effects,
224            forbidden_effects,
225        }
226    }
227}
228
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230pub struct ProcedureEvidenceTestEnvelopeV1 {
231    pub schema_version: String,
232    pub sandbox_profile: String,
233    pub fixtures: Vec<ProcedureFixtureV1>,
234    pub source_fact_ids: Vec<String>,
235    pub tested_tool_schema_digests: BTreeMap<String, String>,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct ProcedureRevocationV1 {
240    pub revocation_id: String,
241    pub reason_digest: String,
242    pub revoked_at: String,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct ProcedureValidationV1 {
247    pub schema_version: String,
248    pub artifact_id: String,
249    pub artifact_digest: String,
250    pub valid: bool,
251    pub reason_codes: Vec<String>,
252    pub validation_digest: String,
253}
254
255impl ProcedureEvidenceTestEnvelopeV1 {
256    pub fn new(
257        sandbox_profile: impl Into<String>,
258        fixtures: Vec<ProcedureFixtureV1>,
259        mut source_fact_ids: Vec<String>,
260    ) -> Self {
261        source_fact_ids.sort();
262        source_fact_ids.dedup();
263        Self {
264            schema_version: "procedure_evidence_test_envelope_v1".into(),
265            sandbox_profile: sandbox_profile.into(),
266            fixtures,
267            source_fact_ids,
268            tested_tool_schema_digests: BTreeMap::new(),
269        }
270    }
271}
272
273/// Immutable procedure version. Lifecycle state is recorded separately as append-only events.
274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
275pub struct ProceduralMemoryArtifactV1 {
276    pub schema_version: String,
277    pub artifact_id: String,
278    pub capability: ProcedureCapabilityV1,
279    pub action: ProcedureActionV1,
280    pub applicability: Vec<ApplicabilityPredicateV1>,
281    pub preconditions: Vec<ProcedurePreconditionV1>,
282    pub steps: Vec<ProcedureStepV1>,
283    pub allowed_tools: Vec<AllowedProcedureToolV1>,
284    pub expected_effects: Vec<ProcedureEffectV1>,
285    pub forbidden_effects: Vec<ProcedureEffectV1>,
286    pub risk: ProcedureRiskV1,
287    pub origin_authority: OriginAuthorityLabelV1,
288    pub principal: String,
289    pub audience: AudienceV1,
290    pub scope: NamespaceScopeV1,
291    pub version: u64,
292    pub supersedes: Option<String>,
293    pub evidence_test_envelope: ProcedureEvidenceTestEnvelopeV1,
294    pub expires_at: Option<String>,
295    pub revocation: Option<ProcedureRevocationV1>,
296    pub artifact_digest: String,
297}
298
299impl ProceduralMemoryArtifactV1 {
300    #[allow(clippy::too_many_arguments)]
301    pub fn new(
302        artifact_id: impl Into<String>,
303        capability: ProcedureCapabilityV1,
304        action: ProcedureActionV1,
305        applicability: Vec<ApplicabilityPredicateV1>,
306        preconditions: Vec<ProcedurePreconditionV1>,
307        mut steps: Vec<ProcedureStepV1>,
308        mut allowed_tools: Vec<AllowedProcedureToolV1>,
309        expected_effects: Vec<ProcedureEffectV1>,
310        forbidden_effects: Vec<ProcedureEffectV1>,
311        risk: ProcedureRiskV1,
312        origin_authority: OriginAuthorityLabelV1,
313        principal: impl Into<String>,
314        audience: Vec<String>,
315        scope: NamespaceScopeV1,
316        version: u64,
317        supersedes: Option<String>,
318        mut evidence_test_envelope: ProcedureEvidenceTestEnvelopeV1,
319        expires_at: Option<String>,
320    ) -> Result<Self, String> {
321        steps.sort_by_key(|step| step.sequence);
322        for (index, step) in steps.iter_mut().enumerate() {
323            if step.sequence == 1 && index > 0 {
324                step.sequence = u32::try_from(index + 1).map_err(|_| "too many steps")?;
325            }
326        }
327        allowed_tools.sort_by(|a, b| a.tool.cmp(&b.tool));
328        evidence_test_envelope.tested_tool_schema_digests = allowed_tools
329            .iter()
330            .map(|tool| (tool.tool.clone(), tool.schema_digest.clone()))
331            .collect();
332        let mut artifact = Self {
333            schema_version: PROCEDURAL_MEMORY_ARTIFACT_V1.into(),
334            artifact_id: artifact_id.into(),
335            capability,
336            action,
337            applicability,
338            preconditions,
339            steps,
340            allowed_tools,
341            expected_effects,
342            forbidden_effects,
343            risk,
344            origin_authority,
345            principal: principal.into(),
346            audience: AudienceV1::new(audience),
347            scope,
348            version,
349            supersedes,
350            evidence_test_envelope,
351            expires_at,
352            revocation: None,
353            artifact_digest: String::new(),
354        };
355        artifact.refresh_digest();
356        Ok(artifact)
357    }
358
359    pub fn compute_digest(&self) -> String {
360        let mut unsigned = self.clone();
361        unsigned.artifact_digest.clear();
362        digest_serializable(&unsigned)
363    }
364
365    pub fn refresh_digest(&mut self) {
366        self.artifact_digest = self.compute_digest();
367    }
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
371#[serde(rename_all = "snake_case")]
372pub enum ProcedureLifecycleDispositionV1 {
373    Compiled,
374    Tested,
375    Promoted,
376    Quarantined,
377    Revoked,
378    RolledBack,
379}
380
381impl ProcedureLifecycleDispositionV1 {
382    fn as_str(self) -> &'static str {
383        match self {
384            Self::Compiled => "compiled",
385            Self::Tested => "tested",
386            Self::Promoted => "promoted",
387            Self::Quarantined => "quarantined",
388            Self::Revoked => "revoked",
389            Self::RolledBack => "rolled_back",
390        }
391    }
392}
393
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct ProcedureFixtureReceiptV1 {
396    pub fixture_id: String,
397    pub passed: bool,
398    pub reason_codes: Vec<String>,
399    pub fixture_digest: String,
400}
401
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
403pub struct ProcedureTestReceiptV1 {
404    pub schema_version: String,
405    pub artifact_id: String,
406    pub artifact_digest: String,
407    pub sandbox_profile: String,
408    pub fixture_count: usize,
409    pub fixtures: Vec<ProcedureFixtureReceiptV1>,
410    pub passed: bool,
411    pub idempotent: bool,
412    pub rollback_verified: bool,
413    pub test_envelope_digest: String,
414    pub receipt_digest: String,
415}
416
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
418pub struct ProcedureLifecycleReceiptV1 {
419    pub schema_version: String,
420    pub receipt_id: String,
421    pub caller_idempotency_key: String,
422    pub operation: String,
423    pub artifact_id: String,
424    pub artifact_digest: String,
425    pub principal: String,
426    pub disposition: ProcedureLifecycleDispositionV1,
427    pub reason_codes: Vec<String>,
428    pub test_receipt: Option<ProcedureTestReceiptV1>,
429    pub prior_event_digest: Option<String>,
430    pub event_id: String,
431    pub event_digest: String,
432    pub receipt_digest: String,
433    pub committed_at: String,
434}
435
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
437pub struct ProcedureLifecyclePermitV1 {
438    pub schema_version: String,
439    pub principal: String,
440    pub caller_id: String,
441    pub capability: String,
442    pub elevation: String,
443}
444
445impl ProcedureLifecyclePermitV1 {
446    pub const CAPABILITY: &'static str = "memory.procedure.lifecycle";
447
448    pub fn elevated(principal: impl Into<String>, caller_id: impl Into<String>) -> Self {
449        Self {
450            schema_version: PROCEDURE_LIFECYCLE_POLICY_V1.into(),
451            principal: principal.into(),
452            caller_id: caller_id.into(),
453            capability: Self::CAPABILITY.into(),
454            elevation: "explicit_operator_approval".into(),
455        }
456    }
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
460pub struct ProcedureActionPermitV1 {
461    pub schema_version: String,
462    pub principal: String,
463    pub caller_id: String,
464    pub capability: String,
465    pub scope: NamespaceScopeV1,
466    pub elevation: String,
467    pub expires_at: String,
468}
469
470impl ProcedureActionPermitV1 {
471    pub const CAPABILITY: &'static str = "memory.procedure.action";
472
473    pub fn elevated(
474        principal: impl Into<String>,
475        caller_id: impl Into<String>,
476        scope: NamespaceScopeV1,
477    ) -> Self {
478        Self {
479            schema_version: PROCEDURE_ACTION_POLICY_V1.into(),
480            principal: principal.into(),
481            caller_id: caller_id.into(),
482            capability: Self::CAPABILITY.into(),
483            scope,
484            elevation: "explicit_operator_approval".into(),
485            expires_at: "2999-01-01T00:00:00Z".into(),
486        }
487    }
488}
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
491#[serde(rename_all = "snake_case")]
492pub enum ProcedureAccessPathV1 {
493    Search,
494    DirectId,
495    Cache,
496    Export,
497    Replay,
498}
499
500#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
501pub struct ProcedureRetrievalRequestV1 {
502    pub schema_version: String,
503    pub capability: ProcedureCapabilityV1,
504    pub action: ProcedureActionV1,
505    pub context: Value,
506    pub caller: CallerPrincipalV1,
507    pub subject: SubjectPrincipalV1,
508    pub audience: AudienceV1,
509    pub scope: NamespaceScopeV1,
510    pub purpose: GovernedAccessPurposeV1,
511    pub access_path: ProcedureAccessPathV1,
512    pub artifact_id: Option<String>,
513    pub action_permit: Option<ProcedureActionPermitV1>,
514}
515
516impl ProcedureRetrievalRequestV1 {
517    #[allow(clippy::too_many_arguments)]
518    pub fn new(
519        capability: ProcedureCapabilityV1,
520        action: ProcedureActionV1,
521        context: Value,
522        caller: CallerPrincipalV1,
523        subject: SubjectPrincipalV1,
524        audience: Vec<String>,
525        scope: NamespaceScopeV1,
526        purpose: GovernedAccessPurposeV1,
527        access_path: ProcedureAccessPathV1,
528    ) -> Self {
529        Self {
530            schema_version: "procedure_retrieval_request_v1".into(),
531            capability,
532            action,
533            context,
534            caller,
535            subject,
536            audience: AudienceV1::new(audience),
537            scope,
538            purpose,
539            access_path,
540            artifact_id: None,
541            action_permit: None,
542        }
543    }
544}
545
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
547pub struct GovernedProcedureDecisionV1 {
548    pub schema_version: String,
549    pub artifact_id: Option<String>,
550    pub candidate_fit: bool,
551    pub authority_allowed: bool,
552    pub action_allowed: bool,
553    pub test_envelope_passed: bool,
554    pub access_path: ProcedureAccessPathV1,
555    pub reason_codes: Vec<String>,
556    pub origin_decision: Option<OriginAuthorityDecisionV1>,
557    pub decision_digest: String,
558}
559
560#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
561pub struct GovernedProcedureRetrievalV1 {
562    pub schema_version: String,
563    pub candidate: Option<ProceduralMemoryArtifactV1>,
564    pub decision: GovernedProcedureDecisionV1,
565    pub receipt_digest: String,
566}
567
568impl MemoryStore {
569    /// Compile and persist an immutable procedure version. Invalid input is durably quarantined.
570    pub async fn compile_procedure(
571        &self,
572        artifact: ProceduralMemoryArtifactV1,
573        caller_idempotency_key: impl Into<String>,
574    ) -> Result<ProcedureLifecycleReceiptV1, MemoryError> {
575        let key = caller_idempotency_key.into();
576        let payload_digest = digest_serializable(&("compile", &artifact));
577        let static_validation = validate_artifact(&artifact);
578        self.with_write_conn(move |conn| {
579            // Safety: immutable artifact insertion, derivation links, event, and receipt are one
580            // atomic write; a failure cannot expose a partially compiled procedure.
581            with_transaction(conn, |tx| {
582                if let Some(receipt) = load_idempotent_receipt(tx, &key, &payload_digest)? {
583                    return Ok(receipt);
584                }
585                let artifact_json = serde_json::to_string(&artifact).map_err(rejected)?;
586                let existing: Option<String> = tx.query_row(
587                    "SELECT artifact_digest FROM procedural_memory_artifacts WHERE artifact_id = ?1",
588                    params![artifact.artifact_id], |row| row.get(0),
589                ).optional()?;
590                if existing.as_deref().is_some_and(|digest| digest != artifact.artifact_digest) {
591                    return Err(MemoryError::ProceduralMemoryConflict { key });
592                }
593                if existing.is_none() {
594                    tx.execute(
595                        "INSERT INTO procedural_memory_artifacts
596                         (artifact_id, principal, capability_domain, capability_name, action_kind,
597                          version, supersedes, artifact_digest, artifact_json, created_at)
598                         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
599                        params![artifact.artifact_id, artifact.principal, artifact.capability.domain,
600                            artifact.capability.name, artifact.action.kind, artifact.version,
601                            artifact.supersedes, artifact.artifact_digest, artifact_json, now()],
602                    ).map_err(|error| MemoryError::ProceduralMemoryRejected { reason: error.to_string() })?;
603                    for fact_id in &artifact.evidence_test_envelope.source_fact_ids {
604                        let fact_id = fact_id.strip_prefix("fact:").unwrap_or(fact_id);
605                        tx.execute(
606                            "INSERT INTO derivation_edges
607                             (source_kind, source_id, target_kind, target_id, derivation_type,
608                              invalidation_mode, recorded_at)
609                             VALUES ('fact', ?1, 'procedure', ?2, 'procedure_evidence',
610                                     'on_source_change', ?3)",
611                            params![fact_id, artifact.artifact_id, now()],
612                        )?;
613                    }
614                }
615                let source_validation = validate_source_evidence(tx, &artifact);
616                let (disposition, reasons) = match (static_validation, source_validation) {
617                    (Ok(()), Ok(())) => (ProcedureLifecycleDispositionV1::Compiled, vec![]),
618                    (static_result, source_result) => {
619                        let mut reasons = static_result.err().unwrap_or_default();
620                        reasons.extend(source_result.err().unwrap_or_default());
621                        reasons.sort();
622                        reasons.dedup();
623                        (ProcedureLifecycleDispositionV1::Quarantined, reasons)
624                    }
625                };
626                append_lifecycle(tx, &key, "compile", &payload_digest, &artifact,
627                    disposition, reasons, None)
628            })
629        }).await
630    }
631
632    /// Run deterministic fixture simulation. No external tool or command is invoked.
633    pub async fn test_procedure(
634        &self,
635        artifact_id: &str,
636        caller_idempotency_key: impl Into<String>,
637    ) -> Result<ProcedureLifecycleReceiptV1, MemoryError> {
638        let artifact_id = artifact_id.to_string();
639        let key = caller_idempotency_key.into();
640        let payload_digest = digest_serializable(&("test", &artifact_id));
641        self.with_write_conn(move |conn| {
642            // Safety: the deterministic test event and its witnessed receipt commit together.
643            with_transaction(conn, |tx| {
644                if let Some(receipt) = load_idempotent_receipt(tx, &key, &payload_digest)? {
645                    return Ok(receipt);
646                }
647                let artifact = load_artifact_tx(tx, &artifact_id)?.ok_or_else(|| {
648                    MemoryError::ProceduralMemoryNotFound {
649                        artifact_id: artifact_id.clone(),
650                    }
651                })?;
652                require_latest(
653                    tx,
654                    &artifact_id,
655                    &[ProcedureLifecycleDispositionV1::Compiled],
656                )?;
657                let test = run_fixture_tests(&artifact);
658                let (disposition, reasons) = if test.passed {
659                    (ProcedureLifecycleDispositionV1::Tested, vec![])
660                } else {
661                    (
662                        ProcedureLifecycleDispositionV1::Quarantined,
663                        test.fixtures
664                            .iter()
665                            .flat_map(|f| f.reason_codes.clone())
666                            .collect(),
667                    )
668                };
669                append_lifecycle(
670                    tx,
671                    &key,
672                    "test",
673                    &payload_digest,
674                    &artifact,
675                    disposition,
676                    reasons,
677                    Some(test),
678                )
679            })
680        })
681        .await
682    }
683
684    pub async fn promote_procedure(
685        &self,
686        permit: ProcedureLifecyclePermitV1,
687        artifact_id: &str,
688        caller_idempotency_key: impl Into<String>,
689    ) -> Result<ProcedureLifecycleReceiptV1, MemoryError> {
690        lifecycle_control(
691            self,
692            permit,
693            artifact_id.to_string(),
694            caller_idempotency_key.into(),
695            "promote",
696            ProcedureLifecycleDispositionV1::Promoted,
697            None,
698        )
699        .await
700    }
701
702    pub async fn quarantine_procedure(
703        &self,
704        permit: ProcedureLifecyclePermitV1,
705        artifact_id: &str,
706        caller_idempotency_key: impl Into<String>,
707        reason: impl Into<String>,
708    ) -> Result<ProcedureLifecycleReceiptV1, MemoryError> {
709        lifecycle_control(
710            self,
711            permit,
712            artifact_id.to_string(),
713            caller_idempotency_key.into(),
714            "quarantine",
715            ProcedureLifecycleDispositionV1::Quarantined,
716            Some(reason.into()),
717        )
718        .await
719    }
720
721    pub async fn revoke_procedure(
722        &self,
723        permit: ProcedureLifecyclePermitV1,
724        artifact_id: &str,
725        caller_idempotency_key: impl Into<String>,
726        reason: impl Into<String>,
727    ) -> Result<ProcedureLifecycleReceiptV1, MemoryError> {
728        lifecycle_control(
729            self,
730            permit,
731            artifact_id.to_string(),
732            caller_idempotency_key.into(),
733            "revoke",
734            ProcedureLifecycleDispositionV1::Revoked,
735            Some(reason.into()),
736        )
737        .await
738    }
739
740    /// Roll back an active promotion without mutating or deleting the immutable version.
741    pub async fn rollback_procedure(
742        &self,
743        permit: ProcedureLifecyclePermitV1,
744        artifact_id: &str,
745        caller_idempotency_key: impl Into<String>,
746        reason: impl Into<String>,
747    ) -> Result<ProcedureLifecycleReceiptV1, MemoryError> {
748        lifecycle_control(
749            self,
750            permit,
751            artifact_id.to_string(),
752            caller_idempotency_key.into(),
753            "rollback",
754            ProcedureLifecycleDispositionV1::RolledBack,
755            Some(reason.into()),
756        )
757        .await
758    }
759
760    /// Return at most one procedure candidate plus a witnessed fit/authority decision.
761    /// This function never invokes the procedure or any tool named by it.
762    pub async fn retrieve_procedure(
763        &self,
764        request: ProcedureRetrievalRequestV1,
765    ) -> Result<GovernedProcedureRetrievalV1, MemoryError> {
766        self.with_read_conn(move |conn| retrieve_governed(conn, request))
767            .await
768    }
769}
770
771async fn lifecycle_control(
772    store: &MemoryStore,
773    permit: ProcedureLifecyclePermitV1,
774    artifact_id: String,
775    key: String,
776    operation: &'static str,
777    disposition: ProcedureLifecycleDispositionV1,
778    reason: Option<String>,
779) -> Result<ProcedureLifecycleReceiptV1, MemoryError> {
780    validate_lifecycle_permit(&permit)?;
781    let payload_digest = digest_serializable(&(operation, &permit, &artifact_id, &reason));
782    store
783        .with_write_conn(move |conn| {
784            // Safety: lifecycle transition and immutable receipt are committed atomically.
785            with_transaction(conn, |tx| {
786                if let Some(receipt) = load_idempotent_receipt(tx, &key, &payload_digest)? {
787                    return Ok(receipt);
788                }
789                let artifact = load_artifact_tx(tx, &artifact_id)?.ok_or_else(|| {
790                    MemoryError::ProceduralMemoryNotFound {
791                        artifact_id: artifact_id.clone(),
792                    }
793                })?;
794                if artifact.principal != permit.principal {
795                    return Err(MemoryError::ProceduralMemoryUnauthorized {
796                        principal: permit.principal,
797                    });
798                }
799                let latest = latest_disposition(tx, &artifact_id)?
800                    .ok_or_else(|| rejected("missing compile event"))?;
801                match disposition {
802                    ProcedureLifecycleDispositionV1::Promoted => {
803                        if latest != ProcedureLifecycleDispositionV1::Tested {
804                            return Err(rejected(
805                                "promotion requires the latest deterministic test to pass",
806                            ));
807                        }
808                        if let Some(previous_id) = artifact.supersedes.as_ref() {
809                            let previous = load_artifact_tx(tx, previous_id)?
810                                .ok_or_else(|| rejected("superseded version not found"))?;
811                            if previous.principal != artifact.principal
812                                || previous.capability != artifact.capability
813                                || previous.action.kind != artifact.action.kind
814                                || previous.version.checked_add(1) != Some(artifact.version)
815                                || latest_disposition(tx, previous_id)?
816                                    != Some(ProcedureLifecycleDispositionV1::Promoted)
817                            {
818                                return Err(rejected(
819                                    "supersession must name the active prior immutable version",
820                                ));
821                            }
822                        } else if artifact.version != 1 {
823                            return Err(rejected("version greater than one requires supersedes"));
824                        }
825                        if artifact.expires_at.as_deref().is_some_and(|value| {
826                            DateTime::parse_from_rfc3339(value)
827                                .ok()
828                                .map_or(true, |expiry| expiry.with_timezone(&Utc) <= Utc::now())
829                        }) {
830                            return Err(rejected("expired procedure cannot be promoted"));
831                        }
832                        let forgotten = tx.query_row(
833                            "SELECT EXISTS(SELECT 1 FROM forgetting_artifact_invalidations
834                             WHERE surface_kind = 'procedure' AND artifact_id = ?1)",
835                            params![artifact.artifact_id],
836                            |row| row.get::<_, bool>(0),
837                        )?;
838                        if forgotten || validate_source_evidence(tx, &artifact).is_err() {
839                            return Err(rejected("procedure evidence is stale or forgotten"));
840                        }
841                    }
842                    ProcedureLifecycleDispositionV1::Quarantined => {
843                        if latest == ProcedureLifecycleDispositionV1::Revoked {
844                            return Err(rejected("revoked procedure cannot transition"));
845                        }
846                    }
847                    ProcedureLifecycleDispositionV1::Revoked => {
848                        if latest == ProcedureLifecycleDispositionV1::Revoked {
849                            return Err(rejected("procedure is already revoked"));
850                        }
851                    }
852                    ProcedureLifecycleDispositionV1::RolledBack => {
853                        if latest != ProcedureLifecycleDispositionV1::Promoted {
854                            return Err(rejected("rollback requires the active promoted version"));
855                        }
856                    }
857                    _ => return Err(rejected("invalid controlled lifecycle transition")),
858                }
859                append_lifecycle(
860                    tx,
861                    &key,
862                    operation,
863                    &payload_digest,
864                    &artifact,
865                    disposition,
866                    reason
867                        .into_iter()
868                        .map(|text| format!("reason:{}", digest_serializable(&text)))
869                        .collect(),
870                    None,
871                )
872            })
873        })
874        .await
875}
876
877fn retrieve_governed(
878    conn: &rusqlite::Connection,
879    request: ProcedureRetrievalRequestV1,
880) -> Result<GovernedProcedureRetrievalV1, MemoryError> {
881    let artifact = load_candidate(conn, &request)?;
882    let mut reasons = Vec::new();
883    let path_purpose_valid = match request.access_path {
884        ProcedureAccessPathV1::Export => request.purpose == GovernedAccessPurposeV1::Export,
885        ProcedureAccessPathV1::Replay => request.purpose == GovernedAccessPurposeV1::Replay,
886        ProcedureAccessPathV1::Search
887        | ProcedureAccessPathV1::DirectId
888        | ProcedureAccessPathV1::Cache => matches!(
889            request.purpose,
890            GovernedAccessPurposeV1::Recall | GovernedAccessPurposeV1::Action
891        ),
892    };
893    if request.schema_version != "procedure_retrieval_request_v1"
894        || !request.context.is_object()
895        || !path_purpose_valid
896    {
897        reasons.push("invalid_request_or_access_path_purpose".into());
898    }
899    let mut fit = false;
900    let mut authority_allowed = false;
901    let mut action_allowed = false;
902    let mut tested = false;
903    let mut origin_decision = None;
904    let candidate = if let Some(artifact) = artifact {
905        let current = artifact.expires_at.as_deref().map_or(true, |value| {
906            DateTime::parse_from_rfc3339(value)
907                .ok()
908                .is_some_and(|expiry| expiry.with_timezone(&Utc) > Utc::now())
909        });
910        if !current {
911            reasons.push("procedure_expired".into());
912        }
913        fit = reasons.is_empty()
914            && artifact.artifact_digest == artifact.compute_digest()
915            && artifact.capability == request.capability
916            && artifact.action.kind == request.action.kind
917            && artifact
918                .applicability
919                .iter()
920                .all(|item| item.matches(&request.context))
921            && artifact
922                .preconditions
923                .iter()
924                .all(|item| item.predicate.matches(&request.context));
925        if !fit {
926            reasons.push("applicability_or_precondition_failed".into());
927        }
928        tested = latest_passing_test(conn, &artifact.artifact_id)?.is_some();
929        if !tested {
930            reasons.push("passing_test_envelope_absent".into());
931        }
932        let access = GovernedAccessRequestV1::for_principals(
933            request.caller.clone(),
934            request.subject.clone(),
935            request.audience.0.clone(),
936            request.purpose,
937            request.scope.clone(),
938        );
939        let decision = evaluate_governed_access_v1(
940            &artifact.artifact_id,
941            Some(&artifact.scope.namespace),
942            Some(&artifact.origin_authority),
943            artifact
944                .revocation
945                .as_ref()
946                .map(|item| item.revocation_id.as_str()),
947            &access,
948        );
949        authority_allowed = decision.allowed && artifact.principal == request.caller.0;
950        if !authority_allowed {
951            reasons.push("origin_or_principal_denied".into());
952        }
953        origin_decision = Some(decision);
954        action_allowed = request.purpose != GovernedAccessPurposeV1::Action
955            || action_permit_allows(request.action_permit.as_ref(), &request, &artifact);
956        if request.purpose == GovernedAccessPurposeV1::Action && !action_allowed {
957            reasons.push("explicit_action_elevation_required".into());
958        }
959        if current && fit && authority_allowed && tested {
960            Some(artifact)
961        } else {
962            None
963        }
964    } else {
965        reasons.push("no_current_promoted_candidate".into());
966        None
967    };
968    if reasons.is_empty() {
969        reasons.push("procedure_candidate_governed".into());
970    }
971    let decision_digest = digest_serializable(&(
972        "governed_procedure_decision_v1",
973        candidate.as_ref().map(|a| &a.artifact_id),
974        fit,
975        authority_allowed,
976        action_allowed,
977        tested,
978        request.access_path,
979        &reasons,
980        &origin_decision,
981    ));
982    let decision = GovernedProcedureDecisionV1 {
983        schema_version: "governed_procedure_decision_v1".into(),
984        artifact_id: candidate.as_ref().map(|a| a.artifact_id.clone()),
985        candidate_fit: fit,
986        authority_allowed,
987        action_allowed: action_allowed && fit && authority_allowed && tested,
988        test_envelope_passed: tested,
989        access_path: request.access_path,
990        reason_codes: reasons,
991        origin_decision,
992        decision_digest,
993    };
994    let receipt_digest =
995        digest_serializable(&("governed_procedure_retrieval_v1", &candidate, &decision));
996    Ok(GovernedProcedureRetrievalV1 {
997        schema_version: "governed_procedure_retrieval_v1".into(),
998        candidate,
999        decision,
1000        receipt_digest,
1001    })
1002}
1003
1004fn load_candidate(
1005    conn: &rusqlite::Connection,
1006    request: &ProcedureRetrievalRequestV1,
1007) -> Result<Option<ProceduralMemoryArtifactV1>, MemoryError> {
1008    let sql = String::from(
1009        "SELECT a.artifact_json FROM procedural_memory_artifacts a
1010         WHERE a.capability_domain = ?1 AND a.capability_name = ?2 AND a.action_kind = ?3
1011           AND (?4 IS NULL OR a.artifact_id = ?4)
1012           AND (SELECT e.disposition FROM procedural_memory_events e
1013                WHERE e.artifact_id = a.artifact_id ORDER BY e.rowid DESC LIMIT 1) = 'promoted'
1014           AND NOT EXISTS (SELECT 1 FROM procedural_memory_artifacts newer
1015                           WHERE newer.supersedes = a.artifact_id
1016                             AND EXISTS (SELECT 1 FROM procedural_memory_events promoted
1017                                         WHERE promoted.artifact_id = newer.artifact_id
1018                                           AND promoted.disposition = 'promoted')
1019                             AND (SELECT pe.disposition FROM procedural_memory_events pe
1020                                  WHERE pe.artifact_id = newer.artifact_id
1021                                  ORDER BY pe.rowid DESC LIMIT 1) != 'rolled_back')
1022           AND NOT EXISTS (SELECT 1 FROM forgetting_artifact_invalidations fi
1023                           WHERE fi.surface_kind = 'procedure' AND fi.artifact_id = a.artifact_id)
1024         ORDER BY a.version DESC, a.artifact_id ASC LIMIT 1",
1025    );
1026    // The SQL is constant and every selector remains parameterized; access path never chooses a
1027    // weaker query. Keeping one statement is the direct-ID/cache/export/replay bypass defense.
1028    let raw: Option<String> = conn
1029        .query_row(
1030            &sql,
1031            params![
1032                request.capability.domain,
1033                request.capability.name,
1034                request.action.kind,
1035                request.artifact_id
1036            ],
1037            |row| row.get(0),
1038        )
1039        .optional()?;
1040    raw.map(|json| {
1041        serde_json::from_str(&json).map_err(|error| MemoryError::CorruptData {
1042            table: "procedural_memory_artifacts",
1043            row_id: request.artifact_id.clone().unwrap_or_default(),
1044            detail: error.to_string(),
1045        })
1046    })
1047    .transpose()
1048}
1049
1050fn validate_artifact(artifact: &ProceduralMemoryArtifactV1) -> Result<(), Vec<String>> {
1051    let mut reasons = Vec::new();
1052    if artifact.schema_version != PROCEDURAL_MEMORY_ARTIFACT_V1
1053        || artifact.artifact_id.trim().is_empty()
1054        || artifact.capability.domain.trim().is_empty()
1055        || artifact.capability.name.trim().is_empty()
1056        || artifact.action.kind.trim().is_empty()
1057        || artifact.action.description.trim().is_empty()
1058        || artifact.principal.trim().is_empty()
1059        || !artifact.scope.is_bound()
1060        || artifact.audience.0.is_empty()
1061        || artifact.version == 0
1062    {
1063        reasons.push("required_typed_field_missing".into());
1064    }
1065    if artifact.artifact_digest != artifact.compute_digest() {
1066        reasons.push("artifact_digest_mismatch".into());
1067    }
1068    if artifact.origin_authority.origin_principal != artifact.principal
1069        || artifact.origin_authority.scopes.assertion != crate::AuthorityScopeV1::Denied
1070        || artifact.origin_authority.resource_scope != artifact.scope
1071        || artifact.origin_authority.revocation_status != crate::RevocationStatusV1::Active
1072    {
1073        reasons.push("origin_authority_or_nonassertion_boundary_invalid".into());
1074    }
1075    if artifact.version == 1 && artifact.supersedes.is_some()
1076        || artifact.version > 1 && artifact.supersedes.as_deref().map_or(true, str::is_empty)
1077    {
1078        reasons.push("version_supersession_invalid".into());
1079    }
1080    if artifact
1081        .expires_at
1082        .as_deref()
1083        .is_some_and(|value| DateTime::parse_from_rfc3339(value).is_err())
1084    {
1085        reasons.push("expiry_invalid".into());
1086    }
1087    if artifact.steps.is_empty()
1088        || artifact.allowed_tools.is_empty()
1089        || artifact.expected_effects.is_empty()
1090        || artifact.evidence_test_envelope.fixtures.is_empty()
1091    {
1092        reasons.push("steps_tools_effects_and_tests_required".into());
1093    }
1094    let mut sequences = BTreeSet::new();
1095    let mut step_ids = BTreeSet::new();
1096    let allowed: BTreeMap<_, _> = artifact
1097        .allowed_tools
1098        .iter()
1099        .map(|tool| (&tool.tool, tool))
1100        .collect();
1101    let used_tools: BTreeSet<_> = artifact
1102        .steps
1103        .iter()
1104        .map(|step| step.tool.as_str())
1105        .collect();
1106    let allowed_names: BTreeSet<_> = artifact
1107        .allowed_tools
1108        .iter()
1109        .map(|tool| tool.tool.as_str())
1110        .collect();
1111    if used_tools != allowed_names {
1112        reasons.push("tool_manifest_widening".into());
1113    }
1114    if artifact.allowed_tools.iter().any(|tool| {
1115        prohibited_tool(&tool.tool) || schema_has_command_surface(&tool.argument_schema)
1116    }) {
1117        reasons.push("general_purpose_execution_surface_forbidden".into());
1118    }
1119    for (index, step) in artifact.steps.iter().enumerate() {
1120        if step.sequence != u32::try_from(index + 1).unwrap_or(u32::MAX)
1121            || !sequences.insert(step.sequence)
1122            || !step_ids.insert(&step.step_id)
1123        {
1124            reasons.push("steps_not_strictly_ordered".into());
1125        }
1126        if prohibited_tool(&step.tool) || step.action_kind != "tool_call" {
1127            reasons.push("malicious_or_unsupported_step".into());
1128        }
1129        match allowed.get(&step.tool) {
1130            Some(tool) => {
1131                if tool.schema_digest != digest_value(&tool.argument_schema)
1132                    || validate_arguments(&step.arguments, &tool.argument_schema).is_err()
1133                {
1134                    reasons.push("argument_schema_drift_or_widening".into());
1135                }
1136            }
1137            None => reasons.push("tool_not_in_manifest".into()),
1138        }
1139        if artifact.risk >= ProcedureRiskV1::Medium
1140            && step.rollback.as_deref().map_or(true, str::is_empty)
1141        {
1142            reasons.push("rollback_required_for_elevated_risk".into());
1143        }
1144    }
1145    let manifest_digests: BTreeMap<_, _> = artifact
1146        .allowed_tools
1147        .iter()
1148        .map(|tool| (tool.tool.clone(), tool.schema_digest.clone()))
1149        .collect();
1150    if artifact.evidence_test_envelope.tested_tool_schema_digests != manifest_digests {
1151        reasons.push("tested_tool_schema_drift".into());
1152    }
1153    for effect in &artifact.forbidden_effects {
1154        if artifact.expected_effects.contains(effect) {
1155            reasons.push("expected_forbidden_effect_collision".into());
1156        }
1157    }
1158    for fixture in &artifact.evidence_test_envelope.fixtures {
1159        let fixture_tools: BTreeSet<_> =
1160            fixture.available_tools.iter().map(String::as_str).collect();
1161        if fixture_tools != allowed_names {
1162            reasons.push("fixture_tool_manifest_widening".into());
1163        }
1164    }
1165    if artifact.evidence_test_envelope.schema_version != "procedure_evidence_test_envelope_v1"
1166        || artifact.evidence_test_envelope.sandbox_profile != "sandbox-v1"
1167    {
1168        reasons.push("unapproved_sandbox_profile".into());
1169    }
1170    reasons.sort();
1171    reasons.dedup();
1172    if reasons.is_empty() {
1173        Ok(())
1174    } else {
1175        Err(reasons)
1176    }
1177}
1178
1179fn validate_source_evidence(
1180    tx: &Transaction<'_>,
1181    artifact: &ProceduralMemoryArtifactV1,
1182) -> Result<(), Vec<String>> {
1183    let mut reasons = Vec::new();
1184    for raw_id in &artifact.evidence_test_envelope.source_fact_ids {
1185        let fact_id = raw_id.strip_prefix("fact:").unwrap_or(raw_id);
1186        let row: Result<Option<(String, Option<String>, bool, bool)>, rusqlite::Error> = tx
1187            .query_row(
1188                "SELECT f.namespace, o.label_json,
1189                        EXISTS(SELECT 1 FROM forgotten_facts ff WHERE ff.fact_id = f.id),
1190                        EXISTS(SELECT 1 FROM origin_authority_revocations r WHERE r.fact_id = f.id)
1191                 FROM facts f LEFT JOIN origin_authority_labels o ON o.fact_id = f.id
1192                 WHERE f.id = ?1",
1193                params![fact_id],
1194                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1195            )
1196            .optional();
1197        match row {
1198            Ok(Some((namespace, Some(label_json), false, false))) => {
1199                let label: Result<OriginAuthorityLabelV1, _> = serde_json::from_str(&label_json);
1200                if label.as_ref().map_or(true, |label| {
1201                    label.origin_principal != artifact.principal
1202                        || namespace != artifact.scope.namespace
1203                        || label.revocation_status != crate::RevocationStatusV1::Active
1204                }) {
1205                    reasons.push("source_fact_authority_or_scope_mismatch".into());
1206                }
1207            }
1208            Ok(_) => reasons.push("source_fact_missing_revoked_or_forgotten".into()),
1209            Err(_) => reasons.push("source_fact_validation_failed".into()),
1210        }
1211    }
1212    reasons.sort();
1213    reasons.dedup();
1214    if reasons.is_empty() {
1215        Ok(())
1216    } else {
1217        Err(reasons)
1218    }
1219}
1220
1221/// Deterministically validate a procedure without storing or executing it.
1222pub fn validate_procedure_artifact_v1(
1223    artifact: &ProceduralMemoryArtifactV1,
1224) -> ProcedureValidationV1 {
1225    let mut reasons = validate_artifact(artifact).err().unwrap_or_default();
1226    reasons.sort();
1227    reasons.dedup();
1228    let valid = reasons.is_empty();
1229    let validation_digest = digest_serializable(&(
1230        "procedure_validation_v1",
1231        &artifact.artifact_id,
1232        &artifact.artifact_digest,
1233        valid,
1234        &reasons,
1235    ));
1236    ProcedureValidationV1 {
1237        schema_version: "procedure_validation_v1".into(),
1238        artifact_id: artifact.artifact_id.clone(),
1239        artifact_digest: artifact.artifact_digest.clone(),
1240        valid,
1241        reason_codes: reasons,
1242        validation_digest,
1243    }
1244}
1245
1246fn run_fixture_tests(artifact: &ProceduralMemoryArtifactV1) -> ProcedureTestReceiptV1 {
1247    let first = evaluate_fixture_set(artifact);
1248    let second = evaluate_fixture_set(artifact);
1249    let idempotent = first == second;
1250    let passed = idempotent && first.iter().all(|fixture| fixture.passed);
1251    let rollback_verified = artifact
1252        .steps
1253        .iter()
1254        .all(|step| step.rollback.as_deref().is_some_and(|v| !v.is_empty()));
1255    let envelope_digest = digest_serializable(&artifact.evidence_test_envelope);
1256    let mut receipt = ProcedureTestReceiptV1 {
1257        schema_version: PROCEDURE_TEST_RECEIPT_V1.into(),
1258        artifact_id: artifact.artifact_id.clone(),
1259        artifact_digest: artifact.artifact_digest.clone(),
1260        sandbox_profile: artifact.evidence_test_envelope.sandbox_profile.clone(),
1261        fixture_count: first.len(),
1262        fixtures: first,
1263        passed,
1264        idempotent,
1265        rollback_verified,
1266        test_envelope_digest: envelope_digest,
1267        receipt_digest: String::new(),
1268    };
1269    receipt.receipt_digest = digest_serializable(&(
1270        &receipt.schema_version,
1271        &receipt.artifact_id,
1272        &receipt.artifact_digest,
1273        &receipt.sandbox_profile,
1274        receipt.fixture_count,
1275        &receipt.fixtures,
1276        receipt.passed,
1277        receipt.idempotent,
1278        receipt.rollback_verified,
1279        &receipt.test_envelope_digest,
1280    ));
1281    receipt
1282}
1283
1284fn evaluate_fixture_set(artifact: &ProceduralMemoryArtifactV1) -> Vec<ProcedureFixtureReceiptV1> {
1285    artifact
1286        .evidence_test_envelope
1287        .fixtures
1288        .iter()
1289        .map(|fixture| {
1290            let mut reasons = Vec::new();
1291            if !artifact
1292                .applicability
1293                .iter()
1294                .all(|p| p.matches(&fixture.context))
1295                || !artifact
1296                    .preconditions
1297                    .iter()
1298                    .all(|p| p.predicate.matches(&fixture.context))
1299            {
1300                reasons.push("fixture_not_applicable".into());
1301            }
1302            if artifact
1303                .steps
1304                .iter()
1305                .any(|step| !fixture.available_tools.contains(&step.tool))
1306            {
1307                reasons.push("fixture_tool_unavailable".into());
1308            }
1309            if fixture.expected_effects != artifact.expected_effects {
1310                reasons.push("expected_effect_mismatch".into());
1311            }
1312            if fixture
1313                .forbidden_effects
1314                .iter()
1315                .any(|effect| artifact.expected_effects.contains(effect))
1316                || artifact
1317                    .forbidden_effects
1318                    .iter()
1319                    .any(|effect| fixture.expected_effects.contains(effect))
1320            {
1321                reasons.push("forbidden_effect_observed".into());
1322            }
1323            reasons.sort();
1324            reasons.dedup();
1325            let fixture_digest = digest_serializable(&(
1326                &fixture.fixture_id,
1327                &fixture.context,
1328                &fixture.available_tools,
1329                &fixture.expected_effects,
1330                &fixture.forbidden_effects,
1331                &reasons,
1332            ));
1333            ProcedureFixtureReceiptV1 {
1334                fixture_id: fixture.fixture_id.clone(),
1335                passed: reasons.is_empty(),
1336                reason_codes: reasons,
1337                fixture_digest,
1338            }
1339        })
1340        .collect()
1341}
1342
1343fn validate_arguments(arguments: &Value, schema: &Value) -> Result<(), ()> {
1344    if schema.get("type").and_then(Value::as_str) != Some("object") {
1345        return Err(());
1346    }
1347    let args = arguments.as_object().ok_or(())?;
1348    let properties = schema
1349        .get("properties")
1350        .and_then(Value::as_object)
1351        .ok_or(())?;
1352    if schema.get("additionalProperties").and_then(Value::as_bool) != Some(false) {
1353        return Err(());
1354    }
1355    let required: BTreeSet<&str> = schema
1356        .get("required")
1357        .and_then(Value::as_array)
1358        .ok_or(())?
1359        .iter()
1360        .map(|value| value.as_str().ok_or(()))
1361        .collect::<Result<_, _>>()?;
1362    if required.iter().any(|field| !args.contains_key(*field))
1363        || args.keys().any(|field| !properties.contains_key(field))
1364    {
1365        return Err(());
1366    }
1367    for (field, value) in args {
1368        let expected = properties
1369            .get(field)
1370            .and_then(|v| v.get("type"))
1371            .and_then(Value::as_str)
1372            .ok_or(())?;
1373        let matches = match expected {
1374            "boolean" => value.is_boolean(),
1375            "string" => value.is_string(),
1376            "number" => value.is_number(),
1377            "integer" => value.as_i64().is_some() || value.as_u64().is_some(),
1378            "array" => value.is_array(),
1379            "object" => value.is_object(),
1380            _ => false,
1381        };
1382        if !matches {
1383            return Err(());
1384        }
1385    }
1386    Ok(())
1387}
1388
1389fn prohibited_tool(tool: &str) -> bool {
1390    let normalized = tool.trim().to_ascii_lowercase();
1391    [
1392        "shell",
1393        "bash",
1394        "powershell",
1395        "command",
1396        "exec",
1397        "process",
1398        "terminal",
1399    ]
1400    .iter()
1401    .any(|part| normalized.contains(part))
1402        || matches!(normalized.as_str(), "sh" | "cmd")
1403}
1404
1405fn schema_has_command_surface(schema: &Value) -> bool {
1406    schema
1407        .get("properties")
1408        .and_then(Value::as_object)
1409        .is_some_and(|properties| {
1410            properties.keys().any(|key| {
1411                matches!(
1412                    key.to_ascii_lowercase().as_str(),
1413                    "command" | "cmd" | "shell" | "script" | "program" | "executable"
1414                )
1415            })
1416        })
1417}
1418
1419fn action_permit_allows(
1420    permit: Option<&ProcedureActionPermitV1>,
1421    request: &ProcedureRetrievalRequestV1,
1422    artifact: &ProceduralMemoryArtifactV1,
1423) -> bool {
1424    let Some(permit) = permit else {
1425        return false;
1426    };
1427    permit.schema_version == PROCEDURE_ACTION_POLICY_V1
1428        && permit.capability == ProcedureActionPermitV1::CAPABILITY
1429        && permit.principal == request.caller.0
1430        && permit.principal == artifact.principal
1431        && !permit.caller_id.trim().is_empty()
1432        && permit.elevation == "explicit_operator_approval"
1433        && permit.scope == request.scope
1434        && permit.scope == artifact.scope
1435        && DateTime::parse_from_rfc3339(&permit.expires_at)
1436            .ok()
1437            .is_some_and(|expiry| expiry.with_timezone(&Utc) > Utc::now())
1438}
1439
1440fn validate_lifecycle_permit(permit: &ProcedureLifecyclePermitV1) -> Result<(), MemoryError> {
1441    if permit.schema_version != PROCEDURE_LIFECYCLE_POLICY_V1
1442        || permit.capability != ProcedureLifecyclePermitV1::CAPABILITY
1443        || permit.principal.trim().is_empty()
1444        || permit.caller_id.trim().is_empty()
1445        || permit.elevation != "explicit_operator_approval"
1446    {
1447        Err(MemoryError::ProceduralMemoryUnauthorized {
1448            principal: permit.principal.clone(),
1449        })
1450    } else {
1451        Ok(())
1452    }
1453}
1454
1455fn append_lifecycle(
1456    tx: &Transaction<'_>,
1457    key: &str,
1458    operation: &str,
1459    payload_digest: &str,
1460    artifact: &ProceduralMemoryArtifactV1,
1461    disposition: ProcedureLifecycleDispositionV1,
1462    mut reasons: Vec<String>,
1463    test_receipt: Option<ProcedureTestReceiptV1>,
1464) -> Result<ProcedureLifecycleReceiptV1, MemoryError> {
1465    if key.trim().is_empty() {
1466        return Err(rejected("idempotency key is required"));
1467    }
1468    reasons.sort();
1469    reasons.dedup();
1470    let prior_event_digest: Option<String> = tx.query_row(
1471        "SELECT event_digest FROM procedural_memory_events WHERE artifact_id = ?1 ORDER BY rowid DESC LIMIT 1",
1472        params![artifact.artifact_id], |row| row.get(0)).optional()?;
1473    let committed_at = now();
1474    let event_id = uuid::Uuid::new_v4().to_string();
1475    let reason_digest = digest_serializable(&reasons);
1476    let event_digest = digest_serializable(&(
1477        "procedure_lifecycle_event_v1",
1478        &event_id,
1479        &artifact.artifact_id,
1480        disposition,
1481        &reason_digest,
1482        &test_receipt,
1483        &prior_event_digest,
1484        &committed_at,
1485    ));
1486    tx.execute(
1487        "INSERT INTO procedural_memory_events
1488         (event_id, artifact_id, disposition, reason_digest, test_receipt_json,
1489          prior_event_digest, event_digest, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1490        params![
1491            event_id,
1492            artifact.artifact_id,
1493            disposition.as_str(),
1494            reason_digest,
1495            test_receipt
1496                .as_ref()
1497                .map(serde_json::to_string)
1498                .transpose()
1499                .map_err(rejected)?,
1500            prior_event_digest,
1501            event_digest,
1502            committed_at
1503        ],
1504    )?;
1505    let mut receipt = ProcedureLifecycleReceiptV1 {
1506        schema_version: PROCEDURE_LIFECYCLE_RECEIPT_V1.into(),
1507        receipt_id: uuid::Uuid::new_v4().to_string(),
1508        caller_idempotency_key: key.into(),
1509        operation: operation.into(),
1510        artifact_id: artifact.artifact_id.clone(),
1511        artifact_digest: artifact.artifact_digest.clone(),
1512        principal: artifact.principal.clone(),
1513        disposition,
1514        reason_codes: reasons,
1515        test_receipt,
1516        prior_event_digest,
1517        event_id,
1518        event_digest,
1519        receipt_digest: String::new(),
1520        committed_at,
1521    };
1522    receipt.receipt_digest = digest_serializable(&(
1523        &receipt.schema_version,
1524        &receipt.receipt_id,
1525        &receipt.caller_idempotency_key,
1526        &receipt.operation,
1527        &receipt.artifact_id,
1528        &receipt.artifact_digest,
1529        &receipt.principal,
1530        receipt.disposition,
1531        &receipt.reason_codes,
1532        &receipt.test_receipt,
1533        &receipt.prior_event_digest,
1534        &receipt.event_id,
1535        &receipt.event_digest,
1536        &receipt.committed_at,
1537    ));
1538    tx.execute(
1539        "INSERT INTO procedural_memory_receipts
1540         (receipt_id, caller_idempotency_key, operation, payload_digest, artifact_id,
1541          receipt_json, receipt_digest, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1542        params![
1543            receipt.receipt_id,
1544            key,
1545            operation,
1546            payload_digest,
1547            artifact.artifact_id,
1548            serde_json::to_string(&receipt).map_err(rejected)?,
1549            receipt.receipt_digest,
1550            receipt.committed_at
1551        ],
1552    )?;
1553    Ok(receipt)
1554}
1555
1556fn load_idempotent_receipt(
1557    tx: &Transaction<'_>,
1558    key: &str,
1559    payload_digest: &str,
1560) -> Result<Option<ProcedureLifecycleReceiptV1>, MemoryError> {
1561    let stored: Option<(String, String)> = tx.query_row(
1562        "SELECT payload_digest, receipt_json FROM procedural_memory_receipts WHERE caller_idempotency_key = ?1",
1563        params![key], |row| Ok((row.get(0)?, row.get(1)?))).optional()?;
1564    let Some((stored_digest, json)) = stored else {
1565        return Ok(None);
1566    };
1567    if stored_digest != payload_digest {
1568        return Err(MemoryError::ProceduralMemoryConflict { key: key.into() });
1569    }
1570    serde_json::from_str(&json)
1571        .map(Some)
1572        .map_err(|error| MemoryError::CorruptData {
1573            table: "procedural_memory_receipts",
1574            row_id: key.into(),
1575            detail: error.to_string(),
1576        })
1577}
1578
1579fn load_artifact_tx(
1580    tx: &Transaction<'_>,
1581    artifact_id: &str,
1582) -> Result<Option<ProceduralMemoryArtifactV1>, MemoryError> {
1583    let raw: Option<String> = tx
1584        .query_row(
1585            "SELECT artifact_json FROM procedural_memory_artifacts WHERE artifact_id = ?1",
1586            params![artifact_id],
1587            |row| row.get(0),
1588        )
1589        .optional()?;
1590    raw.map(|json| {
1591        serde_json::from_str(&json).map_err(|error| MemoryError::CorruptData {
1592            table: "procedural_memory_artifacts",
1593            row_id: artifact_id.into(),
1594            detail: error.to_string(),
1595        })
1596    })
1597    .transpose()
1598}
1599
1600fn latest_disposition(
1601    tx: &Transaction<'_>,
1602    artifact_id: &str,
1603) -> Result<Option<ProcedureLifecycleDispositionV1>, MemoryError> {
1604    let raw: Option<String> = tx.query_row(
1605        "SELECT disposition FROM procedural_memory_events WHERE artifact_id = ?1 ORDER BY rowid DESC LIMIT 1",
1606        params![artifact_id], |row| row.get(0)).optional()?;
1607    raw.map(|value| parse_disposition(&value)).transpose()
1608}
1609
1610fn require_latest(
1611    tx: &Transaction<'_>,
1612    artifact_id: &str,
1613    allowed: &[ProcedureLifecycleDispositionV1],
1614) -> Result<(), MemoryError> {
1615    let latest = latest_disposition(tx, artifact_id)?;
1616    if latest.is_some_and(|state| allowed.contains(&state)) {
1617        Ok(())
1618    } else {
1619        Err(rejected("invalid lifecycle predecessor"))
1620    }
1621}
1622
1623fn latest_passing_test(
1624    conn: &rusqlite::Connection,
1625    artifact_id: &str,
1626) -> Result<Option<ProcedureTestReceiptV1>, MemoryError> {
1627    let raw: Option<String> = conn
1628        .query_row(
1629            "SELECT test_receipt_json FROM procedural_memory_events
1630         WHERE artifact_id = ?1 AND disposition = 'tested' AND test_receipt_json IS NOT NULL
1631         ORDER BY rowid DESC LIMIT 1",
1632            params![artifact_id],
1633            |row| row.get(0),
1634        )
1635        .optional()?;
1636    raw.map(|json| {
1637        serde_json::from_str(&json).map_err(|error| MemoryError::CorruptData {
1638            table: "procedural_memory_events",
1639            row_id: artifact_id.into(),
1640            detail: error.to_string(),
1641        })
1642    })
1643    .transpose()
1644}
1645
1646fn parse_disposition(value: &str) -> Result<ProcedureLifecycleDispositionV1, MemoryError> {
1647    match value {
1648        "compiled" => Ok(ProcedureLifecycleDispositionV1::Compiled),
1649        "tested" => Ok(ProcedureLifecycleDispositionV1::Tested),
1650        "promoted" => Ok(ProcedureLifecycleDispositionV1::Promoted),
1651        "quarantined" => Ok(ProcedureLifecycleDispositionV1::Quarantined),
1652        "revoked" => Ok(ProcedureLifecycleDispositionV1::Revoked),
1653        "rolled_back" => Ok(ProcedureLifecycleDispositionV1::RolledBack),
1654        _ => Err(rejected("unknown lifecycle disposition")),
1655    }
1656}
1657
1658/// Verify the content-addressed deterministic fixture-test receipt.
1659pub fn verify_procedure_test_receipt_v1(receipt: &ProcedureTestReceiptV1) -> bool {
1660    receipt.schema_version == PROCEDURE_TEST_RECEIPT_V1
1661        && receipt.receipt_digest
1662            == digest_serializable(&(
1663                &receipt.schema_version,
1664                &receipt.artifact_id,
1665                &receipt.artifact_digest,
1666                &receipt.sandbox_profile,
1667                receipt.fixture_count,
1668                &receipt.fixtures,
1669                receipt.passed,
1670                receipt.idempotent,
1671                receipt.rollback_verified,
1672                &receipt.test_envelope_digest,
1673            ))
1674}
1675
1676/// Verify a witnessed lifecycle receipt and its embedded fixture receipt, when present.
1677pub fn verify_procedure_lifecycle_receipt_v1(receipt: &ProcedureLifecycleReceiptV1) -> bool {
1678    let reason_digest = digest_serializable(&receipt.reason_codes);
1679    let expected_event_digest = digest_serializable(&(
1680        "procedure_lifecycle_event_v1",
1681        &receipt.event_id,
1682        &receipt.artifact_id,
1683        receipt.disposition,
1684        &reason_digest,
1685        &receipt.test_receipt,
1686        &receipt.prior_event_digest,
1687        &receipt.committed_at,
1688    ));
1689    receipt.schema_version == PROCEDURE_LIFECYCLE_RECEIPT_V1
1690        && receipt
1691            .test_receipt
1692            .as_ref()
1693            .map_or(true, verify_procedure_test_receipt_v1)
1694        && receipt.event_digest == expected_event_digest
1695        && receipt.receipt_digest
1696            == digest_serializable(&(
1697                &receipt.schema_version,
1698                &receipt.receipt_id,
1699                &receipt.caller_idempotency_key,
1700                &receipt.operation,
1701                &receipt.artifact_id,
1702                &receipt.artifact_digest,
1703                &receipt.principal,
1704                receipt.disposition,
1705                &receipt.reason_codes,
1706                &receipt.test_receipt,
1707                &receipt.prior_event_digest,
1708                &receipt.event_id,
1709                &receipt.event_digest,
1710                &receipt.committed_at,
1711            ))
1712}
1713
1714fn value_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
1715    path.split('.')
1716        .try_fold(value, |current, part| current.as_object()?.get(part))
1717}
1718
1719fn digest_value(value: &Value) -> String {
1720    digest_serializable(value)
1721}
1722
1723fn digest_serializable<T: Serialize + ?Sized>(value: &T) -> String {
1724    let bytes = serde_json::to_vec(value).expect("versioned procedure contracts serialize");
1725    format!("blake3:{}", blake3::hash(&bytes).to_hex())
1726}
1727
1728fn rejected(error: impl ToString) -> MemoryError {
1729    MemoryError::ProceduralMemoryRejected {
1730        reason: error.to_string(),
1731    }
1732}
1733
1734fn now() -> String {
1735    Utc::now().format("%Y-%m-%d %H:%M:%S%.6f").to_string()
1736}