Skip to main content

graphforge_storage/
embedding_mutations.rs

1//! Durable per-lineage mutation evidence for embedding freshness.
2
3use std::collections::BTreeSet;
4use std::fs::{File, OpenOptions};
5use std::io::Write;
6use std::path::{Path, PathBuf};
7use std::time::Instant;
8
9use serde::Deserialize;
10
11use crate::{
12    EmbeddingCompatibilityId, EmbeddingGenerationId, EmbeddingGenerationManifest,
13    EmbeddingMutationObservation, EmbeddingSourceFingerprint, EmbeddingSourceState,
14    SearchArtifactError, SearchCoordinationLimits,
15};
16
17/// Mutation journal schema implemented by this release.
18pub const EMBEDDING_MUTATION_JOURNAL_VERSION: u32 = 1;
19const JOURNAL_FILE: &str = "mutations.json";
20const MAX_DEFAULT_BYTES: usize = 64 * 1024 * 1024;
21
22/// Bounds for one durable lineage mutation journal.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct EmbeddingMutationJournalLimits {
25    /// Maximum distinct changed UUIDs retained since publication.
26    pub changed_uuids: usize,
27    /// Maximum canonical journal bytes.
28    pub metadata_bytes: usize,
29}
30
31impl Default for EmbeddingMutationJournalLimits {
32    fn default() -> Self {
33        Self {
34            changed_uuids: 1_000_000,
35            metadata_bytes: MAX_DEFAULT_BYTES,
36        }
37    }
38}
39
40/// One relevant committed graph mutation supplied by the write-path hook.
41#[derive(Clone, Copy, Debug)]
42pub struct EmbeddingMutationBatch<'a> {
43    /// Durable source state immediately after this committed batch.
44    pub current_source: EmbeddingSourceState,
45    /// Raw stable UUIDs changed in dependency scope by this batch.
46    pub changed_uuids: &'a [[u8; 16]],
47    /// Whether this batch changed topology for a structural space.
48    pub structural_mutation: bool,
49    /// Whether the hook proved the complete affected scope.
50    pub scope_proven: bool,
51}
52
53/// Exact durable mutation evidence since one active generation.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct EmbeddingMutationJournal {
56    compatibility_id: EmbeddingCompatibilityId,
57    generation_id: EmbeddingGenerationId,
58    recorded_source: EmbeddingSourceFingerprint,
59    current_source: EmbeddingSourceState,
60    changed_uuids: BTreeSet<[u8; 16]>,
61    relevant_committed_batches: u64,
62    structural_mutation: bool,
63    scope_proven: bool,
64}
65
66impl EmbeddingMutationJournal {
67    /// Compatibility lineage bound to this evidence.
68    #[must_use]
69    pub const fn compatibility_id(&self) -> EmbeddingCompatibilityId {
70        self.compatibility_id
71    }
72
73    /// Active complete generation bound to this evidence.
74    #[must_use]
75    pub const fn generation_id(&self) -> EmbeddingGenerationId {
76        self.generation_id
77    }
78
79    /// Sorted distinct changed UUIDs retained since reset.
80    #[must_use]
81    pub fn changed_uuids(&self) -> &BTreeSet<[u8; 16]> {
82        &self.changed_uuids
83    }
84
85    /// Reconstruct the exact pure freshness-policy observation.
86    ///
87    /// # Errors
88    /// Returns a validation error only if durable fields are internally inconsistent.
89    pub fn observation(&self) -> Result<EmbeddingMutationObservation, SearchArtifactError> {
90        let changed_distinct_uuids = u64::try_from(self.changed_uuids.len())
91            .map_err(|_| exhausted("embedding_mutation_changed_uuids", self.changed_uuids.len()))?;
92        EmbeddingMutationObservation::new(
93            self.current_source,
94            changed_distinct_uuids,
95            self.relevant_committed_batches,
96            self.structural_mutation,
97            self.scope_proven,
98        )
99    }
100
101    fn reset(manifest: &EmbeddingGenerationManifest) -> Self {
102        Self {
103            compatibility_id: manifest.compatibility_id(),
104            generation_id: manifest.generation_id(),
105            recorded_source: manifest.source().fingerprint(),
106            current_source: manifest.source(),
107            changed_uuids: BTreeSet::new(),
108            relevant_committed_batches: 0,
109            structural_mutation: false,
110            scope_proven: true,
111        }
112    }
113
114    fn to_json(
115        &self,
116        limits: EmbeddingMutationJournalLimits,
117    ) -> Result<Vec<u8>, SearchArtifactError> {
118        let changed = self.changed_uuids.iter().map(encode).collect::<Vec<_>>();
119        let source = self.current_source;
120        let bytes = serde_json::to_vec(&serde_json::json!({
121            "changed_uuids": changed,
122            "compatibility_id": self.compatibility_id.to_hex(),
123            "current_dependency_input_digest": encode(&source.dependency_input_digest()),
124            "current_eligible_uuid_count": source.eligible_uuid_count(),
125            "current_graph_generation": source.graph_generation(),
126            "current_label_membership_digest": encode(&source.label_membership_digest()),
127            "current_source_fingerprint": source.fingerprint().to_hex(),
128            "generation_id": self.generation_id.to_hex(),
129            "journal_version": EMBEDDING_MUTATION_JOURNAL_VERSION,
130            "recorded_source_fingerprint": self.recorded_source.to_hex(),
131            "relevant_committed_batches": self.relevant_committed_batches,
132            "scope_proven": self.scope_proven,
133            "structural_mutation": self.structural_mutation,
134        }))
135        .map_err(|error| SearchArtifactError::Build(error.to_string()))?;
136        if bytes.len() > limits.metadata_bytes {
137            return Err(exhausted(
138                "embedding_mutation_journal_bytes",
139                limits.metadata_bytes,
140            ));
141        }
142        Ok(bytes)
143    }
144}
145
146/// Atomically reset durable evidence to one verified active generation.
147pub fn reset_embedding_mutation_journal<C>(
148    project_dir: &Path,
149    manifest: &EmbeddingGenerationManifest,
150    limits: EmbeddingMutationJournalLimits,
151    coordination: SearchCoordinationLimits,
152    mut checkpoint: C,
153) -> Result<EmbeddingMutationJournal, SearchArtifactError>
154where
155    C: FnMut() -> Result<(), SearchArtifactError>,
156{
157    checkpoint()?;
158    let root = checked_space_root(project_dir, manifest.compatibility_id())?;
159    let _lock = WriterLock::acquire(&root, coordination, &mut checkpoint)?;
160    let path = root.join(JOURNAL_FILE);
161    ensure_optional_regular_file(&path)?;
162    let journal = EmbeddingMutationJournal::reset(manifest);
163    let bytes = journal.to_json(limits)?;
164    checkpoint()?;
165    persist(&path, &bytes)?;
166    Ok(journal)
167}
168
169/// Merge one relevant committed batch without losing the prior durable journal.
170pub fn merge_embedding_mutation_batch<C>(
171    project_dir: &Path,
172    manifest: &EmbeddingGenerationManifest,
173    batch: EmbeddingMutationBatch<'_>,
174    limits: EmbeddingMutationJournalLimits,
175    coordination: SearchCoordinationLimits,
176    mut checkpoint: C,
177) -> Result<EmbeddingMutationJournal, SearchArtifactError>
178where
179    C: FnMut() -> Result<(), SearchArtifactError>,
180{
181    checkpoint()?;
182    let root = checked_space_root(project_dir, manifest.compatibility_id())?;
183    let _lock = WriterLock::acquire(&root, coordination, &mut checkpoint)?;
184    let path = root.join(JOURNAL_FILE);
185    let mut journal = read_linked(&path, manifest, limits)?
186        .ok_or_else(|| SearchArtifactError::Missing { path: path.clone() })?;
187    if batch.current_source.graph_generation() <= journal.current_source.graph_generation() {
188        return Err(invalid(
189            "embedding mutation batch",
190            "current graph generation must increase",
191        ));
192    }
193    for uuid in batch.changed_uuids {
194        checkpoint()?;
195        journal.changed_uuids.insert(*uuid);
196        if journal.changed_uuids.len() > limits.changed_uuids {
197            return Err(exhausted(
198                "embedding_mutation_changed_uuids",
199                limits.changed_uuids,
200            ));
201        }
202    }
203    journal.relevant_committed_batches = journal
204        .relevant_committed_batches
205        .checked_add(1)
206        .ok_or_else(|| exhausted("embedding_mutation_batches", usize::MAX))?;
207    journal.current_source = batch.current_source;
208    journal.structural_mutation |= batch.structural_mutation;
209    journal.scope_proven &= batch.scope_proven;
210    let bytes = journal.to_json(limits)?;
211    checkpoint()?;
212    persist(&path, &bytes)?;
213    Ok(journal)
214}
215
216/// Reopen exact durable evidence linked to the verified active generation.
217pub fn read_embedding_mutation_journal(
218    project_dir: &Path,
219    manifest: &EmbeddingGenerationManifest,
220    limits: EmbeddingMutationJournalLimits,
221) -> Result<Option<EmbeddingMutationJournal>, SearchArtifactError> {
222    let path = checked_space_root(project_dir, manifest.compatibility_id())?.join(JOURNAL_FILE);
223    read_linked(&path, manifest, limits)
224}
225
226fn read_linked(
227    path: &Path,
228    manifest: &EmbeddingGenerationManifest,
229    limits: EmbeddingMutationJournalLimits,
230) -> Result<Option<EmbeddingMutationJournal>, SearchArtifactError> {
231    let Some(bytes) = bounded_read(path, limits.metadata_bytes)? else {
232        return Ok(None);
233    };
234    let raw: RawJournal =
235        serde_json::from_slice(&bytes).map_err(|error| corrupt(path, error.to_string()))?;
236    if raw.journal_version != u64::from(EMBEDDING_MUTATION_JOURNAL_VERSION) {
237        return Err(SearchArtifactError::IncompatibleManifest {
238            path: path.to_path_buf(),
239            found: raw.journal_version,
240            supported: EMBEDDING_MUTATION_JOURNAL_VERSION,
241        });
242    }
243    let compatibility_id = EmbeddingCompatibilityId::from_hex(&raw.compatibility_id)
244        .map_err(|error| corrupt(path, error.to_string()))?;
245    let generation_id = EmbeddingGenerationId::from_hex(&raw.generation_id)
246        .map_err(|error| corrupt(path, error.to_string()))?;
247    let recorded_source = EmbeddingSourceFingerprint::from_hex(&raw.recorded_source_fingerprint)
248        .map_err(|error| corrupt(path, error.to_string()))?;
249    if compatibility_id != manifest.compatibility_id()
250        || generation_id != manifest.generation_id()
251        || recorded_source != manifest.source().fingerprint()
252    {
253        return Err(corrupt(
254            path,
255            "journal linkage does not match active generation",
256        ));
257    }
258    let current_source = EmbeddingSourceState::new(
259        raw.current_graph_generation,
260        decode32(path, &raw.current_label_membership_digest)?,
261        decode32(path, &raw.current_dependency_input_digest)?,
262        raw.current_eligible_uuid_count,
263    );
264    if current_source.fingerprint().to_hex() != raw.current_source_fingerprint {
265        return Err(corrupt(path, "current source fingerprint mismatch"));
266    }
267    if current_source.graph_generation() < manifest.source().graph_generation() {
268        return Err(corrupt(
269            path,
270            "current source predates the recorded generation source",
271        ));
272    }
273    let mut changed_uuids = BTreeSet::new();
274    for value in raw.changed_uuids {
275        if !changed_uuids.insert(decode16(path, &value)?) {
276            return Err(corrupt(path, "duplicate changed UUID"));
277        }
278    }
279    if changed_uuids.len() > limits.changed_uuids {
280        return Err(exhausted(
281            "embedding_mutation_changed_uuids",
282            limits.changed_uuids,
283        ));
284    }
285    let journal = EmbeddingMutationJournal {
286        compatibility_id,
287        generation_id,
288        recorded_source,
289        current_source,
290        changed_uuids,
291        relevant_committed_batches: raw.relevant_committed_batches,
292        structural_mutation: raw.structural_mutation,
293        scope_proven: raw.scope_proven,
294    };
295    journal
296        .observation()
297        .map_err(|error| corrupt(path, error.to_string()))?;
298    if journal.to_json(limits)? != bytes {
299        return Err(corrupt(path, "journal bytes are not exact canonical JSON"));
300    }
301    Ok(Some(journal))
302}
303
304#[derive(Deserialize)]
305#[serde(deny_unknown_fields)]
306struct RawJournal {
307    journal_version: u64,
308    compatibility_id: String,
309    generation_id: String,
310    recorded_source_fingerprint: String,
311    current_source_fingerprint: String,
312    current_graph_generation: u64,
313    current_label_membership_digest: String,
314    current_dependency_input_digest: String,
315    current_eligible_uuid_count: u64,
316    changed_uuids: Vec<String>,
317    relevant_committed_batches: u64,
318    structural_mutation: bool,
319    scope_proven: bool,
320}
321
322fn checked_space_root(
323    project: &Path,
324    id: EmbeddingCompatibilityId,
325) -> Result<PathBuf, SearchArtifactError> {
326    let embeddings = project.join("embeddings");
327    ensure_real_directory(&embeddings)?;
328    let spaces = embeddings.join("spaces");
329    ensure_real_directory(&spaces)?;
330    let root = spaces.join(id.to_hex());
331    ensure_real_directory(&root)?;
332    Ok(root)
333}
334
335fn ensure_real_directory(path: &Path) -> Result<(), SearchArtifactError> {
336    let metadata = std::fs::symlink_metadata(path)
337        .map_err(|source| io("inspect embedding directory", path, source))?;
338    if metadata.file_type().is_symlink() || !metadata.is_dir() {
339        return Err(corrupt(path, "embedding path must be a real directory"));
340    }
341    Ok(())
342}
343
344fn ensure_optional_regular_file(path: &Path) -> Result<(), SearchArtifactError> {
345    match std::fs::symlink_metadata(path) {
346        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
347            Err(corrupt(path, "journal path must be a regular file"))
348        }
349        Ok(_) => Ok(()),
350        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
351        Err(source) => Err(io("inspect mutation journal", path, source)),
352    }
353}
354
355fn bounded_read(path: &Path, max: usize) -> Result<Option<Vec<u8>>, SearchArtifactError> {
356    let metadata = match std::fs::symlink_metadata(path) {
357        Ok(metadata) => metadata,
358        Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
359        Err(source) => return Err(io("inspect mutation journal", path, source)),
360    };
361    if metadata.file_type().is_symlink() || !metadata.is_file() {
362        return Err(corrupt(path, "journal must be a regular file"));
363    }
364    if metadata.len() > usize_limit(max) {
365        return Err(exhausted("embedding_mutation_journal_bytes", max));
366    }
367    std::fs::read(path)
368        .map(Some)
369        .map_err(|source| io("read mutation journal", path, source))
370}
371
372fn persist(path: &Path, bytes: &[u8]) -> Result<(), SearchArtifactError> {
373    ensure_optional_regular_file(path)?;
374    let parent = path
375        .parent()
376        .ok_or_else(|| SearchArtifactError::Build("journal has no parent".to_owned()))?;
377    let mut temp = tempfile::Builder::new()
378        .prefix(".mutations.json.")
379        .suffix(".tmp")
380        .tempfile_in(parent)
381        .map_err(|source| io("create mutation journal temp", path, source))?;
382    temp.write_all(bytes)
383        .map_err(|source| io("write mutation journal", path, source))?;
384    temp.as_file()
385        .sync_all()
386        .map_err(|source| io("sync mutation journal", path, source))?;
387    temp.persist(path)
388        .map_err(|error| io("publish mutation journal", path, error.error))?;
389    sync_dir(parent)
390}
391
392struct WriterLock {
393    file: File,
394}
395
396impl WriterLock {
397    fn acquire<C>(
398        root: &Path,
399        limits: SearchCoordinationLimits,
400        checkpoint: &mut C,
401    ) -> Result<Self, SearchArtifactError>
402    where
403        C: FnMut() -> Result<(), SearchArtifactError>,
404    {
405        let path = root.join(".writer.lock");
406        ensure_optional_regular_file(&path)?;
407        let file = OpenOptions::new()
408            .read(true)
409            .write(true)
410            .create(true)
411            .truncate(false)
412            .open(&path)
413            .map_err(|source| SearchArtifactError::Lock {
414                path: path.clone(),
415                reason: source.to_string(),
416            })?;
417        let start = Instant::now();
418        loop {
419            match file.try_lock() {
420                Ok(()) => return Ok(Self { file }),
421                Err(std::fs::TryLockError::WouldBlock) => {
422                    checkpoint()?;
423                    if start.elapsed() >= limits.lock_timeout {
424                        return Err(SearchArtifactError::Lock {
425                            path,
426                            reason: "timed out waiting for embedding writer".to_owned(),
427                        });
428                    }
429                    std::thread::sleep(limits.lock_poll_interval);
430                }
431                Err(std::fs::TryLockError::Error(source)) => {
432                    return Err(SearchArtifactError::Lock {
433                        path,
434                        reason: source.to_string(),
435                    });
436                }
437            }
438        }
439    }
440}
441
442impl Drop for WriterLock {
443    fn drop(&mut self) {
444        let _ = self.file.unlock();
445    }
446}
447
448fn encode<const N: usize>(value: &[u8; N]) -> String {
449    const HEX: &[u8; 16] = b"0123456789abcdef";
450    let mut output = String::with_capacity(N * 2);
451    for byte in value {
452        output.push(char::from(HEX[usize::from(byte >> 4)]));
453        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
454    }
455    output
456}
457
458fn decode16(path: &Path, value: &str) -> Result<[u8; 16], SearchArtifactError> {
459    decode(path, value)
460}
461
462fn decode32(path: &Path, value: &str) -> Result<[u8; 32], SearchArtifactError> {
463    decode(path, value)
464}
465
466fn decode<const N: usize>(path: &Path, value: &str) -> Result<[u8; N], SearchArtifactError> {
467    if value.len() != N * 2
468        || !value
469            .bytes()
470            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
471    {
472        return Err(corrupt(path, "invalid lowercase hexadecimal field"));
473    }
474    let mut output = [0; N];
475    for (index, byte) in output.iter_mut().enumerate() {
476        let offset = index * 2;
477        *byte = u8::from_str_radix(&value[offset..offset + 2], 16)
478            .map_err(|error| corrupt(path, error.to_string()))?;
479    }
480    Ok(output)
481}
482
483#[cfg(unix)]
484fn sync_dir(path: &Path) -> Result<(), SearchArtifactError> {
485    File::open(path)
486        .and_then(|file| file.sync_all())
487        .map_err(|source| io("sync mutation journal directory", path, source))
488}
489
490#[cfg(not(unix))]
491fn sync_dir(_: &Path) -> Result<(), SearchArtifactError> {
492    Ok(())
493}
494
495fn usize_limit(limit: usize) -> u64 {
496    u64::try_from(limit).unwrap_or(u64::MAX)
497}
498
499fn exhausted(resource: &'static str, limit: usize) -> SearchArtifactError {
500    SearchArtifactError::ResourceExhausted {
501        resource,
502        limit: usize_limit(limit),
503    }
504}
505
506fn invalid(field: &'static str, reason: impl Into<String>) -> SearchArtifactError {
507    SearchArtifactError::InvalidSelector {
508        field,
509        reason: reason.into(),
510    }
511}
512
513fn corrupt(path: &Path, reason: impl Into<String>) -> SearchArtifactError {
514    SearchArtifactError::CorruptManifest {
515        path: path.to_path_buf(),
516        reason: reason.into(),
517    }
518}
519
520fn io(operation: &'static str, path: &Path, source: std::io::Error) -> SearchArtifactError {
521    SearchArtifactError::Io {
522        operation,
523        path: path.to_path_buf(),
524        source,
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use serde_json::{Value, json};
531
532    use super::*;
533    use crate::{
534        EmbeddingContentDigest, EmbeddingGenerationManifestInput, EmbeddingPublicationFingerprint,
535    };
536
537    const UUID_A: [u8; 16] = [1; 16];
538    const UUID_B: [u8; 16] = [2; 16];
539    const UUID_C: [u8; 16] = [3; 16];
540
541    fn compatibility(marker: u8) -> EmbeddingCompatibilityId {
542        EmbeddingCompatibilityId::from_hex(&format!("{marker:02x}").repeat(32)).unwrap()
543    }
544
545    fn source(generation: u64, marker: u8) -> EmbeddingSourceState {
546        EmbeddingSourceState::new(generation, [marker; 32], [marker + 1; 32], 3)
547    }
548
549    fn manifest(marker: u8) -> EmbeddingGenerationManifest {
550        EmbeddingGenerationManifest::new(EmbeddingGenerationManifestInput {
551            compatibility_id: compatibility(marker),
552            source: source(10, marker),
553            content_digest: EmbeddingContentDigest::digest(&[marker]),
554            vector_count: 3,
555            dimension: 2,
556            generated_at_micros: 20,
557            committed_at_micros: 21,
558            publication_fingerprint: EmbeddingPublicationFingerprint::digest(&[marker]),
559        })
560        .unwrap()
561    }
562
563    fn create_space(project: &Path, manifest: &EmbeddingGenerationManifest) -> PathBuf {
564        let root = project
565            .join("embeddings")
566            .join("spaces")
567            .join(manifest.compatibility_id().to_hex());
568        std::fs::create_dir_all(&root).unwrap();
569        root
570    }
571
572    fn reset(project: &Path, manifest: &EmbeddingGenerationManifest) -> EmbeddingMutationJournal {
573        reset_embedding_mutation_journal(
574            project,
575            manifest,
576            EmbeddingMutationJournalLimits::default(),
577            SearchCoordinationLimits::default(),
578            || Ok(()),
579        )
580        .unwrap()
581    }
582
583    fn merge(
584        project: &Path,
585        manifest: &EmbeddingGenerationManifest,
586        batch: EmbeddingMutationBatch<'_>,
587    ) -> EmbeddingMutationJournal {
588        merge_embedding_mutation_batch(
589            project,
590            manifest,
591            batch,
592            EmbeddingMutationJournalLimits::default(),
593            SearchCoordinationLimits::default(),
594            || Ok(()),
595        )
596        .unwrap()
597    }
598
599    #[test]
600    fn reset_merge_and_reopen_reconstruct_exact_observation() {
601        let project = tempfile::tempdir().unwrap();
602        let manifest = manifest(1);
603        let root = create_space(project.path(), &manifest);
604
605        let initial = reset(project.path(), &manifest);
606        assert_eq!(initial.current_source, manifest.source());
607        assert!(initial.changed_uuids().is_empty());
608
609        merge(
610            project.path(),
611            &manifest,
612            EmbeddingMutationBatch {
613                current_source: source(11, 2),
614                changed_uuids: &[UUID_B, UUID_A, UUID_A],
615                structural_mutation: false,
616                scope_proven: true,
617            },
618        );
619        let merged = merge(
620            project.path(),
621            &manifest,
622            EmbeddingMutationBatch {
623                current_source: source(12, 3),
624                changed_uuids: &[UUID_C, UUID_A],
625                structural_mutation: true,
626                scope_proven: false,
627            },
628        );
629        let reopened = read_embedding_mutation_journal(
630            project.path(),
631            &manifest,
632            EmbeddingMutationJournalLimits::default(),
633        )
634        .unwrap()
635        .unwrap();
636        assert_eq!(reopened, merged);
637        assert_eq!(
638            reopened.changed_uuids().iter().copied().collect::<Vec<_>>(),
639            [UUID_A, UUID_B, UUID_C]
640        );
641        let observation = reopened.observation().unwrap();
642        assert_eq!(observation.current_source(), source(12, 3));
643        assert_eq!(observation.changed_distinct_uuids(), 3);
644        assert_eq!(observation.relevant_committed_batches(), 2);
645        assert!(observation.structural_mutation());
646        assert!(!observation.scope_proven());
647
648        let raw: Value =
649            serde_json::from_slice(&std::fs::read(root.join(JOURNAL_FILE)).unwrap()).unwrap();
650        assert_eq!(
651            raw["changed_uuids"],
652            json!([encode(&UUID_A), encode(&UUID_B), encode(&UUID_C)])
653        );
654        assert_eq!(reset(project.path(), &manifest), initial);
655    }
656
657    #[test]
658    fn missing_journal_is_distinct_and_lineages_are_byte_isolated() {
659        let project = tempfile::tempdir().unwrap();
660        let left = manifest(4);
661        let right = manifest(5);
662        let left_root = create_space(project.path(), &left);
663        let right_root = create_space(project.path(), &right);
664        assert!(
665            read_embedding_mutation_journal(
666                project.path(),
667                &left,
668                EmbeddingMutationJournalLimits::default(),
669            )
670            .unwrap()
671            .is_none()
672        );
673
674        reset(project.path(), &left);
675        reset(project.path(), &right);
676        let right_before = std::fs::read(right_root.join(JOURNAL_FILE)).unwrap();
677        merge(
678            project.path(),
679            &left,
680            EmbeddingMutationBatch {
681                current_source: source(11, 6),
682                changed_uuids: &[UUID_A],
683                structural_mutation: false,
684                scope_proven: true,
685            },
686        );
687        assert_ne!(
688            std::fs::read(left_root.join(JOURNAL_FILE)).unwrap(),
689            right_before
690        );
691        assert_eq!(
692            std::fs::read(right_root.join(JOURNAL_FILE)).unwrap(),
693            right_before
694        );
695    }
696
697    #[test]
698    fn cancellation_limits_monotonicity_and_overflow_preserve_prior_bytes() {
699        let project = tempfile::tempdir().unwrap();
700        let manifest = manifest(7);
701        let root = create_space(project.path(), &manifest);
702        reset(project.path(), &manifest);
703        let path = root.join(JOURNAL_FILE);
704
705        let before = std::fs::read(&path).unwrap();
706        let mut checkpoints = 0;
707        let cancelled = merge_embedding_mutation_batch(
708            project.path(),
709            &manifest,
710            EmbeddingMutationBatch {
711                current_source: source(11, 8),
712                changed_uuids: &[UUID_A, UUID_B],
713                structural_mutation: false,
714                scope_proven: true,
715            },
716            EmbeddingMutationJournalLimits::default(),
717            SearchCoordinationLimits::default(),
718            || {
719                checkpoints += 1;
720                if checkpoints == 3 {
721                    Err(SearchArtifactError::Cancelled)
722                } else {
723                    Ok(())
724                }
725            },
726        )
727        .unwrap_err();
728        assert!(matches!(cancelled, SearchArtifactError::Cancelled));
729        assert_eq!(std::fs::read(&path).unwrap(), before);
730
731        let limited = merge_embedding_mutation_batch(
732            project.path(),
733            &manifest,
734            EmbeddingMutationBatch {
735                current_source: source(11, 8),
736                changed_uuids: &[UUID_A],
737                structural_mutation: false,
738                scope_proven: true,
739            },
740            EmbeddingMutationJournalLimits {
741                changed_uuids: 0,
742                metadata_bytes: MAX_DEFAULT_BYTES,
743            },
744            SearchCoordinationLimits::default(),
745            || Ok(()),
746        )
747        .unwrap_err();
748        assert!(matches!(
749            limited,
750            SearchArtifactError::ResourceExhausted { .. }
751        ));
752        assert_eq!(std::fs::read(&path).unwrap(), before);
753
754        let non_monotonic = merge_embedding_mutation_batch(
755            project.path(),
756            &manifest,
757            EmbeddingMutationBatch {
758                current_source: source(10, 8),
759                changed_uuids: &[],
760                structural_mutation: false,
761                scope_proven: true,
762            },
763            EmbeddingMutationJournalLimits::default(),
764            SearchCoordinationLimits::default(),
765            || Ok(()),
766        )
767        .unwrap_err();
768        assert!(matches!(
769            non_monotonic,
770            SearchArtifactError::InvalidSelector { .. }
771        ));
772        assert_eq!(std::fs::read(&path).unwrap(), before);
773
774        let mut raw: Value = serde_json::from_slice(&before).unwrap();
775        raw["relevant_committed_batches"] = json!(u64::MAX);
776        let maximum = serde_json::to_vec(&raw).unwrap();
777        std::fs::write(&path, &maximum).unwrap();
778        let overflow = merge_embedding_mutation_batch(
779            project.path(),
780            &manifest,
781            EmbeddingMutationBatch {
782                current_source: source(11, 8),
783                changed_uuids: &[],
784                structural_mutation: false,
785                scope_proven: true,
786            },
787            EmbeddingMutationJournalLimits::default(),
788            SearchCoordinationLimits::default(),
789            || Ok(()),
790        )
791        .unwrap_err();
792        assert!(matches!(
793            overflow,
794            SearchArtifactError::ResourceExhausted { .. }
795        ));
796        assert_eq!(std::fs::read(&path).unwrap(), maximum);
797    }
798
799    #[test]
800    fn reset_cancellation_and_metadata_limit_preserve_prior_bytes() {
801        let project = tempfile::tempdir().unwrap();
802        let manifest = manifest(8);
803        let root = create_space(project.path(), &manifest);
804        reset(project.path(), &manifest);
805        let path = root.join(JOURNAL_FILE);
806        let before = std::fs::read(&path).unwrap();
807
808        let mut checkpoints = 0;
809        let cancelled = reset_embedding_mutation_journal(
810            project.path(),
811            &manifest,
812            EmbeddingMutationJournalLimits::default(),
813            SearchCoordinationLimits::default(),
814            || {
815                checkpoints += 1;
816                if checkpoints == 2 {
817                    Err(SearchArtifactError::Cancelled)
818                } else {
819                    Ok(())
820                }
821            },
822        )
823        .unwrap_err();
824        assert!(matches!(cancelled, SearchArtifactError::Cancelled));
825        assert_eq!(std::fs::read(&path).unwrap(), before);
826
827        let limited = reset_embedding_mutation_journal(
828            project.path(),
829            &manifest,
830            EmbeddingMutationJournalLimits {
831                changed_uuids: 1,
832                metadata_bytes: 1,
833            },
834            SearchCoordinationLimits::default(),
835            || Ok(()),
836        )
837        .unwrap_err();
838        assert!(matches!(
839            limited,
840            SearchArtifactError::ResourceExhausted { .. }
841        ));
842        assert_eq!(std::fs::read(&path).unwrap(), before);
843    }
844
845    #[test]
846    fn corruption_incompatible_versions_and_linkage_mismatch_fail_closed() {
847        let cases = [
848            "duplicate",
849            "unknown",
850            "noncanonical",
851            "version",
852            "source-fingerprint",
853            "predates",
854            "duplicate-changed-uuid",
855        ];
856        for case in cases {
857            let project = tempfile::tempdir().unwrap();
858            let manifest = manifest(9);
859            let root = create_space(project.path(), &manifest);
860            reset(project.path(), &manifest);
861            let path = root.join(JOURNAL_FILE);
862            let canonical = std::fs::read(&path).unwrap();
863            let replacement = match case {
864                "duplicate" => {
865                    let mut bytes = br#"{"journal_version":1,"#.to_vec();
866                    bytes.extend_from_slice(&canonical[1..]);
867                    bytes
868                }
869                "unknown" => {
870                    let mut raw: Value = serde_json::from_slice(&canonical).unwrap();
871                    raw["unexpected"] = json!(true);
872                    serde_json::to_vec(&raw).unwrap()
873                }
874                "noncanonical" => {
875                    let mut bytes = canonical.clone();
876                    bytes.push(b'\n');
877                    bytes
878                }
879                "version" => {
880                    let mut raw: Value = serde_json::from_slice(&canonical).unwrap();
881                    raw["journal_version"] = json!(99);
882                    serde_json::to_vec(&raw).unwrap()
883                }
884                "source-fingerprint" => {
885                    let mut raw: Value = serde_json::from_slice(&canonical).unwrap();
886                    raw["current_source_fingerprint"] = json!("00".repeat(32));
887                    serde_json::to_vec(&raw).unwrap()
888                }
889                "predates" => {
890                    let mut raw: Value = serde_json::from_slice(&canonical).unwrap();
891                    raw["current_graph_generation"] = json!(9);
892                    raw["current_source_fingerprint"] = json!(source(9, 9).fingerprint().to_hex());
893                    serde_json::to_vec(&raw).unwrap()
894                }
895                "duplicate-changed-uuid" => {
896                    let mut raw: Value = serde_json::from_slice(&canonical).unwrap();
897                    raw["changed_uuids"] = json!([encode(&UUID_A), encode(&UUID_A)]);
898                    serde_json::to_vec(&raw).unwrap()
899                }
900                _ => unreachable!(),
901            };
902            std::fs::write(&path, &replacement).unwrap();
903            let error = read_embedding_mutation_journal(
904                project.path(),
905                &manifest,
906                EmbeddingMutationJournalLimits::default(),
907            )
908            .unwrap_err();
909            if case == "version" {
910                assert!(matches!(
911                    error,
912                    SearchArtifactError::IncompatibleManifest { found: 99, .. }
913                ));
914            } else {
915                assert!(matches!(error, SearchArtifactError::CorruptManifest { .. }));
916            }
917            assert_eq!(std::fs::read(&path).unwrap(), replacement);
918        }
919
920        let project = tempfile::tempdir().unwrap();
921        let active = manifest(10);
922        let other = manifest(11);
923        let root = create_space(project.path(), &active);
924        reset(project.path(), &active);
925        let path = root.join(JOURNAL_FILE);
926        let before = std::fs::read(&path).unwrap();
927        let mut raw: Value = serde_json::from_slice(&before).unwrap();
928        raw["generation_id"] = json!(other.generation_id().to_hex());
929        let mismatch = serde_json::to_vec(&raw).unwrap();
930        std::fs::write(&path, &mismatch).unwrap();
931        assert!(matches!(
932            read_embedding_mutation_journal(
933                project.path(),
934                &active,
935                EmbeddingMutationJournalLimits::default(),
936            ),
937            Err(SearchArtifactError::CorruptManifest { .. })
938        ));
939        assert_eq!(std::fs::read(&path).unwrap(), mismatch);
940    }
941
942    #[cfg(unix)]
943    #[test]
944    fn symlinked_ancestors_journal_and_lock_fail_closed() {
945        use std::os::unix::fs::symlink;
946
947        let external = tempfile::tempdir().unwrap();
948        let project = tempfile::tempdir().unwrap();
949        let manifest = manifest(12);
950        symlink(external.path(), project.path().join("embeddings")).unwrap();
951        assert!(matches!(
952            reset_embedding_mutation_journal(
953                project.path(),
954                &manifest,
955                EmbeddingMutationJournalLimits::default(),
956                SearchCoordinationLimits::default(),
957                || Ok(()),
958            ),
959            Err(SearchArtifactError::CorruptManifest { .. })
960        ));
961
962        let project = tempfile::tempdir().unwrap();
963        let root = create_space(project.path(), &manifest);
964        let external_file = external.path().join("external");
965        std::fs::write(&external_file, b"unchanged").unwrap();
966        symlink(&external_file, root.join(JOURNAL_FILE)).unwrap();
967        assert!(matches!(
968            reset_embedding_mutation_journal(
969                project.path(),
970                &manifest,
971                EmbeddingMutationJournalLimits::default(),
972                SearchCoordinationLimits::default(),
973                || Ok(()),
974            ),
975            Err(SearchArtifactError::CorruptManifest { .. })
976        ));
977        assert_eq!(std::fs::read(&external_file).unwrap(), b"unchanged");
978
979        std::fs::remove_file(root.join(JOURNAL_FILE)).unwrap();
980        std::fs::remove_file(root.join(".writer.lock")).unwrap();
981        symlink(&external_file, root.join(".writer.lock")).unwrap();
982        assert!(matches!(
983            reset_embedding_mutation_journal(
984                project.path(),
985                &manifest,
986                EmbeddingMutationJournalLimits::default(),
987                SearchCoordinationLimits::default(),
988                || Ok(()),
989            ),
990            Err(SearchArtifactError::CorruptManifest { .. })
991        ));
992        assert_eq!(std::fs::read(&external_file).unwrap(), b"unchanged");
993    }
994}