1use serde::{Deserialize, Serialize};
4
5pub const MEMORY_TRANSITION_CANDIDATE_V1: &str = "memory_transition_candidate_v1";
6pub const MEMORY_TRANSITION_VERIFICATION_V1: &str = "memory_transition_verification_v1";
7pub const MEMORY_TRANSITION_RECORD_V1: &str = "memory_transition_record_v1";
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct SourceArtifactV1 {
12 pub artifact_id: String,
13 pub content: String,
14 pub content_digest: String,
15}
16
17impl SourceArtifactV1 {
18 pub fn new(artifact_id: impl Into<String>, content: impl Into<String>) -> Result<Self, String> {
19 let artifact_id = artifact_id.into();
20 let content = content.into();
21 if artifact_id.trim().is_empty() {
22 return Err("source artifact ID must not be empty".into());
23 }
24 let content_digest = blake3::hash(content.as_bytes()).to_hex().to_string();
25 Ok(Self {
26 artifact_id,
27 content,
28 content_digest,
29 })
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
35pub struct SourceSpanRefV1 {
36 pub artifact_id: String,
37 pub start_byte: usize,
38 pub end_byte: usize,
39}
40
41impl SourceSpanRefV1 {
42 pub fn new(
43 artifact_id: impl Into<String>,
44 start_byte: usize,
45 end_byte: usize,
46 ) -> Result<Self, String> {
47 let artifact_id = artifact_id.into();
48 if artifact_id.trim().is_empty() {
49 return Err("source span artifact ID must not be empty".into());
50 }
51 if start_byte >= end_byte {
52 return Err("source span range must be non-empty and increasing".into());
53 }
54 Ok(Self {
55 artifact_id,
56 start_byte,
57 end_byte,
58 })
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct AssertionDraftV1 {
65 pub assertion_id: String,
66 pub namespace: String,
67 pub content: String,
68 pub source_spans: Vec<SourceSpanRefV1>,
69 pub dependency_fact_ids: Vec<String>,
70}
71
72impl AssertionDraftV1 {
73 pub fn new(
74 assertion_id: impl Into<String>,
75 namespace: impl Into<String>,
76 content: impl Into<String>,
77 source_spans: Vec<SourceSpanRefV1>,
78 dependency_fact_ids: Vec<String>,
79 ) -> Result<Self, String> {
80 let value = Self {
81 assertion_id: assertion_id.into(),
82 namespace: namespace.into(),
83 content: content.into(),
84 source_spans,
85 dependency_fact_ids,
86 };
87 if value.assertion_id.trim().is_empty() {
88 return Err("assertion ID must not be empty".into());
89 }
90 if value.namespace.trim().is_empty() || value.content.is_empty() {
91 return Err("assertion namespace and content must not be empty".into());
92 }
93 if value.source_spans.is_empty() {
94 return Err("assertion must cite at least one exact source span".into());
95 }
96 if value
97 .dependency_fact_ids
98 .iter()
99 .any(|id| id.trim().is_empty())
100 {
101 return Err("dependency fact IDs must not be empty".into());
102 }
103 Ok(value)
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct SupersessionDraftV1 {
110 pub target_fact_id: String,
111 pub replacement_assertion_id: String,
112}
113
114impl SupersessionDraftV1 {
115 pub fn new(
116 target_fact_id: impl Into<String>,
117 replacement_assertion_id: impl Into<String>,
118 ) -> Result<Self, String> {
119 let value = Self {
120 target_fact_id: target_fact_id.into(),
121 replacement_assertion_id: replacement_assertion_id.into(),
122 };
123 if value.target_fact_id.trim().is_empty()
124 || value.replacement_assertion_id.trim().is_empty()
125 {
126 return Err("supersession target and replacement IDs must not be empty".into());
127 }
128 Ok(value)
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(tag = "kind", rename_all = "snake_case")]
134pub enum TransitionOperation {
135 Append {
136 assertion_id: String,
137 },
138 Supersede {
139 draft: SupersessionDraftV1,
140 },
141 Retract {
142 target_fact_id: String,
143 reason: String,
144 source_spans: Vec<SourceSpanRefV1>,
145 },
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct MemoryTransitionCandidateV1 {
151 pub schema_version: String,
152 pub candidate_id: String,
153 pub source_artifacts: Vec<SourceArtifactV1>,
154 pub required_source_spans: Vec<SourceSpanRefV1>,
155 pub assertions: Vec<AssertionDraftV1>,
156 pub operation: TransitionOperation,
157 pub preserved_active_fact_ids: Vec<String>,
159}
160
161impl MemoryTransitionCandidateV1 {
162 #[allow(clippy::too_many_arguments)]
163 pub fn new(
164 candidate_id: impl Into<String>,
165 source_artifacts: Vec<SourceArtifactV1>,
166 required_source_spans: Vec<SourceSpanRefV1>,
167 assertions: Vec<AssertionDraftV1>,
168 operation: TransitionOperation,
169 preserved_active_fact_ids: Vec<String>,
170 ) -> Result<Self, String> {
171 let value = Self {
172 schema_version: MEMORY_TRANSITION_CANDIDATE_V1.into(),
173 candidate_id: candidate_id.into(),
174 source_artifacts,
175 required_source_spans,
176 assertions,
177 operation,
178 preserved_active_fact_ids,
179 };
180 value.validate()?;
181 Ok(value)
182 }
183
184 pub fn validate(&self) -> Result<(), String> {
185 if self.schema_version != MEMORY_TRANSITION_CANDIDATE_V1 {
186 return Err(format!(
187 "unsupported transition candidate schema '{}'",
188 self.schema_version
189 ));
190 }
191 if self.candidate_id.trim().is_empty() {
192 return Err("transition candidate ID must not be empty".into());
193 }
194 if self.source_artifacts.is_empty() {
195 return Err("transition candidate must retain at least one source artifact".into());
196 }
197 let artifacts: std::collections::BTreeMap<&str, &SourceArtifactV1> = self
198 .source_artifacts
199 .iter()
200 .map(|artifact| (artifact.artifact_id.as_str(), artifact))
201 .collect();
202 if artifacts.len() != self.source_artifacts.len() {
203 return Err("source artifact IDs must be unique".into());
204 }
205 for artifact in &self.source_artifacts {
206 if artifact.artifact_id.trim().is_empty() {
207 return Err("source artifact ID must not be empty".into());
208 }
209 let digest = blake3::hash(artifact.content.as_bytes())
210 .to_hex()
211 .to_string();
212 if digest != artifact.content_digest {
213 return Err(format!(
214 "source artifact '{}' content digest mismatch",
215 artifact.artifact_id
216 ));
217 }
218 }
219 if self.required_source_spans.is_empty() {
220 return Err("transition candidate must declare required source spans".into());
221 }
222 if self
223 .preserved_active_fact_ids
224 .iter()
225 .any(|id| id.trim().is_empty())
226 {
227 return Err("preserved active fact IDs must not be empty".into());
228 }
229 let mut all_spans: Vec<&SourceSpanRefV1> = self.required_source_spans.iter().collect();
230 all_spans.extend(
231 self.assertions
232 .iter()
233 .flat_map(|assertion| assertion.source_spans.iter()),
234 );
235 if let TransitionOperation::Retract { source_spans, .. } = &self.operation {
236 all_spans.extend(source_spans);
237 }
238 for span in all_spans {
239 let artifact = artifacts.get(span.artifact_id.as_str()).ok_or_else(|| {
240 format!(
241 "source span references missing source artifact '{}'",
242 span.artifact_id
243 )
244 })?;
245 if span.start_byte >= span.end_byte
246 || span.end_byte > artifact.content.len()
247 || !artifact.content.is_char_boundary(span.start_byte)
248 || !artifact.content.is_char_boundary(span.end_byte)
249 {
250 return Err(format!(
251 "source span {}:{}..{} is outside UTF-8 boundaries",
252 span.artifact_id, span.start_byte, span.end_byte
253 ));
254 }
255 }
256 let assertion_ids: std::collections::BTreeSet<&str> = self
257 .assertions
258 .iter()
259 .map(|assertion| assertion.assertion_id.as_str())
260 .collect();
261 if assertion_ids.len() != self.assertions.len() {
262 return Err("assertion IDs must be unique".into());
263 }
264 let assertion_id = match &self.operation {
265 TransitionOperation::Append { assertion_id } => Some(assertion_id.as_str()),
266 TransitionOperation::Supersede { draft } => {
267 Some(draft.replacement_assertion_id.as_str())
268 }
269 TransitionOperation::Retract {
270 target_fact_id,
271 reason,
272 source_spans,
273 } => {
274 if target_fact_id.trim().is_empty()
275 || reason.trim().is_empty()
276 || source_spans.is_empty()
277 {
278 return Err(
279 "retraction requires a target, reason, and exact source spans".into(),
280 );
281 }
282 None
283 }
284 };
285 if let Some(assertion_id) = assertion_id {
286 let matching = self
287 .assertions
288 .iter()
289 .filter(|draft| draft.assertion_id == assertion_id)
290 .count();
291 if matching != 1 {
292 return Err("operation must reference exactly one assertion draft".into());
293 }
294 }
295 Ok(())
296 }
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(rename_all = "snake_case")]
301pub enum TransitionDisposition {
302 Commit,
303 Quarantine,
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
307pub struct VerificationScore {
308 pub satisfied: u64,
309 pub total: u64,
310}
311
312impl VerificationScore {
313 pub fn is_complete(self) -> bool {
314 self.satisfied == self.total
315 }
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct OmittedSourceSpanV1 {
320 pub span: SourceSpanRefV1,
321 pub exact_text: String,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325pub struct UnsupportedAssertionSpanV1 {
326 pub assertion_id: String,
327 pub start_byte: usize,
328 pub end_byte: usize,
329 pub exact_text: String,
330 pub reason: String,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334pub struct ActiveHeadSimulationV1 {
335 pub consistent: bool,
336 pub replaced_fact_id: Option<String>,
337 pub projected_active_fact_ids: Vec<String>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct DependencySimulationV1 {
342 pub requested_fact_ids: Vec<String>,
343 pub missing_or_inactive_fact_ids: Vec<String>,
344 pub preserved_edge_ids: Vec<String>,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349pub struct MemoryTransitionVerificationV1 {
350 pub schema_version: String,
351 pub candidate_id: String,
352 pub candidate_digest: String,
353 pub source_digest: String,
354 pub coverage: VerificationScore,
355 pub preservation: VerificationScore,
356 pub faithfulness: VerificationScore,
357 pub omitted_spans: Vec<OmittedSourceSpanV1>,
358 pub unsupported_spans: Vec<UnsupportedAssertionSpanV1>,
359 pub omitted_active_fact_ids: Vec<String>,
360 pub diagnostics: Vec<String>,
361 pub active_head_simulation: ActiveHeadSimulationV1,
362 pub dependency_simulation: DependencySimulationV1,
363 pub disposition: TransitionDisposition,
364 pub verification_digest: String,
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct MemoryTransitionRecordV1 {
370 pub schema_version: String,
371 pub record_id: String,
372 pub caller_idempotency_key: String,
373 pub principal: String,
374 pub caller_id: String,
375 pub candidate_digest: String,
376 pub candidate: MemoryTransitionCandidateV1,
377 pub verification: MemoryTransitionVerificationV1,
378 pub disposition: TransitionDisposition,
379 pub authority_receipt_id: Option<String>,
380 pub created_at: String,
381}
382
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(tag = "disposition", rename_all = "snake_case")]
385#[allow(clippy::large_enum_variant)] pub enum MemoryTransitionOutcomeV1 {
387 Committed {
388 record: MemoryTransitionRecordV1,
389 verification: MemoryTransitionVerificationV1,
390 authority_receipt: crate::AuthorityReceiptV1,
391 },
392 Quarantined {
393 record: MemoryTransitionRecordV1,
394 },
395}