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 an opt-in binding that recalls only explicitly activated nodes.
155    ///
156    /// Extraction may still write evidence-backed V2 candidates into the same
157    /// namespace; only explicitly activated nodes are eligible for Active recall.
158    pub fn active_recall(
159        repository: Arc<dyn MemoryRepository>,
160        namespace: MemoryNamespace,
161        recall_policy: DurableMemoryRecallPolicy,
162    ) -> Self {
163        Self {
164            repository,
165            namespace,
166            mode: DurableMemoryMode::ActiveRecall,
167            recall_policy: Some(recall_policy),
168            semantic_recall: None,
169        }
170    }
171
172    /// Add a typed host-owned semantic generation to an Active recall binding.
173    pub fn with_semantic_recall(
174        mut self,
175        semantic_recall: DurableMemorySemanticRecall,
176    ) -> Result<Self, DurableMemorySemanticError> {
177        if self.mode != DurableMemoryMode::ActiveRecall || self.recall_policy.is_none() {
178            return Err(DurableMemorySemanticError::InvalidConfiguration {
179                field: "mode",
180                reason: "semantic recall requires an Active recall binding".to_string(),
181            });
182        }
183        self.semantic_recall = Some(semantic_recall);
184        Ok(self)
185    }
186
187    pub fn repository(&self) -> &Arc<dyn MemoryRepository> {
188        &self.repository
189    }
190
191    pub fn namespace(&self) -> &MemoryNamespace {
192        &self.namespace
193    }
194
195    pub fn mode(&self) -> DurableMemoryMode {
196        self.mode
197    }
198
199    pub fn recall_policy(&self) -> Option<DurableMemoryRecallPolicy> {
200        self.recall_policy
201    }
202
203    pub fn semantic_recall(&self) -> Option<&DurableMemorySemanticRecall> {
204        self.semantic_recall.as_ref()
205    }
206
207    /// Rebuild and verify this binding's semantic partition from one complete
208    /// current Active repository snapshot.
209    pub async fn refresh_semantic_recall(
210        &self,
211        cancellation: CancellationToken,
212    ) -> Result<DurableMemorySemanticRefreshReceipt, DurableMemorySemanticError> {
213        self.refresh_semantic_recall_requiring(
214            VectorMutationConsistency::PartitionAtomic,
215            cancellation,
216        )
217        .await
218    }
219
220    /// Rebuild semantic recall while requiring at least the selected vector
221    /// mutation ordering. An unsupported requirement fails before repository
222    /// snapshot or embedding work begins.
223    pub async fn refresh_semantic_recall_requiring(
224        &self,
225        required_consistency: VectorMutationConsistency,
226        cancellation: CancellationToken,
227    ) -> Result<DurableMemorySemanticRefreshReceipt, DurableMemorySemanticError> {
228        let semantic = self.semantic_recall.as_ref().ok_or_else(|| {
229            DurableMemorySemanticError::InvalidConfiguration {
230                field: "semanticRecall",
231                reason: "refresh requires an attached semantic recall generation".to_string(),
232            }
233        })?;
234        semantic
235            .refresh_repository_namespace(
236                self.repository.as_ref(),
237                &self.namespace,
238                required_consistency,
239                cancellation,
240            )
241            .await
242    }
243
244    /// Return the secret-free identity that must remain exact when a
245    /// persisted session is resumed.
246    pub fn binding(&self) -> DurableMemoryBindingV1 {
247        DurableMemoryBindingV1::new(
248            self.namespace.clone(),
249            self.mode,
250            self.recall_policy,
251            self.semantic_recall
252                .as_ref()
253                .map(|semantic| semantic.binding().clone()),
254        )
255    }
256
257    /// Activate one exact candidate revision with independent decision evidence.
258    pub async fn activate_candidate(
259        &self,
260        activation: DurableMemoryActivation,
261    ) -> Result<MemoryNode, MemoryRepositoryError> {
262        let result = self
263            .repository
264            .apply(MemoryChangeSet::new(
265                activation.idempotency_key,
266                self.namespace.clone(),
267                activation.occurred_at,
268                vec![MemoryOperation::Activate {
269                    node_id: activation.node_id.clone(),
270                    expected_revision: activation.expected_revision,
271                    evidence: vec![activation.decision_evidence],
272                }],
273            ))
274            .await?;
275        result
276            .nodes
277            .into_iter()
278            .find(|node| node.id == activation.node_id && node.status == MemoryStatus::Active)
279            .ok_or_else(|| MemoryRepositoryError::InvariantViolation {
280                message: "activation change returned no active target node".into(),
281            })
282    }
283
284    /// Record an explicit use without widening this binding's namespace.
285    pub async fn record_use(&self, usage: DurableMemoryUse) -> Result<(), MemoryRepositoryError> {
286        let mut event = MemoryAccessEvent::new(
287            usage.event_id,
288            self.namespace.clone(),
289            usage.node_id,
290            usage.node_revision,
291            usage.occurred_at,
292        );
293        if let Some(context_id) = usage.context_id {
294            event = event.with_context_id(context_id);
295        }
296        self.repository.record_use(event).await
297    }
298
299    pub(crate) async fn store_shadow_candidate(
300        &self,
301        item: &MemoryItem,
302        evidence: &DurableTurnEvidence,
303    ) -> Result<MemoryNode, MemoryRepositoryError> {
304        let kind = match item.memory_type {
305            MemoryType::Episodic => DurableMemoryKind::Episodic,
306            MemoryType::Semantic => DurableMemoryKind::Semantic,
307            MemoryType::Procedural => DurableMemoryKind::Procedural,
308            MemoryType::Working => {
309                return Err(MemoryRepositoryError::InvalidInput {
310                    field: "candidate.memoryType".into(),
311                    message: "working memory is not durable".into(),
312                });
313            }
314        };
315        let confidence = item
316            .metadata
317            .get("confidence")
318            .and_then(|value| value.parse::<f32>().ok())
319            .filter(|value| value.is_finite() && (0.0..=1.0).contains(value))
320            .unwrap_or(0.0);
321        let mut draft = MemoryNodeDraft::new(
322            "content-addressed-after-normalization",
323            self.namespace.clone(),
324            kind,
325            MemoryStatus::Candidate,
326            &item.content,
327            vec![evidence.reference.clone()],
328            evidence.occurred_at,
329        )
330        .with_confidence(confidence)
331        .with_importance(item.importance)
332        .with_label("a3s.origin", "code.llm_extraction");
333        for (source, label) in [
334            ("source", "a3s.extraction.source"),
335            ("scope", "a3s.extraction.scope"),
336            ("reason", "a3s.extraction.reason"),
337            ("schema", "a3s.extraction.schema"),
338        ] {
339            if let Some(value) = item.metadata.get(source) {
340                draft = draft.with_label(label, value);
341            }
342        }
343        if !item.tags.is_empty() {
344            let tags = serde_json::to_string(&item.tags).map_err(|error| {
345                MemoryRepositoryError::InvalidInput {
346                    field: "candidate.tags".into(),
347                    message: error.to_string(),
348                }
349            })?;
350            draft = draft.with_label("a3s.extraction.tags", tags);
351        }
352        draft.id = candidate_id(&draft)?;
353
354        let result = self
355            .repository
356            .apply(MemoryChangeSet::new(
357                draft.id.clone(),
358                self.namespace.clone(),
359                evidence.occurred_at,
360                vec![MemoryOperation::Create { node: draft }],
361            ))
362            .await?;
363        result
364            .nodes
365            .into_iter()
366            .next()
367            .ok_or_else(|| MemoryRepositoryError::InvariantViolation {
368                message: "candidate change returned no node".into(),
369            })
370    }
371}
372
373fn invalid(field: &str, message: impl Into<String>) -> MemoryRepositoryError {
374    MemoryRepositoryError::InvalidInput {
375        field: field.into(),
376        message: message.into(),
377    }
378}
379
380fn validate_identifier(field: &str, value: &str) -> Result<(), MemoryRepositoryError> {
381    if value.trim().is_empty() {
382        return Err(invalid(field, "must not be empty or whitespace"));
383    }
384    if value.len() > MAX_IDENTIFIER_BYTES {
385        return Err(invalid(
386            field,
387            format!("must not exceed {MAX_IDENTIFIER_BYTES} bytes"),
388        ));
389    }
390    Ok(())
391}
392
393impl std::fmt::Debug for DurableMemorySession {
394    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395        formatter
396            .debug_struct("DurableMemorySession")
397            .field("namespace", &self.namespace)
398            .field("mode", &self.mode)
399            .field("recall_policy", &self.recall_policy)
400            .field(
401                "semantic_recall",
402                &self
403                    .semantic_recall
404                    .as_ref()
405                    .map(DurableMemorySemanticRecall::binding),
406            )
407            .finish_non_exhaustive()
408    }
409}
410
411#[derive(Debug, Clone)]
412pub(crate) struct DurableTurnEvidence {
413    reference: EvidenceRef,
414    occurred_at: DateTime<Utc>,
415}
416
417#[derive(Serialize)]
418#[serde(rename_all = "camelCase")]
419struct TurnEvidencePayload<'a> {
420    schema: &'static str,
421    session_id: &'a str,
422    prompt: &'a str,
423    response: &'a str,
424    transcript: &'a str,
425}
426
427impl DurableTurnEvidence {
428    pub(crate) fn try_new(
429        session_id: &str,
430        turn_id: &str,
431        prompt: &str,
432        response: &str,
433        transcript: &str,
434        occurred_at: DateTime<Utc>,
435    ) -> Result<Self, MemoryRepositoryError> {
436        let payload = TurnEvidencePayload {
437            schema: "a3s.code.memory.turn-evidence.v1",
438            session_id,
439            prompt,
440            response,
441            transcript,
442        };
443        let encoded =
444            serde_json::to_vec(&payload).map_err(|error| MemoryRepositoryError::InvalidInput {
445                field: "turnEvidence".into(),
446                message: error.to_string(),
447            })?;
448        let digest = format!("sha256:{:x}", Sha256::digest(encoded));
449        let session = utf8_percent_encode(session_id, NON_ALPHANUMERIC);
450        let turn = utf8_percent_encode(turn_id, NON_ALPHANUMERIC);
451        let reference = EvidenceRef::try_new(
452            format!("a3s://session/{session}/turn/{turn}"),
453            digest,
454            EvidenceKind::SessionTurn,
455            occurred_at,
456        )?;
457        Ok(Self {
458            reference,
459            occurred_at,
460        })
461    }
462}
463
464fn candidate_id(draft: &MemoryNodeDraft) -> Result<String, MemoryRepositoryError> {
465    let mut identity = draft.clone();
466    identity.id.clear();
467    let encoded =
468        serde_json::to_vec(&identity).map_err(|error| MemoryRepositoryError::InvalidInput {
469            field: "candidate".into(),
470            message: error.to_string(),
471        })?;
472    let mut hasher = Sha256::new();
473    hasher.update(b"a3s.code.memory.candidate.v1\0");
474    hasher.update(encoded);
475    Ok(format!("a3s-code-candidate-{:x}", hasher.finalize()))
476}
477
478#[cfg(test)]
479#[path = "durable_memory/tests.rs"]
480mod tests;