Skip to main content

agent_engine/memory_backend/
migration.rs

1//! Explicit operator-only legacy migration. Never used by model tools or startup.
2//!
3//! Each atomic source is the original project's built-in JSONL file and validated context
4//! archives. Preview contains counts and hashes, never bodies, tags, IDs or notes.
5//! Sources are opened read-only, nofollow, without creating directories/lock files.
6//! Apply rereads and verifies the preview and sends ONE <=24 MiB transaction. It
7//! never edits config, deletes source, switches selection or performs dual writes.
8//! Stop legacy writers for cutover: file locks are advisory, and old appenders do
9//! not participate in them. Retained descriptors plus a second read detect changes
10//! before dispatch; they cannot make uncooperative legacy processes transactional.
11
12use 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
26/// The complete RPC envelope, not merely its records, must fit this limit.
27pub 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/// Safe to display to the operator: fixed-size metadata, never source text.
33#[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
59// No Debug/Serialize: this carries private bodies and read locks, not a preview.
60struct Snapshot {
61    manifest: MigrationManifest,
62    payload: Value,
63    source: Option<std::fs::File>,
64    raw: Vec<u8>,
65    histories: Vec<Value>,
66}
67
68/// Offline preview. Does not start the service or create/open a destination brain.
69pub fn preview_in(base: &Path, scope: &ProjectScope) -> Result<MigrationManifest> {
70    Ok(snapshot_in(base, scope)?.manifest)
71}
72
73/// Operator-only apply; the source is freshly read and checked, never taken from
74/// an editable saved preview. Only metadata is returned by the service. On an
75/// unknown commit, retry this same preview, not a newly generated record set.
76pub 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    // Keep the read lock alive until the operation has acknowledged.
95    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        // Re-open through the nofollow path as well: catch atomic replacement or
115        // creation after a missing-file snapshot, not just edits to the held fd.
116        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    // This read-only core API validates the original projection digest and scope;
235    // do NOT reseal/reproject, reinterpret logical hashes, or disclose hidden notes.
236    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        // Only use Value for discriminating the line. Strict structs below catch
358        // duplicate/unknown top-level fields, including hidden policy overrides.
359        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        // Built-in records have no opaque metadata. Reject rather than discard
370        // unsupported embedded disclosure/expiry policies, even on excluded rows.
371        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    // Tombstones are independent of record order and survive even without a
431    // live ID. Suppress stale rows before sending; the target also applies all
432    // tombstones first and must suppress target-side forgotten IDs on retry.
433    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
445/// Explicit operator source. These paths/aliases must never originate in a
446/// model tool parameter. No automatic plugin discovery or prefix conversion.
447pub 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    // Keep export-file read lock until acknowledgement. Re-export the source
482    // brain immediately before applying to detect changes after preview/current
483    // read; the read-only exporter independently checks source stability.
484    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        // A host-looking original owner is never a plugin retargeting grant,
561        // even when the inventory has only tombstones/fingerprints or no bodies.
562        || (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    // Core validates/recomputes original legacy archive projections. This
622    // inventory is kept byte/logically exact; service validation before its one
623    // transaction is authoritative for imported histories and capture policies.
624    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
718/// Validate provenance shape only. These retained roots/keys are DATA, never
719/// authority to discover/read a path or widen service membership. In particular,
720/// repository_project may differ from the record owner (e.g. user-wide notes).
721fn 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    // Exporter must already bind target to this EXACT host key. For plugin rows
759    // its preserved _axel.source_project proves the exact operator alias mapping.
760    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    // Forum is a reserved typed domain. Preserve original owner/digest and
778    // refuse ordinary records attempting to impersonate it on import.
779    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        // Even a deletion timestamp change participates in the full source hash.
1026        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            // Historical paths are never resolved, created, or interpreted as an alias.
1256            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        // Use the actual production store path, not a hand-sanitized export row.
1327        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}