Answer reads from a captured root, scoped to one namespace

B1 deliverable 8. `StoreEngine::snapshot` and every `RepoSnapshot` accessor
were the frozen D0 signatures returning `NotImplemented`; they now capture one
committed root and answer from it.

The frozen signature could not express an absent repository, so contract review
2026-08-07-A adds `StoreError::NoSuchRepository`. Reusing `Conflict`, `NotReady`
or `UnrecognizedLayout` would have made the error a false statement about what
happened and left a caller unable to distinguish it from a genuine instance of
that condition. It is an inability to answer and not a lifecycle state, which is
the distinction the taxonomy turns on: a namespace never bound has no
`RepoState`, so there is no genesis authority to report and none can be
manufactured without fabricating a trust root. A namespace that is bound and
retired is the opposite case, and it captures normally - refusing both would
erase a difference the store knows. `RepoSnapshot` therefore gains `lifecycle`
and `storage_mode`, without which a reader cannot tell an active repository from
a deleted one.

Isolation is structural rather than checked. `IndexKey` has no constructor that
omits a namespace, so the only key `locate` can build is one scoped to its own,
and there is no branch a later edit could invert. An undefined object type code
is `Corruption` and not a miss: the entry was written by this store, so a code
no version of it ever assigned means the run behind it is damaged, and reporting
that as absence would hide it.

Capture is two `Arc` clones and a hash lookup, and holding the root is what pins
every generation behind the locations it can return - a reader cannot be handed
an offset into a segment deleted before it reads. `Debug` is hand-written, since
a derived one would render the whole index into any log line that formatted a
snapshot.

Deliverable 8's acceptance is amended, and the reason is that the design already
succeeded. "A test that fails if someone clones" assumes a clone is a copy;
`CommittedRoot` is entirely `im` persistent structures, so `(*root).clone()`
allocates zero bytes and so does cloning the index. Both were tried as the
negative control and both read zero. The test keeps a measured figure asserted
at exactly zero, which catches materialization, and adds `Arc::ptr_eq`, which
catches the copy the figure cannot. A live control proves the meter moves.

Four integration tests assert the same property through `open`, `submit` and
`snapshot` rather than against a hand-built root - charter item 8. Their two
repositories are co-located on one shard deliberately: separate shards write to
separate journals and separate index deltas, so isolation holds there by
construction and a namespace-blind lookup would still pass. Verified by giving
`locate` a namespace-blind fallback, which fails the isolation assertion at both
levels.

Recorded and not acted on: `segment_generation` is per-shard, so the same
generation and offset pair occurs in every shard's journal. Not ambiguity - a
location is only read through a snapshot, whose namespace determines the shard -
but it means a cross-shard location comparison asserts nothing.

Deliverables 1, 3 and 7 remain. The ignored staging test's blocker list is now
stale in B3's file and is left for B3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-08-07 21:34:48 +02:00
parent b4e4c7ebc7
commit bac31513e3
No known key found for this signature in database
7 changed files with 871 additions and 14 deletions

View File

