1use super::MemoryBinding;
13use agent_core::context_archive;
14use agent_core::memory::store::{
15 MemoryProvenance, MemoryRecord, MemoryRetention, MemorySensitivity, ProjectScope,
16 MAX_CONTENT_BYTES,
17};
18use anyhow::{bail, Context, Result};
19use serde::{Deserialize, Serialize};
20use serde_json::{json, Value};
21use sha2::{Digest, Sha256};
22use std::collections::{BTreeMap, BTreeSet};
23use std::io::{Read, Seek, SeekFrom};
24use std::path::Path;
25
26pub const MAX_APPLY_BYTES: usize = 24 * 1024 * 1024;
28const MAX_SOURCE_BYTES: usize = MAX_APPLY_BYTES;
29const MAX_SOURCE_LINES: usize = 100_000;
30const FORMAT: &str = "synaps-legacy-axel-migration/1";
31
32#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
34pub struct MigrationManifest {
35 pub format: &'static str,
36 pub source: &'static str,
37 pub project: String,
38 pub source_project: Option<String>,
39 pub captures: usize,
40 pub fingerprints: usize,
41 pub migration_id: String,
42 pub manifest_digest: String,
43 pub source_jsonl_bytes: usize,
44 pub source_jsonl_present: bool,
45 pub archive_inventory_bytes: usize,
46 pub records: usize,
47 pub tombstones: usize,
48 pub tombstone_suppressed_records: usize,
49 pub excluded_unscoped_records: usize,
50 pub excluded_foreign_records: usize,
51 pub duplicate_records: usize,
52 pub secret_records: usize,
53 pub sensitive_records: usize,
54 pub histories: usize,
55 pub history_tombstones: usize,
56 pub apply_frame_bytes: usize,
57}
58
59struct Snapshot {
61 manifest: MigrationManifest,
62 payload: Value,
63 source: Option<std::fs::File>,
64 raw: Vec<u8>,
65 histories: Vec<Value>,
66}
67
68pub fn preview_in(base: &Path, scope: &ProjectScope) -> Result<MigrationManifest> {
70 Ok(snapshot_in(base, scope)?.manifest)
71}
72
73pub async fn apply(binding: &MemoryBinding, manifest_digest: &str) -> Result<Value> {
77 if !is_digest(manifest_digest) {
78 bail!("--manifest-digest must be the exact 64-character lowercase preview SHA-256");
79 }
80 if !binding.is_axel() {
81 bail!("migration requires an explicitly configured Axel destination; no fallback");
82 }
83 let scope = binding.scope()?;
84 let mut snapshot = snapshot_in(binding.base(), scope)?;
85 snapshot.verify_digest(manifest_digest)?;
86 snapshot.verify_unchanged(binding.base(), scope)?;
87 let result = binding
88 .rpc("migration_apply", snapshot.payload.clone())
89 .await
90 .with_context(|| format!(
91 "migration outcome unconfirmed (migration_id={}, manifest_digest={}); source/config unchanged; reconcile or retry this exact manifest, never create replacement IDs",
92 snapshot.manifest.migration_id, snapshot.manifest.manifest_digest
93 ))?;
94 drop(snapshot);
96 Ok(result)
97}
98
99impl Snapshot {
100 fn verify_digest(&self, digest: &str) -> Result<()> {
101 if digest != self.manifest.manifest_digest {
102 bail!("migration source changed or preview digest mismatched; preview again (nothing imported)");
103 }
104 Ok(())
105 }
106
107 fn verify_unchanged(&mut self, base: &Path, scope: &ProjectScope) -> Result<()> {
108 if let Some(source) = self.source.as_mut() {
109 source.seek(SeekFrom::Start(0))?;
110 if read_bounded(source)? != self.raw {
111 bail!("migration source changed during read; preview again (nothing imported)");
112 }
113 }
114 let current = open_source(base, scope)?;
117 if current.is_some() != self.source.is_some() {
118 bail!("migration source changed during read; preview again (nothing imported)");
119 }
120 if let Some(mut file) = current {
121 #[cfg(unix)]
122 {
123 use std::os::unix::fs::MetadataExt;
124 let held = self.source.as_ref().expect("presence checked").metadata()?;
125 let now = file.metadata()?;
126 if (held.dev(), held.ino()) != (now.dev(), now.ino()) {
127 bail!(
128 "migration source replaced during read; preview again (nothing imported)"
129 );
130 }
131 }
132 if read_bounded(&mut file)? != self.raw {
133 bail!("migration source changed during read; preview again (nothing imported)");
134 }
135 }
136 if archive_inventory(base, scope)? != self.histories {
137 bail!("migration archives changed during read; preview again (nothing imported)");
138 }
139 Ok(())
140 }
141}
142
143fn snapshot_in(base: &Path, scope: &ProjectScope) -> Result<Snapshot> {
144 let mut source = open_source(base, scope)?;
145 if let Some(file) = &source {
146 if !fs4::fs_std::FileExt::try_lock_shared(file).context("cannot lock migration source")? {
147 bail!("migration source is locked; stop legacy writers and retry");
148 }
149 }
150 let raw = match &mut source {
151 Some(file) => read_bounded(file)?,
152 None => Vec::new(),
153 };
154 let parsed = parse_jsonl(&raw, scope)?;
155 if parsed.records.len() + parsed.tombstones.len() > 16384 {
156 bail!("migration exceeds single-transaction 16384 note/tombstone limit; no truncation supported");
157 }
158 let histories = archive_inventory(base, scope)?;
159 let archive_bytes = serde_json::to_vec(&histories)?;
160 let presence = [u8::from(source.is_some())];
161 let digest = hash_parts(&[
162 FORMAT.as_bytes(),
163 base.as_os_str().as_encoded_bytes(),
164 scope.key().as_bytes(),
165 &presence,
166 &raw,
167 &archive_bytes,
168 ]);
169 let migration_id = hash_parts(&[FORMAT.as_bytes(), b"apply", digest.as_bytes()]);
170 let history_tombstones = histories.iter().filter(|h| h["tombstone"] == true).count();
171 let mut manifest = MigrationManifest {
172 format: FORMAT,
173 source: "builtin_project_jsonl_and_context_archives",
174 project: scope.key().into(),
175 source_project: None,
176 captures: 0,
177 fingerprints: 0,
178 migration_id: migration_id.clone(),
179 manifest_digest: digest.clone(),
180 source_jsonl_bytes: raw.len(),
181 source_jsonl_present: source.is_some(),
182 archive_inventory_bytes: archive_bytes.len(),
183 records: parsed.records.len(),
184 tombstones: parsed.tombstones.len(),
185 tombstone_suppressed_records: parsed.suppressed,
186 excluded_unscoped_records: parsed.unscoped,
187 excluded_foreign_records: parsed.foreign,
188 duplicate_records: parsed.duplicates,
189 secret_records: parsed
190 .records
191 .iter()
192 .filter(|r| r.sensitivity == Some(MemorySensitivity::Secret))
193 .count(),
194 sensitive_records: parsed
195 .records
196 .iter()
197 .filter(|r| r.sensitivity == Some(MemorySensitivity::Sensitive))
198 .count(),
199 histories: histories.len() - history_tombstones,
200 history_tombstones,
201 apply_frame_bytes: 0,
202 };
203 let payload = json!({
204 "migration_id": migration_id,
205 "manifest_digest": digest,
206 "records": parsed.records,
207 "tombstones": parsed.tombstones,
208 "histories": histories,
209 });
210 manifest.apply_frame_bytes = check_frame_bound(scope.key(), &payload)?;
211 Ok(Snapshot {
212 manifest,
213 payload,
214 source,
215 raw,
216 histories,
217 })
218}
219
220fn check_frame_bound(project: &str, payload: &Value) -> Result<usize> {
221 let bytes = serde_json::to_vec(&json!({
222 "schema": super::CONTRACT, "project": project,
223 "operation": "migration_apply", "payload": payload,
224 }))?
225 .len()
226 + 1;
227 if bytes > MAX_APPLY_BYTES {
228 bail!("migration exceeds the single-transaction 24 MiB limit; no truncation or partial import is supported");
229 }
230 Ok(bytes)
231}
232
233fn archive_inventory(base: &Path, scope: &ProjectScope) -> Result<Vec<Value>> {
234 let mut values = context_archive::export_in(base, scope.key())
237 .context("cannot read validated legacy archive inventory")?;
238 values.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
239 Ok(values)
240}
241
242#[cfg(unix)]
243fn open_source(base: &Path, scope: &ProjectScope) -> Result<Option<std::fs::File>> {
244 use agent_core::core::private_fs::ConfinedDir;
245 if !base.is_absolute() || base == Path::new("/") {
246 bail!("migration base must be an absolute non-root no-symlink path");
247 }
248 let open = || {
249 ConfinedDir::open_absolute_no_symlinks(base)?
250 .open_file(&["memory".into(), format!("{}.jsonl", scope.namespace())])
251 };
252 match open() {
253 Ok(file) => Ok(Some(file)),
254 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
255 Err(_) => bail!("cannot open project migration source read-only/nofollow"),
256 }
257}
258
259#[cfg(not(unix))]
260fn open_source(_: &Path, _: &ProjectScope) -> Result<Option<std::fs::File>> {
261 bail!("safe legacy migration requires Unix nofollow filesystem support")
262}
263
264fn read_bounded(file: &mut std::fs::File) -> Result<Vec<u8>> {
265 if file.metadata()?.len() > MAX_SOURCE_BYTES as u64 {
266 bail!("legacy JSONL source exceeds 24 MiB; no truncated preview/import is supported");
267 }
268 let mut raw = Vec::new();
269 file.take(MAX_SOURCE_BYTES as u64 + 1)
270 .read_to_end(&mut raw)?;
271 if raw.len() > MAX_SOURCE_BYTES {
272 bail!("legacy JSONL source exceeds 24 MiB; no truncated preview/import is supported");
273 }
274 Ok(raw)
275}
276
277pub(super) fn is_digest(value: &str) -> bool {
278 value.len() == 64
279 && value
280 .bytes()
281 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
282}
283fn valid_id(value: &str) -> bool {
284 !value.is_empty()
285 && value.len() <= 128
286 && value
287 .bytes()
288 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
289}
290fn hash_parts(parts: &[&[u8]]) -> String {
291 let mut hash = Sha256::new();
292 for part in parts {
293 hash.update((part.len() as u64).to_le_bytes());
294 hash.update(part);
295 }
296 format!("{:x}", hash.finalize())
297}
298
299#[derive(Deserialize)]
300#[serde(deny_unknown_fields)]
301struct SourceRecord {
302 namespace: String,
303 timestamp_ms: u64,
304 content: String,
305 #[serde(default)]
306 tags: Vec<String>,
307 meta: Option<Value>,
308 id: Option<String>,
309 project: Option<String>,
310 provenance: Option<SourceProvenance>,
311 sensitivity: Option<MemorySensitivity>,
312 retention: Option<MemoryRetention>,
313}
314#[derive(Deserialize)]
315#[serde(deny_unknown_fields)]
316struct SourceProvenance {
317 source: String,
318 session: Option<String>,
319}
320#[derive(Deserialize)]
321#[serde(deny_unknown_fields)]
322struct SourceTombstone {
323 tombstone: String,
324 timestamp_ms: u64,
325}
326
327struct Parsed {
328 records: Vec<MemoryRecord>,
329 tombstones: BTreeSet<String>,
330 suppressed: usize,
331 unscoped: usize,
332 foreign: usize,
333 duplicates: usize,
334}
335
336fn parse_jsonl(raw: &[u8], scope: &ProjectScope) -> Result<Parsed> {
337 let text = std::str::from_utf8(raw).context("legacy migration source is not UTF-8")?;
338 let mut records = BTreeMap::new();
339 let mut parsed = Parsed {
340 records: Vec::new(),
341 tombstones: BTreeSet::new(),
342 suppressed: 0,
343 unscoped: 0,
344 foreign: 0,
345 duplicates: 0,
346 };
347 for (index, line) in text.lines().enumerate() {
348 if index >= MAX_SOURCE_LINES {
349 bail!("legacy migration source exceeds 100000 lines; no truncation supported");
350 }
351 if line.trim().is_empty() {
352 continue;
353 }
354 let fail = || {
355 anyhow::anyhow!("invalid or unsupported legacy migration record on line {}; no source content is included in this error", index + 1)
356 };
357 let value: Value = serde_json::from_str(line).map_err(|_| fail())?;
360 if value.get("tombstone").is_some() {
361 let tomb: SourceTombstone = serde_json::from_str(line).map_err(|_| fail())?;
362 if !valid_id(&tomb.tombstone) || tomb.timestamp_ms > i64::MAX as u64 {
363 return Err(fail());
364 }
365 parsed.tombstones.insert(tomb.tombstone);
366 continue;
367 }
368 let r: SourceRecord = serde_json::from_str(line).map_err(|_| fail())?;
369 if r.meta.is_some() {
372 return Err(fail());
373 }
374 if r.project.is_none() || r.id.is_none() {
375 parsed.unscoped += 1;
376 continue;
377 }
378 if r.project.as_deref() != Some(scope.key()) {
379 parsed.foreign += 1;
380 continue;
381 }
382 let id = r.id.as_deref().expect("checked");
383 if r.namespace != scope.namespace()
384 || !valid_id(id)
385 || r.content.len() > MAX_CONTENT_BYTES
386 || r.sensitivity.is_none()
387 || r.retention.is_none()
388 || r.provenance.is_none()
389 || r.timestamp_ms > i64::MAX as u64
390 || chrono::DateTime::from_timestamp_millis(r.timestamp_ms as i64).is_none()
391 {
392 return Err(fail());
393 }
394 if let Some(MemoryRetention::MaxAgeDays(days)) = r.retention {
395 let expiry = r
396 .timestamp_ms
397 .checked_add(u64::from(days) * 86_400_000)
398 .ok_or_else(fail)?;
399 if expiry > i64::MAX as u64
400 || chrono::DateTime::from_timestamp_millis(expiry as i64).is_none()
401 {
402 return Err(fail());
403 }
404 }
405 let record = MemoryRecord {
406 namespace: r.namespace,
407 timestamp_ms: r.timestamp_ms,
408 content: r.content,
409 tags: r.tags,
410 meta: r.meta,
411 id: r.id,
412 project: r.project,
413 provenance: r.provenance.map(|p| MemoryProvenance {
414 source: p.source,
415 session: p.session,
416 }),
417 sensitivity: r.sensitivity,
418 retention: r.retention,
419 };
420 let id = record.id.clone().expect("checked");
421 if let Some(prior) = records.get(&id) {
422 if prior != &record {
423 return Err(fail());
424 }
425 parsed.duplicates += 1;
426 } else {
427 records.insert(id, record);
428 }
429 }
430 parsed.suppressed = records
434 .keys()
435 .filter(|id| parsed.tombstones.contains(*id))
436 .count();
437 parsed.records = records
438 .into_iter()
439 .filter(|(id, _)| !parsed.tombstones.contains(id))
440 .map(|(_, r)| r)
441 .collect();
442 Ok(parsed)
443}
444
445pub enum MigrationSource {
448 Builtin,
449 Brain {
450 path: std::path::PathBuf,
451 project: String,
452 },
453 Export {
454 path: std::path::PathBuf,
455 project: String,
456 },
457}
458
459pub async fn preview_from(
460 binding: &MemoryBinding,
461 source: &MigrationSource,
462) -> Result<MigrationManifest> {
463 match source {
464 MigrationSource::Builtin => preview_in(binding.base(), binding.scope()?),
465 _ => Ok(external_snapshot(binding, source).await?.manifest),
466 }
467}
468pub async fn apply_from(
469 binding: &MemoryBinding,
470 source: &MigrationSource,
471 digest: &str,
472) -> Result<Value> {
473 if matches!(source, MigrationSource::Builtin) {
474 return apply(binding, digest).await;
475 }
476 if !is_digest(digest) {
477 bail!("invalid preview manifest digest");
478 }
479 let snapshot = external_snapshot(binding, source).await?;
480 snapshot.verify_digest(digest)?;
481 let current = external_snapshot(binding, source).await?;
485 current.verify_digest(digest)?;
486 let result = binding.rpc("migration_apply", snapshot.payload.clone()).await
487 .context("migration outcome unconfirmed; source/config unchanged; retry this exact manifest to reconcile")?;
488 drop(current);
489 drop(snapshot);
490 Ok(result)
491}
492
493#[derive(Deserialize)]
494#[serde(deny_unknown_fields)]
495struct Inventory {
496 format: String,
497 records: Vec<SourceRecord>,
498 tombstones: Vec<String>,
499 histories: Vec<Value>,
500 #[serde(default)]
501 captures: Vec<Value>,
502 #[serde(default)]
503 fingerprints: Vec<Value>,
504 #[serde(default)]
505 source_digest: Option<String>,
506 source_project: String,
507 target_project: String,
508}
509
510async fn external_snapshot(binding: &MemoryBinding, source: &MigrationSource) -> Result<Snapshot> {
511 let scope = binding.scope()?;
512 let (path, alias, kind) = match source {
513 MigrationSource::Brain { path, project } => (path, project, "legacy_plugin_brain"),
514 MigrationSource::Export { path, project } => (path, project, "operator_private_export"),
515 MigrationSource::Builtin => unreachable!(),
516 };
517 if !path.is_absolute()
518 || path
519 .components()
520 .any(|c| matches!(c, std::path::Component::ParentDir))
521 || alias.is_empty()
522 || alias.len() > 4096
523 || alias.chars().any(char::is_control)
524 {
525 bail!(
526 "source requires an absolute no-traversal path and exact bounded source project alias"
527 );
528 }
529 let (file, raw) = match source {
530 MigrationSource::Brain { .. } => {
531 let value = binding.rpc("legacy_export", json!({"source_brain":path,"source_project":alias})).await
532 .context("read-only legacy export refused; stop writers/checkpoint source, verify authoritative source alias and supported policies")?;
533 (None, serde_json::to_vec(&value)?)
534 }
535 MigrationSource::Export { .. } => {
536 let mut file = open_export(path)?;
537 if !fs4::fs_std::FileExt::try_lock_shared(&file)? {
538 bail!("source export locked");
539 }
540 let raw = read_bounded(&mut file)?;
541 (Some(file), raw)
542 }
543 _ => unreachable!(),
544 };
545 if raw.len() > MAX_SOURCE_BYTES {
546 bail!("source export exceeds 24 MiB; no truncation supported");
547 }
548 let inventory: Inventory = serde_json::from_slice(&raw).map_err(|_| {
549 anyhow::anyhow!(
550 "invalid/unsupported source export schema; full records/tombstones/histories required"
551 )
552 })?;
553 if inventory.format != "synaps-axel-export/1"
554 || inventory
555 .source_digest
556 .as_deref()
557 .is_some_and(|d| !is_digest(d))
558 || inventory.source_project != *alias
559 || inventory.target_project != scope.key()
560 || (host_project(alias) && alias != scope.key())
563 || (alias != scope.key() && (!inventory.captures.is_empty() || !inventory.histories.is_empty()))
564 {
565 bail!("invalid source export identity or source digest");
566 }
567 for fingerprint in &inventory.fingerprints {
568 if !fingerprint.as_object().is_some_and(|o| o.len() == 2)
569 || !matches!(
570 fingerprint["kind"].as_str(),
571 Some("note" | "capture" | "history")
572 )
573 || !fingerprint["digest"].as_str().is_some_and(is_digest)
574 {
575 bail!("invalid exported deletion fingerprint");
576 }
577 }
578 let mut records = BTreeMap::new();
579 let tombstones: BTreeSet<_> = inventory.tombstones.into_iter().collect();
580 if tombstones.iter().any(|s| !valid_id(s)) {
581 bail!("invalid exported tombstone ID");
582 }
583 for record in inventory.records {
584 let r = validate_export_record(record, scope, alias)?;
585 let id = r.id.clone().expect("validated");
586 if let Some(prior) = records.insert(id, r.clone()) {
587 if prior != r {
588 bail!("conflicting exported records");
589 }
590 }
591 }
592 let suppressed = records.keys().filter(|id| tombstones.contains(*id)).count();
593 let records: Vec<_> = records
594 .into_iter()
595 .filter(|(id, _)| !tombstones.contains(id))
596 .map(|(_, r)| r)
597 .collect();
598 for record in &records {
599 if let Some(id) = record
600 .meta
601 .as_ref()
602 .and_then(|m| m.get("capture_id"))
603 .and_then(Value::as_str)
604 {
605 if !inventory
606 .captures
607 .iter()
608 .any(|c| c["capture_id"] == id && c["note_id"].as_str() == record.id.as_deref())
609 {
610 bail!("capture-derived note is missing its structured capture evidence");
611 }
612 }
613 }
614 if records.len() + tombstones.len() > 16384
615 || inventory.histories.len() > 128
616 || inventory.captures.len() > 16384
617 || inventory.fingerprints.len() > 32768
618 {
619 bail!("export exceeds single-transaction record limits; no truncation supported");
620 }
621 let mut histories = inventory.histories;
625 let history_tombstones = histories.iter().filter(|h| h["tombstone"] == true).count();
626 for h in &histories {
627 if !h.is_object()
628 || !h["id"]
629 .as_str()
630 .is_some_and(|s| s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit()))
631 || !h["digest"].as_str().is_some_and(is_digest)
632 || !h["logical_id"].as_str().is_some_and(is_digest)
633 {
634 bail!("unsupported history export identity/digest");
635 }
636 }
637 histories.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
638 let archive_inventory_bytes = serde_json::to_vec(&histories)?.len();
639 let digest = hash_parts(&[
640 FORMAT.as_bytes(),
641 kind.as_bytes(),
642 path.as_os_str().as_encoded_bytes(),
643 alias.as_bytes(),
644 scope.key().as_bytes(),
645 &raw,
646 ]);
647 let migration_id = hash_parts(&[FORMAT.as_bytes(), b"apply", digest.as_bytes()]);
648 let mut manifest = MigrationManifest {
649 format: FORMAT,
650 source: kind,
651 project: scope.key().into(),
652 source_project: Some(alias.clone()),
653 captures: inventory.captures.len(),
654 fingerprints: inventory.fingerprints.len(),
655 migration_id: migration_id.clone(),
656 manifest_digest: digest.clone(),
657 source_jsonl_bytes: raw.len(),
658 source_jsonl_present: true,
659 archive_inventory_bytes,
660 records: records.len(),
661 tombstones: tombstones.len(),
662 tombstone_suppressed_records: suppressed,
663 excluded_unscoped_records: 0,
664 excluded_foreign_records: 0,
665 duplicate_records: 0,
666 secret_records: records
667 .iter()
668 .filter(|r| r.sensitivity == Some(MemorySensitivity::Secret))
669 .count(),
670 sensitive_records: records
671 .iter()
672 .filter(|r| r.sensitivity == Some(MemorySensitivity::Sensitive))
673 .count(),
674 histories: histories.len() - history_tombstones,
675 history_tombstones,
676 apply_frame_bytes: 0,
677 };
678 let payload = json!({"migration_id":migration_id,"manifest_digest":digest,"records":records,"tombstones":tombstones,"histories":histories,"captures":inventory.captures,"fingerprints":inventory.fingerprints});
679 manifest.apply_frame_bytes = check_frame_bound(scope.key(), &payload)?;
680 Ok(Snapshot {
681 manifest,
682 payload,
683 source: file,
684 raw,
685 histories,
686 })
687}
688
689#[cfg(unix)]
690pub(super) fn open_export(path: &Path) -> Result<std::fs::File> {
691 use agent_core::core::private_fs::ConfinedDir;
692 use std::os::unix::fs::MetadataExt;
693 let parent = path.parent().context("source export parent missing")?;
694 let name = path
695 .file_name()
696 .and_then(|n| n.to_str())
697 .context("invalid source export filename")?;
698 let file = ConfinedDir::open_absolute_no_symlinks(parent)?.open_file(&[name.into()])?;
699 let metadata = file.metadata()?;
700 if metadata.mode() & 0o077 != 0 || metadata.uid() != unsafe { libc::geteuid() } {
701 bail!("source export must be operator-owned mode 0600 (or stricter)");
702 }
703 Ok(file)
704}
705#[cfg(not(unix))]
706pub(super) fn open_export(_: &Path) -> Result<std::fs::File> {
707 bail!("private source export requires Unix")
708}
709
710fn host_project(key: &str) -> bool {
711 key.len() == 17
712 && key.starts_with('p')
713 && key[1..]
714 .bytes()
715 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
716}
717
718fn valid_repository_provenance(value: &Value) -> bool {
722 let Some(meta) = value.as_object() else {
723 return false;
724 };
725 meta.len() == 3
726 && ["repository_project", "source_worktree_project"]
727 .iter()
728 .all(|key| {
729 meta.get(*key)
730 .and_then(Value::as_str)
731 .is_some_and(|p| host_project(p) && p != "p0000000000000000")
732 })
733 && meta
734 .get("source_worktree_root")
735 .and_then(Value::as_str)
736 .is_some_and(|root| {
737 !root.is_empty()
738 && root.len() <= 4096
739 && !root.contains('\0')
740 && Path::new(root).is_absolute()
741 && !Path::new(root).components().any(|c| {
742 matches!(
743 c,
744 std::path::Component::ParentDir | std::path::Component::CurDir
745 )
746 })
747 })
748}
749
750fn validate_export_record(
751 r: SourceRecord,
752 scope: &ProjectScope,
753 alias: &str,
754) -> Result<MemoryRecord> {
755 let invalid = || {
756 anyhow::anyhow!("unsupported/conflicting exported record scope or policy; nothing imported")
757 };
758 if r.project.as_deref() != Some(scope.key())
761 || !r.id.as_deref().is_some_and(valid_id)
762 || r.sensitivity.is_none()
763 || r.retention.is_none()
764 || r.provenance.is_none()
765 || r.content.len() > MAX_CONTENT_BYTES
766 || r.namespace.is_empty()
767 || r.namespace.len() > 64
768 || r.namespace.contains('/')
769 || r.namespace.contains('\\')
770 || r.namespace.contains("..")
771 || r.namespace.chars().any(char::is_whitespace)
772 || r.timestamp_ms > i64::MAX as u64
773 || chrono::DateTime::from_timestamp_millis(r.timestamp_ms as i64).is_none()
774 {
775 return Err(invalid());
776 }
777 let forum = r.namespace == agent_core::memory::forum::NAMESPACE
780 || r.id.as_deref().is_some_and(|id| id.starts_with("msg-"))
781 || r.meta
782 .as_ref()
783 .is_some_and(|m| m.get("_synaps_forum").is_some());
784 if forum {
785 let value = r
786 .meta
787 .as_ref()
788 .and_then(|m| m.get("_synaps_forum"))
789 .ok_or_else(invalid)?;
790 let envelope: agent_core::memory::forum::Envelope =
791 serde_json::from_value(value.clone()).map_err(|_| invalid())?;
792 envelope.validate(&r.content).map_err(|_| invalid())?;
793 if alias != scope.key()
794 || r.namespace != agent_core::memory::forum::NAMESPACE
795 || envelope.project != scope.key()
796 || r.id.as_deref() != Some(envelope.id().as_str())
797 || !r.tags.is_empty()
798 || r.sensitivity != Some(MemorySensitivity::Normal)
799 || r.retention != Some(MemoryRetention::MaxAgeDays(envelope.retention_days))
800 || !r.provenance.as_ref().is_some_and(|p| {
801 p.source == envelope.source()
802 && p.session.as_deref() == Some(envelope.author.group.as_str())
803 })
804 {
805 return Err(invalid());
806 }
807 }
808 if let Some(meta) = &r.meta {
809 let m = meta.as_object().ok_or_else(invalid)?;
810 let capture_keys = [
811 "capture_id",
812 "source_digest",
813 "capture_schema",
814 "local_only",
815 ];
816 let capture = m.contains_key("capture_id");
817 let repository = m.get("_synaps_repository");
818 if repository
819 .is_some_and(|value| alias != scope.key() || !valid_repository_provenance(value))
820 || m.keys().any(|key| {
821 key != "_axel"
822 && key != "_synaps_repository"
823 && !(forum && key == "_synaps_forum")
824 && !(capture && capture_keys.contains(&key.as_str()))
825 })
826 {
827 return Err(invalid());
828 }
829 if forum
830 && (capture
831 || repository.is_some()
832 || m.keys()
833 .any(|key| !matches!(key.as_str(), "_synaps_forum" | "_axel")))
834 {
835 return Err(invalid());
836 }
837 if capture {
838 let id = m
839 .get("capture_id")
840 .and_then(Value::as_str)
841 .filter(|s| is_digest(s))
842 .ok_or_else(invalid)?;
843 if alias != scope.key()
844 || r.namespace != "captures"
845 || !r.provenance.as_ref().is_some_and(|p| {
846 p.source == "capture" && p.session.as_deref().is_some_and(|s| !s.is_empty())
847 })
848 || r.id.as_deref() != Some(format!("mem-cap-{id}").as_str())
849 || !m
850 .get("source_digest")
851 .and_then(Value::as_str)
852 .is_some_and(is_digest)
853 || !m.get("capture_schema").is_some_and(Value::is_string)
854 || !m.get("local_only").is_some_and(Value::is_boolean)
855 {
856 return Err(invalid());
857 }
858 }
859 let empty = serde_json::Map::new();
860 let axel = match m.get("_axel") {
861 Some(value) => value.as_object().ok_or_else(invalid)?,
862 None if capture || forum || repository.is_some() => &empty,
863 None => return Err(invalid()),
864 };
865 let allowed = [
866 "disclosure",
867 "expires_ms",
868 "source_project",
869 "provenance",
870 "category",
871 "topic",
872 "title",
873 "abstract",
874 "source_sessions",
875 ];
876 if axel.keys().any(|k| !allowed.contains(&k.as_str()))
877 || match axel.get("source_project").and_then(Value::as_str) {
878 Some(p) => p != alias,
879 None => alias != scope.key(),
880 }
881 {
882 return Err(invalid());
883 }
884 if forum
885 && (axel
886 .keys()
887 .any(|key| !matches!(key.as_str(), "disclosure" | "expires_ms"))
888 || axel
889 .get("disclosure")
890 .is_some_and(|value| value != "standard"))
891 {
892 return Err(invalid());
893 }
894 match axel.get("disclosure").and_then(Value::as_str) {
895 Some("standard") => {}
896 None if alias == scope.key() => {}
897 Some("local_only" | "visible_after_consent" | "persist_never_transmit")
898 if r.sensitivity == Some(MemorySensitivity::Secret) || alias == scope.key() => {}
899 _ => return Err(invalid()),
900 }
901 if let Some(v) = axel.get("expires_ms").filter(|v| !v.is_null()) {
902 let n = v.as_i64().filter(|n| *n >= 0).ok_or_else(invalid)?;
903 if chrono::DateTime::from_timestamp_millis(n).is_none() {
904 return Err(invalid());
905 }
906 if let Some(MemoryRetention::MaxAgeDays(days)) = r.retention {
907 if r.timestamp_ms.checked_add(u64::from(days) * 86_400_000) != Some(n as u64) {
908 return Err(invalid());
909 }
910 }
911 }
912 } else if alias != scope.key() {
913 return Err(invalid());
914 }
915 if let Some(MemoryRetention::MaxAgeDays(days)) = r.retention {
916 let n = r
917 .timestamp_ms
918 .checked_add(u64::from(days) * 86_400_000)
919 .ok_or_else(invalid)?;
920 if n > i64::MAX as u64 || chrono::DateTime::from_timestamp_millis(n as i64).is_none() {
921 return Err(invalid());
922 }
923 }
924 Ok(MemoryRecord {
925 namespace: r.namespace,
926 timestamp_ms: r.timestamp_ms,
927 content: r.content,
928 tags: r.tags,
929 meta: r.meta,
930 id: r.id,
931 project: r.project,
932 provenance: r.provenance.map(|p| MemoryProvenance {
933 source: p.source,
934 session: p.session,
935 }),
936 sensitivity: r.sensitivity,
937 retention: r.retention,
938 })
939}
940
941#[cfg(all(test, unix))]
942mod tests {
943 use super::*;
944 use agent_core::context_archive::ArchiveStore;
945 use agent_core::memory::store::{self, NewMemoryRecord};
946 use std::fs;
947 use std::os::unix::fs::{symlink, PermissionsExt};
948
949 fn fixture() -> (tempfile::TempDir, ProjectScope, std::path::PathBuf) {
950 let tmp = tempfile::tempdir().unwrap();
951 fs::set_permissions(tmp.path(), fs::Permissions::from_mode(0o700)).unwrap();
952 let scope = ProjectScope::for_root(tmp.path()).unwrap();
953 fs::create_dir(tmp.path().join("memory")).unwrap();
954 let path = tmp
955 .path()
956 .join("memory")
957 .join(format!("{}.jsonl", scope.namespace()));
958 (tmp, scope, path)
959 }
960 fn record(scope: &ProjectScope, id: &str) -> Value {
961 json!({"namespace":scope.namespace(),"timestamp_ms":1000,"content":"synthetic-secret-body", "tags":[],"id":id,"project":scope.key(),"provenance":{"source":"user"},"sensitivity":"secret","retention":"standard"})
962 }
963 fn write_lines(path: &Path, rows: &[Value]) {
964 fs::write(
965 path,
966 rows.iter().map(|v| format!("{v}\n")).collect::<String>(),
967 )
968 .unwrap();
969 }
970 fn binding(tmp: &tempfile::TempDir, scope: &ProjectScope) -> MemoryBinding {
971 MemoryBinding::new(
972 tmp.path().to_path_buf(),
973 Ok(scope.clone()),
974 &agent_core::config::MemoryBackendConfig {
975 kind: agent_core::config::MemoryBackendKind::Axel,
976 executable: Some(tmp.path().join("must-not-execute")),
977 brain: Some(tmp.path().join("must-not-create.r8")),
978 user_scope: false,
979 },
980 )
981 }
982 #[tokio::test]
983 async fn changed_source_refuses_before_service_and_leaves_source_intact() {
984 let (tmp, scope, path) = fixture();
985 write_lines(&path, &[record(&scope, "mem-a")]);
986 let preview = preview_in(tmp.path(), &scope).unwrap();
987 let mut changed = record(&scope, "mem-a");
988 changed["content"] = json!("synthetic-changed");
989 write_lines(&path, &[changed]);
990 let before = fs::read(&path).unwrap();
991 let error = apply(&binding(&tmp, &scope), &preview.manifest_digest)
992 .await
993 .unwrap_err();
994 assert!(error.to_string().contains("source changed"));
995 assert_eq!(before, fs::read(&path).unwrap());
996 assert!(!tmp.path().join("must-not-create.r8").exists());
997 }
998 #[test]
999 fn tombstones_first_absent_ids_and_late_reinsert_suppressed() {
1000 let (tmp, scope, path) = fixture();
1001 write_lines(
1002 &path,
1003 &[
1004 json!({"tombstone":"mem-a","timestamp_ms":2}),
1005 record(&scope, "mem-a"),
1006 json!({"tombstone":"mem-absent","timestamp_ms":3}),
1007 record(&scope, "mem-live"),
1008 ],
1009 );
1010 let before = fs::read(&path).unwrap();
1011 let snapshot = snapshot_in(tmp.path(), &scope).unwrap();
1012 assert_eq!(snapshot.manifest.tombstones, 2);
1013 assert_eq!(snapshot.manifest.tombstone_suppressed_records, 1);
1014 assert_eq!(
1015 snapshot.payload["tombstones"],
1016 json!(["mem-a", "mem-absent"])
1017 );
1018 assert_eq!(snapshot.payload["records"].as_array().unwrap().len(), 1);
1019 assert_eq!(fs::read(&path).unwrap(), before);
1020 let preview = serde_json::to_string(&snapshot.manifest).unwrap();
1021 assert!(!preview.contains("synthetic-secret-body"));
1022 assert!(!preview.contains("mem-absent"));
1023 let again = preview_in(tmp.path(), &scope).unwrap();
1024 assert_eq!(snapshot.manifest, again);
1025 write_lines(&path, &[json!({"tombstone":"mem-absent","timestamp_ms":4})]);
1027 assert_ne!(
1028 again.manifest_digest,
1029 preview_in(tmp.path(), &scope).unwrap().manifest_digest
1030 );
1031 }
1032 #[test]
1033 fn unscoped_excluded_foreign_never_prefix_converted_and_malformed_refused() {
1034 let (tmp, scope, path) = fixture();
1035 let mut unscoped = record(&scope, "mem-a");
1036 unscoped.as_object_mut().unwrap().remove("project");
1037 let mut foreign = record(&scope, "mem-b");
1038 foreign["project"] = json!(format!("proj_{}", &scope.key()[1..]));
1039 write_lines(&path, &[unscoped, foreign]);
1040 let manifest = preview_in(tmp.path(), &scope).unwrap();
1041 assert_eq!(manifest.records, 0);
1042 assert_eq!(manifest.excluded_unscoped_records, 1);
1043 assert_eq!(manifest.excluded_foreign_records, 1);
1044 for row in [
1045 json!({"tombstone":"mem-a","timestamp_ms":2,"retention":"never_persist"}),
1046 {
1047 let mut r = record(&scope, "mem-a");
1048 r["meta"] = json!({"retention":"never_persist"});
1049 r
1050 },
1051 {
1052 let mut r = record(&scope, "mem-a");
1053 r["sensitivity"] = json!("unknown");
1054 r
1055 },
1056 ] {
1057 write_lines(&path, &[row]);
1058 assert!(preview_in(tmp.path(), &scope).is_err());
1059 }
1060 fs::write(&path, b"{broken secret source").unwrap();
1061 let error = preview_in(tmp.path(), &scope).unwrap_err().to_string();
1062 assert!(!error.contains("broken secret"));
1063 }
1064 #[test]
1065 fn history_ids_projection_hashes_hidden_notes_and_tombstones_preserved() {
1066 let (tmp, scope, path) = fixture();
1067 write_lines(&path, &[]);
1068 let store = ArchiveStore::new(tmp.path(), scope.key(), "synthetic-logical").unwrap();
1069 let a = store
1070 .seal(
1071 &[std::sync::Arc::new(
1072 json!({"role":"user","content":"synthetic evidence A"}),
1073 )],
1074 "synthetic hidden note",
1075 )
1076 .unwrap();
1077 let b = store
1078 .seal(
1079 &[std::sync::Arc::new(
1080 json!({"role":"user","content":"synthetic evidence B"}),
1081 )],
1082 "",
1083 )
1084 .unwrap();
1085 store.forget(&b.id).unwrap();
1086 let inventory = context_archive::export_in(tmp.path(), scope.key()).unwrap();
1087 let snapshot = snapshot_in(tmp.path(), &scope).unwrap();
1088 assert_eq!(snapshot.manifest.histories, 1);
1089 assert_eq!(snapshot.manifest.history_tombstones, 1);
1090 for row in inventory {
1091 assert!(snapshot.payload["histories"]
1092 .as_array()
1093 .unwrap()
1094 .contains(&row));
1095 }
1096 assert!(snapshot.payload["histories"]
1097 .as_array()
1098 .unwrap()
1099 .iter()
1100 .any(|r| r["id"] == a.id));
1101 let preview = serde_json::to_string(&snapshot.manifest).unwrap();
1102 assert!(!preview.contains("synthetic hidden note"));
1103 assert!(!preview.contains("synthetic evidence"));
1104 }
1105 #[test]
1106 fn no_source_mkdir_and_symlink_ancestor_or_file_refused() {
1107 let (tmp, scope, path) = fixture();
1108 assert!(!tmp.path().join("context-archives").exists());
1109 preview_in(tmp.path(), &scope).unwrap();
1110 assert!(!tmp.path().join("context-archives").exists());
1111 assert!(!path.exists());
1112 let missing = tmp.path().join("absent-base");
1113 preview_in(&missing, &scope).unwrap();
1114 assert!(!missing.exists());
1115 let victim = tmp.path().join("victim");
1116 fs::write(&victim, b"private").unwrap();
1117 symlink(&victim, &path).unwrap();
1118 assert!(preview_in(tmp.path(), &scope).is_err());
1119 let alias = tmp.path().join("alias");
1120 symlink(tmp.path(), &alias).unwrap();
1121 assert!(preview_in(&alias, &scope).is_err());
1122 assert_eq!(fs::read(victim).unwrap(), b"private");
1123 }
1124 #[test]
1125 fn bound_is_whole_frame_and_conflicting_ids_reject() {
1126 let (tmp, scope, path) = fixture();
1127 let first = record(&scope, "mem-a");
1128 let mut second = first.clone();
1129 second["content"] = json!("different");
1130 write_lines(&path, &[first, second]);
1131 assert!(preview_in(tmp.path(), &scope).is_err());
1132 assert!(
1133 check_frame_bound(scope.key(), &json!({"body":"a".repeat(MAX_APPLY_BYTES)})).is_err()
1134 );
1135 }
1136 #[test]
1137 fn forum_export_preserves_identity_and_refuses_forgery() {
1138 use agent_core::memory::forum::{Author, Envelope, Post};
1139 let (_tmp, scope, _) = fixture();
1140 let post = Post {
1141 request_key: "finding".into(),
1142 thread_id: None,
1143 reply_to: None,
1144 title: "Synthetic".into(),
1145 body: "Important finding".into(),
1146 retention_days: 30,
1147 };
1148 let e = Envelope::new(scope.key(), Author::fresh(), &post).unwrap();
1149 let value = json!({"namespace":"forum","timestamp_ms":1000,"content":post.body,"tags":[],"id":e.id(),"project":scope.key(),"provenance":{"source":e.source(),"session":e.author.group},"sensitivity":"normal","retention":{"max_age_days":30},"meta":{"_synaps_forum":e,"_axel":{"disclosure":"standard","expires_ms":2592001000u64}}});
1150 let parse = |v: Value| {
1151 validate_export_record(serde_json::from_value(v).unwrap(), &scope, scope.key())
1152 };
1153 let saved = parse(value.clone()).unwrap();
1154 assert_eq!(
1155 saved.meta.as_ref().unwrap()["_synaps_forum"],
1156 value["meta"]["_synaps_forum"]
1157 );
1158 for (field, bad) in [
1159 ("content", json!("forged")),
1160 ("namespace", json!(scope.namespace())),
1161 ("sensitivity", json!("secret")),
1162 ("tags", json!(["not allowed"])),
1163 ("id", json!("mem-fake")),
1164 ] {
1165 let mut v = value.clone();
1166 v[field] = bad;
1167 assert!(parse(v).is_err(), "{field}");
1168 }
1169 let mut v = value.clone();
1170 v["meta"]["_synaps_forum"]["digest"] = json!("a".repeat(64));
1171 assert!(parse(v).is_err());
1172 let mut v = value.clone();
1173 v["meta"]["_axel"]["expires_ms"] = json!(1);
1174 assert!(parse(v).is_err());
1175 let mut v = value;
1176 v["meta"].as_object_mut().unwrap().remove("_synaps_forum");
1177 assert!(parse(v).is_err());
1178 }
1179
1180 #[tokio::test]
1181 async fn operator_export_preserves_capture_fingerprints_and_requires_exact_alias() {
1182 let (tmp, scope, _) = fixture();
1183 let path = tmp.path().join("source.json");
1184 let capture = json!({"capture_id":"a".repeat(64),"note_id":"mem-cap-synthetic","payload_digest":"b".repeat(64),"source_digest":"c".repeat(64),"evidence":null,"withheld":true,"tombstoned":true});
1185 let fingerprint = json!({"kind":"history","digest":"f".repeat(64)});
1186 let inventory = json!({"format":"synaps-axel-export/1","source_project":scope.key(),"target_project":scope.key(),"records":[record(&scope,"mem-a")],"tombstones":["mem-gone"],"histories":[],"captures":[capture],"fingerprints":[fingerprint]});
1187 fs::write(&path, serde_json::to_vec(&inventory).unwrap()).unwrap();
1188 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
1189 let source = MigrationSource::Export {
1190 path: path.clone(),
1191 project: scope.key().into(),
1192 };
1193 let snapshot = external_snapshot(&binding(&tmp, &scope), &source)
1194 .await
1195 .unwrap();
1196 assert_eq!(snapshot.payload["captures"], inventory["captures"]);
1197 assert_eq!(snapshot.payload["fingerprints"], inventory["fingerprints"]);
1198 assert_eq!(snapshot.manifest.captures, 1);
1199 assert_eq!(snapshot.manifest.fingerprints, 1);
1200 let wrong = MigrationSource::Export {
1201 path,
1202 project: "proj_wrong".into(),
1203 };
1204 assert!(external_snapshot(&binding(&tmp, &scope), &wrong)
1205 .await
1206 .is_err());
1207 }
1208 #[tokio::test]
1209 async fn bodyless_export_requires_exact_format_source_and_target() {
1210 let (tmp, scope, _) = fixture();
1211 let path = tmp.path().join("bodyless.json");
1212 let source = MigrationSource::Export {
1213 path: path.clone(),
1214 project: scope.key().into(),
1215 };
1216 let valid = json!({"format":"synaps-axel-export/1","source_project":scope.key(),"target_project":scope.key(),"records":[],"tombstones":["mem-gone"],"histories":[],"fingerprints":[{"kind":"note","digest":"a".repeat(64)}]});
1217 for key in ["format", "source_project", "target_project"] {
1218 for missing in [true, false] {
1219 let mut value = valid.clone();
1220 if missing {
1221 value.as_object_mut().unwrap().remove(key);
1222 } else {
1223 value[key] = json!("wrong");
1224 }
1225 fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
1226 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
1227 assert!(
1228 external_snapshot(&binding(&tmp, &scope), &source)
1229 .await
1230 .is_err(),
1231 "{key}, missing={missing}"
1232 );
1233 }
1234 }
1235 fs::write(&path, serde_json::to_vec(&valid).unwrap()).unwrap();
1236 assert!(external_snapshot(&binding(&tmp, &scope), &source)
1237 .await
1238 .is_ok());
1239 }
1240 #[test]
1241 fn repository_provenance_is_preserved_as_data_not_scope_authority() {
1242 let (tmp, scope, _) = fixture();
1243 let provenance = json!({"repository_project":"p1111111111111111",
1244 "source_worktree_project":"p2222222222222222", "source_worktree_root":"/synthetic/moved\nroot"});
1245 for owner in [scope.clone(), ProjectScope::user_scope(tmp.path()).unwrap()] {
1246 let mut value = record(&owner, "mem-provenance");
1247 value["meta"] = json!({"_synaps_repository":provenance});
1248 let parsed = validate_export_record(
1249 serde_json::from_value(value.clone()).unwrap(),
1250 &owner,
1251 owner.key(),
1252 )
1253 .unwrap();
1254 assert_eq!(parsed.meta, Some(value["meta"].clone()));
1255 assert_eq!(parsed.project.as_deref(), Some(owner.key()));
1257 for bad in [
1258 json!({}),
1259 json!({"repository_project":"p0000000000000000",
1260 "source_worktree_project":"p2222222222222222","source_worktree_root":"/synthetic/root"}),
1261 {
1262 let mut p = provenance.clone();
1263 p["extra"] = json!(true);
1264 p
1265 },
1266 {
1267 let mut p = provenance.clone();
1268 p["source_worktree_root"] = json!("../escape");
1269 p
1270 },
1271 {
1272 let mut p = provenance.clone();
1273 p["source_worktree_project"] = json!("proj_wrong");
1274 p
1275 },
1276 ] {
1277 let mut invalid = value.clone();
1278 invalid["meta"]["_synaps_repository"] = bad;
1279 assert!(validate_export_record(
1280 serde_json::from_value(invalid).unwrap(),
1281 &owner,
1282 owner.key()
1283 )
1284 .is_err());
1285 }
1286 }
1287 }
1288
1289 #[tokio::test]
1290 async fn bodyless_host_inventory_cannot_retarget_owner_even_with_matching_target() {
1291 let (tmp, scope, _) = fixture();
1292 let path = tmp.path().join("bodyless.json");
1293 let source = MigrationSource::Export {
1294 path: path.clone(),
1295 project: "p1111111111111111".into(),
1296 };
1297 let value = json!({"format":"synaps-axel-export/1","source_project":"p1111111111111111",
1298 "target_project":scope.key(),"records":[],"tombstones":["mem-gone"],"histories":[],"captures":[],"fingerprints":[]});
1299 fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
1300 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
1301 assert!(external_snapshot(&binding(&tmp, &scope), &source)
1302 .await
1303 .is_err());
1304 }
1305
1306 #[tokio::test]
1307 #[ignore = "requires explicitly selected service binary; synthetic temp state only"]
1308 async fn real_production_note_metadata_ttl_export_import_roundtrip() {
1309 let (tmp, scope, _) = fixture();
1310 let executable = std::path::PathBuf::from(
1311 std::env::var_os("SYNAPS_AXEL_TEST_BIN").expect("explicit synthetic test binary"),
1312 );
1313 let make_binding = |name: &str| {
1314 MemoryBinding::new(
1315 tmp.path().into(),
1316 Ok(scope.clone()),
1317 &agent_core::config::MemoryBackendConfig {
1318 kind: agent_core::config::MemoryBackendKind::Axel,
1319 executable: Some(executable.clone()),
1320 brain: Some(tmp.path().join(name)),
1321 user_scope: false,
1322 },
1323 )
1324 };
1325 let mut source_binding = make_binding("source.r8");
1326 std::sync::Arc::get_mut(&mut source_binding.0)
1328 .unwrap()
1329 .repository = Some(
1330 ProjectScope::discover_repository_with_override(scope.root(), Some(scope.root()))
1331 .unwrap(),
1332 );
1333 let saved = source_binding
1334 .store(NewMemoryRecord {
1335 content: "synthetic production-shaped TTL note".into(),
1336 tags: vec!["synthetic".into()],
1337 provenance: MemoryProvenance {
1338 source: "user".into(),
1339 session: None,
1340 },
1341 sensitivity: MemorySensitivity::Normal,
1342 retention: MemoryRetention::MaxAgeDays(7),
1343 })
1344 .await
1345 .unwrap();
1346 let original = source_binding
1347 .rpc("export", json!({"full":true}))
1348 .await
1349 .unwrap();
1350 assert!(original["records"][0]["meta"]["_synaps_repository"].is_object());
1351 assert_eq!(
1352 original["records"][0]["meta"]["_axel"]["expires_ms"],
1353 saved.timestamp_ms + 7 * 86_400_000
1354 );
1355 let path = tmp.path().join("full.json");
1356 fs::write(&path, serde_json::to_vec(&original).unwrap()).unwrap();
1357 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
1358 let source = MigrationSource::Export {
1359 path,
1360 project: scope.key().into(),
1361 };
1362 let target = make_binding("target.r8");
1363 let preview = preview_from(&target, &source).await.unwrap();
1364 apply_from(&target, &source, &preview.manifest_digest)
1365 .await
1366 .unwrap();
1367 assert_eq!(
1368 target.rpc("export", json!({"full":true})).await.unwrap(),
1369 original
1370 );
1371 let id = saved.id.unwrap();
1372 assert_eq!(
1373 target.fetch(&[&id]).await.unwrap()[0].timestamp_ms,
1374 saved.timestamp_ms
1375 );
1376 target.forget(&id).await.unwrap();
1377 apply_from(&target, &source, &preview.manifest_digest)
1378 .await
1379 .unwrap();
1380 assert!(target.fetch(&[&id]).await.is_err());
1381 }
1382
1383 #[tokio::test]
1384 #[ignore = "requires explicitly selected service binary; synthetic temp state only"]
1385 async fn real_migration_preserves_tombstones_source_and_retry_deletion() {
1386 let (tmp, scope, path) = fixture();
1387 let record = store::store_record_in(
1388 tmp.path(),
1389 &scope,
1390 NewMemoryRecord {
1391 content: "synthetic migration content".into(),
1392 tags: vec![],
1393 provenance: MemoryProvenance {
1394 source: "user".into(),
1395 session: None,
1396 },
1397 sensitivity: MemorySensitivity::Normal,
1398 retention: MemoryRetention::Standard,
1399 },
1400 )
1401 .unwrap();
1402 let mut f = fs::OpenOptions::new().append(true).open(&path).unwrap();
1403 use std::io::Write;
1404 writeln!(f, "{{\"tombstone\":\"mem-absent\",\"timestamp_ms\":1}}").unwrap();
1405 drop(f);
1406 let before = fs::read(&path).unwrap();
1407 let exe = std::env::var_os("SYNAPS_AXEL_TEST_BIN").expect("explicit synthetic test binary");
1408 let binding = MemoryBinding::new(
1409 tmp.path().into(),
1410 Ok(scope.clone()),
1411 &agent_core::config::MemoryBackendConfig {
1412 kind: agent_core::config::MemoryBackendKind::Axel,
1413 executable: Some(exe.into()),
1414 brain: Some(tmp.path().join("target.r8")),
1415 user_scope: false,
1416 },
1417 );
1418 let preview = preview_in(tmp.path(), &scope).unwrap();
1419 apply(&binding, &preview.manifest_digest).await.unwrap();
1420 let inventory = binding.rpc("export", json!({"full":true})).await.unwrap();
1421 assert!(inventory["tombstones"]
1422 .as_array()
1423 .unwrap()
1424 .contains(&json!("mem-absent")));
1425 let id = record.id.as_deref().unwrap();
1426 binding.forget(id).await.unwrap();
1427 apply(&binding, &preview.manifest_digest).await.unwrap();
1428 assert!(binding.fetch(&[id]).await.is_err());
1429 assert_eq!(fs::read(&path).unwrap(), before);
1430 }
1431 #[tokio::test]
1432 #[ignore = "requires explicitly selected service binary; synthetic temp state only"]
1433 async fn real_capture_export_preview_apply_query_roundtrip() {
1434 let (tmp, scope, _) = fixture();
1435 let executable = std::path::PathBuf::from(
1436 std::env::var_os("SYNAPS_AXEL_TEST_BIN").expect("explicit synthetic test binary"),
1437 );
1438 let make_binding = |name: &str| {
1439 MemoryBinding::new(
1440 tmp.path().into(),
1441 Ok(scope.clone()),
1442 &agent_core::config::MemoryBackendConfig {
1443 kind: agent_core::config::MemoryBackendKind::Axel,
1444 executable: Some(executable.clone()),
1445 brain: Some(tmp.path().join(name)),
1446 user_scope: false,
1447 },
1448 )
1449 };
1450 let source_binding = make_binding("source.r8");
1451 let target_binding = make_binding("target.r8");
1452 let chat = json!({"schema":"chat_turn_capture/1","capture_id":"a".repeat(64),"project_id":scope.key(),"session_id":"synthetic","turn_id":"turn1","turn_ordinal":1,"source_digest":"b".repeat(64),"user":"synthetic question","assistant":"synthetic answer","tools":[]});
1453 let summary = json!({"schema":"conversation_summary/1","capture_id":"7".repeat(64),"project_id":scope.key(),"source_session_id":"synthetic","source_message_count":2,"source_turn_range":{"first":0,"last":1,"digest":"8".repeat(64)},"summary":"synthetic restricted summary","summary_provider":null,"summary_model":null,"local_only":true,"prompt_stack_digest":"9".repeat(64),"redaction_policy":"policy_exclusions","content_classes":["user_text"],"summarized_at_unix_ms":store::now_ms()});
1454 source_binding.rpc("capture", chat).await.unwrap();
1455 source_binding.rpc("capture", summary).await.unwrap();
1456 let inventory = source_binding
1457 .rpc("export", json!({"full":true}))
1458 .await
1459 .unwrap();
1460 let path = tmp.path().join("private-export.json");
1461 fs::write(&path, serde_json::to_vec(&inventory).unwrap()).unwrap();
1462 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
1463 let source = MigrationSource::Export {
1464 path: path.clone(),
1465 project: scope.key().into(),
1466 };
1467 let preview = preview_from(&target_binding, &source).await.unwrap();
1468 assert_eq!(preview.captures, 2);
1469 assert_eq!(preview.secret_records, 1);
1470 apply_from(&target_binding, &source, &preview.manifest_digest)
1471 .await
1472 .unwrap();
1473 for capture in ["a".repeat(64), "7".repeat(64)] {
1474 let result = target_binding
1475 .rpc("capture_query", json!({"capture_id":capture}))
1476 .await
1477 .unwrap();
1478 assert_eq!(result["committed"], true);
1479 }
1480 let roundtrip = target_binding
1481 .rpc("export", json!({"full":true}))
1482 .await
1483 .unwrap();
1484 assert_eq!(inventory["captures"], roundtrip["captures"]);
1485 assert_eq!(inventory["records"], roundtrip["records"]);
1486 assert!(target_binding
1487 .fetch(&[&format!("mem-cap-{}", "7".repeat(64))])
1488 .await
1489 .is_err());
1490 let before = fs::read(&path).unwrap();
1491 target_binding
1492 .forget(&format!("mem-cap-{}", "a".repeat(64)))
1493 .await
1494 .unwrap();
1495 apply_from(&target_binding, &source, &preview.manifest_digest)
1496 .await
1497 .unwrap();
1498 assert_eq!(
1499 target_binding
1500 .rpc("capture_query", json!({"capture_id":"a".repeat(64)}))
1501 .await
1502 .unwrap()["tombstoned"],
1503 true
1504 );
1505 assert_eq!(before, fs::read(&path).unwrap());
1506 }
1507}