Skip to main content

a3s_code_core/durable_memory/
binding.rs

1use super::{
2    invalid, DurableMemoryMode, DurableMemoryRecallPolicy, DurableMemorySemanticBindingV1,
3    DURABLE_MEMORY_CONTEXT_ID_PROFILE_V1, DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2,
4};
5use a3s_memory::repository::{
6    MemoryNamespace, MemoryRepositoryError, MEMORY_LEXICAL_QUERY_PROFILE_V1,
7};
8use serde::{Deserialize, Serialize};
9
10/// Schema version for the secret-free durable-memory binding persisted with a
11/// session snapshot.
12pub const DURABLE_MEMORY_BINDING_SCHEMA_VERSION: u32 = 4;
13
14/// Schema version for sessions that bind an exact semantic serving generation.
15pub const DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION: u32 = 5;
16
17/// Current A3S Memory query algorithm bound into new durable-memory sessions.
18pub const DURABLE_MEMORY_RETRIEVAL_PROFILE_V1: &str = MEMORY_LEXICAL_QUERY_PROFILE_V1;
19
20const LEGACY_WORD_RETRIEVAL_PROFILE_V1: &str = "a3s.memory.lexical.word.v1";
21const LEGACY_HOST_CONTEXT_ID_PROFILE_V0: &str = "a3s.code.memory.context.host-id.v0";
22const LEGACY_SESSION_RUN_CONTEXT_BINDING_SCHEMA_VERSION: u32 = 3;
23const LEGACY_RETRIEVAL_BINDING_SCHEMA_VERSION: u32 = 2;
24const LEGACY_BASE_BINDING_SCHEMA_VERSION: u32 = 1;
25
26fn legacy_retrieval_profile() -> String {
27    LEGACY_WORD_RETRIEVAL_PROFILE_V1.to_string()
28}
29
30fn legacy_context_id_profile() -> String {
31    LEGACY_HOST_CONTEXT_ID_PROFILE_V0.to_string()
32}
33
34/// Exact Code-visible durable-memory authority and serving policy.
35///
36/// The live repository is intentionally absent. Hosts own repository
37/// construction and must re-inject it after restart, while Code persists this
38/// descriptor to reject namespace, mode, recall-policy, query-semantics, or
39/// admission-identity drift.
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct DurableMemoryBindingV1 {
43    schema_version: u32,
44    namespace: MemoryNamespace,
45    mode: DurableMemoryMode,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    recall_policy: Option<DurableMemoryRecallPolicy>,
48    #[serde(default = "legacy_retrieval_profile")]
49    retrieval_profile: String,
50    #[serde(default = "legacy_context_id_profile")]
51    context_id_profile: String,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    semantic_recall: Option<DurableMemorySemanticBindingV1>,
54}
55
56impl DurableMemoryBindingV1 {
57    pub(super) fn new(
58        namespace: MemoryNamespace,
59        mode: DurableMemoryMode,
60        recall_policy: Option<DurableMemoryRecallPolicy>,
61        semantic_recall: Option<DurableMemorySemanticBindingV1>,
62    ) -> Self {
63        let schema_version = if semantic_recall.is_some() {
64            DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION
65        } else {
66            DURABLE_MEMORY_BINDING_SCHEMA_VERSION
67        };
68        Self {
69            schema_version,
70            namespace,
71            mode,
72            recall_policy,
73            retrieval_profile: DURABLE_MEMORY_RETRIEVAL_PROFILE_V1.to_string(),
74            context_id_profile: DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2.to_string(),
75            semantic_recall,
76        }
77    }
78
79    pub fn schema_version(&self) -> u32 {
80        self.schema_version
81    }
82
83    pub fn namespace(&self) -> &MemoryNamespace {
84        &self.namespace
85    }
86
87    pub fn mode(&self) -> DurableMemoryMode {
88        self.mode
89    }
90
91    pub fn recall_policy(&self) -> Option<DurableMemoryRecallPolicy> {
92        self.recall_policy
93    }
94
95    pub fn retrieval_profile(&self) -> &str {
96        &self.retrieval_profile
97    }
98
99    pub fn context_id_profile(&self) -> &str {
100        &self.context_id_profile
101    }
102
103    pub fn semantic_recall(&self) -> Option<&DurableMemorySemanticBindingV1> {
104        self.semantic_recall.as_ref()
105    }
106
107    pub(crate) fn validate(&self) -> Result<(), MemoryRepositoryError> {
108        if self.retrieval_profile != DURABLE_MEMORY_RETRIEVAL_PROFILE_V1
109            && self.retrieval_profile != LEGACY_WORD_RETRIEVAL_PROFILE_V1
110        {
111            return Err(invalid(
112                "durableMemoryBinding.retrievalProfile",
113                format!(
114                    "unrecognized durable-memory retrieval profile `{}`",
115                    self.retrieval_profile
116                ),
117            ));
118        }
119        if self.context_id_profile != DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2
120            && self.context_id_profile != DURABLE_MEMORY_CONTEXT_ID_PROFILE_V1
121            && self.context_id_profile != LEGACY_HOST_CONTEXT_ID_PROFILE_V0
122        {
123            return Err(invalid(
124                "durableMemoryBinding.contextIdProfile",
125                format!(
126                    "unrecognized durable-memory context identity profile `{}`",
127                    self.context_id_profile
128                ),
129            ));
130        }
131        let (expected_retrieval_profile, expected_context_id_profile, expects_semantic) = match self
132            .schema_version
133        {
134            DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION => (
135                DURABLE_MEMORY_RETRIEVAL_PROFILE_V1,
136                DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2,
137                true,
138            ),
139            DURABLE_MEMORY_BINDING_SCHEMA_VERSION => (
140                DURABLE_MEMORY_RETRIEVAL_PROFILE_V1,
141                DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2,
142                false,
143            ),
144            LEGACY_SESSION_RUN_CONTEXT_BINDING_SCHEMA_VERSION => (
145                DURABLE_MEMORY_RETRIEVAL_PROFILE_V1,
146                DURABLE_MEMORY_CONTEXT_ID_PROFILE_V1,
147                false,
148            ),
149            LEGACY_RETRIEVAL_BINDING_SCHEMA_VERSION => (
150                DURABLE_MEMORY_RETRIEVAL_PROFILE_V1,
151                LEGACY_HOST_CONTEXT_ID_PROFILE_V0,
152                false,
153            ),
154            LEGACY_BASE_BINDING_SCHEMA_VERSION => (
155                LEGACY_WORD_RETRIEVAL_PROFILE_V1,
156                LEGACY_HOST_CONTEXT_ID_PROFILE_V0,
157                false,
158            ),
159            _ => {
160                return Err(invalid(
161                    "durableMemoryBinding.schemaVersion",
162                    format!(
163                        "unsupported schema version {}; expected {} or {}, legacy {}, legacy {}, or legacy {}",
164                        self.schema_version,
165                        DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION,
166                        DURABLE_MEMORY_BINDING_SCHEMA_VERSION,
167                        LEGACY_SESSION_RUN_CONTEXT_BINDING_SCHEMA_VERSION,
168                        LEGACY_RETRIEVAL_BINDING_SCHEMA_VERSION,
169                        LEGACY_BASE_BINDING_SCHEMA_VERSION
170                    ),
171                ));
172            }
173        };
174        if self.retrieval_profile != expected_retrieval_profile {
175            return Err(invalid(
176                "durableMemoryBinding.retrievalProfile",
177                format!(
178                    "retrieval profile `{}` is incompatible with schema version {}",
179                    self.retrieval_profile, self.schema_version
180                ),
181            ));
182        }
183        if self.context_id_profile != expected_context_id_profile {
184            return Err(invalid(
185                "durableMemoryBinding.contextIdProfile",
186                format!(
187                    "context identity profile `{}` is incompatible with schema version {}",
188                    self.context_id_profile, self.schema_version
189                ),
190            ));
191        }
192        MemoryNamespace::try_new(
193            self.namespace.tenant_id(),
194            self.namespace.principal_id(),
195            self.namespace.scope_id(),
196        )?;
197        match (expects_semantic, self.semantic_recall.as_ref()) {
198            (true, Some(semantic)) => {
199                semantic.validate().map_err(|error| {
200                    invalid("durableMemoryBinding.semanticRecall", error.to_string())
201                })?;
202                if self.mode != DurableMemoryMode::ActiveRecall {
203                    return Err(invalid(
204                        "durableMemoryBinding.mode",
205                        "semantic recall requires active recall mode",
206                    ));
207                }
208            }
209            (true, None) => {
210                return Err(invalid(
211                    "durableMemoryBinding.semanticRecall",
212                    "hybrid schema requires an exact semantic recall binding",
213                ));
214            }
215            (false, Some(_)) => {
216                return Err(invalid(
217                    "durableMemoryBinding.semanticRecall",
218                    "semantic recall is incompatible with this schema version",
219                ));
220            }
221            (false, None) => {}
222        }
223        match (self.mode, self.recall_policy) {
224            (DurableMemoryMode::ActiveRecall, Some(policy)) => {
225                DurableMemoryRecallPolicy::try_new(
226                    policy.max_results(),
227                    policy.min_lexical_score(),
228                )?
229                .try_with_related_lookups(policy.max_related_lookups())?;
230                Ok(())
231            }
232            (DurableMemoryMode::ActiveRecall, None) => Err(invalid(
233                "durableMemoryBinding.recallPolicy",
234                "active recall mode requires a recall policy",
235            )),
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::durable_memory::{
244        DurableMemorySemanticRecall, DurableMemorySemanticRecallPolicy, DurableMemorySession,
245    };
246    use crate::embedding::{
247        EmbeddingBatchRequest, EmbeddingBatchResponse, EmbeddingExecutorConfig,
248        EmbeddingNormalization, EmbeddingProvider, EmbeddingProviderDescriptor,
249        EmbeddingProviderError,
250    };
251    use a3s_memory::repository::InMemoryRepository;
252    use a3s_memory::vector::{InMemoryVectorIndex, VectorIndex, VectorIndexDescriptor};
253    use std::sync::Arc;
254    use tokio_util::sync::CancellationToken;
255
256    struct DescriptorOnlyProvider;
257
258    #[async_trait::async_trait]
259    impl EmbeddingProvider for DescriptorOnlyProvider {
260        fn descriptor(&self) -> EmbeddingProviderDescriptor {
261            EmbeddingProviderDescriptor::new("fixture", "semantic-binding", 2)
262                .with_revision("fixture-r1")
263                .with_normalization(EmbeddingNormalization::Unit)
264        }
265
266        async fn embed(
267            &self,
268            _request: EmbeddingBatchRequest,
269            _cancellation: CancellationToken,
270        ) -> Result<EmbeddingBatchResponse, EmbeddingProviderError> {
271            Err(EmbeddingProviderError::InvalidRequest)
272        }
273    }
274
275    fn hybrid_binding() -> DurableMemoryBindingV1 {
276        let namespace = MemoryNamespace::try_new("tenant", "principal", "semantic").unwrap();
277        let repository = Arc::new(InMemoryRepository::new());
278        let index: Arc<dyn VectorIndex> =
279            Arc::new(InMemoryVectorIndex::new(VectorIndexDescriptor::new(2)).unwrap());
280        let semantic = DurableMemorySemanticRecall::new(
281            format!("sha256:{}", "a".repeat(64)),
282            Arc::new(DescriptorOnlyProvider),
283            EmbeddingExecutorConfig::default(),
284            index,
285            DurableMemorySemanticRecallPolicy::try_new(8, 0.7).unwrap(),
286        )
287        .unwrap();
288        DurableMemorySession::active_recall(
289            repository,
290            namespace,
291            DurableMemoryRecallPolicy::try_new(4, 0.2).unwrap(),
292        )
293        .with_semantic_recall(semantic)
294        .unwrap()
295        .binding()
296    }
297
298    #[test]
299    fn hybrid_schema_requires_a_valid_semantic_binding_and_active_mode() {
300        let binding = hybrid_binding();
301        assert_eq!(
302            binding.schema_version(),
303            DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION
304        );
305        binding.validate().unwrap();
306
307        let encoded = serde_json::to_value(&binding).unwrap();
308        let mut missing = encoded.clone();
309        missing.as_object_mut().unwrap().remove("semanticRecall");
310        let missing: DurableMemoryBindingV1 = serde_json::from_value(missing).unwrap();
311        assert!(missing.validate().is_err());
312
313        let mut legacy_with_semantic = encoded.clone();
314        legacy_with_semantic["schemaVersion"] =
315            serde_json::json!(DURABLE_MEMORY_BINDING_SCHEMA_VERSION);
316        let legacy_with_semantic: DurableMemoryBindingV1 =
317            serde_json::from_value(legacy_with_semantic).unwrap();
318        assert!(legacy_with_semantic.validate().is_err());
319
320        let mut shadow = encoded.clone();
321        shadow["mode"] = serde_json::json!("shadow_candidates");
322        assert!(
323            serde_json::from_value::<DurableMemoryBindingV1>(shadow).is_err(),
324            "shadow_candidates mode must fail closed at decode after HARNESS-CONV4"
325        );
326
327        let mut unsupported_fusion = encoded;
328        unsupported_fusion["semanticRecall"]["fusionProfile"] =
329            serde_json::json!("a3s.code.memory.hybrid.unknown.v1");
330        let unsupported_fusion: DurableMemoryBindingV1 =
331            serde_json::from_value(unsupported_fusion).unwrap();
332        assert!(unsupported_fusion.validate().is_err());
333
334        let mut unpinned_embedding = serde_json::to_value(&binding).unwrap();
335        unpinned_embedding["semanticRecall"]["embedding"]["revision"] = serde_json::Value::Null;
336        let unpinned_embedding: DurableMemoryBindingV1 =
337            serde_json::from_value(unpinned_embedding).unwrap();
338        assert!(unpinned_embedding.validate().is_err());
339
340        let mut mismatched_dimension = serde_json::to_value(&binding).unwrap();
341        mismatched_dimension["semanticRecall"]["vectorIndex"]["dimension"] = serde_json::json!(3);
342        let mismatched_dimension: DurableMemoryBindingV1 =
343            serde_json::from_value(mismatched_dimension).unwrap();
344        assert!(mismatched_dimension.validate().is_err());
345    }
346
347    #[test]
348    fn semantic_generation_identity_round_trips_and_detects_drift() {
349        let binding = hybrid_binding();
350        let encoded = serde_json::to_value(&binding).unwrap();
351        let round_trip: DurableMemoryBindingV1 = serde_json::from_value(encoded.clone()).unwrap();
352        assert_eq!(round_trip, binding);
353
354        let mut drifted = encoded;
355        drifted["semanticRecall"]["embedding"]["model"] = serde_json::json!("semantic-binding-v2");
356        let drifted: DurableMemoryBindingV1 = serde_json::from_value(drifted).unwrap();
357        drifted.validate().unwrap();
358        assert_ne!(drifted, binding);
359    }
360}