Skip to main content

a3s_code_core/
durable_memory.rs

1//! Host-bound durable-memory integration.
2//!
3//! Code owns extraction and admission policy. `a3s-memory` owns the exact
4//! namespace and repository integrity boundary. V2 recall is explicit and
5//! admits only the current active node revision selected by final assembly.
6
7mod binding;
8mod context;
9mod fusion;
10mod policy;
11mod semantic;
12mod semantic_binding;
13mod semantic_refresh;
14pub use binding::{
15    DurableMemoryBindingV1, DURABLE_MEMORY_BINDING_SCHEMA_VERSION,
16    DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION, DURABLE_MEMORY_RETRIEVAL_PROFILE_V1,
17};
18pub(crate) use context::{durable_memory_context_id, DurableMemoryRecallIdentity};
19pub use context::{DURABLE_MEMORY_CONTEXT_ID_PROFILE_V1, DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2};
20pub use policy::{
21    DurableMemoryMode, DurableMemoryRecallChannel, DurableMemoryRecallHit,
22    DurableMemoryRecallPolicy, DurableMemoryRecallPreview,
23};
24pub use semantic::DurableMemorySemanticRecall;
25pub(crate) use semantic::SemanticRefreshEmbeddingCache;
26pub use semantic_binding::{
27    DurableMemorySemanticBindingV1, DurableMemorySemanticError, DurableMemorySemanticRecallPolicy,
28    DURABLE_MEMORY_SEMANTIC_BINDING_SCHEMA_V1, DURABLE_MEMORY_SEMANTIC_FUSION_PROFILE_V1,
29};
30pub(crate) use semantic_refresh::DurableMemorySemanticRefreshRun;
31pub use semantic_refresh::{
32    DurableMemorySemanticRefreshCheckpoint, DurableMemorySemanticRefreshReceipt,
33    DURABLE_MEMORY_SEMANTIC_REFRESH_CHECKPOINT_SCHEMA_V1,
34    DURABLE_MEMORY_SEMANTIC_REFRESH_PROFILE_V1,
35};
36
37use a3s_memory::repository::{
38    DurableMemoryKind, EvidenceKind, EvidenceRef, MemoryAccessEvent, MemoryChangeSet,
39    MemoryNamespace, MemoryNode, MemoryNodeDraft, MemoryOperation, MemoryRepository,
40    MemoryRepositoryError, MemoryStatus, MAX_IDENTIFIER_BYTES,
41};
42use a3s_memory::vector::VectorMutationConsistency;
43use a3s_memory::{MemoryItem, MemoryType};
44use chrono::{DateTime, Utc};
45use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
46use serde::Serialize;
47use sha2::{Digest, Sha256};
48use std::sync::Arc;
49use tokio_util::sync::CancellationToken;
50
51/// Explicit, evidence-backed request to activate one candidate revision.
52#[derive(Debug, Clone)]
53pub struct DurableMemoryActivation {
54    idempotency_key: String,
55    node_id: String,
56    expected_revision: u64,
57    decision_evidence: EvidenceRef,
58    occurred_at: DateTime<Utc>,
59}
60
61impl DurableMemoryActivation {
62    pub fn try_new(
63        idempotency_key: impl Into<String>,
64        node_id: impl Into<String>,
65        expected_revision: u64,
66        decision_evidence: EvidenceRef,
67        occurred_at: DateTime<Utc>,
68    ) -> Result<Self, MemoryRepositoryError> {
69        let idempotency_key = idempotency_key.into();
70        let node_id = node_id.into();
71        validate_identifier("activation.idempotencyKey", &idempotency_key)?;
72        validate_identifier("activation.nodeId", &node_id)?;
73        if expected_revision == 0 {
74            return Err(invalid(
75                "activation.expectedRevision",
76                "must be greater than zero",
77            ));
78        }
79        if !matches!(
80            decision_evidence.kind,
81            EvidenceKind::Manual | EvidenceKind::Verification
82        ) {
83            return Err(invalid(
84                "activation.decisionEvidence.kind",
85                "must be manual or verification evidence",
86            ));
87        }
88        if decision_evidence.occurred_at > occurred_at {
89            return Err(invalid(
90                "activation.decisionEvidence.occurredAt",
91                "must not follow activation occurredAt",
92            ));
93        }
94        Ok(Self {
95            idempotency_key,
96            node_id,
97            expected_revision,
98            decision_evidence,
99            occurred_at,
100        })
101    }
102}
103
104/// Explicit observation that a caller used one exact active node revision.
105#[derive(Debug, Clone)]
106pub struct DurableMemoryUse {
107    event_id: String,
108    node_id: String,
109    node_revision: u64,
110    occurred_at: DateTime<Utc>,
111    context_id: Option<String>,
112}
113
114impl DurableMemoryUse {
115    pub fn try_new(
116        event_id: impl Into<String>,
117        node_id: impl Into<String>,
118        node_revision: u64,
119        occurred_at: DateTime<Utc>,
120    ) -> Result<Self, MemoryRepositoryError> {
121        let event_id = event_id.into();
122        let node_id = node_id.into();
123        validate_identifier("use.eventId", &event_id)?;
124        validate_identifier("use.nodeId", &node_id)?;
125        if node_revision == 0 {
126            return Err(invalid("use.nodeRevision", "must be greater than zero"));
127        }
128        Ok(Self {
129            event_id,
130            node_id,
131            node_revision,
132            occurred_at,
133            context_id: None,
134        })
135    }
136
137    pub fn with_context_id(mut self, context_id: impl Into<String>) -> Self {
138        self.context_id = Some(context_id.into());
139        self
140    }
141}
142
143/// Exact repository and namespace supplied by the embedding host.
144#[derive(Clone)]
145pub struct DurableMemorySession {
146    repository: Arc<dyn MemoryRepository>,
147    namespace: MemoryNamespace,
148    mode: DurableMemoryMode,
149    recall_policy: Option<DurableMemoryRecallPolicy>,
150    semantic_recall: Option<DurableMemorySemanticRecall>,
151}
152
153impl DurableMemorySession {
154    /// Create a candidate-only binding. V2 recall remains disabled.
155    pub fn shadow(repository: Arc<dyn MemoryRepository>, namespace: MemoryNamespace) -> Self {
156        Self {
157            repository,
158            namespace,
159            mode: DurableMemoryMode::ShadowCandidates,
160            recall_policy: None,
161            semantic_recall: None,
162        }
163    }
164
165    /// Create an opt-in binding that recalls only explicitly activated nodes.
166    pub fn active_recall(
167        repository: Arc<dyn MemoryRepository>,
168        namespace: MemoryNamespace,
169        recall_policy: DurableMemoryRecallPolicy,
170    ) -> Self {
171        Self {
172            repository,
173            namespace,
174            mode: DurableMemoryMode::ActiveRecall,
175            recall_policy: Some(recall_policy),
176            semantic_recall: None,
177        }
178    }
179
180    /// Add a typed host-owned semantic generation to an Active recall binding.
181    pub fn with_semantic_recall(
182        mut self,
183        semantic_recall: DurableMemorySemanticRecall,
184    ) -> Result<Self, DurableMemorySemanticError> {
185        if self.mode != DurableMemoryMode::ActiveRecall || self.recall_policy.is_none() {
186            return Err(DurableMemorySemanticError::InvalidConfiguration {
187                field: "mode",
188                reason: "semantic recall requires an Active recall binding".to_string(),
189            });
190        }
191        self.semantic_recall = Some(semantic_recall);
192        Ok(self)
193    }
194
195    pub fn repository(&self) -> &Arc<dyn MemoryRepository> {
196        &self.repository
197    }
198
199    pub fn namespace(&self) -> &MemoryNamespace {
200        &self.namespace
201    }
202
203    pub fn mode(&self) -> DurableMemoryMode {
204        self.mode
205    }
206
207    pub fn recall_policy(&self) -> Option<DurableMemoryRecallPolicy> {
208        self.recall_policy
209    }
210
211    pub fn semantic_recall(&self) -> Option<&DurableMemorySemanticRecall> {
212        self.semantic_recall.as_ref()
213    }
214
215    /// Rebuild and verify this binding's semantic partition from one complete
216    /// current Active repository snapshot.
217    pub async fn refresh_semantic_recall(
218        &self,
219        cancellation: CancellationToken,
220    ) -> Result<DurableMemorySemanticRefreshReceipt, DurableMemorySemanticError> {
221        self.refresh_semantic_recall_requiring(
222            VectorMutationConsistency::PartitionAtomic,
223            cancellation,
224        )
225        .await
226    }
227
228    /// Rebuild semantic recall while requiring at least the selected vector
229    /// mutation ordering. An unsupported requirement fails before repository
230    /// snapshot or embedding work begins.
231    pub async fn refresh_semantic_recall_requiring(
232        &self,
233        required_consistency: VectorMutationConsistency,
234        cancellation: CancellationToken,
235    ) -> Result<DurableMemorySemanticRefreshReceipt, DurableMemorySemanticError> {
236        let semantic = self.semantic_recall.as_ref().ok_or_else(|| {
237            DurableMemorySemanticError::InvalidConfiguration {
238                field: "semanticRecall",
239                reason: "refresh requires an attached semantic recall generation".to_string(),
240            }
241        })?;
242        semantic
243            .refresh_repository_namespace(
244                self.repository.as_ref(),
245                &self.namespace,
246                required_consistency,
247                cancellation,
248            )
249            .await
250    }
251
252    /// Return the secret-free identity that must remain exact when a
253    /// persisted session is resumed.
254    pub fn binding(&self) -> DurableMemoryBindingV1 {
255        DurableMemoryBindingV1::new(
256            self.namespace.clone(),
257            self.mode,
258            self.recall_policy,
259            self.semantic_recall
260                .as_ref()
261                .map(|semantic| semantic.binding().clone()),
262        )
263    }
264
265    /// Activate one exact candidate revision with independent decision evidence.
266    pub async fn activate_candidate(
267        &self,
268        activation: DurableMemoryActivation,
269    ) -> Result<MemoryNode, MemoryRepositoryError> {
270        let result = self
271            .repository
272            .apply(MemoryChangeSet::new(
273                activation.idempotency_key,
274                self.namespace.clone(),
275                activation.occurred_at,
276                vec![MemoryOperation::Activate {
277                    node_id: activation.node_id.clone(),
278                    expected_revision: activation.expected_revision,
279                    evidence: vec![activation.decision_evidence],
280                }],
281            ))
282            .await?;
283        result
284            .nodes
285            .into_iter()
286            .find(|node| node.id == activation.node_id && node.status == MemoryStatus::Active)
287            .ok_or_else(|| MemoryRepositoryError::InvariantViolation {
288                message: "activation change returned no active target node".into(),
289            })
290    }
291
292    /// Record an explicit use without widening this binding's namespace.
293    pub async fn record_use(&self, usage: DurableMemoryUse) -> Result<(), MemoryRepositoryError> {
294        let mut event = MemoryAccessEvent::new(
295            usage.event_id,
296            self.namespace.clone(),
297            usage.node_id,
298            usage.node_revision,
299            usage.occurred_at,
300        );
301        if let Some(context_id) = usage.context_id {
302            event = event.with_context_id(context_id);
303        }
304        self.repository.record_use(event).await
305    }
306
307    pub(crate) async fn store_shadow_candidate(
308        &self,
309        item: &MemoryItem,
310        evidence: &DurableTurnEvidence,
311    ) -> Result<MemoryNode, MemoryRepositoryError> {
312        let kind = match item.memory_type {
313            MemoryType::Episodic => DurableMemoryKind::Episodic,
314            MemoryType::Semantic => DurableMemoryKind::Semantic,
315            MemoryType::Procedural => DurableMemoryKind::Procedural,
316            MemoryType::Working => {
317                return Err(MemoryRepositoryError::InvalidInput {
318                    field: "candidate.memoryType".into(),
319                    message: "working memory is not durable".into(),
320                });
321            }
322        };
323        let confidence = item
324            .metadata
325            .get("confidence")
326            .and_then(|value| value.parse::<f32>().ok())
327            .filter(|value| value.is_finite() && (0.0..=1.0).contains(value))
328            .unwrap_or(0.0);
329        let mut draft = MemoryNodeDraft::new(
330            "content-addressed-after-normalization",
331            self.namespace.clone(),
332            kind,
333            MemoryStatus::Candidate,
334            &item.content,
335            vec![evidence.reference.clone()],
336            evidence.occurred_at,
337        )
338        .with_confidence(confidence)
339        .with_importance(item.importance)
340        .with_label("a3s.origin", "code.llm_extraction");
341        for (source, label) in [
342            ("source", "a3s.extraction.source"),
343            ("scope", "a3s.extraction.scope"),
344            ("reason", "a3s.extraction.reason"),
345            ("schema", "a3s.extraction.schema"),
346        ] {
347            if let Some(value) = item.metadata.get(source) {
348                draft = draft.with_label(label, value);
349            }
350        }
351        if !item.tags.is_empty() {
352            let tags = serde_json::to_string(&item.tags).map_err(|error| {
353                MemoryRepositoryError::InvalidInput {
354                    field: "candidate.tags".into(),
355                    message: error.to_string(),
356                }
357            })?;
358            draft = draft.with_label("a3s.extraction.tags", tags);
359        }
360        draft.id = candidate_id(&draft)?;
361
362        let result = self
363            .repository
364            .apply(MemoryChangeSet::new(
365                draft.id.clone(),
366                self.namespace.clone(),
367                evidence.occurred_at,
368                vec![MemoryOperation::Create { node: draft }],
369            ))
370            .await?;
371        result
372            .nodes
373            .into_iter()
374            .next()
375            .ok_or_else(|| MemoryRepositoryError::InvariantViolation {
376                message: "candidate change returned no node".into(),
377            })
378    }
379}
380
381fn invalid(field: &str, message: impl Into<String>) -> MemoryRepositoryError {
382    MemoryRepositoryError::InvalidInput {
383        field: field.into(),
384        message: message.into(),
385    }
386}
387
388fn validate_identifier(field: &str, value: &str) -> Result<(), MemoryRepositoryError> {
389    if value.trim().is_empty() {
390        return Err(invalid(field, "must not be empty or whitespace"));
391    }
392    if value.len() > MAX_IDENTIFIER_BYTES {
393        return Err(invalid(
394            field,
395            format!("must not exceed {MAX_IDENTIFIER_BYTES} bytes"),
396        ));
397    }
398    Ok(())
399}
400
401impl std::fmt::Debug for DurableMemorySession {
402    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        formatter
404            .debug_struct("DurableMemorySession")
405            .field("namespace", &self.namespace)
406            .field("mode", &self.mode)
407            .field("recall_policy", &self.recall_policy)
408            .field(
409                "semantic_recall",
410                &self
411                    .semantic_recall
412                    .as_ref()
413                    .map(DurableMemorySemanticRecall::binding),
414            )
415            .finish_non_exhaustive()
416    }
417}
418
419#[derive(Debug, Clone)]
420pub(crate) struct DurableTurnEvidence {
421    reference: EvidenceRef,
422    occurred_at: DateTime<Utc>,
423}
424
425#[derive(Serialize)]
426#[serde(rename_all = "camelCase")]
427struct TurnEvidencePayload<'a> {
428    schema: &'static str,
429    session_id: &'a str,
430    prompt: &'a str,
431    response: &'a str,
432    transcript: &'a str,
433}
434
435impl DurableTurnEvidence {
436    pub(crate) fn try_new(
437        session_id: &str,
438        turn_id: &str,
439        prompt: &str,
440        response: &str,
441        transcript: &str,
442        occurred_at: DateTime<Utc>,
443    ) -> Result<Self, MemoryRepositoryError> {
444        let payload = TurnEvidencePayload {
445            schema: "a3s.code.memory.turn-evidence.v1",
446            session_id,
447            prompt,
448            response,
449            transcript,
450        };
451        let encoded =
452            serde_json::to_vec(&payload).map_err(|error| MemoryRepositoryError::InvalidInput {
453                field: "turnEvidence".into(),
454                message: error.to_string(),
455            })?;
456        let digest = format!("sha256:{:x}", Sha256::digest(encoded));
457        let session = utf8_percent_encode(session_id, NON_ALPHANUMERIC);
458        let turn = utf8_percent_encode(turn_id, NON_ALPHANUMERIC);
459        let reference = EvidenceRef::try_new(
460            format!("a3s://session/{session}/turn/{turn}"),
461            digest,
462            EvidenceKind::SessionTurn,
463            occurred_at,
464        )?;
465        Ok(Self {
466            reference,
467            occurred_at,
468        })
469    }
470}
471
472fn candidate_id(draft: &MemoryNodeDraft) -> Result<String, MemoryRepositoryError> {
473    let mut identity = draft.clone();
474    identity.id.clear();
475    let encoded =
476        serde_json::to_vec(&identity).map_err(|error| MemoryRepositoryError::InvalidInput {
477            field: "candidate".into(),
478            message: error.to_string(),
479        })?;
480    let mut hasher = Sha256::new();
481    hasher.update(b"a3s.code.memory.candidate.v1\0");
482    hasher.update(encoded);
483    Ok(format!("a3s-code-candidate-{:x}", hasher.finalize()))
484}
485
486#[cfg(test)]
487#[path = "durable_memory/tests.rs"]
488mod tests;