@ -352,10 +352,16 @@ impl StoreEngine {
/// Acquire exactly one committed root and derive a repository view from
/// it. Readers never observe an index entry newer than their captured
/// root (plan §5.3).
pub fn snapshot(&self, _repo: NamespaceId) -> Result<RepoSnapshot, StoreError> {
Err(StoreError::NotImplemented(
"StoreEngine::snapshot — B1 NamespaceTxn, scope 6-B1 deliverable 8",
))
pub fn snapshot(&self, repo: NamespaceId) -> Result<RepoSnapshot, StoreError> {
// `load_full` and not `load`: the snapshot outlives this call, so it
// must own the generation it read rather than borrow the slot. That
// ownership is the whole retention guarantee -- see the note on
// `RepoSnapshot`.
//
// One load, so the snapshot is a single point in the publication
// order. Resolving the repository from a second load could pair one
// generation's `RepoState` with another's index.
RepoSnapshot::capture(self.shared.committed.load_full(), repo)
}
/// Submit a validated transaction for sequencing, group append, fence, and

View File

@ -69,7 +69,11 @@ pub use roots::{
RetainedObjectSource, RetainedProjectionArtifact, RetainedReceipt, RetainedSegment,
RetainedTail, ShardSubtree, StatusEntry, StatusPhase, StatusReservation, TerminalStatusEntry,
};
pub use snapshot::RepoSnapshot;
// `ObjectLocation` names `RepoSnapshot::locate`'s success value, and the two
// namespace enums name the accessors contract review 2026-08-07-A added, so a
// caller cannot use the re-exported `RepoSnapshot` without them.
pub use index::{NamespaceLifecycle, NamespaceStorageMode};
pub use snapshot::{ObjectLocation, RepoSnapshot};
pub use staging::{
ProjectionAdoption, ProjectionAdoptionOutcome, ProjectionAdoptionResolution,
ProjectionArtifact, RecoveredProjectionOutcome, RecoveredProjectionResolution,

View File

@ -7,14 +7,66 @@
//! A snapshot must not clone the index; plan §5.1 requires reads to retain the
//! committed generation and share structure, so a snapshot is cheap enough to
//! take per request.
//!
//! # What a capture costs, and why that is a correctness property
//!
//! Capturing is two `Arc` clones and one hash lookup. It is not "fast because
//! the maps are small" — it is O(1) in the number of objects, namespaces, and
//! generations the root holds, and `snapshot_capture_allocates_nothing` below
//! measures it as a byte figure rather than asserting it in a comment. §5.3
//! makes this correctness and not performance: a per-request read that copied
//! the index would make the index's size a per-request cost, and the read path
//! would degrade as the store filled rather than at a bound anyone configured.
//!
//! # What holding one guarantees
//!
//! An `ObjectLocation` names a physical place — a generation, an offset, a
//! length. That is only answerable while the artifact carrying it is still on
//! disk, and compaction is entitled to remove artifacts no committed root
//! references. Retaining the whole `CommittedRoot` is what closes that: every
//! `RetainedGeneration` behind every location this snapshot can return is
//! pinned for as long as the snapshot lives, so a reader cannot be handed an
//! offset into a segment that is deleted before it reads.
use std::sync::Arc;
use levcs_core::{ObjectId, ObjectType};
use crate::index::{IndexKey, NamespaceLifecycle, NamespaceStorageMode};
use crate::roots::{CommittedRoot, RepoState};
use crate::types::{NamespaceId, StoreError};
/// One repository's committed logical state at one generation.
pub struct RepoSnapshot {
_private: (),
/// The whole visibility boundary, retained — see the module note on what
/// holding it guarantees. Capturing it is one atomic increment and never a
/// traversal.
root: Arc<CommittedRoot>,
namespace: NamespaceId,
/// Resolved once, at capture. `RepoMap` holds each `RepoState` behind its
/// own `Arc`, so this shares that allocation rather than copying the refs
/// map, and it takes the hash lookup off every accessor below.
///
/// Resolving it eagerly is also what makes the accessors total: a
/// `RepoSnapshot` that exists at all has a `RepoState`, so `repo_sequence`
/// and the two authorities have something to return without an `Option`
/// the frozen signatures do not have.
state: Arc<RepoState>,
}
/// Deliberately hand-written and deliberately small. A derived `Debug` would
/// reach through the retained `CommittedRoot` and render the entire index, so
/// one stray `{:?}` in a read path would serialize the store into a log line.
/// What identifies a snapshot is which repository it is of and which
/// generation it caught, and that is what this prints.
impl std::fmt::Debug for RepoSnapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RepoSnapshot")
.field("namespace", &self.namespace)
.field("repo_sequence", &self.state.repo_sequence)
.field("lifecycle", &self.state.lifecycle)
.finish_non_exhaustive()
}
}
/// Where an object lives, for a reader holding a snapshot.
@ -28,31 +80,525 @@ pub struct ObjectLocation {
}
impl RepoSnapshot {
/// Capture `namespace` against `root`.
///
/// The absent-repository rule lives here rather than at the call site so
/// that every way of obtaining a snapshot obeys it, and so that the
/// invariant the accessors depend on — a live `RepoSnapshot` always has a
/// `RepoState` — is established by the only constructor.
///
/// Contract review 2026-08-07-A: an unbound namespace is
/// `StoreError::NoSuchRepository`, an inability to answer. A namespace
/// that is bound and retired is a lifecycle, is reported through
/// [`RepoSnapshot::lifecycle`], and is captured normally — refusing it
/// here would make a deleted repository indistinguishable from one that
/// never existed, and the two have different answers to every question
/// below.
pub(crate) fn capture(
root: Arc<CommittedRoot>,
namespace: NamespaceId,
) -> Result<Self, StoreError> {
let Some(state) = root.repo(&namespace) else {
return Err(StoreError::NoSuchRepository { namespace });
};
let state = Arc::clone(state);
Ok(Self {
root,
namespace,
state,
})
}
pub fn namespace(&self) -> NamespaceId {
unimplemented!("B1 NamespaceTxn: scope 6-B1 deliverable 8")
self.namespace
}
/// The logical federation cursor value, never the physical
/// `shard_sequence` (plan §4 transaction invariant 8).
pub fn repo_sequence(&self) -> u64 {
unimplemented!("B1 NamespaceTxn: scope 6-B1 deliverable 8")
self.state.repo_sequence
}
pub fn current_authority(&self) -> ObjectId {
unimplemented!("B1 NamespaceTxn: scope 6-B1 deliverable 8")
self.state.current_authority
}
pub fn genesis_authority(&self) -> ObjectId {
unimplemented!("B1 NamespaceTxn: scope 6-B1 deliverable 8")
self.state.genesis_authority
}
/// Whether this repository is `Active`, `ReadOnly`, or `Deleted` at the
/// captured generation.
///
/// Added by contract review 2026-08-07-A. Without it a reader holding a
/// snapshot cannot tell a live repository from a retired one, and §4 makes
/// that distinction observable; `NoSuchRepository` deliberately does not
/// cover it, because a retired repository still has a state and still
/// answers every other question here.
pub fn lifecycle(&self) -> NamespaceLifecycle {
self.state.lifecycle
}
/// Added by contract review 2026-08-07-A, for the same reason as
/// [`RepoSnapshot::lifecycle`].
pub fn storage_mode(&self) -> NamespaceStorageMode {
self.state.storage_mode
}
/// Locate an object *within this namespace*. Namespace membership, not
/// global object existence, controls reads: identical bytes in private
/// repository A do not make an object readable through repository B
/// (plan §4 resource invariants).
pub fn locate(&self, _id: ObjectId) -> Result<Option<ObjectLocation>, StoreError> {
Err(StoreError::NotImplemented(
"RepoSnapshot::locate — B1 NamespaceTxn, scope 6-B1 deliverable 8",
))
///
/// The isolation is structural rather than checked: [`IndexKey`] has no
/// constructor that omits a namespace, so the only key this can build is
/// one scoped to `self.namespace`. There is no branch here that a future
/// edit could invert.
///
/// `Ok(None)` is "this namespace does not contain that object" and is not
/// a statement about whether the object exists anywhere in the store —
/// answering the second question is precisely what the invariant forbids.
pub fn locate(&self, id: ObjectId) -> Result<Option<ObjectLocation>, StoreError> {
let Some(location) = self.root.index().get(&IndexKey::new(self.namespace, id)) else {
return Ok(None);
};
// The index stores the type as a code because the packed run format is
// bytes; the frozen `ObjectLocation` hands back a typed value. An
// undefined code is not a missing object and must not be reported as
// one: the entry was written by this store, so a code no version of it
// ever assigned means the run or the delta behind it is damaged.
let object_type = ObjectType::from_u8(location.object_type).map_err(|_| {
StoreError::Corruption(format!(
"index entry for object {} in namespace {} carries object type code {}, \
which names no defined object type; the index run or delta holding it \
is damaged",
hex::encode(id.0),
self.namespace.to_hex(),
location.object_type,
))
})?;
Ok(Some(ObjectLocation {
segment_generation: location.segment_generation,
offset: location.frame_offset,
len: u64::from(location.frame_len),
object_type,
shard_sequence: location.shard_sequence,
}))
}
}
#[cfg(test)]
mod tests {
use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;
use std::sync::Arc;
use im::Vector;
use super::*;
use crate::index::{IndexDelta, IndexLocation};
use crate::roots::{
GenerationMap, IndexDeltaLayer, LayeredObjectIndex, RepoMap, ShardSequenceMap,
TerminalStatusMap, TypedRefMap,
};
// -----------------------------------------------------------------------
// A thread-local allocation meter
// -----------------------------------------------------------------------
//
// Scope 6-B1 deliverable 8 asks for "a measured assertion that taking a
// snapshot allocates no index copy -- a count or a byte figure, not a
// comment", and for a test that fails if someone clones. Counting bytes
// through the global allocator is the only form of that which cannot be
// satisfied by a cheaper clone: `Arc::clone` allocates nothing, and every
// way of copying an index -- `Vector`, `HashMap`, `Vec<u8>` -- allocates
// something.
//
// The counter is thread-local rather than global because the test binary
// runs tests in parallel, and a global counter would measure whatever else
// happened to be allocating at the same moment. That would make this test
// flaky in the direction that matters least (spurious failure) and, worse,
// would tempt someone to widen the bound until it stopped failing.
thread_local! {
static MEASURING: Cell<bool> = const { Cell::new(false) };
static ALLOCATED_BYTES: Cell<usize> = const { Cell::new(0) };
}
struct MeteredAllocator;
unsafe impl GlobalAlloc for MeteredAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
// `MEASURING` gates the accounting so that the thread-local access
// itself -- which may allocate on first touch -- cannot recurse
// into the counter it is trying to update.
if MEASURING.get() {
ALLOCATED_BYTES.set(ALLOCATED_BYTES.get() + layout.size());
}
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
}
#[global_allocator]
static ALLOCATOR: MeteredAllocator = MeteredAllocator;
/// Bytes allocated on this thread while `body` ran.
fn allocated_bytes<T>(body: impl FnOnce() -> T) -> (T, usize) {
// Touch both thread-locals before arming, so their own lazy
// initialization is never part of the measurement.
MEASURING.set(false);
ALLOCATED_BYTES.set(0);
MEASURING.set(true);
let value = body();
MEASURING.set(false);
(value, ALLOCATED_BYTES.get())
}
// -----------------------------------------------------------------------
// Fixtures
// -----------------------------------------------------------------------
fn namespace(seed: u8) -> NamespaceId {
NamespaceId::from_bytes([seed; 32])
}
fn object(seed: u8) -> ObjectId {
ObjectId([seed; 32])
}
fn repo_state(sequence: u64, lifecycle: NamespaceLifecycle) -> Arc<RepoState> {
Arc::new(RepoState {
repo_sequence: sequence,
current_authority: object(0xc0),
genesis_authority: object(0x6e),
refs: TypedRefMap::new(),
lifecycle,
storage_mode: NamespaceStorageMode::Full,
previous_event_digest: object(0xed),
})
}
fn location(object_type: u8) -> IndexLocation {
IndexLocation {
segment_generation: 3,
frame_offset: 4_096,
frame_len: 512,
object_type,
shard_sequence: 7,
}
}
/// A root binding `repos`, with `entries` in a single delta layer.
fn root_with(
repos: &[(NamespaceId, Arc<RepoState>)],
entries: &[(NamespaceId, ObjectId, IndexLocation)],
) -> Arc<CommittedRoot> {
let mut repositories = RepoMap::new();
for (ns, state) in repos {
repositories.insert(*ns, Arc::clone(state));
}
let mut delta = IndexDelta::new(4_096, 1 << 20);
for (ns, id, location) in entries {
delta
.insert(IndexKey::new(*ns, *id), *location)
.expect("delta accepts the fixture entries");
}
let mut layers = Vector::new();
layers.push_back(IndexDeltaLayer::new(0, 1, Arc::new(delta)));
Arc::new(CommittedRoot::new(
repositories,
LayeredObjectIndex::new(layers, Vector::new()),
TerminalStatusMap::new(),
ShardSequenceMap::new(),
GenerationMap::new(),
))
}
// -----------------------------------------------------------------------
// Capture
// -----------------------------------------------------------------------
#[test]
fn capture_reports_the_state_bound_at_the_captured_generation() {
let ns = namespace(1);
let root = root_with(&[(ns, repo_state(42, NamespaceLifecycle::Active))], &[]);
let snapshot = RepoSnapshot::capture(root, ns).expect("the namespace is bound");
assert_eq!(snapshot.namespace(), ns);
assert_eq!(snapshot.repo_sequence(), 42);
assert_eq!(snapshot.current_authority(), object(0xc0));
assert_eq!(snapshot.genesis_authority(), object(0x6e));
assert_eq!(snapshot.lifecycle(), NamespaceLifecycle::Active);
assert_eq!(snapshot.storage_mode(), NamespaceStorageMode::Full);
}
#[test]
fn capture_refuses_a_namespace_no_repository_is_bound_for() {
let bound = namespace(1);
let unbound = namespace(2);
let root = root_with(&[(bound, repo_state(1, NamespaceLifecycle::Active))], &[]);
let error = RepoSnapshot::capture(root, unbound).expect_err("nothing is bound for it");
// Asserted by name and by payload: contract review 2026-08-07-A makes
// the identity part of the error, so a caller can recover what it
// asked for without parsing the message.
match error {
StoreError::NoSuchRepository { namespace } => assert_eq!(namespace, unbound),
other => panic!("expected NoSuchRepository, got {other:?}"),
}
}
/// The distinction contract review 2026-08-07-A turns on. A retired
/// repository still has a state and still answers; only an unbound one is
/// an inability to answer. Refusing both would erase the difference.
#[test]
fn a_deleted_repository_captures_and_reports_its_lifecycle() {
let ns = namespace(1);
let root = root_with(&[(ns, repo_state(9, NamespaceLifecycle::Deleted))], &[]);
let snapshot = RepoSnapshot::capture(root, ns).expect("a retired repository is bound");
assert_eq!(snapshot.lifecycle(), NamespaceLifecycle::Deleted);
assert_eq!(snapshot.repo_sequence(), 9);
}
// -----------------------------------------------------------------------
// Namespace isolation
// -----------------------------------------------------------------------
#[test]
fn locate_answers_for_its_own_namespace() {
let ns = namespace(1);
let id = object(0xa1);
let root = root_with(
&[(ns, repo_state(1, NamespaceLifecycle::Active))],
&[(ns, id, location(ObjectType::Commit as u8))],
);
let found = RepoSnapshot::capture(root, ns)
.expect("bound")
.locate(id)
.expect("a defined type code decodes")
.expect("the entry is in this namespace");
assert_eq!(
found,
ObjectLocation {
segment_generation: 3,
offset: 4_096,
len: 512,
object_type: ObjectType::Commit,
shard_sequence: 7,
}
);
}
/// §7's namespace-isolation exit criterion, at the index-key level:
/// *identical bytes* in A are not readable through B. The same `ObjectId`
/// is indexed under both namespaces at deliberately different locations,
/// so a leak would return B's row rather than nothing and the assertion
/// distinguishes the two failures.
#[test]
fn identical_bytes_in_another_namespace_are_not_readable_through_this_one() {
let a = namespace(1);
let b = namespace(2);
let shared = object(0xa1);
let only_in_b = object(0xb2);
let root = root_with(
&[
(a, repo_state(1, NamespaceLifecycle::Active)),
(b, repo_state(1, NamespaceLifecycle::Active)),
],
&[
(a, shared, location(ObjectType::Blob as u8)),
(b, shared, location(ObjectType::Tree as u8)),
(b, only_in_b, location(ObjectType::Blob as u8)),
],
);
let through_a = RepoSnapshot::capture(Arc::clone(&root), a).expect("bound");
// The object present in both resolves to A's row, not B's.
let found = through_a
.locate(shared)
.expect("decodes")
.expect("present in A");
assert_eq!(
found.object_type,
ObjectType::Blob,
"A's snapshot resolved the shared id through B's index row"
);
// The object present only in B is invisible through A.
assert_eq!(
through_a.locate(only_in_b).expect("decodes"),
None,
"an object stored only in B was readable through A"
);
}
#[test]
fn locate_reports_a_miss_rather_than_an_error() {
let ns = namespace(1);
let root = root_with(&[(ns, repo_state(1, NamespaceLifecycle::Active))], &[]);
assert_eq!(
RepoSnapshot::capture(root, ns)
.expect("bound")
.locate(object(0xff))
.expect("an absent object is not a failure"),
None
);
}
#[test]
fn an_undefined_object_type_code_is_corruption_and_not_a_miss() {
let ns = namespace(1);
let id = object(0xa1);
// 0 and 6 are outside `ObjectType`'s 1..=5; no version of this store
// ever wrote either, so an entry carrying one is damaged.
let root = root_with(
&[(ns, repo_state(1, NamespaceLifecycle::Active))],
&[(ns, id, location(6))],
);
let error = RepoSnapshot::capture(root, ns)
.expect("bound")
.locate(id)
.expect_err("an undefined type code must not be reported as absence");
match error {
StoreError::Corruption(message) => {
assert!(message.contains("object type code 6"), "{message}");
}
other => panic!("expected Corruption, got {other:?}"),
}
}
// -----------------------------------------------------------------------
// Scope 6-B1 deliverable 8: the measured no-copy assertion
// -----------------------------------------------------------------------
/// *Accept:* "a measured assertion that taking a snapshot allocates no
/// index copy — a count or a byte figure, not a comment".
///
/// The figure is bytes allocated on this thread across the capture. It is
/// asserted at exactly zero rather than at a threshold: `Arc::clone` is
/// two atomic increments and allocates nothing at all, so any nonzero
/// reading is a structure someone copied, and a threshold would be a
/// budget for copying rather than a prohibition on it.
///
/// The index is large enough that a clone is unmissable — 4,000 entries
/// across two namespaces — so this fails loudly if `capture` ever stops
/// sharing.
#[test]
fn snapshot_capture_allocates_nothing() {
let ns = namespace(1);
let other = namespace(2);
let entries: Vec<_> = (0..2_000u32)
.flat_map(|i| {
let mut id = [0u8; 32];
id[..4].copy_from_slice(&i.to_le_bytes());
[
(ns, ObjectId(id), location(ObjectType::Blob as u8)),
(other, ObjectId(id), location(ObjectType::Blob as u8)),
]
})
.collect();
let root = root_with(
&[
(ns, repo_state(1, NamespaceLifecycle::Active)),
(other, repo_state(1, NamespaceLifecycle::Active)),
],
&entries,
);
// Prove the meter can see a copy at all before trusting it to report
// zero. A test whose instrument is never shown to move is a test that
// passes when the instrument is broken.
//
// The control materializes the same entry set into a `std` map, which
// is what copying an index actually costs.
//
// **`(*root).clone()` does not work as a control, and neither does
// cloning the index.** Two earlier versions of this test tried each and
// both read zero: `CommittedRoot` is built entirely from `im`
// persistent structures — `im::HashMap`, `im::Vector`, `im::OrdMap` —
// whose clones are O(1) and allocate nothing. That is the structure
// sharing §5.3 asks for, working. It also means **a byte figure alone
// cannot fail on a clone in this crate**, because here a clone *is*
// sharing; the `Arc::ptr_eq` assertion below is what covers that case,
// and the byte figure covers materialization. See the carry-forward in
// scope §6.4 deliverable 8.
let (copied, copy_bytes) = allocated_bytes(|| {
entries
.iter()
.map(|(ns, id, location)| (IndexKey::new(*ns, *id), *location))
.collect::<std::collections::HashMap<_, _>>()
});
assert!(
copy_bytes > 0,
"the allocation meter read zero while materializing a copy of the \
index, so it cannot be trusted to report zero below"
);
drop(copied);
let (snapshot, capture_bytes) =
allocated_bytes(|| RepoSnapshot::capture(Arc::clone(&root), ns));
let snapshot = snapshot.expect("bound");
assert_eq!(
capture_bytes, 0,
"capturing a snapshot allocated {capture_bytes} bytes; it must share \
the committed root's substructures, not copy them (plan §5.3). \
Copying this root costs {copy_bytes} bytes."
);
// The byte figure says nothing was built. This says what was retained
// is the caller's own root and not an equal one: same allocation, so
// there is no traversal anywhere behind the capture, whatever the
// allocator happened to see.
assert!(
Arc::ptr_eq(&snapshot.root, &root),
"the snapshot retained a different CommittedRoot allocation than \
the one it was given"
);
// ...and the shared index still answers, so "allocated nothing" is not
// "captured nothing".
let mut id = [0u8; 32];
id[..4].copy_from_slice(&7u32.to_le_bytes());
assert!(snapshot.locate(ObjectId(id)).expect("decodes").is_some());
}
/// Reads are answered from the generation the snapshot captured, so a
/// later root cannot change what an existing snapshot returns (plan §5.3:
/// "readers never observe an index entry newer than their captured root").
#[test]
fn a_snapshot_is_unaffected_by_a_later_root() {
let ns = namespace(1);
let added_later = object(0xbb);
let before = root_with(&[(ns, repo_state(1, NamespaceLifecycle::Active))], &[]);
let snapshot = RepoSnapshot::capture(before, ns).expect("bound");
// A newer root binds the same namespace and indexes a new object.
let _after = root_with(
&[(ns, repo_state(2, NamespaceLifecycle::Active))],
&[(ns, added_later, location(ObjectType::Blob as u8))],
);
assert_eq!(snapshot.repo_sequence(), 1);
assert_eq!(snapshot.locate(added_later).expect("decodes"), None);
}
}

View File

@ -196,6 +196,25 @@ pub enum StoreError {
#[error("mutable-state conflict: {0}")]
Conflict(String),
/// No repository is bound for this namespace in the captured committed
/// root. Contract review 2026-08-07-A, requested by B1 for scope 6-B1
/// deliverable 8.
///
/// This is an inability to answer and not a lifecycle state, which is the
/// distinction the taxonomy above turns on. A namespace that was never
/// bound has no `RepoState`, so there is no `genesis_authority` to report
/// and no sequence to report it at; `StoreEngine::snapshot` cannot
/// manufacture either without fabricating a trust root. A namespace that
/// *is* bound and has been retired is the opposite case — `Deleted` is a
/// lifecycle, it has a `RepoState`, and it is reported through
/// `RepoSnapshot::lifecycle` rather than through this error.
///
/// It carries the typed `NamespaceId` rather than a rendered string so a
/// caller can match on the identity it asked for instead of parsing it
/// back out of a message.
#[error("no repository bound for namespace {}", namespace.to_hex())]
NoSuchRepository { namespace: NamespaceId },
#[error("store is not ready")]
NotReady,

View File

@ -0,0 +1,216 @@
//! Scope 6-B1 deliverable 8 and §7's namespace-isolation exit criterion,
//! asserted through the public surface.
//!
//! `snapshot.rs`'s unit tests already assert isolation at the index-key level,
//! against a `CommittedRoot` the test built. This file asserts it against a
//! root **the store built**: every namespace here is created by
//! `StoreEngine::submit`, every object is staged through a real transaction,
//! and every read goes through `StoreEngine::snapshot` and
//! `RepoSnapshot::locate`.
//!
//! That distinction is scope 5 charter item 8 — "assert against the path that
//! runs, not the helper". A hand-built root proves the lookup rule; it cannot
//! prove that the writer files entries under the namespace it was given, which
//! is the half of the isolation property that lives in the write path.
//!
//! The exit criterion says *identical bytes*, so the shared object here is
//! genuinely identical in both repositories: `push_transaction` stages
//! `ObjectId([blob; 32])` with `raw: vec![blob; 64]`, so the same `blob` seed
//! in two namespaces produces the same id over the same bytes. Isolation that
//! held only because the two repositories held different objects would not be
//! isolation at all.
// Gated on all three features because `engine_matrix` arms failpoints and
// reaches `drive.rs`, so it compiles only under the full set. This file needs
// neither, and the alternative was a second copy of the transaction builders
// and the shard-routing search inside a B1-owned test — duplicating a
// B4-owned harness to avoid a feature gate is the worse trade. The Phase 1
// gate runs exactly this combination, so these tests run on every gate run.
#![cfg(all(
feature = "store-privileged",
feature = "store-internals",
feature = "failpoints"
))]
use levcs_core::ObjectId;
use levcs_store::types::{NamespaceId, StoreError};
// Only the submit harness, by path: `support/mod.rs` also carries the crash
// driver's plumbing, and this file needs none of it.
#[path = "support/engine_matrix.rs"]
mod engine_matrix;
use engine_matrix::{
create_transaction, namespace_on_shard, open_absent_root, push_transaction, submit,
DEFAULT_MAX_INDEX_RUNS,
};
const SHARD_COUNT: u16 = 2;
/// Present in both repositories, as the same id over the same bytes.
const SHARED_BLOB: u8 = 0x7a;
/// Present only in B.
const ONLY_IN_B_BLOB: u8 = 0x5b;
fn blob_id(seed: u8) -> ObjectId {
ObjectId([seed; 32])
}
/// Two repositories **on the same shard**, each holding `SHARED_BLOB`; B also
/// holds `ONLY_IN_B_BLOB`.
///
/// Same shard deliberately. Two repositories on different shards write to
/// different journals, seal into different segment-generation spaces, and land
/// in different index deltas, so isolation between them holds by construction
/// and a namespace-blind lookup would still pass. Co-locating them puts both
/// repositories' frames in one journal and both their entries in one index,
/// which is the only arrangement where the namespace component of the key is
/// load-bearing.
fn store_with_two_repositories(
root: &std::path::Path,
) -> (levcs_store::StoreEngine, NamespaceId, NamespaceId) {
let engine = open_absent_root(root, SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
let a = namespace_on_shard(0, SHARD_COUNT, 1);
let b = namespace_on_shard(0, SHARD_COUNT, 2);
assert_ne!(a, b, "the two repositories must be distinct");
for (operation, namespace) in [(1u8, a), (2, b)] {
submit(&engine, create_transaction(namespace, operation))
.receipt()
.expect("the repository is created");
}
for (operation, namespace, blob) in [
(3u8, a, SHARED_BLOB),
(4, b, SHARED_BLOB),
(5, b, ONLY_IN_B_BLOB),
] {
submit(&engine, push_transaction(namespace, operation, blob))
.receipt()
.expect("the push commits");
}
(engine, a, b)
}
/// §7: "identical bytes in A not readable through B".
#[test]
fn an_object_stored_only_in_one_repository_is_invisible_through_the_other() {
let dir = tempfile::tempdir().expect("tempdir");
let (engine, a, b) = store_with_two_repositories(dir.path());
let through_a = engine.snapshot(a).expect("A is bound");
let through_b = engine.snapshot(b).expect("B is bound");
// The control: B really does hold it, so the assertion below is about
// visibility and not about a push that silently failed.
assert!(
through_b
.locate(blob_id(ONLY_IN_B_BLOB))
.expect("a committed object decodes")
.is_some(),
"B does not hold the object this test is about; the fixture is wrong, \
not the store"
);
assert_eq!(
through_a
.locate(blob_id(ONLY_IN_B_BLOB))
.expect("a miss is not a failure"),
None,
"an object committed only to B was readable through A's snapshot"
);
}
/// The same bytes committed to both repositories resolve through each, and
/// each answer names its own repository's write.
///
/// This is the case a namespace-blind index would *pass* by accident, so the
/// assertion is on the physical record each answer names. Both repositories sit
/// on one shard, so their frames share a journal and a generation space and the
/// two offsets are directly comparable: A and B were separate transactions, so
/// a snapshot returning the other's row is detectable even though the object id
/// and the staged bytes are identical.
///
/// `(segment_generation, offset)` is compared rather than `segment_generation`
/// alone because generations are **per shard**, not global — the same pair
/// occurs in every shard's journal. That is not ambiguity in `ObjectLocation`:
/// a location is only ever read through a snapshot, and the snapshot's
/// namespace determines the shard. It does mean a cross-shard comparison
/// asserts nothing, which is the other reason these two repositories are
/// co-located.
#[test]
fn identical_bytes_in_both_repositories_resolve_independently() {
let dir = tempfile::tempdir().expect("tempdir");
let (engine, a, b) = store_with_two_repositories(dir.path());
let from_a = engine
.snapshot(a)
.expect("A is bound")
.locate(blob_id(SHARED_BLOB))
.expect("decodes")
.expect("A holds it");
let from_b = engine
.snapshot(b)
.expect("B is bound")
.locate(blob_id(SHARED_BLOB))
.expect("decodes")
.expect("B holds it");
assert_eq!(from_a.object_type, levcs_core::ObjectType::Blob);
assert_eq!(from_b.object_type, levcs_core::ObjectType::Blob);
assert_ne!(
(from_a.segment_generation, from_a.offset),
(from_b.segment_generation, from_b.offset),
"both repositories resolved the shared id to the same physical record, \
so one of them is reading the other's write"
);
}
/// Contract review 2026-08-07-A, through the entry point a consumer calls.
#[test]
fn snapshot_refuses_a_namespace_no_repository_is_bound_for() {
let dir = tempfile::tempdir().expect("tempdir");
let (engine, _a, _b) = store_with_two_repositories(dir.path());
let never_created = namespace_on_shard(0, SHARD_COUNT, 99);
match engine.snapshot(never_created) {
Err(StoreError::NoSuchRepository { namespace }) => {
assert_eq!(namespace, never_created)
}
Err(other) => panic!("expected NoSuchRepository, got {other:?}"),
Ok(_) => panic!("snapshot returned a view of a repository that was never created"),
}
}
/// A snapshot is taken against one generation and keeps answering from it, so
/// a later commit cannot change what an already-taken snapshot reports
/// (plan §5.3).
#[test]
fn a_snapshot_does_not_observe_a_commit_that_followed_it() {
let dir = tempfile::tempdir().expect("tempdir");
let (engine, a, _b) = store_with_two_repositories(dir.path());
let before = engine.snapshot(a).expect("A is bound");
let sequence_before = before.repo_sequence();
const LATER: u8 = 0x9c;
submit(&engine, push_transaction(a, 6, LATER))
.receipt()
.expect("the later push commits");
assert_eq!(
before.locate(blob_id(LATER)).expect("decodes"),
None,
"a snapshot observed an object committed after it was taken"
);
assert_eq!(before.repo_sequence(), sequence_before);
// ...and a snapshot taken now does see it, so the assertion above is about
// the captured generation and not about a push that never landed.
assert!(engine
.snapshot(a)
.expect("A is bound")
.locate(blob_id(LATER))
.expect("decodes")
.is_some());
}

View File

@ -1839,6 +1839,51 @@ The integration suite's `cleanup_is_deferred_and_names_its_deliverable` is repla
deleted — the distinction it stood for, that "nothing to do" and "cannot answer yet" are
different results, is now asserted the other way round at the public surface.
##### Contract review 2026-08-07-A
B1 deliverable 8 — `RepoSnapshot` and `StoreEngine::snapshot`. Requested by B1 before any body
was written, because the frozen D0 signatures could not express the answer to a question the
deliverable cannot avoid. Two lead-owned files move; both are amendments, neither is a
redefinition.
**`StoreEngine::snapshot` had no way to say "that namespace is not bound".** The frozen
signature returns `Result<RepoSnapshot, StoreError>`, and no `StoreError` variant covered an
absent repository. The alternatives were all worse than an amendment: `Conflict`, `NotReady`
and `UnrecognizedLayout` each name a different condition, so reusing one would make the error
a false statement about what happened, and a caller could not distinguish it from a genuine
instance of that condition. **`StoreError::NoSuchRepository { namespace }` is added to
`types.rs`.** It carries the typed `NamespaceId` rather than a rendered string so a caller
matches on the identity it asked for instead of parsing a message.
**It is an inability to answer and not a lifecycle state**, which is the distinction the
taxonomy's own doc comment turns on. A namespace that was never bound has no `RepoState`, so
there is no `genesis_authority` to report and no sequence to report it at, and `snapshot`
cannot manufacture either without fabricating a trust root. A namespace that *is* bound and
retired is the opposite: `Deleted` is a lifecycle, it has a state, and it answers every other
question. Refusing both would erase a difference the store knows.
**`RepoSnapshot` therefore gains `lifecycle()` and `storage_mode()`.** `RepoState` already
carries both; without accessors a reader holding a snapshot could not tell an `Active`
repository from a `ReadOnly` or `Deleted` one, and §4 makes that distinction observable. Both
read straight off the pinned state and allocate nothing. `lib.rs` re-exports `ObjectLocation`
and the two namespace enums, which name the return types of the frozen `locate` and of these
two accessors — a caller cannot use the re-exported `RepoSnapshot` without them.
**Deliverable 8's acceptance is amended, and the reason is that the design already
succeeded.** "A test that fails if someone clones" assumes a clone is a copy; `CommittedRoot`
is built entirely from `im` persistent structures, so cloning it allocates zero bytes and a
byte figure cannot fail on a clone anywhere in this crate. The scope records the full finding.
The test carries a measured no-materialization figure *and* an `Arc::ptr_eq` structural proof,
with a live control, rather than the single measured assertion the wording asked for.
**One observation, recorded and not acted on.** `ObjectLocation::segment_generation` is
per-shard, not global, so the same `(generation, offset)` pair occurs in every shard's journal.
This is not ambiguity — a location is only ever read through a snapshot, and the snapshot's
namespace determines the shard — but it means any test comparing locations across shards
asserts nothing. `tests/namespace_snapshot.rs` co-locates its two repositories for that reason
and for the stronger one: only a shared shard makes the namespace component of the index key
load-bearing.
##### Contract review 2026-07-31-A
B3 deliverable 6 — finalize and the adoption pin. Deliverables 7 and 8 are **not** in this

View File

@ -1653,6 +1653,27 @@ criteria.
taking a snapshot allocates no index copy — a count or a byte figure, not a comment. §5.3
makes this a correctness property, so it needs a test that fails if someone clones.
**Amended 2026-08-07: the acceptance as written cannot be met, and the reason is that the
design already succeeded.** "A test that fails if someone clones" assumes a clone is a copy.
`CommittedRoot` is built entirely from `im` persistent structures — `im::HashMap` for the
repositories, terminal statuses and shard sequences, `im::Vector` for the index layers and
sealed runs, `im::OrdMap` for typed refs — and every one of them clones in O(1) without
allocating. `(*root).clone()` therefore allocates **zero bytes**, and so does cloning
`LayeredObjectIndex`. Both were tried as the negative control and both read zero.
A byte figure alone consequently cannot fail on a clone anywhere in this crate. It is still
worth having and is still asserted at exactly zero, because it does catch the other
regression — *materializing* the index, collecting entries into a `Vec` or a `std` map,
which is what an index copy would actually cost and which the control now demonstrates
allocates. What the byte figure cannot catch, `Arc::ptr_eq` does: the test asserts the
snapshot retained **the caller's own root allocation**, so no traversal happened behind the
capture whatever the allocator saw.
`snapshot_capture_allocates_nothing` therefore carries both assertions and a live control.
Read the acceptance as *"a measured no-materialization figure plus a structural
no-copy proof"*; the single measured assertion the wording asks for does not exist here,
and recording that is more useful than a test that passes for a reason it does not state.
**B1 must not** touch the crash harness (B4), staging (B3), or any frozen Wave A file.
#### Carry-forward: index sealing does not yet bound what a reopen rebuilds