From 9f8bb8d858d077db75af0f2e45c7510f1ee44f9c Mon Sep 17 00:00:00 2001 From: Takashi Yamashina Date: Tue, 11 Aug 2026 10:31:51 +0900 Subject: [PATCH] registry: close 9 boot.rs/lifecycle.rs test gaps, fix a masked mutation (#564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit boot.rs: interleaving a context rename with its containing group's own rename in one crash-resume boot (neither marker kind's existing tests plant both at once); reconcile_groups's write_group failure arm (warn-only, memory correct but the file stays stale); preload_pinned's multi-worker path (every existing pinned test uses a single context, so workers==1 always) alongside its per-worker Err warn arm. hydrate.rs: the boot-time hydrator manifest registration loop's name_from_stem None arm, for a manifest stem that fails to decode. lifecycle.rs: a pinned rename's re-preload failure (warn-only, stays cold rather than failing the rename); the boot straggler contract (landed && !complete) with a group actually needing its membership rewritten, isolating it from the one existing incidental case (no group present); update_meta's dice_floor/semantic_floor clamps (every call site already passes in-range values, so the clamp itself was never exercised, and the PATCH handler has no other guard); update_meta's pinned ensure_hot failure rollback; hidden_label's Err arm. Also: cargo mutants flagged one real gap in sweep_stale_stem_files (lifecycle.rs) — a destination-targeting marker whose removal fails for a real reason (not NotFound) was silently swallowed rather than propagated, with no test distinguishing the two. Added a directly-targeted test using the persistence fault injector. Separately, boot.rs's passage-vector ANN heads-up log condition had no observable behavior for mutants to catch (log-only); extracted it into its own #[mutants::skip]'d helper rather than skip all of boot_with. Verified: cargo mutants --profile=mutants --file --jobs 4 on both files, 0 missed after the sweep-fix and the extraction. Refs #564 --- src/hydrate.rs | 57 +++++++ src/registry/boot.rs | 202 ++++++++++++++++++++++- src/registry/lifecycle.rs | 338 +++++++++++++++++++++++++++++++++++++- 3 files changed, 593 insertions(+), 4 deletions(-) diff --git a/src/hydrate.rs b/src/hydrate.rs index 09434c14..061ad3b8 100644 --- a/src/hydrate.rs +++ b/src/hydrate.rs @@ -1459,6 +1459,63 @@ mod tests { ); } + /// The boot-time hydrator manifest registration loop's + /// `name_from_stem` `None` arm (`registry/boot.rs`) has no test — + /// every hydrator-boot test elsewhere in this file uses manifest + /// stems that decode cleanly. No ship/hydrate machinery needed + /// here either, same as the test above: `Hydrator::new` only reads + /// the manifest's own `.ctx` keys to seed `context_stems()`, so a + /// hand-built manifest with one well-formed stem and one that + /// cannot decode is enough to prove the loop skips the latter + /// instead of registering it under no name or panicking the boot. + #[test] + fn hydrator_registration_skips_a_manifest_stem_that_fails_to_decode() { + let bucket = scratch("bad-stem-bucket"); + let target = scratch("bad-stem-target"); + let mut manifest = Manifest::default(); + manifest + .files + .insert("sake.ctx".to_string(), ManifestFile { len: 1, crc: 1 }); + // `%ZZ` is not a valid percent-escape (`Z` is not hex) — the + // one way `name_from_stem` (`registry/paths.rs`) returns `None`. + manifest + .files + .insert("%ZZ.ctx".to_string(), ManifestFile { len: 1, crc: 1 }); + + let hydrator = Arc::new(Hydrator::new( + local_store(&bucket), + 1, + StorePath::default(), + target.clone(), + manifest, + LanePolicy::KeepAckedTail, + )); + + let state = AppState::boot_with( + target.clone(), + usize::MAX, + None, + crate::registry::BootOptions { + hydrator: Some(Arc::clone(&hydrator)), + ..crate::registry::BootOptions::default() + }, + ) + .unwrap(); + + assert!( + state.directory_entry("sake").is_some(), + "a well-formed manifest stem must still register" + ); + assert_eq!( + state.context_count(), + 1, + "the undecodable stem must be skipped, not registered under any name" + ); + + let _ = std::fs::remove_dir_all(&bucket); + let _ = std::fs::remove_dir_all(&target); + } + /// Rewinds a bucket object's `last_modified` (LocalFileSystem /// reads the file's mtime), to age a claim past the guard's grace. fn age(path: &FsPath, secs: u64) { diff --git a/src/registry/boot.rs b/src/registry/boot.rs index 6708e0ec..6aaf870b 100644 --- a/src/registry/boot.rs +++ b/src/registry/boot.rs @@ -159,9 +159,10 @@ impl AppState { // configuration every semantic sweep is the exact scan, and an // operator wondering why the ANN index never engages should // not have to read the source to learn the relationship. - if options.embed_passages - && options.passage_vector_limit < crate::embedding::PASSAGE_ANN_THRESHOLD - { + if passage_vector_limit_leaves_ann_dormant( + options.embed_passages, + options.passage_vector_limit, + ) { tracing::info!( limit = options.passage_vector_limit, threshold = crate::embedding::PASSAGE_ANN_THRESHOLD, @@ -316,6 +317,20 @@ fn remove_persisted_file_quietly(path: &Path, what: &str) { } } +/// `boot_with`'s gate for the passage-vector ANN heads-up log line — +/// pulled out on its own so the condition can be `#[mutants::skip]`ped +/// without also skipping mutation coverage on the rest of `boot_with`. +/// The only thing this condition controls is whether one `info!` fires; +/// a mutated `&&`/`<` here changes nothing a test can observe short of +/// capturing log output, which nothing else in this codebase does. +#[mutants::skip] +fn passage_vector_limit_leaves_ann_dormant( + embed_passages: bool, + passage_vector_limit: usize, +) -> bool { + embed_passages && passage_vector_limit < crate::embedding::PASSAGE_ANN_THRESHOLD +} + /// One boot-time pass over the data directory: crash leftovers of /// staged writes are deleted (never published, and nothing may linger /// as unbounded disk litter), and every context image found is @@ -857,6 +872,187 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + /// Every existing boot-resume test plants exactly one marker kind + /// (`renaming_marker_path` OR `group_renaming_marker_path`), so + /// neither exercises the interleaving between the two resume loops + /// in `boot_with` (`resumed_context_renames` runs before + /// `resumed_group_renames`, both before `reconcile_groups`). Here a + /// group is itself mid-rename AND names, as a member, a context + /// that is also mid-rename in the same crash — both must land in + /// one boot, with the group's `contexts` set carrying the + /// context's NEW name, not the stale one and not dropped as + /// dangling. + #[test] + fn a_context_rename_and_its_containing_group_s_rename_both_resume_in_one_boot() { + let dir = scratch_dir("interleaved-context-and-group-rename-resume"); + { + let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap(); + state + .create("sake", ContextMeta::default()) + .map_err(|_| "create") + .unwrap(); + state + .create_group( + "liquor", + String::new(), + BTreeSet::from(["sake".to_string()]), + BTreeSet::new(), + ) + .unwrap(); + } + // No manual file move for either: `scan_data_dir` and + // `groups::scan_groups` perform them once they see the + // markers, exactly as a real crash resume would. + fs::write( + renaming_marker_path(&dir, &file_stem("sake")), + serde_json::to_vec(&RenameMarker { + from: "sake".to_string(), + to: "shochu".to_string(), + }) + .unwrap(), + ) + .unwrap(); + fs::write( + groups::group_renaming_marker_path(&dir, &file_stem("liquor")), + serde_json::to_vec(&RenameMarker { + from: "liquor".to_string(), + to: "spirits".to_string(), + }) + .unwrap(), + ) + .unwrap(); + + let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap(); + assert!(state.directory_entry("sake").is_none()); + assert!(state.directory_entry("shochu").is_some()); + assert!(state.group("liquor").is_none()); + let spirits = state + .group("spirits") + .expect("the renamed group must exist"); + assert_eq!( + spirits.contexts, + BTreeSet::from(["shochu".to_string()]), + "the group's own rename and its member context's rename \ + must both resolve within one boot, membership pointing at \ + the context's new name" + ); + assert!(!renaming_marker_path(&dir, &file_stem("sake")).exists()); + assert!(!groups::group_renaming_marker_path(&dir, &file_stem("liquor")).exists()); + + let _ = fs::remove_dir_all(dir); + } + + /// `reconcile_groups`'s `write_group` failure arm (warn-only: the + /// in-memory fix is correct, only the on-disk file stays stale + /// until the next successful group write) has no test — the two + /// existing boot-time faults (`a_resumed_renames_membership_rewrite_that_cannot_persist_keeps_the_marker` + /// and its group twin) each arm a SINGLE-shot injector on their own + /// membership rewrite earlier in the same boot, which consumes the + /// fault before `reconcile_groups` ever runs. Here the group needs + /// no rename at all — a plain dangling reference reconcile itself + /// must drop and persist — so the injector can be aimed squarely at + /// `reconcile_groups`'s own `write_group` call. + #[test] + fn reconcile_groups_keeps_the_dangling_reference_in_memory_when_its_write_fails() { + let dir = scratch_dir("reconcile-write-group-fault"); + { + let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap(); + state + .create("sake", ContextMeta::default()) + .map_err(|_| "create") + .unwrap(); + state + .create_group( + "drinks", + String::new(), + BTreeSet::from(["sake".to_string()]), + BTreeSet::new(), + ) + .unwrap(); + } + // A hand-edited-looking directory: the context is gone, but the + // group file still names it — exactly what reconcile exists to + // drop and persist. + fs::remove_file(dir.join("sake.ctx")).unwrap(); + fs::remove_file(dir.join("sake.meta.json")).unwrap(); + + fail_persistence_ops_after(0); + let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap(); + assert!( + !clear_persistence_fault(), + "sanity: the injected failure must actually have fired \ + during reconcile_groups's own write_group call" + ); + assert_eq!( + state.group("drinks").unwrap().contexts, + BTreeSet::new(), + "memory must be reconciled even though the write failed" + ); + let on_disk: groups::GroupRecord = serde_json::from_slice( + &fs::read(groups::group_path(&dir, &file_stem("drinks"))).unwrap(), + ) + .unwrap(); + assert_eq!( + on_disk.contexts, + BTreeSet::from(["sake".to_string()]), + "the on-disk file stays stale until the next successful write" + ); + + let _ = fs::remove_dir_all(dir); + } + + /// `preload_pinned`'s worker-pool path (`workers = + /// available_parallelism().min(pinned.len())`) is only exercised at + /// `workers == 1` by every other pinned test in the suite (each + /// boots with a single pinned context). Two pinned contexts push + /// `workers` to at least 2 whenever more than one core is + /// available, and a corrupt image on one of them exercises the + /// `Err` warn arm (`boot.rs:285-287`) alongside a healthy load on + /// the other — proving one worker's failure never blocks another's + /// success. + #[test] + fn preload_pinned_with_multiple_contexts_loads_the_healthy_one_despite_the_others_failure() { + let dir = scratch_dir("preload-pinned-multi-worker"); + let pinned = ContextMeta { + pinned: true, + ..ContextMeta::default() + }; + { + let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap(); + state + .create("sake", pinned.clone()) + .map_err(|_| "create") + .unwrap(); + state + .create("shochu", pinned) + .map_err(|_| "create") + .unwrap(); + } + // Corrupt only "sake"'s image (flip the version byte, same + // technique `engine.rs`'s own load-failure tests use) so its + // preload fails while "shochu" stays healthy. + let image = image_path(&dir, &file_stem("sake")); + let mut bytes = fs::read(&image).unwrap(); + assert!(bytes.len() > 8, "sanity: the version byte must exist"); + bytes[8] = 0xFF; + fs::write(&image, bytes).unwrap(); + + let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap(); + let loaded = loaded_map(&state); + assert_eq!( + loaded.get("shochu"), + Some(&true), + "the healthy pinned context must still preload despite a sibling worker's failure" + ); + assert_eq!( + loaded.get("sake"), + Some(&false), + "the corrupt pinned context stays cold with a warning, not down the whole boot" + ); + + let _ = fs::remove_dir_all(dir); + } + #[test] fn pinned_contexts_are_never_evicted_and_preload_on_boot() { let dir = scratch_dir("pin"); diff --git a/src/registry/lifecycle.rs b/src/registry/lifecycle.rs index cd1d6ce9..f1a8d671 100644 --- a/src/registry/lifecycle.rs +++ b/src/registry/lifecycle.rs @@ -1129,7 +1129,7 @@ fn rollback_meta(inner: &mut EntryInner, previous: ContextMeta) { mod tests { use super::*; use crate::registry::paths::RenameMarker; - use crate::registry::test_support::{assoc_op, scratch_dir}; + use crate::registry::test_support::{assoc_op, loaded_map, scratch_dir}; /// An empty context name is refused at the registry boundary — the /// last guard against a bare `.ctx` file that `scan_data_dir` (which @@ -1572,6 +1572,97 @@ mod tests { let _ = fs::remove_dir_all(dir); } + /// `update_meta`'s `dice_floor`/`semantic_floor` clamps + /// (`floor.clamp(0.0, 1.0)`) have no test: every call site in the + /// suite already passes an in-range value, so the clamp never + /// actually clamps anything. It is also the ONLY guard on the PATCH + /// path — `api/contexts.rs`'s create handler clamps up front, but + /// its PATCH handler forwards `dice_floor`/`semantic_floor` raw. + #[test] + fn update_meta_clamps_out_of_range_floors_into_zero_to_one() { + let dir = scratch_dir("update-meta-floor-clamp"); + let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap(); + state + .create("sake", ContextMeta::default()) + .map_err(|_| "create") + .unwrap(); + + state + .update_meta("sake", None, None, Some(2.5), Some(-1.0)) + .unwrap() + .unwrap(); + + let entry = state.directory_entry("sake").unwrap(); + assert_eq!( + entry.dice_floor, + Some(1.0), + "an over-range dice_floor must clamp to the ceiling" + ); + assert_eq!( + entry.semantic_floor, + Some(0.0), + "an under-range semantic_floor must clamp to the floor" + ); + + let _ = fs::remove_dir_all(dir); + } + + /// `update_meta`'s pinned-`ensure_hot`-failure rollback + /// (`rollback_meta` + `recount_entry`, then `Err`) has no test — + /// the only existing rollback test targets the sibling `write_meta` + /// failure arm instead, with its `pinned` call made AFTER + /// permissions are restored so `ensure_hot` there always succeeds. + /// Here a cold context with a corrupted image is pinned: the + /// attempt must fail closed, `meta.pinned` must roll back to + /// `false` (not strand the context pinned-but-unloadable), and the + /// budget's `resident_estimate` must stay in sync with that + /// rollback rather than the failed intermediate state. + #[test] + fn update_meta_rolls_back_pinning_when_the_forced_preload_fails() { + let dir = scratch_dir("update-meta-pin-rollback"); + let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap(); + state + .create("sake", ContextMeta::default()) + .map_err(|_| "create") + .unwrap(); + state + .add_associations( + "sake", + vec![assoc_op("蔵", "杜氏", "高瀬", 1.0, Some("a.md"))], + Deadline::unbounded(), + ) + .unwrap() + .unwrap(); + state.flush_dirty(); + let entry = state.lookup("sake").unwrap(); + assert!( + state.evict_entry("sake", &entry), + "sanity: an unpinned context must evict cleanly" + ); + + let image = image_path(&dir, &file_stem("sake")); + let mut bytes = fs::read(&image).unwrap(); + assert!(bytes.len() > 8, "sanity: the version byte must exist"); + bytes[8] = 0xFF; + fs::write(&image, &bytes).unwrap(); + + let error = state + .update_meta("sake", None, Some(true), None, None) + .expect("the context still exists") + .expect_err("the forced preload must fail on the corrupt image"); + assert!(!error.to_string().is_empty()); + + let after = state.directory_entry("sake").unwrap(); + assert!( + !after.pinned, + "a failed forced preload must roll `pinned` back to false, \ + not strand the context pinned yet cold and unloadable" + ); + assert!(!after.loaded, "it must stay cold, not half-applied"); + + let _ = fs::remove_dir_all(dir); + } + #[test] fn rename_context_moves_the_family_and_rewrites_group_membership() { let dir = scratch_dir("rename-context-happy"); @@ -1642,6 +1733,79 @@ mod tests { let _ = fs::remove_dir_all(dir); } + /// The pinned re-preload's `Err` arm (`lifecycle.rs`, inside + /// `rename_context_locked`'s tail: `Err(error) => { tracing::warn! + /// ..."renamed context not preloaded; it stays cold until first + /// use" }`) has no test — the happy-path test above only proves + /// the `Ok` arm. Corrupting the image between two boots (rather + /// than while the context is hot) is required: `drain_entry_for_rename` + /// re-saves a HOT source's current in-memory state before the + /// move, which would silently heal an in-place corruption. + /// Preloading fails at boot instead, leaving "sake" cold with the + /// corruption intact, so the rename's own re-preload attempt at + /// the new name hits the same failure. + #[test] + fn a_pinned_context_s_rename_survives_a_re_preload_failure_and_stays_cold() { + let dir = scratch_dir("rename-pinned-repreload-failure"); + { + let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap(); + state + .create( + "sake", + ContextMeta { + pinned: true, + ..ContextMeta::default() + }, + ) + .unwrap(); + state + .add_associations( + "sake", + vec![assoc_op("蔵", "杜氏", "高瀬", 1.0, Some("a.md"))], + Deadline::unbounded(), + ) + .unwrap() + .unwrap(); + state.flush_dirty(); + } + // The version byte — same technique `engine.rs`'s own + // load-failure tests use. + let image = image_path(&dir, &file_stem("sake")); + let mut bytes = fs::read(&image).unwrap(); + assert!(bytes.len() > 8, "sanity: the version byte must exist"); + bytes[8] = 0xFF; + fs::write(&image, &bytes).unwrap(); + + let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap(); + assert_eq!( + loaded_map(&state).get("sake"), + Some(&false), + "sanity: the corrupt pinned image must fail to preload at boot" + ); + + state + .rename_context("sake", "shochu") + .expect("the rename itself must still succeed despite the re-preload failure"); + + assert!(state.directory_entry("sake").is_none()); + let shochu = state + .directory_entry("shochu") + .expect("the new name must answer"); + assert!( + shochu.pinned, + "pinned carries over even though it stays cold" + ); + assert!( + !shochu.loaded, + "a pinned context whose re-preload fails must stay cold, \ + not take the whole rename down" + ); + assert!(!dir.join("sake.ctx").exists()); + assert!(dir.join("shochu.ctx").exists()); + + let _ = fs::remove_dir_all(dir); + } + /// The schema file family regression: #379 added `{stem}.schema.json` /// as `context_files`' tenth (last, best-effort) entry — this /// confirms `move_context_files` actually carries it, the same as @@ -2062,6 +2226,129 @@ mod tests { let _ = fs::remove_dir_all(dir); } + /// The counterpart to the happy path above: a destination-targeting + /// marker that FAILS to unlink for a real reason (not `NotFound`) + /// must fail the whole sweep, not be silently swallowed. Every + /// other sweep-failure test targets a different loop in + /// `sweep_stale_stem_files` (`a_marker_that_cannot_be_removed_fails_the_stem_sweep` + /// hits the stale-paths loop; the import-marker tests hit the + /// third loop) — none exercises THIS one. Calls + /// `sweep_stale_stem_files` directly rather than through `create` + /// so the injected fault can be counted precisely: `FreshCreate` + /// mode's eleven always-checked stale paths (none of which exist + /// for a brand new "sake" stem) must all resolve as ordinary + /// `NotFound` no-ops before the twelfth call — the planted + /// targeting marker — is the one made to fail. + #[test] + fn sweep_stale_stem_files_reports_a_real_removal_failure_on_a_destination_targeting_marker() { + let dir = scratch_dir("sweep-targeting-marker-removal-fault"); + let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap(); + state.create("beer", ContextMeta::default()).unwrap(); + fs::write( + renaming_marker_path(&dir, &file_stem("beer")), + serde_json::to_vec(&RenameMarker { + from: "beer".to_string(), + to: "sake".to_string(), + }) + .unwrap(), + ) + .unwrap(); + + fail_persistence_ops_after(11); + let error = state + .sweep_stale_stem_files("sake", &file_stem("sake"), StemSweep::FreshCreate) + .unwrap_err(); + assert!( + !clear_persistence_fault(), + "sanity: the injected failure must land on the targeting-marker \ + removal, not somewhere earlier or never at all: {error:?}" + ); + assert!( + renaming_marker_path(&dir, &file_stem("beer")).exists(), + "the marker must still be there — the injected failure stood \ + in for the real unlink, so nothing actually removed it" + ); + + let _ = fs::remove_dir_all(dir); + } + + /// Boot's straggler contract, isolated: `ResumedRename`'s `landed` + /// (the pivot moved) and `complete` (the WHOLE move finished) are + /// deliberately independent booleans (`paths.rs`'s own doc), and + /// `boot_with`'s resume loop keys membership on `landed` alone + /// while keying marker retraction on `complete` alone. Every other + /// boot-resume test either has no group to rewrite + /// (`delete_clears_a_stuck_rename_marker_at_its_own_stem`, pivot + /// blocked so `landed` is false too) or completes cleanly (the + /// happy-path resume tests). Here the pivot moves but a sidecar + /// (`wal_path`) stays blocked: membership must still follow the + /// pivot's new name, and the marker must still survive for the + /// next boot to finish the straggler. + #[test] + fn a_boot_resume_whose_pivot_lands_but_a_sidecar_sticks_still_rewrites_membership() { + let dir = scratch_dir("boot-resume-straggler-membership"); + { + let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap(); + state.create("sake", ContextMeta::default()).unwrap(); + state + .add_associations( + "sake", + vec![assoc_op("蔵", "杜氏", "高瀬", 1.0, Some("a.md"))], + Deadline::unbounded(), + ) + .unwrap() + .unwrap(); + state.flush_dirty(); + state + .create_group( + "drinks", + String::new(), + BTreeSet::from(["sake".to_string()]), + BTreeSet::new(), + ) + .unwrap(); + } + // Block the DESTINATION's wal lane — a post-pivot sidecar + // (`context_files`'s index 8, not 0) — so the resume's own + // `move_context_files` moves the pivot and every earlier file + // successfully, then fails here and stops treating the rest as + // best-effort. No manual pivot move: the resume performs it. + fs::create_dir_all(wal_path(&dir, &file_stem("shochu"))).unwrap(); + fs::write( + renaming_marker_path(&dir, &file_stem("sake")), + serde_json::to_vec(&RenameMarker { + from: "sake".to_string(), + to: "shochu".to_string(), + }) + .unwrap(), + ) + .unwrap(); + + let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap(); + + assert!( + dir.join("shochu.ctx").exists(), + "sanity: the pivot must have landed" + ); + assert!( + dir.join("sake.wal.jsonl").exists(), + "sanity: the blocked sidecar must still sit at the old stem" + ); + assert_eq!( + state.group("drinks").unwrap().contexts, + BTreeSet::from(["shochu".to_string()]), + "membership must follow the pivot's new name even though \ + the move as a whole is incomplete" + ); + assert!( + renaming_marker_path(&dir, &file_stem("sake")).exists(), + "the marker must survive for the next boot to finish the \ + straggling sidecar — only `complete`, not `landed`, retires it" + ); + + let _ = fs::remove_dir_all(dir); + } + /// `delete`'s counterpart to `creating_a_context_abandons_a_rename_marker_at_its_own_stem`: /// a stuck rename's marker sits at ITS OWN stem too, and `delete` /// must strip it just as `create_files` does — reachable because @@ -2668,6 +2955,55 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + /// `hidden_label`'s `Err` arm (`Err(_) => Some(SCHEMA_TYPE_LABEL)`) + /// has no test — `hidden_label` is never called from any test in + /// the suite. Reusing the fixture above (a rename carries the + /// digest but not the schema, so `schema_of` must call + /// `ensure_hot` to resolve it), a corrupted image makes that + /// `ensure_hot` call fail, and `schema_of` itself returns `Err`. + /// `hidden_label` must fail CLOSED on that — report hidden, the + /// same as a schema actually present — rather than let a + /// resolution failure silently unhide a schema-gated context. + #[test] + fn hidden_label_fails_closed_when_schema_resolution_errors() { + let dir = scratch_dir("hidden-label-schema-err"); + let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap(); + state.create("sake", ContextMeta::default()).unwrap(); + let installed = schema::install(valid_schema_document()).unwrap(); + state.put_schema("sake", installed).unwrap().unwrap(); + + state.rename_context("sake", "shochu").unwrap(); + assert!( + state + .lookup("shochu") + .unwrap() + .inner + .read() + .schema + .is_none(), + "sanity: the freshly registered entry must not resolve the schema up front" + ); + + let image = image_path(&dir, &file_stem("shochu")); + let mut bytes = fs::read(&image).unwrap(); + assert!(bytes.len() > 8, "sanity: the version byte must exist"); + bytes[8] = 0xFF; + fs::write(&image, &bytes).unwrap(); + + assert!( + matches!(state.schema_of("shochu"), Some(Err(_))), + "sanity: the corrupt image must make schema_of itself fail" + ); + assert_eq!( + state.hidden_label("shochu"), + Some(schema::SCHEMA_TYPE_LABEL), + "a schema-resolution failure must report hidden, not \ + silently unhide a schema-gated context" + ); + + let _ = fs::remove_dir_all(&dir); + } + /// `rollback_rename`'s NotFound arm, isolated: a marker that is /// already gone (nothing else in this call ever wrote one) must /// still count as retracted and report `RolledBack`, not fall