Skip to main content

alef/snippets/
cache.rs

1use crate::snippets::error::Result;
2use crate::snippets::types::{SideEffectClass, Snippet, ValidationLevel, ValidationResult};
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6/// The `CacheEntry` struct's own on-disk shape, bumped only when that shape changes
7/// incompatibly (a field added, removed, or retyped). This is deliberately independent of
8/// [`ALEF_VALIDATOR_VERSION`] below -- it answers "can this JSON still deserialize", not "was
9/// this verdict computed by the classification logic running today". ~keep
10const CACHE_SCHEMA_VERSION: u32 = 2;
11
12/// Alef defect #138: the cache key used to cover only the snippet's own content (language,
13/// level, code, metadata) and the session fingerprint -- never anything that identifies *which
14/// alef* produced the verdict. A validator classification fix (a `Fail` pattern narrowed, a
15/// dependency-error heuristic corrected, an `achievable_level` gap reclassified) changes no byte
16/// this hash was built from, so `alef snippets check` replayed the previous release's verdict
17/// after every such fix, and the only working remedy consumers found was `--cache off` on every
18/// run -- a permanent admission the cache could not be trusted to invalidate itself.
19///
20/// This crate is single-binary and root-flat (`alef`, both bin and lib): the snippet validators
21/// under `src/snippets/validators/` ship at exactly the same version as everything else, so the
22/// crate's own release version is a complete, zero-maintenance proxy for "which classification
23/// logic computed this". Folding it into the hash means a stale entry from a different alef
24/// version is simply a cache miss under a different filename, not a stored verdict a version
25/// check has to remember to compare -- the same reasoning [`CACHE_SCHEMA_VERSION`] already uses
26/// for the entry's own shape, extended to cover the logic that filled it in. ~keep
27///
28/// It is a complete proxy only for *released* logic. Two builds of one unreleased version are two
29/// different validators wearing one number, which is defect #612 on
30/// [`ValidationCache::invalidation_key`]. That key now salts on the full build identity instead;
31/// this constant remains the salt for [`ValidationCache::key`], whose job is naming compilation
32/// scratch directories, not deciding whether a verdict survives. ~keep
33const ALEF_VALIDATOR_VERSION: &str = env!("CARGO_PKG_VERSION");
34
35#[derive(Debug, Serialize, Deserialize)]
36struct CacheEntry {
37    schema_version: u32,
38    result: ValidationResult,
39}
40
41pub struct ValidationCache {
42    directory: PathBuf,
43}
44
45impl ValidationCache {
46    #[must_use]
47    pub fn new(directory: PathBuf) -> Self {
48        Self { directory }
49    }
50
51    /// A stable, deterministic identifier for one `(snippet, level, session)` triple.
52    ///
53    /// This is **not** the cache-invalidation key -- see [`Self::invalidation_key`] for that. It
54    /// is reused as-is by the Java/Rust/Kotlin/C# batch validators purely to name deterministic
55    /// per-snippet compilation-unit directories/files, a purpose that has nothing to do with
56    /// whether a cached verdict should survive an annotation or policy edit. Narrowing this
57    /// function to only what those callers need (and *not* widening it to everything
58    /// [`Self::invalidation_key`] covers) keeps that unrelated naming scheme stable and avoids
59    /// forcing every batch validator to thread new parameters through for no behavioral benefit
60    /// to them. ~keep
61    #[must_use]
62    pub fn key(snippet: &Snippet, level: ValidationLevel, session_fingerprint: Option<&str>) -> String {
63        Self::key_for_validator_version(snippet, level, session_fingerprint, ALEF_VALIDATOR_VERSION)
64    }
65
66    /// The actual hash, parameterized on the validator version so a test can prove two different
67    /// versions of alef never share a cache key without needing two real builds. Production code
68    /// only ever reaches this through [`Self::key`], which always passes
69    /// [`ALEF_VALIDATOR_VERSION`].
70    fn key_for_validator_version(
71        snippet: &Snippet,
72        level: ValidationLevel,
73        session_fingerprint: Option<&str>,
74        validator_version: &str,
75    ) -> String {
76        let mut hasher = blake3::Hasher::new();
77        hasher.update(validator_version.as_bytes());
78        hasher.update(snippet.language.to_string().as_bytes());
79        hasher.update(level.to_string().as_bytes());
80        hasher.update(snippet.code.as_bytes());
81        hasher.update(format!("{:?}", snippet.metadata).as_bytes());
82        if let Some(fingerprint) = session_fingerprint {
83            hasher.update(fingerprint.as_bytes());
84        }
85        hasher.finalize().to_hex().to_string()
86    }
87
88    /// The cache-invalidation key actually used to read and write `.alef/snippets/*.json`.
89    ///
90    /// Alef defect #554: [`Self::key`] covered the snippet's own content (language, level, code,
91    /// `metadata`) and the session fingerprint, but never `snippet.annotation` -- the
92    /// `<!-- snippet:*-only -->`/`<!-- snippet:skip -->` comment a doc author edits directly above
93    /// a fenced block -- nor the `docs.snippets` side-effect policy (`deny_unclassified`,
94    /// `allowed_side_effects`) that `runner::side_effect_rejection` reads before a `Run`-level
95    /// snippet is even handed to a validator. Both feed `runner::classify_result` /
96    /// `runner::side_effect_rejection` and can change a verdict for byte-identical snippet
97    /// content, so a consumer correcting only the annotation on six TOML snippets kept replaying
98    /// the previous run's `Downgraded`/`capability_capped` verdict through every `alef all` until
99    /// an explicit `alef cache clear` -- and, in the dangerous direction nobody reported, a
100    /// snippet whose annotation or side-effect policy newly makes it *fail* would just as silently
101    /// keep serving a stale cached `Pass`. ~keep
102    ///
103    /// Alef defect #612: the salt above was [`ALEF_VALIDATOR_VERSION`] -- the semver and nothing
104    /// else. A semver does not identify a build. During a release cycle every candidate binary
105    /// self-reports the same version while being built from a different commit, so a verdict
106    /// computed by one candidate is served to the next: a validator fix shipped between the two is
107    /// masked by a stale `Pass`, and -- the direction nobody reports -- a breakage introduced
108    /// between them is masked the same way. Five candidate binaries all reporting `0.72.0` were
109    /// handed to a consumer in one cycle, and a consumer agent independently noticed the binary on
110    /// its PATH changing commit mid-investigation while the version string never moved.
111    ///
112    /// The salt is now `bin_cli::build_info::build_identity()`, the same stamped provenance
113    /// `alef --version` prints, asked for rather than re-derived: see that function for why a
114    /// clean build's identity deliberately excludes the build time (so released binaries keep a
115    /// warm, shareable cache) and a dirty build's deliberately includes it. It subsumes
116    /// [`ALEF_VALIDATOR_VERSION`], which the identity's first component still is. ~keep
117    fn invalidation_key(
118        snippet: &Snippet,
119        level: ValidationLevel,
120        session_fingerprint: Option<&str>,
121        deny_unclassified: bool,
122        allowed_side_effects: &[SideEffectClass],
123    ) -> String {
124        Self::invalidation_key_for_build_identity(
125            snippet,
126            level,
127            session_fingerprint,
128            deny_unclassified,
129            allowed_side_effects,
130            crate::bin_cli::build_info::build_identity(),
131        )
132    }
133
134    /// [`Self::invalidation_key`] with the alef build identity supplied by the caller, so a test
135    /// can observe the effect of an alef upgrade -- or of a same-version rebuild from another
136    /// commit -- without producing two real binaries. Mirrors
137    /// [`Self::key_for_validator_version`]'s reason for existing.
138    fn invalidation_key_for_build_identity(
139        snippet: &Snippet,
140        level: ValidationLevel,
141        session_fingerprint: Option<&str>,
142        deny_unclassified: bool,
143        allowed_side_effects: &[SideEffectClass],
144        alef_build_identity: &str,
145    ) -> String {
146        let mut hasher = blake3::Hasher::new();
147        hasher.update(alef_build_identity.as_bytes());
148        hasher.update(snippet.language.to_string().as_bytes());
149        hasher.update(level.to_string().as_bytes());
150        hasher.update(snippet.code.as_bytes());
151        hasher.update(format!("{:?}", snippet.metadata).as_bytes());
152        hasher.update(format!("{:?}", snippet.annotation).as_bytes());
153        hasher.update(deny_unclassified.to_string().as_bytes());
154        hasher.update(format!("{allowed_side_effects:?}").as_bytes());
155        if let Some(fingerprint) = session_fingerprint {
156            hasher.update(fingerprint.as_bytes());
157        }
158        hasher.finalize().to_hex().to_string()
159    }
160
161    pub fn load(
162        &self,
163        snippet: &Snippet,
164        level: ValidationLevel,
165        session_fingerprint: Option<&str>,
166        deny_unclassified: bool,
167        allowed_side_effects: &[SideEffectClass],
168    ) -> Option<ValidationResult> {
169        let path = self.path_for(
170            snippet,
171            level,
172            session_fingerprint,
173            deny_unclassified,
174            allowed_side_effects,
175        );
176        let content = std::fs::read_to_string(path).ok()?;
177        let entry: CacheEntry = serde_json::from_str(&content).ok()?;
178        (entry.schema_version == CACHE_SCHEMA_VERSION).then_some(entry.result)
179    }
180
181    /// # Errors
182    ///
183    /// Returns an error when the cache directory or entry cannot be written.
184    pub fn store(
185        &self,
186        snippet: &Snippet,
187        level: ValidationLevel,
188        session_fingerprint: Option<&str>,
189        deny_unclassified: bool,
190        allowed_side_effects: &[SideEffectClass],
191        result: &ValidationResult,
192    ) -> Result<()> {
193        crate::core::cache_dir::ensure_project_cache_dir(&self.directory)?;
194        let entry = CacheEntry {
195            schema_version: CACHE_SCHEMA_VERSION,
196            result: result.clone(),
197        };
198        std::fs::write(
199            self.path_for(
200                snippet,
201                level,
202                session_fingerprint,
203                deny_unclassified,
204                allowed_side_effects,
205            ),
206            serde_json::to_vec_pretty(&entry)?,
207        )?;
208        Ok(())
209    }
210
211    fn path_for(
212        &self,
213        snippet: &Snippet,
214        level: ValidationLevel,
215        session_fingerprint: Option<&str>,
216        deny_unclassified: bool,
217        allowed_side_effects: &[SideEffectClass],
218    ) -> PathBuf {
219        self.directory.join(format!(
220            "{}.json",
221            Self::invalidation_key(
222                snippet,
223                level,
224                session_fingerprint,
225                deny_unclassified,
226                allowed_side_effects
227            )
228        ))
229    }
230}
231
232#[must_use]
233pub fn default_cache_dir(root: &Path) -> PathBuf {
234    root.join(".alef/snippets")
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::snippets::types::{
241        Language, SnippetAnnotation, SnippetAnnotationKind, SnippetMetadata, SnippetStatus, SourceOrigin,
242    };
243
244    fn snippet(code: &str) -> Snippet {
245        let path = PathBuf::from("example.md");
246        Snippet {
247            id: None,
248            path: path.clone(),
249            language: Language::Rust,
250            title: None,
251            code: code.to_string(),
252            start_line: 1,
253            block_index: 0,
254            annotation: None,
255            metadata: SnippetMetadata::default(),
256            source_origin: SourceOrigin {
257                path,
258                line: 1,
259                block_index: 0,
260            },
261        }
262    }
263
264    #[test]
265    fn cache_key_changes_with_content_and_level() {
266        let first = snippet("fn main() {}");
267        let second = snippet("fn main() { panic!() }");
268        assert_ne!(
269            ValidationCache::key(&first, ValidationLevel::Syntax, None),
270            ValidationCache::key(&second, ValidationLevel::Syntax, None)
271        );
272        assert_ne!(
273            ValidationCache::key(&first, ValidationLevel::Syntax, None),
274            ValidationCache::key(&first, ValidationLevel::Run, None)
275        );
276        assert_ne!(
277            ValidationCache::key(&first, ValidationLevel::Run, Some("binding-a")),
278            ValidationCache::key(&first, ValidationLevel::Run, Some("binding-b"))
279        );
280    }
281
282    /// Alef defect #138's load-bearing assertion: two different alef releases must never agree on
283    /// a cache key for the *same* snippet, content, level and session -- otherwise a validator
284    /// classification fix ships and `alef snippets check` keeps serving the previous release's
285    /// verdict for every snippet whose content never changed. Parameterized on the version string
286    /// directly (`key_for_validator_version`, not `key`) because this test cannot build two real
287    /// alef binaries; it instead pins the exact mechanism `key` delegates to. ~keep
288    #[test]
289    fn cache_key_changes_with_the_alef_version_that_computed_it() {
290        let snippet = snippet("fn main() {}");
291
292        let older = ValidationCache::key_for_validator_version(&snippet, ValidationLevel::Syntax, None, "0.64.0");
293        let newer = ValidationCache::key_for_validator_version(&snippet, ValidationLevel::Syntax, None, "0.64.1");
294
295        assert_ne!(
296            older, newer,
297            "a validator fix in a new alef release must invalidate every prior release's cache \
298             entries, not replay their verdicts"
299        );
300    }
301
302    /// `ValidationCache::key` (the batch-naming entry point every batch validator actually calls)
303    /// must itself be wired to the running binary's own version, not just the
304    /// version-parameterized helper in isolation -- this is what a hand-copied reimplementation
305    /// of the fix would miss.
306    #[test]
307    fn the_public_key_function_is_pinned_to_this_build_own_alef_version() {
308        let snippet = snippet("fn main() {}");
309
310        assert_eq!(
311            ValidationCache::key(&snippet, ValidationLevel::Syntax, None),
312            ValidationCache::key_for_validator_version(
313                &snippet,
314                ValidationLevel::Syntax,
315                None,
316                env!("CARGO_PKG_VERSION")
317            )
318        );
319    }
320
321    fn passing_result(snippet: &Snippet, level: ValidationLevel) -> ValidationResult {
322        ValidationResult {
323            snippet: snippet.clone(),
324            status: SnippetStatus::Pass,
325            level,
326            requested_level: level,
327            effective_level: level,
328            message: None,
329            duration_ms: 1,
330            capability_capped: false,
331            downgrade_reason: None,
332            unresolved_dependency: false,
333            timed_out: false,
334            preflight_skipped: false,
335        }
336    }
337
338    /// External catalogues (voom, `tar --exclude-caching`, `rsync --exclude-tag`, Borg, restic)
339    /// probe for a `CACHEDIR.TAG` *directly inside* the directory they are looking at, and the
340    /// directory a consumer's tree actually shows them is `.alef/` -- not the `.alef/snippets/`
341    /// leaf this cache writes into. Tagging only the leaf therefore left every per-project
342    /// `.alef/` unproven and invisible: measured across one machine, 82 of 83 of them carried no
343    /// tag at their own root, holding 18.86 GB of regenerable cache no such tool would reclaim.
344    /// ~keep
345    #[test]
346    fn storing_a_verdict_tags_the_alef_root_as_well_as_the_snippet_cache_directory() {
347        let project = tempfile::tempdir().expect("project directory");
348        let cache = ValidationCache::new(default_cache_dir(project.path()));
349        let stored = snippet("fn main() {}");
350        let result = passing_result(&stored, ValidationLevel::Compile);
351
352        cache
353            .store(&stored, ValidationLevel::Compile, None, false, &[], &result)
354            .expect("store cache entry");
355
356        for directory in [project.path().join(".alef"), default_cache_dir(project.path())] {
357            assert!(
358                crate::core::cache_dir::is_tagged(&directory),
359                "{} must carry a valid CACHEDIR.TAG",
360                directory.display()
361            );
362        }
363    }
364
365    /// The cheap, string-level half of the fix: `invalidation_key` must separate an annotation
366    /// edit and a side-effect-policy edit from each other and from the unmodified baseline, all
367    /// while every other input stays byte-identical. The decisive proof that this actually blocks
368    /// a stale disk read is the `ValidationCache::store`/`load` round trip below -- this test only
369    /// pins the hash inputs so a regression here is cheap to catch without touching disk. ~keep
370    #[test]
371    fn invalidation_key_separates_annotation_and_side_effect_policy() {
372        let plain = snippet("value = 1");
373        let mut annotated = plain.clone();
374        annotated.annotation = Some(SnippetAnnotation {
375            kind: SnippetAnnotationKind::SyntaxOnly,
376            reason: None,
377        });
378
379        let base = ValidationCache::invalidation_key(&plain, ValidationLevel::Compile, None, false, &[]);
380        assert_ne!(
381            base,
382            ValidationCache::invalidation_key(&annotated, ValidationLevel::Compile, None, false, &[]),
383            "an annotation-only change must move the invalidation key"
384        );
385        assert_ne!(
386            base,
387            ValidationCache::invalidation_key(&plain, ValidationLevel::Compile, None, true, &[]),
388            "a deny_unclassified-only change must move the invalidation key"
389        );
390        assert_ne!(
391            base,
392            ValidationCache::invalidation_key(
393                &plain,
394                ValidationLevel::Compile,
395                None,
396                false,
397                &[SideEffectClass::Network]
398            ),
399            "an allowed_side_effects-only change must move the invalidation key"
400        );
401        assert_eq!(
402            base,
403            ValidationCache::invalidation_key(&plain, ValidationLevel::Compile, None, false, &[]),
404            "the invalidation key must be deterministic for unchanged inputs"
405        );
406    }
407
408    /// Alef defect #554, direction 1 -- the reported symptom: a consumer corrected a
409    /// `<!-- snippet:*-only -->` annotation on six TOML snippets and `alef all` kept reporting the
410    /// old `Downgraded`/`capability_capped` verdict until an explicit `alef cache clear`. Goes
411    /// through the real `ValidationCache::store`/`load` round trip -- the exact API
412    /// `runner::cached_result`/`runner::finalize_result` call -- so this proves the actual read
413    /// path a validation run takes, not just that two key strings differ in isolation. ~keep
414    #[test]
415    fn cache_load_misses_when_only_the_annotation_changes() {
416        let directory = tempfile::tempdir().expect("cache directory");
417        let cache = ValidationCache::new(directory.path().into());
418        let mut original = snippet("value = 1");
419        original.language = Language::Toml;
420        let cached = passing_result(&original, ValidationLevel::Compile);
421        cache
422            .store(&original, ValidationLevel::Compile, None, false, &[], &cached)
423            .expect("store cache entry");
424
425        assert!(
426            cache
427                .load(&original, ValidationLevel::Compile, None, false, &[])
428                .is_some(),
429            "querying with the exact snippet that wrote the cache must still be a hit"
430        );
431
432        let mut annotated = original.clone();
433        annotated.annotation = Some(SnippetAnnotation {
434            kind: SnippetAnnotationKind::SyntaxOnly,
435            reason: Some("author correction".to_string()),
436        });
437        assert!(
438            cache
439                .load(&annotated, ValidationLevel::Compile, None, false, &[])
440                .is_none(),
441            "correcting only the snippet's annotation must miss the cache, not replay the \
442             previous annotation's verdict -- in either direction: a snippet whose corrected \
443             annotation should now pass must not keep reporting the old downgrade, and a snippet \
444             whose new annotation should now fail must not keep reporting the old cached pass"
445        );
446    }
447
448    /// One release candidate's identity: this crate's real semver, a clean tree, a fixed build
449    /// time, and the caller's commit -- so a test that varies only `commit` really varies only
450    /// the commit.
451    fn clean_build(commit: &str) -> String {
452        crate::bin_cli::build_info::render_build_identity(env!("CARGO_PKG_VERSION"), commit, "clean", "1000000000")
453    }
454
455    const SHA_A: &str = "964c552a267ccfb50a0e6f1d3b2c4a8e7f019d24";
456    const SHA_B: &str = "1d3b5f7902e4c6a8b0d2f4061e3c5a7b9d0f2e48";
457
458    /// Alef defect #612, the failing direction: two candidate binaries of one unreleased version,
459    /// differing only by the commit they were built from, must not share a cache key. Everything
460    /// else -- semver, snippet, level, session, annotation, side-effect policy -- is held
461    /// byte-identical, so the commit is the only thing that can move the key. ~keep
462    #[test]
463    fn invalidation_key_separates_two_builds_of_one_version_at_different_commits() {
464        let snippet = snippet("value = 1");
465        let key_of = |identity: &str| {
466            ValidationCache::invalidation_key_for_build_identity(
467                &snippet,
468                ValidationLevel::Compile,
469                None,
470                false,
471                &[],
472                identity,
473            )
474        };
475
476        assert_ne!(
477            key_of(&clean_build(SHA_A)),
478            key_of(&clean_build(SHA_B)),
479            "two candidate binaries reporting the same semver from different commits must not \
480             share a snippet cache key, or one candidate's verdicts are replayed for the other -- \
481             masking both a fix shipped between them and a breakage introduced between them"
482        );
483    }
484
485    /// Alef defect #612, the half that proves caching survives the fix. A key made unique per run
486    /// would satisfy the test above and silently destroy a cache whose cost a consumer already
487    /// reported as a bottleneck. A released binary is built from a tag with a clean tree, so two
488    /// builds of it must agree no matter when they were compiled. ~keep
489    #[test]
490    fn invalidation_key_is_shared_by_two_clean_builds_of_one_commit() {
491        let snippet = snippet("value = 1");
492        let key_of = |identity: &str| {
493            ValidationCache::invalidation_key_for_build_identity(
494                &snippet,
495                ValidationLevel::Compile,
496                None,
497                false,
498                &[],
499                identity,
500            )
501        };
502        let morning =
503            crate::bin_cli::build_info::render_build_identity(env!("CARGO_PKG_VERSION"), SHA_A, "clean", "1000000000");
504        let evening =
505            crate::bin_cli::build_info::render_build_identity(env!("CARGO_PKG_VERSION"), SHA_A, "clean", "1999999999");
506
507        assert_eq!(
508            key_of(&morning),
509            key_of(&evening),
510            "two clean builds of one commit must share a cache key; a key that varies per build \
511             turns every release run into a cold-cache run of the whole validation pass"
512        );
513        assert_eq!(
514            key_of(&morning),
515            key_of(&clean_build(SHA_A)),
516            "the invalidation key must be deterministic for one build identity"
517        );
518    }
519
520    /// The disk-level half, taking the exact read path `runner::cached_result` takes. A verdict
521    /// keyed the way the pre-fix code keyed it -- on the semver alone -- is sitting in the cache
522    /// directory; `load` must not find it. The second assertion is what stops that from being
523    /// satisfied by a cache that never hits at all. ~keep
524    #[test]
525    fn cache_load_ignores_a_verdict_keyed_on_the_semver_alone() {
526        let directory = tempfile::tempdir().expect("cache directory");
527        let cache = ValidationCache::new(directory.path().into());
528        let subject = snippet("value = 1");
529        let verdict = passing_result(&subject, ValidationLevel::Compile);
530
531        let stale_key = ValidationCache::invalidation_key_for_build_identity(
532            &subject,
533            ValidationLevel::Compile,
534            None,
535            false,
536            &[],
537            ALEF_VALIDATOR_VERSION,
538        );
539        let entry = CacheEntry {
540            schema_version: CACHE_SCHEMA_VERSION,
541            result: verdict.clone(),
542        };
543        std::fs::write(
544            directory.path().join(format!("{stale_key}.json")),
545            serde_json::to_vec_pretty(&entry).expect("serialize cache entry"),
546        )
547        .expect("plant a semver-keyed cache entry");
548
549        assert!(
550            cache
551                .load(&subject, ValidationLevel::Compile, None, false, &[])
552                .is_none(),
553            "a verdict keyed on the semver alone must not be replayed: it could have been computed \
554             by any of the candidate binaries that share this version string, including one built \
555             before the validator fix under test"
556        );
557
558        cache
559            .store(&subject, ValidationLevel::Compile, None, false, &[], &verdict)
560            .expect("store cache entry");
561        assert!(
562            cache
563                .load(&subject, ValidationLevel::Compile, None, false, &[])
564                .is_some(),
565            "this binary's own verdict must still be served, or the fix has replaced stale caching \
566             with no caching"
567        );
568    }
569
570    /// The wiring assertion. `invalidation_key` -- the one every `store`/`load` goes through --
571    /// must salt on the running binary's full stamped provenance, not merely delegate to a helper
572    /// that could be handed the semver. This is what a hand-copied reimplementation would miss. ~keep
573    #[test]
574    fn invalidation_key_is_pinned_to_this_build_provenance_not_just_its_semver() {
575        let subject = snippet("value = 1");
576        let live = ValidationCache::invalidation_key(&subject, ValidationLevel::Compile, None, false, &[]);
577
578        assert_ne!(
579            live,
580            ValidationCache::invalidation_key_for_build_identity(
581                &subject,
582                ValidationLevel::Compile,
583                None,
584                false,
585                &[],
586                ALEF_VALIDATOR_VERSION,
587            ),
588            "the snippet cache key must salt on more than the semver -- a semver does not identify \
589             a build, and every candidate binary in a release cycle shares one"
590        );
591        assert_eq!(
592            live,
593            ValidationCache::invalidation_key_for_build_identity(
594                &subject,
595                ValidationLevel::Compile,
596                None,
597                false,
598                &[],
599                crate::bin_cli::build_info::build_identity(),
600            ),
601            "the salt must be the same build identity `alef --version` reports, asked for rather \
602             than re-derived"
603        );
604    }
605
606    /// Alef defect #554, direction 2: the `docs.snippets` side-effect policy
607    /// (`deny_unclassified`/`allowed_side_effects`) feeds `runner::side_effect_rejection` before a
608    /// `Run`-level snippet is even handed to a validator. An `alef.toml` edit to that policy must
609    /// miss the cache the same way an annotation edit does, with code, metadata, level, and
610    /// session held byte-identical -- otherwise tightening the policy would keep replaying a
611    /// `Pass` computed under the old, looser policy: the dangerous direction, since nothing about
612    /// that stale `Pass` announces it was never re-checked against the new rule. ~keep
613    #[test]
614    fn cache_load_misses_when_only_the_side_effect_policy_changes() {
615        let directory = tempfile::tempdir().expect("cache directory");
616        let cache = ValidationCache::new(directory.path().into());
617        let side_effecting = snippet("run_side_effecting_thing();");
618        let cached = passing_result(&side_effecting, ValidationLevel::Run);
619        cache
620            .store(&side_effecting, ValidationLevel::Run, None, false, &[], &cached)
621            .expect("store cache entry");
622
623        assert!(
624            cache
625                .load(&side_effecting, ValidationLevel::Run, None, false, &[])
626                .is_some(),
627            "querying with the exact policy that wrote the cache must still be a hit"
628        );
629        assert!(
630            cache
631                .load(&side_effecting, ValidationLevel::Run, None, true, &[])
632                .is_none(),
633            "tightening deny_unclassified must miss the cache, not replay a Pass computed under \
634             the previous, looser policy"
635        );
636        assert!(
637            cache
638                .load(
639                    &side_effecting,
640                    ValidationLevel::Run,
641                    None,
642                    false,
643                    &[SideEffectClass::Network]
644                )
645                .is_none(),
646            "widening allowed_side_effects must also miss the cache"
647        );
648    }
649}