1use 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#[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 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 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 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 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 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 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 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 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
1221pub 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
1658pub 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
1676pub 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}