Reclaim displaced single-generation index runs

This commit is contained in:
Levi Neuwirth 2026-07-31 15:05:51 -04:00
parent fcc8d82545
commit b3415da9f3
5 changed files with 409 additions and 69 deletions

View File

@ -7285,36 +7285,101 @@ mod index_maintenance_tests {
}
/// Every path that names the frames something other than the identity they
/// carry owes the same refusal, so the tests assert it through one function
/// rather than through copies that can drift apart the way the code did.
fn assert_the_open_refuses_naming_the_run(
/// carry owes the same reclamation, so the tests assert it through one
/// function rather than through copies that can drift apart the way the code
/// did.
///
/// Contract review 2026-07-30-B refused these roots; 2026-07-30-G reclaims
/// them. "Reclaimed" is four claims and the test is worth nothing without
/// all four: the open succeeds, the manifest stops naming the run, every
/// object the run covered still resolves, and the `.idx` is still on the
/// device. A store that merely opened would be indistinguishable from one
/// that dropped the objects on the floor.
fn assert_the_open_reclaims_the_run(
options: StoreOptions,
root: &Path,
covered: IndexKey,
resolvable: &[ObjectId],
) {
let (run, named) = the_published_runs_generation(root, covered);
let runs = [run];
let (run, displaced) = the_published_runs_generation(root, covered);
let root_uuid = segment::read_format(&RootLayout::new(root))
.expect("FORMAT")
.root_uuid;
match StoreEngine::open(options) {
Err(StoreError::Corruption(message)) => assert!(
message.contains(&runs[0]),
"the refusal must name the run that cannot be resolved, not just \
report a generation: {message}"
),
Err(other) => panic!("expected Corruption naming the run, got {other:?}"),
// Not prose: the state this refuses is verified here, so removing
// the refusal reports what it costs rather than a bare expectation.
Ok(opened) => {
let committed = opened.committed_root();
let pinned = committed.object_source(0, named).expect("resolve");
let opened = StoreEngine::open(options).unwrap_or_else(|error| {
panic!(
"the displaced run {run} covers only the identity being displaced, so replay \
rebuilds every entry it held; refusing is an outage on a root that has lost \
nothing: {error:?}"
)
});
assert!(
!manifest_runs(root, 0, root_uuid).contains(&run),
"the manifest still names {run}; a row is what makes a run authoritative, so \
leaving it hands the unresolvable run to the next open"
);
assert_eq!(
opened.shared.recovery_reports[0]
.reclaimed_index_runs
.iter()
.filter_map(|path| path.file_name())
.filter_map(|name| name.to_str())
.collect::<Vec<_>>(),
vec![run.as_str()],
"a reclamation is invisible in the opened store, so it has to be reported"
);
let committed = opened.committed_root();
for object in resolvable {
let key = IndexKey::new(covered.namespace, *object);
let location = committed.index().get(&key).unwrap_or_else(|| {
panic!(
"the open succeeded; published run {} names generation {named} and the \
reopened root pins {pinned:?} there every lookup reaching the run \
rather than the replay delta above it reads nothing",
runs[0]
);
}
"{} was covered by the reclaimed run and is now in no index at all",
object.to_hex()
)
});
assert!(
committed
.object_source(0, location.segment_generation)
.expect("resolve the source of a reclaimed run's entry")
.is_some(),
"{} resolves to generation {} and nothing pins it",
object.to_hex(),
location.segment_generation
);
}
// The manifest governs the *next* open. This session must stop consulting
// and stop pinning the run in the same breath, or a direct run consumer
// — `StoreEngine::checkpoint` is one — reads locations that resolve to
// nothing for as long as the process lives. A lookup would not show it:
// the replay delta sits above the runs and answers first.
assert!(
committed
.index()
.sealed_runs()
.iter()
.all(|sealed| !sealed.references_segment_generation(displaced)),
"a run this session still consults holds locations against the displaced \
generation {displaced}, which now resolves to nothing"
);
assert!(
committed
.retained_generations()
.values()
.filter(|generation| generation.id.shard_index == 0)
.flat_map(|generation| generation.index_runs.iter())
.all(|retained| retained.path().file_name() != Some(std::ffi::OsStr::new(&run))),
"{run} is still pinned by the retained generation, so it is still part of \
what this generation claims to hold"
);
assert!(
root.join("shards/00/indexes").join(&run).exists(),
"the reclaimed {run} must be left on the device; recovery unlinking it would \
destroy the evidence for a state this store has only just learned to handle"
);
}
/// The generation the published run names for `covered` — read out of the
@ -7347,18 +7412,23 @@ mod index_maintenance_tests {
(runs[0].clone(), location.segment_generation)
}
/// Contract review 2026-07-30-B: the two states an occupied identity leaves.
/// Contract reviews 2026-07-30-B and 2026-07-30-G: what an occupied identity
/// costs.
///
/// The tail's identity is generation 1 and a published run holds locations
/// against it. An orphan `.seg` occupies that name, so recovery must seal
/// the frames under a different one — and the run then stays authoritative
/// through the manifest while resolving to nothing. Recovery refuses.
/// the frames under a different one — and the run would then stay
/// authoritative through the manifest while resolving to nothing. B refused
/// the open. G reclaims the run instead, which is sound here and only here:
/// every location it holds names the tail, and the tail's frames are above
/// any checkpoint horizon, so replay rebuilds all of them.
///
/// Replay masks this while a lookup goes through the delta above the run, so
/// the assertion is the open itself. `StoreEngine::checkpoint` is a direct
/// Replay masks the dangling state while a lookup goes through the delta
/// above the run, which is why the assertion is what the *manifest* names
/// rather than what a lookup answers. `StoreEngine::checkpoint` is a direct
/// run consumer and would not be masked.
#[test]
fn an_orphan_holding_a_published_runs_identity_refuses_the_open() {
fn an_orphan_holding_a_published_runs_identity_reclaims_it() {
let serial = writer_serial();
let temporary = tempfile::tempdir().expect("tempdir");
let namespace = NamespaceId([0x4A; 32]);
@ -7378,10 +7448,11 @@ mod index_maintenance_tests {
orphan_segment_at(temporary.path(), 0, 1);
}
assert_the_open_refuses_naming_the_run(
assert_the_open_reclaims_the_run(
configure(),
temporary.path(),
IndexKey::new(namespace, ObjectId([0x60; 32])),
&[ObjectId([0x60; 32]), ObjectId([0x61; 32])],
);
}
@ -7394,9 +7465,10 @@ mod index_maintenance_tests {
/// the identity it reuses is still not the one the frames carry, so the
/// published run naming generation 1 is stranded exactly as it would be by a
/// fresh fallback. Resumption is a reason to keep a choice, not a reason to
/// skip the check on it.
/// skip the check on it — and under 2026-07-30-G the check reclaims rather
/// than refuses, on the resumed path exactly as on the fresh one.
#[test]
fn a_resumed_fallback_refuses_on_the_identity_it_resumes() {
fn a_resumed_fallback_reclaims_on_the_identity_it_resumes() {
let serial = writer_serial();
let temporary = tempfile::tempdir().expect("tempdir");
let namespace = NamespaceId([0x4C; 32]);
@ -7417,10 +7489,11 @@ mod index_maintenance_tests {
resumable_prefix_at(temporary.path(), 0, 2);
}
assert_the_open_refuses_naming_the_run(
assert_the_open_reclaims_the_run(
configure(),
temporary.path(),
IndexKey::new(namespace, ObjectId([0x60; 32])),
&[ObjectId([0x60; 32]), ObjectId([0x61; 32])],
);
}
@ -7480,15 +7553,98 @@ mod index_maintenance_tests {
);
// Now occupy the identity the frames carry, so the next open must
// displace them — and owes the refusal for the run that names them.
// displace them — and owes the reclamation for the run that names them.
orphan_segment_at(temporary.path(), 0, 2);
assert_the_open_refuses_naming_the_run(
assert_the_open_reclaims_the_run(
configure(),
temporary.path(),
IndexKey::new(namespace, ObjectId([0x60; 32])),
&[ObjectId([0x60; 32]), ObjectId([0x61; 32])],
);
}
/// The case reclamation must *not* reach, which is the whole reason it is
/// conditional.
///
/// A run may hold locations against more than one identity: a reopen replays
/// what the previous session sealed into a segment and what its journal
/// still holds, and a seal over that backlog covers both. Displacing the
/// tail's identity strands only the entries naming the tail — the ones
/// naming the segment are fine, and they may sit below a checkpoint horizon,
/// which replay does not touch. Discarding a run like that would delete the
/// only index those objects have.
///
/// So recovery still refuses here, and the refusal is the good outcome: an
/// operator gets a diagnosable outage on a root that has lost nothing,
/// rather than a store that opens and has silently dropped an index. The
/// precondition — that the run really does name two generations — is
/// asserted, or this test would pass while proving only that something
/// refused.
#[test]
fn a_run_naming_more_than_the_displaced_identity_is_refused_not_discarded() {
let serial = writer_serial();
let temporary = tempfile::tempdir().expect("tempdir");
let namespace = NamespaceId([0x4F; 32]);
let configure = || {
let mut options = sealing_options(&serial, temporary.path(), 4_000_000);
// Fan-out, not entry pressure: the seal has to land once the replay
// delta and a fresh layer are both present, so the run it writes
// spans the segment recovery sealed and the journal above it.
options.max_index_runs = 2;
options.max_open_index_runs = 2;
options
};
{
let engine = StoreEngine::open(configure()).expect("open a fresh root");
block_on(engine.submit(create_transaction(namespace, 2))).expect("create");
block_on(engine.submit(push_transaction(namespace, 0x60, 0x60, None)))
.expect("a first object, which the next open seals into a segment");
}
{
let engine = StoreEngine::open(configure()).expect("reopen over the sealed prefix");
block_on(engine.submit(push_transaction(namespace, 0x61, 0x61, None)))
.expect("a second layer above the replayed one");
block_on(engine.submit(push_transaction(namespace, 0x62, 0x62, None)))
.expect("crossing the fan-out ceiling, which seals across both");
assert_eq!(engine.index_maintenance().sealed_runs, 1, "the seal ran");
}
let (run, older) = the_published_runs_generation(
temporary.path(),
IndexKey::new(namespace, ObjectId([0x60; 32])),
);
let (_, displaced) = the_published_runs_generation(
temporary.path(),
IndexKey::new(namespace, ObjectId([0x61; 32])),
);
assert_ne!(
older, displaced,
"{run} names one identity, so it is the reclaimable case and this test is not \
exercising the refusal it claims to"
);
// Occupy the identity the journal's frames carry, so the next open must
// displace it — and finds a run it cannot discard.
orphan_segment_at(temporary.path(), 0, displaced);
match StoreEngine::open(configure()) {
Err(StoreError::Corruption(message)) => {
assert!(
message.contains(&run) && message.contains("other generations"),
"the refusal must name the run and why it cannot be discarded: {message}"
);
}
Err(other) => panic!("expected Corruption naming the run, got {other:?}"),
Ok(opened) => {
let committed = opened.committed_root();
panic!(
"the open succeeded; {run} also names generation {older}, whose frames \
replay need not rebuild, and generation {older} now pins {:?}",
committed.object_source(0, older)
);
}
}
}
/// Resumption itself is not the hazard, and a guard that treated it as one
/// would refuse every interrupted recovery on a root that has ever sealed.
///

View File

@ -1148,6 +1148,41 @@ impl IndexRun {
false
}
/// Does every entry in this run name `generation`, and is there at least
/// one?
///
/// Granted to recovery by contract review 2026-07-30-G, the sibling of
/// [`IndexRun::references_segment_generation`] and its one caller's second
/// question. Knowing a run holds locations against the identity recovery is
/// about to displace says the run is *affected*; knowing it holds nothing
/// else says the run is **reclaimable**, because replay rebuilds every
/// location it held and the run is redundant rather than lost.
///
/// The distinction is the whole safety argument for discarding one. A run
/// mixing the displaced identity with an older one covers frames replay may
/// not rebuild — anything below the checkpoint horizon is not replayed at
/// all — and discarding that run would delete the only index those objects
/// have. So this is deliberately not `!references_some_other_generation`
/// with an empty run counting as reclaimable: emptiness is answered here
/// too, and an empty run is not something to reclaim on this evidence.
pub fn references_only_segment_generation(&self, generation: u64) -> bool {
let mut seen = false;
for i in 0..self.section_count {
let section = self.section(i);
for j in 0..section.entry_count {
if self
.entry_location(section.first_entry + j, &section)
.segment_generation
!= generation
{
return false;
}
seen = true;
}
}
seen
}
fn find_section(&self, namespace: &NamespaceId) -> Option<SectionHeader> {
let mut lo = 0u64;
let mut hi = self.section_count;

View File

@ -1431,6 +1431,15 @@ pub struct ShardRecoveryReport {
/// copied or sealed any prefix. The inode is byte-for-byte crash evidence.
pub preserved_journal: Option<PathBuf>,
pub promotions: Vec<VisibilityPromotion>,
/// Published index runs this recovery dropped from the manifest because the
/// seal displaced the only identity they held (contract review
/// 2026-07-30-G).
///
/// Reported rather than inferred. A reclamation is invisible in the opened
/// store — the objects are all still there, rebuilt by replay — so without
/// this an operator cannot tell a root that reclaimed a run from one that
/// never had it, and the `.idx` left on the device has no other explanation.
pub reclaimed_index_runs: Vec<PathBuf>,
/// Scope 3.8 step 12: readiness is true only after replay and
/// catalog/genesis validation complete. A computed field, never a default.
pub ready: bool,
@ -1445,6 +1454,7 @@ impl ShardRecoveryReport {
active_journal: None,
checkpoint_sequence: None,
offline_rebuild_required: false,
reclaimed_index_runs: Vec::new(),
adopted_shard_sequences: Vec::new(),
tail_stop: None,
quarantined: None,
@ -1948,6 +1958,7 @@ fn recover_shard_under_lock(
logical: logical_generation,
tail: tail_generation,
manifest: manifest_generation,
discarded: reclaimed_runs,
} = recovery_generations_for_journal(
&paths,
&root_uuid,
@ -2058,7 +2069,42 @@ fn recover_shard_under_lock(
let first = full_scan.frames.first().expect("non-empty").shard_sequence;
let last = full_scan.frames.last().expect("non-empty").shard_sequence;
let (mut retained_tail_ranges, index_runs, checkpoints) = selection
// Inside the seal, because the seal is what displaces
// the identity. `logical` is chosen before the journal
// is scanned, so it can name a fallback generation for a
// journal that turns out to hold no frame — and then
// nothing is renamed, the surviving tail keeps
// `preferred`, and a run holding locations against
// `preferred` goes on resolving. Reclaiming it out there
// would discard a run that was never stranded.
//
// A reclaimed run must stop being authoritative in the
// same instant it stops resolving: dropped from what this
// generation retains, from what the layered index
// consults, and from the manifest published below.
// Leaving it in any one of the three is the dangling-run
// state the reclamation exists to end.
if !reclaimed_runs.is_empty() {
let reclaimed: std::collections::BTreeSet<&Path> =
reclaimed_runs.iter().map(PathBuf::as_path).collect();
// Matched by pointer, not by name. Both lists were
// built from the same manifest rows in the same loop,
// so the `Arc` identifies the run exactly; re-deriving
// a name here would be a second way of saying which
// run this is, and the two could disagree.
let dropped: Vec<Arc<IndexRun>> = retained_index_runs
.iter()
.filter(|retained| reclaimed.contains(retained.path()))
.map(|retained| Arc::clone(retained.run()))
.collect();
retained_index_runs
.retain(|retained| !reclaimed.contains(retained.path()));
sealed_runs_newest_first
.retain(|run| !dropped.iter().any(|gone| Arc::ptr_eq(gone, run)));
report.reclaimed_index_runs = reclaimed_runs.clone();
}
let (mut retained_tail_ranges, mut index_runs, checkpoints) = selection
.as_ref()
.map(|selected| {
(
@ -2068,6 +2114,20 @@ fn recover_shard_under_lock(
)
})
.unwrap_or((Vec::new(), Vec::new(), Vec::new()));
// The row is what makes a run authoritative, so this is
// where a reclamation actually takes effect: every
// earlier step only stopped *this* session consulting
// the run, and a row left here would hand it back to the
// next open.
if !reclaimed_runs.is_empty() {
let gone: std::collections::BTreeSet<&std::ffi::OsStr> = reclaimed_runs
.iter()
.filter_map(|path| path.file_name())
.collect();
index_runs.retain(|(_, filename)| {
!gone.contains(std::ffi::OsStr::new(filename))
});
}
retained_tail_ranges.push(TailRange {
generation: logical_generation,
first_shard_sequence: first,
@ -2633,6 +2693,17 @@ pub(crate) struct RecoveryGenerations {
/// The generation of the manifest this recovery installs. A counter over
/// manifests, which an index run's manifest also advances.
manifest: u64,
/// Published index runs this recovery **reclaims**: paths whose every
/// location named the identity the seal below is displacing.
///
/// Empty unless the frames are being renamed, which is the only thing that
/// can strand a run. A path here is dropped from the manifest this recovery
/// installs, from the retained generation, and from the layered index — all
/// three, or a run that is no longer authoritative goes on answering
/// lookups. The file itself is left on the device: nothing but a manifest
/// makes a run authoritative, and unlinking it during recovery would destroy
/// the evidence for a state this store has only just learned to handle.
discarded: Vec<PathBuf>,
}
/// Choose all three.
@ -2828,40 +2899,52 @@ fn recovery_generations_for_journal(
// choose against it. It is what every decision below is measured from.
let preferred = active_tail_logical_generation(selection);
// Contract review 2026-07-30-B, and the reason it is a closure rather than
// two copies: displacement can be decided here or in a session that crashed,
// and the two paths must not drift.
// Contract reviews 2026-07-30-B and 2026-07-30-G, and the reason it is a
// closure rather than two copies: displacement can be decided here or in a
// session that crashed, and the two paths must not drift.
//
// Naming the frames anything but `preferred` is what breaks a published run
// holding locations against `preferred` — the manifest goes on naming the
// run, `object_source` answers `None` for every location in it, and a reader
// that reaches the run rather than the replay delta above it reads nothing
// and reports nothing. Replay masks that for exactly as long as nothing
// consumes runs directly, which is not a property to build a checkpointer
// on.
// and reports nothing.
//
// So recovery refuses. Not because refusing is good — it is an outage on a
// root whose data is all present — but because the alternative is a store
// that opens and lies. The closure is for recovery to discard a run whose
// covered identity was not preserved, at which point this becomes successful
// reclamation; it is a change to what recovery *reclaims* and belongs with
// the checkpointing work that will exercise it (scope §6.5).
let refuse_if_displacement_strands_a_run = |chosen: u64| -> Result<(), StoreError> {
// B refused every such root. G reclaims the ones it can prove, and the proof
// is what `preferred` *is*: one past the last retained tail range, which is
// the identity of the **active journal** and of nothing else. Frames at that
// identity are above the manifest's committed prefix, therefore above any
// checkpoint horizon, therefore replayed in full — so a run holding only
// locations at `preferred` is rebuilt entry for entry by the delta this
// recovery is about to construct, at the generation the frames actually
// receive. Discarding it loses nothing; keeping it publishes locations that
// resolve to nothing.
//
// A run that mixes `preferred` with any other identity is still refused, and
// that is not conservatism. Its other entries may name a segment below the
// checkpoint horizon, which replay does not touch, so discarding the run
// would delete the only index those objects have — trading a diagnosable
// outage for silent loss, which is the trade B was written to prevent.
let mut discarded: Vec<PathBuf> = Vec::new();
let mut reclaim_or_refuse_displaced_runs = |chosen: u64| -> Result<(), StoreError> {
if chosen == preferred {
return Ok(());
}
match published_runs
for retained in published_runs
.iter()
.find(|retained| retained.run().references_segment_generation(preferred))
.filter(|retained| retained.run().references_segment_generation(preferred))
{
Some(retained) => Err(StoreError::Corruption(format!(
"this recovery must name its frames generation {chosen} rather than {preferred}, \
and published index run {} holds locations against {preferred}; recovery cannot \
yet discard a run whose covered identity was not preserved",
retained.path().display()
))),
None => Ok(()),
if !retained.run().references_only_segment_generation(preferred) {
return Err(StoreError::Corruption(format!(
"this recovery must name its frames generation {chosen} rather than \
{preferred}, and published index run {} holds locations against {preferred} \
alongside locations against other generations; discarding it would drop \
entries replay does not rebuild",
retained.path().display()
)));
}
discarded.push(retained.path().to_path_buf());
}
Ok(())
};
// Resuming: the identity was already chosen and the artifact carries it.
@ -2872,11 +2955,12 @@ fn recovery_generations_for_journal(
// resumable artifact *at* `preferred` displaces nothing and the guard is a
// no-op on it.
if let Some(logical) = resumable.into_iter().next() {
refuse_if_displacement_strands_a_run(logical)?;
reclaim_or_refuse_displaced_runs(logical)?;
return Ok(RecoveryGenerations {
logical,
tail: preferred,
manifest,
discarded,
});
}
@ -2886,6 +2970,7 @@ fn recovery_generations_for_journal(
logical: preferred,
tail: preferred,
manifest,
discarded,
});
}
@ -2905,11 +2990,12 @@ fn recovery_generations_for_journal(
.ok_or_else(|| {
StoreError::Corruption("no logical generation remains for a recovery segment".into())
})?;
refuse_if_displacement_strands_a_run(logical)?;
reclaim_or_refuse_displaced_runs(logical)?;
Ok(RecoveryGenerations {
logical,
tail: preferred,
manifest,
discarded,
})
}

View File

@ -1676,6 +1676,60 @@ B4 surface this commit touches — the emitter's remaining claims are unchanged.
**Next, and in this dispatch:** recovery discarding an index run whose covered identity was not
preserved, which turns 2026-07-30-B's refusal into reclamation.
##### Contract review 2026-07-30-G
Recovery run-discard, which 2026-07-30-B deferred and named as the thing that would turn its refusal
into reclamation. It is a change to what recovery *reclaims*, not an addition to it, which is why it
is its own commit with its own mutation evidence.
**The state.** A `.seg` occupies the identity the active tail's frames already carry, so recovery
must seal them under a different generation. A published index run holding locations against the
displaced identity then stays authoritative through the manifest while resolving to nothing. B
refused every root in that state. Refusing is an outage on a root that has lost nothing, and B said
so at the time.
**What makes reclamation sound, and it is `preferred` itself.** `active_tail_logical_generation` is
one past the last retained tail range, so it is the identity of the **active journal** and of nothing
else. Frames at that identity sit above the manifest's committed prefix, therefore above any
checkpoint horizon, therefore replayed in full. A run holding *only* locations at `preferred` is
rebuilt entry for entry by the delta recovery is about to construct, at the generation the frames
actually receive. Discarding it loses nothing; keeping it publishes locations that resolve to
nothing.
**What is still refused, and why that is the good outcome.** A run may name more than one identity: a
reopen replays what the previous session sealed into a segment *and* what its journal still holds,
and a seal over that backlog covers both. Displacing the tail strands only the tail's entries — the
segment's may sit below a checkpoint horizon, which replay does not touch. Discarding that run would
delete the only index those objects have, trading a diagnosable outage for silent loss, which is the
trade B existed to prevent. So the refusal survives for mixed runs and now says why.
**Frozen-seam amendment to `index.rs` (A2).** `references_only_segment_generation`, the sibling of
B's `references_segment_generation` and the second question its one caller has to ask: the first says
a run is *affected*, this one says it is *reclaimable*. Read-only, exact rather than a range test,
and deliberately false for an empty run — emptiness is not something to reclaim on this evidence.
**A reclamation is three removals, not one.** The manifest row is what makes a run authoritative, so
dropping it governs the next open; but this session must also stop consulting the run and stop
pinning it, or a direct run consumer — `StoreEngine::checkpoint` is one — reads dangling locations
for as long as the process lives. A lookup will not reveal it, because the replay delta sits above
the runs and answers first. The `.idx` itself is **left on the device**: nothing but a manifest makes
a run authoritative, and unlinking during recovery would destroy the evidence for a state this store
has only just learned to handle. `ShardRecoveryReport::reclaimed_index_runs` reports it, because a
reclamation is otherwise invisible in the opened store.
**One placement that matters.** The reclamation is applied inside the seal, not where the generation
is chosen. `logical` is decided before the journal is scanned, so it can name a fallback for a
journal that turns out to hold no frame — and then nothing is renamed, the surviving tail keeps
`preferred` (2026-07-30-B amendment 2), and a run at `preferred` goes on resolving. Reclaiming there
would discard a run that was never stranded.
**Mutation evidence.** Four mutations, each caught: treating every run as reclaimable fails the mixed
-run refusal; treating none as reclaimable fails all three reclamation regressions; leaving the
manifest row fails them on the authority assertion; and leaving the run in this session's index and
pins fails them on the direct-consumer assertion. That last mutation **survived the first version of
these tests**, because the replay delta shadows the run for ordinary lookups — the assertion that
catches it was added after the mutation showed the gap rather than before.
##### Contract review 2026-07-30-F
Two P1s and a P2 against the checkpoint-equivalence half of 2026-07-30-E. The namespace and

View File

@ -1710,13 +1710,21 @@ costs:
- **Nothing published names the displaced identity.** Recovery falls back to a free generation and
succeeds. An orphan segment is a documented state in which the active journal stays the authority,
and refusing here would turn a recoverable root into an outage.
- **An index run the manifest names holds locations against it.** Recovery refuses the open with
`Corruption`, naming the run. Proceeding would publish a manifest whose run is authoritative and
resolves to nothing; the replay delta above it hides that from every lookup until the first
consumer that reads runs directly, which is the checkpointer.
- **An index run the manifest names holds locations against it, and against nothing else.** Recovery
**reclaims** it: the row leaves the manifest it installs, the run leaves the retained generation
and the layered index, and the open succeeds. Sound because the displaced identity is the active
journal's — one past the last retained tail range, and nothing else can carry it — so its frames
are above every checkpoint horizon and replay rebuilds each entry the run held. The `.idx` stays
on the device and `ShardRecoveryReport::reclaimed_index_runs` records what happened.
- **The run also holds locations against some other identity.** Recovery still refuses the open with
`Corruption`, naming the run. Its other entries may name a segment below the checkpoint horizon,
which replay does not touch, so discarding the run would delete the only index those objects have.
A diagnosable outage on a root that has lost nothing is the right outcome; publishing a manifest
whose run is authoritative and resolves to nothing is not — the replay delta above it hides that
from every lookup until the first consumer that reads runs directly, which is the checkpointer.
The test is what the recovery *names its frames*, not how it got there: an interrupted recovery
resuming a fallback an earlier session chose strands the same run, and takes the same refusal. A
resuming a fallback an earlier session chose strands the same run, and takes the same treatment. A
resumed seal at the identity the frames already carry displaces nothing and opens normally.
The fallback applies only to frames being **sealed**. A journal that survives recovery keeps the
@ -1726,10 +1734,11 @@ from the manifest across every open — the property the split rests on. Giving
fallback identity moved a number nothing recorded, and the next open, deriving the old one, searched
published runs for a generation the frames no longer carried.
The closure is for recovery to **discard an index run whose covered identity was not preserved**,
which turns the refusal into successful reclamation. It is a change to what recovery reclaims and
belongs with the checkpointing work that will exercise it. Until then a root carrying both an orphan
segment and a run against the identity it holds does not open.
**Closed by contract review 2026-07-30-G.** Recovery discards an index run whose covered identity was
not preserved, which turned the refusal into successful reclamation for every run it can prove
redundant. The refusal survives only where the proof does not hold — a run naming more than the
displaced identity — and it is no longer a placeholder for missing capability but the correct answer
for a run whose other entries replay would not rebuild.
### 6.5 B3 — StagingSessions