Compare commits

...

14 Commits

Author SHA1 Message Date
Levi Neuwirth 4f799e32a4
Let a sealed index run be read back entry by entry
Nothing in the store has ever removed an index run. `seal_index` pushes
one per seal, adoption pushes the runs staging materialized, and each
successor generation carries the predecessor's forward. That was invisible
while nothing checkpointed on a cadence; wiring the P2 harness to
checkpoint inside its measured window made it immediate, and a 125-second
run died with `max_open_index_runs (observed: 33, allowed: 32)`.

The frozen workload is arithmetically unreachable without compaction: 300s
warmup plus 900s measured against 32 open runs allows at most one
checkpoint per ~37 seconds and ends at the ceiling with no headroom for a
fan-out-triggered seal. Raising the ceiling is the disclosed-weakening
pattern retired earlier today, and would make `runs_sealed` describe a run
whose fan-out grows unbounded; shortening the measured window is a Phase 0
contract. Merging runs is the only option that does not trade what the
number means for the ability to produce one.

A merge cannot be written against an API that answers point lookups only,
so `IndexRun::entries` is granted by contract review 2026-08-09-C --
requested rather than emitted, because `index.rs` is a frozen Wave A
interface. It is additive and read-only; no byte of the format moves. The
order is the one already on the device, so a caller that re-encodes what
it reads produces the layout it consumed. It reports no per-entry error:
every value is decoded from bytes the run validated at `open`, and a run
that could not be trusted entry by entry should not have opened.

The test asserts the walk against the run's own `get` rather than against
the delta it was built from. The delta is what the encoder was given; the
question is whether the decoder reads back what was written, and a merge
built on an iterator that disagreed with `get` would relocate entries
silently.

The compaction that consumes this is not here. Its shape is recorded in
the review: partition by generation domain, because a run section is
per-namespace with a 16-bit generation span and adopted-projection
generations sit at `1 << 63`, so journal-backed and staging-owned runs can
never merge into one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGdH5V43XWnj1PdHqiPktQ
2026-08-09 20:15:11 +02:00
Levi Neuwirth b5db29354e
Make a passing P2 bundle reachable, and measure real windows
Two things stood between the harness and a bundle that could encode a
pass, and neither was a hardware or duration problem.

`run_conditions.checkpointing` had to be "exercised", and no code path
could produce it: `CheckpointProbe` had two variants and the probe mapped
`Ok(_)` to `EnabledNotReached` unconditionally. The scope doc's claim that
"what remains is a run long enough to take a checkpoint, not a mechanism
to build" was wrong -- the harness took no checkpoint inside the measured
window at all.

It does now, on a thread that lives exactly as long as the submitters, so
the cost lands in the reported rate. That is the honest place for it: a
number that excluded index maintenance would not describe a steady state
either. `Exercised` is derived from checkpoints the run took, never from
the probe -- the probe can only establish that checkpointing is possible,
which is what `enabled_not_reached` already says.

Checkpointing made the emitter refuse: sealing had run and left a partial
backlog, and `index_maintenance` requires a drained one for `runs_sealed`.
A steady-state run always has a partial backlog wherever the clock stops,
so the strict check and the periodic checkpoints could not both hold. The
run now takes one closing checkpoint *after* the window, which changes no
reported rate and leaves the strict check intact. The alternative --
accepting a bounded backlog -- would assert a steady state while carrying
a backlog the bundle has no field to report.

`run` was a stub that still said submit "is B1 NamespaceTxn ... cannot
produce a P2 result". It is implemented, sharing one emitter with
`emit-skeleton` so their measurement blocks cannot drift, and differing
only in the window arithmetic and the `skeleton` flag. Commits are counted
per one-minute window at the commit site: a counter per minute is bounded
by the run's length, while a timestamp per commit is bounded by its
throughput -- 22 million of them at target. Whole windows only, and a
measured run with no whole window is refused rather than falling back to
the skeleton's synthesized one. Both halves of section 3 are checked: the
percentage the schema records, and the floor no single window may fall
below, which nothing downstream would otherwise notice.

`scripts/close-phase1.sh` runs the gate, the recovery campaign, and the P2
repetitions, and reports one checklist. It is a reporter and never a
promoter: it exits non-zero unless every criterion passes and never edits
a bundle to make one pass, so it is safe to rehearse on non-reference
hardware -- which is how the `run` stub was found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGdH5V43XWnj1PdHqiPktQ
2026-08-09 20:14:56 +02:00
Levi Neuwirth b10d5eee88
Declare a raised index-run ceiling without naming a cause
`run_conditions.index_run_ceiling` offered one value for a raise, and it
named a reason: `raised_because_index_sealing_unimplemented`. The emitter
never established that reason. It derives the declaration by comparing the
configured ceiling with the store default, and a comparison of two numbers
cannot know why they differ -- so the bundle asserted a cause on the
strength of a subtraction.

That was accurate while the unimplemented seal was the only reason to
raise the ceiling. Sealing landed, the raise was dropped, and the value
became a false explanation waiting for the next run that raises the
ceiling for any other purpose -- which the schema would have called valid.

`raised_above_store_default` is added and is what the emitter now writes.
It states the fact the comparison establishes and stops there.

The old spelling is retained and deprecated rather than removed.
`schema_version` is `const: 1`, so there is no later version to move
archived bundles to, and this repository has never held a bundle to check
against; the reference machine may hold ones that declare it. Invalidating
evidence already produced is worse than carrying a spelling nothing emits.

Deprecated does not mean unchecked, and that was the trap. The `allOf`
rule flooring a declared raise at 65 accepts either spelling, so a bundle
using the old one is still cross-checked against the recorded ceiling. Had
the rule kept keying on the new string alone, the deprecated value would
have skipped the cross-check entirely and been valid while recording 64.
`both_raised_spellings_are_accepted_and_bound_the_same_way` asserts, for
each spelling, that it validates and that it is refused when it records
the store default; narrowing the rule to the new value alone fails it.

The protocol crate's `submit_path_bundle` keeps the deprecated spelling
deliberately, as the standing proof that an archived bundle validates.

Recorded as contract review 2026-08-09-B. The scope doc's carry-forward
closed on the grounds that the enum was untouched -- true then, superseded
now -- so it carries an amendment rather than a rewrite, and its "truthful
values today" table now points at the corrections recorded elsewhere in
the same document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGdH5V43XWnj1PdHqiPktQ
2026-08-09 18:38:04 +02:00
Levi Neuwirth 660b4c74d0
Retire a disclosed weakening that no longer exists
The B4 harness documented `max_index_runs` as a ceiling reached by
refusal: B1's slice sealed no delta layer into an `IndexRun`, so `submit`
returned `NotImplemented` after exactly that many group publications, and
a driver needing more had to raise the ceiling to run at all. That was
recorded as a disclosed weakening because it configured around a missing
deliverable rather than a tuning choice.

Every part of it is now false. Sealing is implemented, the ceiling is
drained by sealing rather than reached by refusing, and no
`NotImplemented` refusal remains in the engine's production paths. A
45-second soak at the default published 5,192 groups -- 81x the ceiling --
at a flat rate. Leaving the disclosure in place understates the store in
its own evidence.

The same claim appeared in `store-bench.rs`'s `configured_ceilings`
comment and in the plan's note on why `max_index_runs` became a required
bundle member; both now say the run seals against the ceiling.

`scripts/check-phase1.sh` was not prose. Its crash-matrix pending-row
check was guarded on B1 having landed, proxied by `engine.rs` no longer
mentioning `NotImplemented`. B1 landed and the token survived in a module
doc and in two tests asserting an error is *not* one, so the grep matched
and the check silently stopped running. Zero pending rows made that
harmless, but a gate condition that had inverted is worth more than the
row it was guarding. The transitional guard is gone and the check is
unconditional.

`CONTENTION_MAX_INDEX_RUNS` is removed rather than aliased: the driver
reaches a genuinely lost committed-root CAS after 11 and 12 transactions
against a ceiling of 64, reproducibly, and the row's test panics rather
than passes if the failpoint never fires. The old note measured a worst
case that could exceed 64, which no longer refuses either.

`bench/result-schema.json` is untouched. Nothing in-tree emits
`raised_because_index_sealing_unimplemented` now, but it remains a valid
declaration for a bundle that did raise the ceiling, and retiring it is a
compatibility decision of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGdH5V43XWnj1PdHqiPktQ
2026-08-09 18:13:14 +02:00
Levi Neuwirth ee886f6138
Tell a legacy root apart from one this store cannot read
Startup states 3 and 4 shared a refusal that named both, because the
recognizer separating them had not been written. That under-classification
was honest but it cost the operator the answer: a legacy instance holding
real data and a directory belonging to something else got the same
"refusing to modify", and only one of them has a next step.

State 3 is the scope's signature and nothing looser: at least one entry
whose name is 64 hexadecimal characters and which holds a `.levcs`
directory. It is recognized, never inferred -- a missing FORMAT is
evidence of nothing, and most roots without one are state 4.

Neither half of the signature may be reached through a symlink. The entry
type comes from the directory entry itself and `.levcs` is checked with
`symlink_metadata`, because the claim being made is about what this root
*holds*, not what a link in it can reach. `Path::is_dir` follows links, so
a `.levcs` pointing at any directory anywhere satisfied it, and a root
that borrowed the shape from elsewhere was answered with a migration
command for repositories it does not have.

The recognizer opens no file and creates nothing, and runs last -- only
for a root already known non-empty and unformatted. A probe that wrote so
much as a directory would destroy the byte-identity guarantee state 4
promises, and destroy it before the refusal that promises it.

State 3 carries the exact command with the source filled in and
`--destination` left as a placeholder: only the operator knows where the
v2 root goes, and inventing one produces a command that runs and writes
somewhere nobody chose. State 4 reports what was observed and suggests
nothing, because the directory may be another application's, a partial
backup, or a root whose FORMAT was deleted, and a migration taken on faith
costs data. A test asserts state 4's message does not mention migration.

`tree_image` no longer follows links either. It records a symlink as its
own kind carrying its target, which is both what let the new tests be
written and strictly more sensitive than before: it now notices a symlink
replaced by a real directory of the same name, the one substitution a
contents-only image called unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGdH5V43XWnj1PdHqiPktQ
2026-08-09 17:49:46 +02:00
Levi Neuwirth df2e22a3b0
Coalesce a resubmit onto the request already in flight
A same-ID/same-digest submit arriving while the first is still in flight
was refused. Nothing is wrong with it: it is one request that reached the
store twice, and the durable path cannot answer it because there is no
receipt yet and no terminal entry to read.

The follower reserves nothing and sequences nothing. `accept` removes its
waiter and moves its completion onto the leader's, so from there it is
answered by whatever resolves the leader and by nothing else -- one frame,
one sequence, two callers answered. The tests assert that by requiring the
follower's `repo_sequence` to equal the leader's, because a second
sequence would mean a second frame for one operation.

Followers live on the leader's `Waiter` rather than in a table keyed by
operation, so a follower cannot outlive the request it follows: every path
that resolves a waiter drops it and takes its followers with it. All eight
completion sites now go through `Waiter::resolve`, which answers the
followers and then the leader with one outcome.

That centralization is the point rather than tidiness. Only one of those
sites is the happy path; the rest are pre-append refusal, poison drain,
deadline removal, and panic unwind. A follower any of them forgot would
not fail -- it would hang, with neither receipt nor error, which is the
outcome hardest to notice and hardest to diagnose. So the case worth
proving is a leader taken down inside the poison window, and
`a_follower_is_answered_when_its_leader_is_poisoned` arms one.

The two coalescing tests hold the fault serial even though only one arms a
failpoint. The registry is a one-shot global, and without it the arming
test fired inside its sibling's engine -- a real interference that made
both pass alone and fail together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGdH5V43XWnj1PdHqiPktQ
2026-08-09 16:38:13 +02:00
Levi Neuwirth d884db3f3e
Answer a retry of a committed operation
A client that submitted, lost the answer, and submitted again was refused.
Retrying is the normal case, and the operation ID exists precisely so the
store can say what happened the first time instead of either appending a
second frame or turning a recoverable disconnect into a failure.

The rule is `oracle::coalescing_decision`'s durable branch applied to a
terminal entry rather than restated beside it: a matching stable digest
returns the durable receipt, and a different one is a conflict. The digest
is what makes the answer safe. An operation ID alone cannot tell a retry
from a different request reusing an identity, and answering the second
with the first's receipt would tell a caller its transaction committed
when another one did.

An expired entry is refused rather than answered. Its tombstone still
binds the ID against reuse, but the receipt is gone and a tombstone is not
a statement about this submit's outcome. The remaining `TransactionStatus`
variants are named and poison: a terminal entry reporting `Pending`,
`Resolving`, or `Unknown` is a contradiction, and a catch-all would answer
it with whatever the last arm happened to be.

The receipt reaches `accept` as a field rather than through `StoreError`,
so `accept` completes the waiter with it. Carrying a success through the
error channel would make every caller of `prepare` responsible for
noticing that one variant means it worked.

Nothing is sequenced and no frame is appended for a retry, and the tests
replay one through a reopen, where the answer can only come from the root
recovery rebuilt.

In-flight coalescing -- a same-digest resubmit attaching to a leader that
has not resolved yet -- is still refused. It changes waiter and completion
ownership and is left to its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGdH5V43XWnj1PdHqiPktQ
2026-08-09 16:23:22 +02:00
Levi Neuwirth 9b2117a406
Seal a full journal and continue in a fresh one
A journal is preallocated, and a shard that filled it stopped accepting
work. Nothing about the refused transactions was wrong and no amount of
retrying made room, so the shard was simply done -- the one failure mode
a store cannot have.

Rotation cannot happen from inside preparation. The open group's frames
are built but not appended and each carries `journal_id` in its header,
so sealing mid-formation would leave them naming a journal that no longer
takes writes. `accept` therefore publishes the open group, rotates, and
re-prepares, which is the shape the group-full path already used. The
reservation is released first; speculative state is not advanced until
past the fit test, so that release is the whole rollback.

Two triggers, and the second is not implied by the first: a group that no
longer fits must rotate or be refused, and a cursor that has reached
`segment_max_bytes` must rotate so segments stay near their configured
size. With `segment_max_bytes` below `journal_preallocate_bytes` that
boundary arrives first and every time, and checking only the fit let
segments grow to the whole preallocation whatever the ceiling said. The
threshold is deliberately not a per-group cap: it is read before a group
is added rather than inside one, so a segment may overshoot by at most a
group and no committed group is ever split.

A frame no *empty* journal could hold is a ceiling, not a rotation --
sealing would produce a fresh journal that refuses it again, forever. It
is measured against the preallocation less the journal header, because a
new journal's cursor starts past that header; comparing against the whole
preallocation called frames in that gap rotatable and retried them into
the same refusal.

The manifest advances `committed_shard_sequence` to the sealed segment's
last. `validate_manifest_sequence_coverage` requires the final retained
tail range to end exactly there, and sealing does make that prefix
durable under a second, manifest-referenced name. Installing an index run
is the case that differs and correctly carries the field forward: a run
makes no journal frame more durable than it already was.

The successor generation pins the new segment and the fresh tail. The
predecessor's active tail is not carried forward -- that file is the
segment now, and retaining both would leave one logical generation naming
two sources, which `object_source` refuses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGdH5V43XWnj1PdHqiPktQ
2026-08-09 16:13:21 +02:00
Levi Neuwirth fb71bba615
Install an adopted projection through submit
`adopt_projection` reached `build` and stopped there. Wiring it through
`submit` is one change because there is no safe partial: the moment the
builder stops refusing, a submit that ignores the adoption drops a live
pin with no outcome, which is the leak scope 6.5 declares a bug.

The pin cannot live inside the transaction. `Prepared` retains an
`Arc<ValidatedTransaction>` so a group can be re-sequenced when the
pre-mark deadline recheck drops a member, and an `Arc` has no move-out.
It travels beside it in a one-shot `AdoptionSlot` that settles itself
from an append phase rather than from a guess: the phase advances at the
first frame write, not at the fence, so torn and unfenced bytes stay
recovery-owned. That makes the unenumerated routes safe by construction
-- queue rejection, pre-append errors and panics, deadline reforming,
writer unwinding -- instead of correct only where someone remembered.

Two orderings are now statements rather than drop-order accidents. Both
the poison window and every pre-append refusal settle the pin before
resolving the waiter; otherwise a submit could return while staging still
believed the pin was live.

Membership could not travel as an index delta. Adopted generations sit in
a reserved band `1 << 63` away from journal generations, and an index run
packs `segment_generation` as a 16-bit delta from a per-namespace section
base, so one section cannot hold both domains -- the adoption committed
and the next checkpoint poisoned the shard. B3 now materializes
staging-owned runs post-fence, deterministically and idempotently, and
`resolve_committed` only opens, verifies, and pins them. Recovery replays
the same materialization, which is what makes a crash between the fence
and materialization recoverable rather than ambiguous. Runs partition one
per adoption, splitting every 65,536 ordinals, because two adoptions'
sequences differ by more than a section can span.

The run count is therefore knowable before anything is appended, so the
ceiling check is a refusal at revalidation rather than a poison after the
fence.

Payload kind is a matrix axis: all eight Wave B rows drive inline and
staged-projection, and the adoption outcome is read from staging's
counters rather than derived, so the fixture and the run remain two
independent derivations.

Contract review 2026-08-09-A records the interface changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGdH5V43XWnj1PdHqiPktQ
2026-08-09 15:39:46 +02:00
Levi Neuwirth 9bc298868a
Represent every committed sequence in index coverage
A delta layer's `through_shard_sequence` states that every frame from its
shard through that sequence is accounted for in the index. Skipping the
layer when the delta was empty left a group that introduced no objects
advancing the committed sequence without advancing coverage, so the next
checkpoint refused: it read the highest layer stamp as the reach of the
index and concluded that pruning would drop objects that never existed.

Any transaction introducing no objects reached it. An empty layer is the
smallest honest way to record that the sequence happened and carried
nothing, so `with_subtree` now installs one whenever the publication
advances the shard's committed sequence. A maintenance publication
appends no frame and still owes nothing.

`coverable_through` had the same gap from the other side: it stopped at
the first layer whose entries did not resolve as a segment or an active
tail, which excluded adopted projection artifacts. Those are the most
durable entries in the index -- not in the journal at all, and carried
forward by every successor generation -- and were read as the least.

The empty layer counts toward layer fan-out and may seal into a
zero-entry run. That is the bounded-run model working as designed and is
the failure-safe direction: a stamped empty layer costs a run slot, an
unstamped one costs the ability to checkpoint at all.

Asserted with a plain object-less transaction rather than through the
adopting path that found it, so the regression names what actually broke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGdH5V43XWnj1PdHqiPktQ
2026-08-09 15:38:18 +02:00
Levi Neuwirth e0c599c432
Make the payload a matrix axis rather than a second row list
D3 adopts a staged projection through the same eight Wave B locations an
inline transaction reaches, and it fails differently at each. Giving adoption
its own failpoint rows would produce a parallel list that drifts from this one
the first time a location is added to either, so payload kind becomes a third
axis alongside the location the failpoint names and the action the driver
chooses - the same independence `actions` already documents.

The adoption expectation gets the two-derivation treatment the recovery
outcome has. The fixture states it, `PhysicalStateClass::adoption_outcome`
derives it, and the matrix requires them to agree. The rule is physical and
nothing else: whether a frame binding the artifacts exists on the device. No
bytes means the artifacts are unreferenced and reclaiming them is correct; a
fenced frame binds the manifest, so the pin outlives the process; fenced and
published means the writer already settled `Adopted` against a root that
references them.

Note the asymmetry against `required_outcome`. `PartialFrame` and
`WholeFrameUnfenced` are `AbsentRetriable` there and `TransferredToRecovery`
here, because bytes are on the device and only recovery may say what they mean.
A transaction that will not commit and a pin whose artifacts may be referenced
are different questions, and collapsing them is how a retriable refusal would
come to delete content.

Both derivations run on every submit row now, before any row lists
`staged_projection`. That is deliberate: turning the kind on becomes a matter of
listing it rather than of also getting the expectation right in the same commit.
Verified by stating `DefinitivePreAppendFailure` on `DuringCommittedRootBuild`,
which the class derives as `TransferredToRecovery` - the assertion names the row
and says what settling it that way would license.

No row lists `staged_projection` yet and nothing drives one, so this commit adds
no coverage claim. D3 does not merge without the adoption cases running.

`inline` is required on every submit row rather than merely allowed: it is the
payload the eight locations were characterized with, and a row that dropped it
while adding the new kind would move coverage sideways while reading as having
added some.

check-phase1.sh reports GATE_EXIT=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 22:27:37 +02:00
Levi Neuwirth d36f6a86f7
Measure at the ceiling a deployment would run
`store-bench` opened its measured store with `ENGINE_MAX_INDEX_RUNS =
1_000_000` against a store default of 64, carrying the comment "raised because
index-delta sealing is unimplemented". Sealing landed in fef8520, so the reason
had lapsed and two things followed that did not follow before: the emitted
`index_run_ceiling` named a reason that was no longer true, and at an
effectively infinite fan-out ceiling no seal in a measured run was ever
triggered by fan-out pressure, so `index_maintenance: runs_sealed` was earned by
entry pressure alone.

The raise is dropped rather than the enum renamed, so `bench/result-schema.json`
is untouched and no contract review was needed. The store opens at its own
default and the derivation reports `store_default` from the comparison it
already made.

Demonstrated rather than assumed, because the whole question was whether a run
survives the ceiling the raise existed to escape. A 45-second submit-path run at
`max_index_runs = 64` published 5,192 groups - 81x the ceiling - with no
`NotImplemented` refusal, and its rate was flat against a 5-second run at the
same ceiling (113.2/s vs 117.7/s). Both figures are debug-build and diagnostic
hardware and are evidence of survival, not of throughput. The emitted bundle
declares `index_run_ceiling: store_default` against a configured ceiling of 64,
with `index_maintenance: runs_sealed` alongside.

The raised branch of the derivation does not become dead code. The existing
fixture keeps covering it and `a_run_at_the_store_default_declares_store_default`
covers the branch a real run now takes, because a comparison needs both sides
exercised. The constant survives as a test-only value under a name that says so,
rather than being deleted and leaving the branch reachable only from a
configuration nothing produces.

The rationale block above the run is corrected too: it counted two of B1's
deliverables as bounding the bundle when only checkpointing still does, and it
still described the ceiling it no longer raises.

check-phase1.sh reports GATE_EXIT=0; verify-store-recovery.sh --cycles 2 reports
matrix=pass, bundle=schema-valid, acknowledged_loss=0, torn_transactions=0,
VERIFY_EXIT=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 22:10:05 +02:00
Levi Neuwirth 93692e2a88
Resolve the target directory instead of assuming it
`verify-store-recovery.sh` looked for `store-crash-driver` and `store-bench`
under `$repo_root/target/debug`. `CARGO_TARGET_DIR`, a `build.target-dir` in a
config.toml, and a shared workspace target all move that, and on a host that
sets one the script builds successfully and then exits 70 saying the driver
"was not built" - which reads as a compilation failure and is not one. It asks
cargo now.

The working directory deliberately does not follow. It holds crash roots whose
filesystem is part of what the campaign measures, which is why the script
already refuses tmpfs; a shared target directory may be on a different one. The
distinction is commented so the remaining `$repo_root/target` is not read as a
missed substitution.

Two scope claims were behind the code and one of them undersold the work.

Section 7 said the harness satisfied two of four run conditions. It satisfies
three: index sealing earned `index_maintenance = runs_sealed`, derived from the
`IndexRun` files read back off the device. `checkpointing` has moved from
`unimplemented` to `enabled_not_reached` - the mechanism exists and short runs
do not accumulate enough work to trip it - so what remains is a run long enough
to take a checkpoint, not a mechanism to build.

Section 6.6 item 5 said the emitter wrote a schema-invalid bundle. That closed
with 062797d. The passage is kept rather than deleted because the mechanism is
the point: the gate was red for a schema the store satisfied, since three
`store-bench` unit tests validate the emitted bundle against the schema rather
than against substrings. That is schema conformance living inside the gate,
which is what review 2026-07-24-B was after.

New carry-forward: `ENGINE_MAX_INDEX_RUNS` outlived its reason. It is 1,000,000
against a store default of 64 and still carries the comment "raised because
index-delta sealing is unimplemented", but sealing landed in fef8520. Two
consequences follow that did not before - the emitted `index_run_ceiling` names
a reason that is no longer true, and at that ceiling the fan-out trigger never
fires, so `runs_sealed` above is earned by entry pressure alone. It is left as a
decision rather than taken: dropping the raise needs the measured run to survive
the real ceiling, and renaming the value is an amendment to the lead-owned
schema, whose own rule is to request it rather than emit it.

Verified on this box at b4e4c7e: `check-phase1.sh` reports GATE_EXIT=0, and
`verify-store-recovery.sh --cycles 2` reports matrix=pass, bundle=schema-valid
on both paths, acknowledged_loss=0, torn_transactions=0, VERIFY_EXIT=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:35:34 +02:00
Levi Neuwirth bac31513e3
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>
2026-08-07 21:34:48 +02:00
28 changed files with 5901 additions and 551 deletions

View File

@ -560,9 +560,10 @@
"index_run_ceiling": {
"enum": [
"store_default",
"raised_above_store_default",
"raised_because_index_sealing_unimplemented"
],
"description": "Whether StoreOptions::max_index_runs was left at the store default (64) or raised so the run could reach its measured seconds despite unimplemented index-delta sealing. Cross-checked against resources.configured_ceilings.max_index_runs by the two \"index run ceiling\" rules in allOf: \"store_default\" bounds the recorded value at 64 and the raised value floors it at 65, so a bundle that declares one and records the other is invalid. The recorded value must be the value the run configured, never a constant restated here."
"description": "Whether StoreOptions::max_index_runs was left at the store default (64) or raised above it. Cross-checked against resources.configured_ceilings.max_index_runs by the two \"index run ceiling\" rules in allOf: \"store_default\" bounds the recorded value at 64 and either raised value floors it at 65, so a bundle that declares one and records the other is invalid. The recorded value must be the value the run configured, never a constant restated here. DEPRECATED: \"raised_because_index_sealing_unimplemented\" is accepted only so archived v1 bundles stay valid; index-delta sealing is implemented and the emitter no longer produces it. It states a cause the emitter never verified — the declaration is derived by comparing the configured ceiling with the store default, so it named a reason for a fact it only observed. Emit \"raised_above_store_default\", which is that fact and nothing more. Contract review 2026-08-09-B."
},
"receipt_reconciliation": {
"enum": [
@ -782,7 +783,7 @@
"max_index_runs": {
"type": "integer",
"minimum": 1,
"description": "The StoreOptions::max_index_runs the run actually configured, read back from the options the store opened with — never the store default restated here, and never omitted. Bounded against run_conditions.index_run_ceiling by the two \"index run ceiling\" rules in allOf, so a defaulted or contradicted value is invalid rather than merely unverified. It is a ceiling this workload genuinely reaches: index-delta sealing being unimplemented makes it the limit submit refuses at."
"description": "The StoreOptions::max_index_runs the run actually configured, read back from the options the store opened with — never the store default restated here, and never omitted. Bounded against run_conditions.index_run_ceiling by the two \"index run ceiling\" rules in allOf, so a defaulted or contradicted value is invalid rather than merely unverified. It is a ceiling this workload genuinely reaches: the run seals against it, and its fan-out trigger is what imposes the steady state a P2 number has to be measured in."
}
},
"additionalProperties": {
@ -1574,7 +1575,10 @@
"run_conditions": {
"properties": {
"index_run_ceiling": {
"const": "raised_because_index_sealing_unimplemented"
"enum": [
"raised_above_store_default",
"raised_because_index_sealing_unimplemented"
]
}
},
"required": [

View File

@ -944,6 +944,11 @@ fn submit_path_bundle() -> serde_json::Value {
"mutation_path": "store_engine_submit",
"checkpointing": "unimplemented",
"index_maintenance": "deltas_retained_in_memory",
// Deliberately the deprecated spelling: this fixture is the standing
// proof that an archived v1 bundle still validates. The emitter
// writes `raised_above_store_default` now, and
// `both_raised_spellings_are_accepted_and_bound_the_same_way`
// covers the pair. Contract review 2026-08-09-B.
"index_run_ceiling": "raised_because_index_sealing_unimplemented",
"receipt_reconciliation": "acceptance_of_any_committed_status",
"objects_new_source": "summed_from_receipts",
@ -1513,3 +1518,43 @@ fn the_instance_gates_are_not_loosened_by_the_storage_path_split() {
);
}
}
/// Both spellings of a raised ceiling are accepted, and both are bound the same
/// way.
///
/// The deprecated one is kept only so an archived v1 bundle stays valid --
/// `schema_version` is `const: 1`, so there is no later version to move it to,
/// and this repository has never held a bundle to check against. What must not
/// happen is the deprecation becoming a second, weaker rule: a bundle that
/// declares the old string still has to record a ceiling above the default, or
/// "deprecated" would have quietly turned into "unchecked".
///
/// The new spelling states only what the emitter observes. It compares the
/// configured ceiling with the store default and cannot know why they differ,
/// so it may not name a cause -- which is exactly what the old string did, for
/// a cause that is no longer true. Contract review 2026-08-09-B.
#[test]
fn both_raised_spellings_are_accepted_and_bound_the_same_way() {
for spelling in [
"raised_above_store_default",
"raised_because_index_sealing_unimplemented",
] {
let mut bundle = submit_path_bundle();
bundle["run_conditions"]["index_run_ceiling"] = serde_json::json!(spelling);
assert_valid(
&bundle,
&format!("{spelling} must be an accepted declaration of a raised ceiling"),
);
// The floor still applies. Recording the store default under either
// spelling is a bundle whose declaration and measurement disagree.
bundle["resources"]["configured_ceilings"]["max_index_runs"] = serde_json::json!(64);
assert_invalid(
&bundle,
&format!(
"{spelling} recording the store default must be refused; a deprecated \
spelling is still cross-checked"
),
);
}
}

View File

@ -933,11 +933,19 @@ pub enum CheckpointProbe {
/// `StoreEngine::checkpoint` refused `NotImplemented`, so no checkpoint was
/// taken and none could have been.
RefusedNotImplemented,
/// `StoreEngine::checkpoint` returned a lease. The measured window still
/// contains no checkpoint — the harness takes none inside it — so the
/// truthful value is `enabled_not_reached`, and reaching one is the
/// emitter's next change rather than a relabelling of this one.
/// `StoreEngine::checkpoint` returned a lease, but no checkpoint was taken
/// inside the measured window. The value is honest and cannot pass: a run
/// whose lookup fan-out grows for its whole duration and which never
/// checkpoints is not measuring the steady state a P2 number is supposed to
/// characterize.
EnabledNotReached,
/// At least one checkpoint completed **inside the measured interval**.
///
/// Counted from checkpoints the run actually took, never from the ability
/// to take one -- that is what `EnabledNotReached` already records, and the
/// distinction between "the store can checkpoint" and "this measurement
/// contains one" is the whole reason scope §7 requires this field.
Exercised,
}
/// Whether the index reached a steady state, from what is on the device.
@ -1176,7 +1184,14 @@ impl RunConditions {
if observations.configured_max_index_runs == observations.default_max_index_runs {
"store_default"
} else if observations.configured_max_index_runs > observations.default_max_index_runs {
"raised_because_index_sealing_unimplemented"
// The fact, and only the fact. This branch is reached by
// comparing two numbers and knows nothing about *why* the
// ceiling was raised, so it may not name a reason.
// `raised_because_index_sealing_unimplemented` did, and the
// reason it named is no longer true; the schema keeps accepting
// it for archived bundles and this emitter never writes it
// again. Contract review 2026-08-09-B.
"raised_above_store_default"
} else {
return Err(format!(
"refusing to emit a bundle: the run configured max_index_runs = {} below \
@ -1228,6 +1243,7 @@ impl RunConditions {
checkpointing: match observations.checkpoint {
CheckpointProbe::RefusedNotImplemented => "unimplemented",
CheckpointProbe::EnabledNotReached => "enabled_not_reached",
CheckpointProbe::Exercised => "exercised",
},
index_maintenance,
index_run_ceiling,
@ -2455,7 +2471,9 @@ pub fn assemble_bundle(
// The `u32` from the `StoreOptions` the store was opened with,
// carried through the run rather than `ENGINE_MAX_INDEX_RUNS`
// written out a second time. It is the ceiling this workload
// genuinely reaches: `submit` refuses `NotImplemented` at it.
// genuinely reaches -- the fan-out trigger fires on it and the
// run seals against it, which is the steady state a P2 number
// has to be measured in.
(
"max_index_runs".into(),
jint(i128::from(inputs.observations.configured_max_index_runs)),
@ -3056,20 +3074,30 @@ fn skeleton_ack_record(sequence: u64) -> AckRecord {
//
// # What this run can and cannot claim, stated before the code
//
// Two of B1's unimplemented deliverables bound it, and both are a bound on the
// *bundle*, not merely on this file:
// One condition still bounds it, and it is a bound on the *bundle*, not merely
// on this file:
//
// * `submit` refuses `NotImplemented` after `max_index_runs` group
// publications, because sealing the in-memory index delta into an
// `IndexRun` is unimplemented. The ceiling is raised here so the run can
// reach its measured seconds at all, which means the run holds every delta
// layer it ever published in memory and its lookup fan-out grows for the
// whole run. A P2 measurement is of a steady state; this is not one, and
// the bundle says so through `outcome` and its verdicts.
// * `StoreEngine::checkpoint` is unimplemented, so no checkpoint is taken.
// Section 7 requires that a P2 run not have been achieved with
// checkpointing disabled. This one was. That alone makes the
// `storage_primitive` gate unearnable today, whatever the rate says.
// * `StoreEngine::checkpoint` exists but this run never accumulates enough
// work to trip it, so the bundle declares `enabled_not_reached`. Section 7
// requires that a P2 run not have been achieved with checkpointing
// disabled, and `exercised` is the value it wants. Until a run reaches
// that, the `storage_primitive` gate stays unearnable whatever the rate
// says.
//
// One bound that stood here is gone, and the ceiling below is the evidence.
// `submit` used to refuse `NotImplemented` after `max_index_runs` group
// publications because sealing the in-memory delta into an `IndexRun` was
// unimplemented, so this file raised the ceiling to a million to reach its
// measured seconds at all — which meant the run held every delta layer it ever
// published and its lookup fan-out grew for the whole run. Sealing landed, so
// the raise outlived its reason and was dropped: the store opens at its own
// default and the bundle declares `index_run_ceiling: store_default`.
//
// Dropping it is what makes `index_maintenance: runs_sealed` mean what it
// reads. At a ceiling of a million the fan-out trigger never fired and every
// seal in a measured run came from entry pressure; at the default the run seals
// on fan-out as well, which is the steady state the ceiling exists to impose
// and the one a P2 number has to be of.
//
// A third bound stood here until B1 landed startup state 1: `StoreEngine::open`
// refused to build an absent root, so this run seeded one with
@ -3087,8 +3115,18 @@ fn skeleton_ack_record(sequence: u64) -> AckRecord {
// `transaction_status` on a reopened engine rather than comparing sequence
// sets.
/// Raised because index-delta sealing is unimplemented; see the note above.
const ENGINE_MAX_INDEX_RUNS: u32 = 1_000_000;
/// A ceiling above the store default, used **only** by the derivation's unit
/// tests so the `raised_*` branch stays covered after the measured run stopped
/// producing it.
///
/// No measured run configures this. It is not the value the old
/// `ENGINE_MAX_INDEX_RUNS` had a right to: that one was applied to the store,
/// and this one exists so a test can hand `RunConditions::derive` a raised
/// observation and check it says so. Deleting it would leave the branch
/// reachable only from a configuration nothing here produces, which is how a
/// derivation quietly stops being a derivation.
#[cfg(test)]
const RAISED_MAX_INDEX_RUNS_FOR_DERIVATION_TESTS: u32 = 1_000_000;
/// One commit's objects, matching the frozen workload: one 1 KiB blob, one
/// tree, one commit.
@ -3289,6 +3327,17 @@ struct EngineRun {
/// Group publications, counted as fences: `journal::append_group_and_fence`
/// performs exactly one per group and A1's acceptance pins that.
groups: u64,
/// Checkpoints that completed inside the measured interval. Zero is a
/// truthful answer for a run too short to reach the interval, and it is
/// what keeps `checkpointing` at `enabled_not_reached` rather than
/// promoting a run that never checkpointed.
checkpoints_taken: u64,
/// Commits in each whole one-minute window of the measured interval.
///
/// Whole windows only: a trailing partial minute is not a one-minute window
/// and including it would report a rate over less than a minute as though
/// it were one, which §3's rule is specifically about.
window_commits: Vec<u64>,
signing_micros_p50: f64,
/// Signing samples taken inside the measured interval, on the same basis.
signings: u64,
@ -3315,6 +3364,7 @@ fn run_engine(
seconds: u64,
shard_count: u16,
submitters_per_shard: usize,
checkpoint_interval: Duration,
) -> Result<EngineRun, String> {
use levcs_store::segment::RootLayout;
use levcs_store::transaction::StagedObject;
@ -3358,7 +3408,7 @@ fn run_engine(
options.max_group_bytes = 8 * 1024 * 1024;
options.max_group_idle = Duration::from_millis(1);
options.journal_preallocate_bytes = 64 * 1024 * 1024;
options.max_index_runs = ENGINE_MAX_INDEX_RUNS;
// Deliberately left at the store default. See the note above.
options.signer = Some(signer.clone());
options
};
@ -3488,7 +3538,64 @@ fn run_engine(
let started = Instant::now();
let deadline = started + Duration::from_secs(seconds.max(1));
// Checkpoints taken inside the measured interval, by a thread that lives
// exactly as long as the submitters do.
//
// Inside the window on purpose. A checkpoint taken before or after it would
// leave the measured seconds describing a store that never compacted its
// index -- fan-out growing for the whole run -- which is the condition
// scope §7's stop clause exists to exclude. Its cost is therefore *in* the
// reported rate, which is the honest place for it: a P2 number that
// excluded checkpoint cost would not describe a steady state either.
// Commits per one-minute window, indexed by whole minutes since the window
// opened. Counted here rather than reconstructed from per-transaction
// timestamps: the §3 rule needs a rate per minute, and a counter per minute
// is bounded by the run's length while a timestamp per commit is bounded by
// its throughput -- 22 million of them at the P2 target.
let window_commits: Mutex<Vec<u64>> = Mutex::new(Vec::new());
let checkpoints_taken = AtomicU64::new(0);
let checkpoint_failure: Mutex<Option<String>> = Mutex::new(None);
std::thread::scope(|scope| {
{
let engine = &engine;
let stop = &stop;
let checkpoints_taken = &checkpoints_taken;
let checkpoint_failure = &checkpoint_failure;
scope.spawn(move || {
// Polled rather than slept in one block, so the thread leaves
// promptly when the window closes instead of holding the scope
// open for a whole interval past it.
let mut next = Instant::now() + checkpoint_interval;
while !stop.load(Ordering::Relaxed) && Instant::now() < deadline {
if Instant::now() < next {
std::thread::sleep(Duration::from_millis(5));
continue;
}
match engine.checkpoint() {
Ok(_) => {
checkpoints_taken.fetch_add(1, Ordering::Relaxed);
}
// Recorded and fatal to the run rather than retried. A
// checkpoint that fails mid-measurement leaves the
// store in a state the bundle would have to describe,
// and continuing would report a rate for a run whose
// index maintenance stopped working partway.
Err(error) => {
let mut slot =
checkpoint_failure.lock().unwrap_or_else(|e| e.into_inner());
if slot.is_none() {
*slot = Some(format!("{error:?}"));
}
stop.store(true, Ordering::Relaxed);
return;
}
}
next = Instant::now() + checkpoint_interval;
}
});
}
for shard in 0..shard_count {
for submitter in 0..submitters_per_shard {
let namespace = namespaces[shard as usize];
@ -3501,6 +3608,7 @@ fn run_engine(
let unaccounted = &unaccounted;
let results = &results;
let latencies = &latencies;
let window_commits = &window_commits;
let objects_new = &objects_new;
let raw_bytes = &raw_bytes;
scope.spawn(move || {
@ -3662,6 +3770,19 @@ fn run_engine(
.lock()
.unwrap_or_else(|p| p.into_inner())
.push(micros);
{
// The window this commit landed in, from the
// same clock the deadline uses. A commit is
// counted when it is acknowledged, which is
// what the rate is a rate of.
let minute = started.elapsed().as_secs() / 60;
let mut windows =
window_commits.lock().unwrap_or_else(|p| p.into_inner());
if windows.len() <= minute as usize {
windows.resize(minute as usize + 1, 0);
}
windows[minute as usize] += 1;
}
results.lock().unwrap_or_else(|p| p.into_inner()).push((
namespace,
operation,
@ -3763,6 +3884,26 @@ fn run_engine(
signing.sort_unstable();
let signing_p50 = percentile(&signing, 0.50) as f64;
// One last checkpoint, **after** the measured window has closed.
//
// A steady-state run is publishing deltas continuously and sealing them
// periodically, so at the instant the window closes there is essentially
// always a partial backlog -- it is an artifact of where the clock stopped,
// not a statement about whether maintenance kept pace. `index_maintenance`
// requires a drained backlog to declare `runs_sealed`, and that check is
// worth keeping strict: the alternative is asserting a steady state while
// carrying a backlog the bundle has no field to report.
//
// Draining here reconciles the two without weakening either. It is outside
// the measured interval, so it changes no reported rate; the checkpoints
// that establish maintenance kept pace are the periodic ones *inside* it,
// counted separately and reported as `checkpointing`.
if checkpoints_taken.load(Ordering::Relaxed) > 0 {
engine
.checkpoint()
.map_err(|error| format!("the closing checkpoint failed: {error:?}"))?;
}
drop(ack);
drop(engine);
@ -3818,7 +3959,15 @@ fn run_engine(
// What the store answers about checkpoints, asked of the store rather than
// asserted about it. Asked after the reconciliation so a checkpoint the day
// this stops refusing cannot move what the reconciliation read.
let checkpoint = probe_checkpointing(&reopened)?;
// `Exercised` comes from the run, never from the probe: the probe can only
// establish that a checkpoint is *possible*, and the field is about whether
// this measurement contains one. A run that took none falls through to the
// probe and reports `enabled_not_reached`, which cannot pass.
let checkpoint = if checkpoints_taken.load(Ordering::Relaxed) > 0 {
CheckpointProbe::Exercised
} else {
probe_checkpointing(&reopened)?
};
// Read from the live engine, after the checkpoint probe, so it describes
// the state the bundle is about — and before the handle is dropped, because
// the reading only exists while the root is open.
@ -3854,7 +4003,29 @@ fn run_engine(
// as index runs rather than counted as directory entries.
let index_scan = scan_index_runs(root, shard_count);
// A checkpoint that failed mid-window invalidates the measurement rather
// than reducing it: the reported rate would be for a run whose index
// maintenance stopped partway, which is not a steady state either.
if let Some(error) = checkpoint_failure.into_inner().unwrap_or(None) {
return Err(format!(
"a checkpoint failed inside the measured interval: {error}. The run is \
discarded rather than reported, because its rate would describe a store \
that stopped maintaining its index partway through."
));
}
// Whole windows only. The run stops at its deadline, so the last bucket is
// almost always a fraction of a minute; keeping it would divide a partial
// minute's commits by sixty seconds and report a rate no window achieved.
let mut window_commits = window_commits
.into_inner()
.unwrap_or_else(|p| p.into_inner());
let whole_windows = (elapsed.as_secs() / 60) as usize;
window_commits.truncate(whole_windows);
Ok(EngineRun {
window_commits,
checkpoints_taken: checkpoints_taken.load(Ordering::Relaxed),
facts: RunFacts {
initialization,
mutation: MutationPath::StoreEngineSubmit,
@ -3962,6 +4133,14 @@ fn check_global_uniqueness(records: &[AckRecord]) -> Result<UniquenessCheck, Str
struct MeasuredRun {
latencies_micros: Vec<u64>,
groups: u64,
/// Checkpoints completed inside the measured interval. The journal seam has
/// no engine and therefore no checkpoint, so it is always zero there --
/// which is why the drive path declares `unimplemented` rather than this.
checkpoints_taken: u64,
/// Commits per whole one-minute window, empty for a run shorter than a
/// minute. Empty is what makes the §3 rule unevaluable rather than
/// vacuously satisfied.
window_commits: Vec<u64>,
transactions: u64,
/// Counted from the objects the store actually staged on the submit path,
/// and derived as `transactions * 3` on the drive path, where there are no
@ -3999,6 +4178,9 @@ struct MeasuredRun {
impl From<SkeletonRun> for MeasuredRun {
fn from(run: SkeletonRun) -> Self {
Self {
// The journal seam has no engine to checkpoint.
checkpoints_taken: 0,
window_commits: Vec::new(),
groups: run.groups,
transactions: run.transactions,
objects_new: run.transactions * OBJECTS_PER_COMMIT,
@ -4032,6 +4214,8 @@ impl From<SkeletonRun> for MeasuredRun {
impl From<EngineRun> for MeasuredRun {
fn from(run: EngineRun) -> Self {
Self {
checkpoints_taken: run.checkpoints_taken,
window_commits: run.window_commits.clone(),
groups: run.groups,
transactions: run.transactions,
objects_new: run.objects_new,
@ -4309,24 +4493,27 @@ fn dispatch(subcommand: &str, flags: &Flags) -> Result<ExitCode, String> {
}
}
} else if subcommand == "emit-skeleton" {
emit_skeleton(&repo_root, &workload, &profile, flags)
emit_bundle(&repo_root, &workload, &profile, flags, true)
} else if subcommand == "run" {
Err(
"the P2 run goes through StoreEngine::submit, which is B1 NamespaceTxn \
(scope 6-B1). Wave A can produce a P1-micro number and a skeleton \
bundle; it cannot produce a P2 result."
.to_string(),
)
emit_bundle(&repo_root, &workload, &profile, flags, false)
} else {
Err(format!("unknown subcommand {subcommand:?}\n\n{USAGE}"))
}
}
fn emit_skeleton(
/// Emit one bundle, either as a skeleton or as a measured P2 run.
///
/// One function for both because the two must not drift: a skeleton whose
/// measurement block is assembled by different code from the run's would let a
/// field be checked in rehearsal and unchecked in the campaign. What differs is
/// stated in `skeleton` and nowhere else -- the window arithmetic below, and
/// the `skeleton` flag the outcome rule reads.
fn emit_bundle(
repo_root: &Path,
workload: &FrozenWorkload,
profile: &FrozenProfile,
flags: &Flags,
skeleton: bool,
) -> Result<ExitCode, String> {
if flags.get("allow-unsigned").is_none() {
return Err(
@ -4341,8 +4528,24 @@ fn emit_skeleton(
let root = PathBuf::from(flags.required("root")?);
let out = PathBuf::from(flags.required("out")?);
let seconds = flags.number::<u64>("seconds", 2)?;
// A skeleton is a rehearsal and defaults to seconds; a measured run defaults
// to the frozen `p2_p3_measured_seconds`, so the campaign's length comes
// from the workload rather than from whatever the caller typed.
let seconds = flags.number::<u64>(
"seconds",
if skeleton {
2
} else {
workload.measured_seconds
},
)?;
let group_len = flags.number::<usize>("group-len", 16)?;
// How often the measured window takes a checkpoint. The default is short
// enough that even a brief run reaches one, because a run that silently
// never checkpointed is exactly the state scope §7 forbids a P2 number from
// having been obtained in.
let checkpoint_interval =
Duration::from_millis(flags.number::<u64>("checkpoint-interval-millis", 1_000)?);
// Precheck 1 runs against the skeleton's own budget, not P2's: the point
// is to exercise the code path and the arithmetic, and demanding 280 GB
@ -4373,7 +4576,14 @@ fn emit_skeleton(
let shards = flags.number::<u16>("shards", 4)?;
let submitters = flags.number::<usize>("submitters-per-shard", group_len.max(1))?;
run_engine(
&root, &ack_path, ack_fault, group_len, seconds, shards, submitters,
&root,
&ack_path,
ack_fault,
group_len,
seconds,
shards,
submitters,
checkpoint_interval,
)?
.into()
} else if path == "drive" {
@ -4437,6 +4647,52 @@ fn emit_skeleton(
0.0
};
// The §3 window rule, computed from whole one-minute windows the run
// actually recorded.
//
// A skeleton has none -- it is shorter than a minute by design -- so it
// reports the single whole-run rate and a trivially-satisfied percentage,
// and its `skeleton` flag is what stops the outcome rule reading that as a
// pass. A measured run with no whole window is refused outright rather than
// falling back to the same synthesis: a P2 bundle whose window rule was
// evaluated over a synthesized window would state the rule as met without
// having tested it.
let target_rate = flags.number::<u64>("target-rate", 75_000)? as f64;
let (window_rates, windows_meeting_target) = if run.window_commits.is_empty() {
if !skeleton {
return Err(format!(
"refusing to emit a measured bundle: the run recorded no whole one-minute \
window (it ran for {seconds}s). Section 3's rule is about one-minute \
windows, and a run too short to contain one cannot have met it. Pass \
--seconds 60 or more, or emit-skeleton if a rehearsal was intended."
));
}
(vec![rate], 100.0)
} else {
let rates: Vec<f64> = run
.window_commits
.iter()
.map(|commits| *commits as f64 / 60.0)
.collect();
// Both halves of the rule. The percentage is what the schema records;
// the floor is checked here because a single window below 90% of target
// fails §3 outright however good the percentage is, and nothing
// downstream would notice it.
let meeting = rates.iter().filter(|r| **r >= target_rate).count();
let percent = (meeting as f64 / rates.len() as f64) * 100.0;
let floor = target_rate * 0.90;
let percent = if rates.iter().any(|r| *r < floor) {
// Reported as a failure of the rule rather than as a separate
// field, because the schema has one number for it and a bundle that
// passed the percentage while dipping below the floor must not read
// as having met the rule.
0.0
} else {
percent
};
(rates, percent)
};
let mut histogram_input = String::new();
for value in &sorted {
let _ = write!(histogram_input, "{value},");
@ -4479,14 +4735,8 @@ fn emit_skeleton(
latency_p99: percentile(&sorted, 0.99),
latency_max: sorted.last().copied().unwrap_or(0),
histogram_digest: digest_hex(histogram_input.as_bytes()),
// A run shorter than a minute has no one-minute windows. What is
// reported is the single whole-run rate, and the percentage is
// therefore trivially 100 — which is why the bundle's `outcome` is
// `preliminary` and its own gate verdict `not-applicable`. A P2 run
// computes real windows; nothing here should be read as having met the
// section 3 window rule.
one_minute_windows: vec![rate],
windows_meeting_target_percent: 100.0,
one_minute_windows: window_rates.clone(),
windows_meeting_target_percent: windows_meeting_target,
ack_journal_digest: run.ack_journal_digest.clone(),
acknowledged_loss: run.acknowledged_loss,
torn_transactions: run.torn_transactions,
@ -5127,9 +5377,14 @@ sys.exit(1 if errors else 0)
assert_eq!(submit.initialization_path, "store_engine_open");
assert_eq!(submit.mutation_path, "store_engine_submit");
assert_eq!(
submit.index_run_ceiling, "raised_because_index_sealing_unimplemented",
"the submit run raises max_index_runs above the store default, and the \
declaration must follow from that comparison rather than from the path"
submit.index_run_ceiling, "raised_above_store_default",
"this fixture configures max_index_runs above the store default, and the \
declaration must follow from that comparison rather than from the path. \
The measured submit run no longer raises it -- see \
`a_run_at_the_store_default_declares_store_default` -- so this is the \
only remaining cover for the raised branch. It must be the reason-free \
value: this branch compares two numbers and cannot know why they differ \
(contract review 2026-08-09-B)"
);
assert_eq!(submit.index_maintenance, "deltas_retained_in_memory");
assert_eq!(
@ -5181,6 +5436,31 @@ sys.exit(1 if errors else 0)
);
}
/// The measured submit run stopped raising `max_index_runs` once index
/// sealing landed, so the value it now declares is `store_default`. This
/// asserts the derivation follows the observation to that value rather
/// than to the path it came from.
///
/// It is the other half of the pair: the fixture in
/// `the_ten_run_conditions_are_derived_from_what_the_run_observed` keeps
/// the raised branch covered, and this covers the branch a real run now
/// takes. Neither is redundant, because the derivation is a comparison and
/// a comparison needs both sides exercised.
#[test]
fn a_run_at_the_store_default_declares_store_default() {
let mut at_default = submit_observations();
at_default.configured_max_index_runs = at_default.default_max_index_runs;
let conditions = RunConditions::derive(&at_default, true, "diagnostic")
.expect("a store-default ceiling declares");
assert_eq!(
conditions.index_run_ceiling, "store_default",
"a run that configured exactly the store default must declare it, \
whatever path it ran"
);
}
#[test]
fn an_observation_with_no_named_value_is_refused_rather_than_guessed() {
// Charter item 6 at the emitter: the schema's enumerations are closed
@ -5372,8 +5652,19 @@ sys.exit(1 if errors else 0)
let directory = tempfile::tempdir().expect("tempdir");
let root = directory.path().join("root");
let ack = directory.path().join("ack-journal");
let run = run_engine(&root, &ack, AckJournalFault::None, 4, 1, 2, 1)
.expect("an engine-driven run");
// A one-second run with a half-second interval, so the measured window
// contains a checkpoint rather than depending on the default cadence.
let run = run_engine(
&root,
&ack,
AckJournalFault::None,
4,
1,
2,
1,
Duration::from_millis(500),
)
.expect("an engine-driven run");
// The exclusion is not vacuous: creating the repositories really did
// fence and really did sign, so there is something to exclude. Without
@ -5454,6 +5745,7 @@ sys.exit(1 if errors else 0)
1,
2,
0,
Duration::from_secs(3_600),
)
.err()
.expect("a run with no submitter measures nothing");
@ -5468,6 +5760,7 @@ sys.exit(1 if errors else 0)
1,
0,
1,
Duration::from_secs(3_600),
)
.err()
.expect("no shard is no repository and no transaction");
@ -6013,7 +6306,7 @@ sys.exit(1 if errors else 0)
refuses(
&submit,
submit.replace(
"\"index_run_ceiling\": \"raised_because_index_sealing_unimplemented\"",
"\"index_run_ceiling\": \"raised_above_store_default\"",
"\"index_run_ceiling\": \"store_default\"",
),
"declaring the store default while recording a raised ceiling must be \
@ -6024,7 +6317,7 @@ sys.exit(1 if errors else 0)
&bundle,
bundle.replace(
"\"index_run_ceiling\": \"store_default\"",
"\"index_run_ceiling\": \"raised_because_index_sealing_unimplemented\"",
"\"index_run_ceiling\": \"raised_above_store_default\"",
),
"declaring a raise while recording the default must be rejected in the \
other direction",
@ -6235,7 +6528,7 @@ sys.exit(1 if errors else 0)
groups: 1,
unsealed_delta_backlog: None,
},
configured_max_index_runs: ENGINE_MAX_INDEX_RUNS,
configured_max_index_runs: RAISED_MAX_INDEX_RUNS_FOR_DERIVATION_TESTS,
default_max_index_runs: default_max_index_runs(),
receipts: ReceiptComparison::AnyCommittedStatusAccepted,
objects_new_counted: true,

File diff suppressed because it is too large Load Diff

View File

@ -1126,6 +1126,36 @@ impl IndexRun {
/// at all — but a section covering the range does not mean an entry in it
/// does, and answering `true` on the range alone would turn recoverable
/// roots into outages for a generation no entry mentions.
/// Every entry this run holds, in stored order.
///
/// Granted to B1 by contract review 2026-08-09-C, for index-run compaction.
/// Merging sealed runs is the only way the open-run count stays under
/// `max_open_index_runs` across a measured campaign -- nothing else in the
/// store ever removes a run -- and a merge cannot be written against an API
/// that answers only point lookups.
///
/// Read-only and additive: no byte of the format moves, and the order is
/// the one already on the device (sections ascending by namespace, entries
/// ascending within a section), so a caller that re-encodes what it reads
/// produces the same layout it consumed.
///
/// Deliberately not `Iterator`-returning-`Result`: every value here is
/// decoded from bytes this run already validated at `open`, so there is no
/// per-entry failure to report. A run that could not be trusted entry by
/// entry is one that should not have opened.
pub fn entries(&self) -> impl Iterator<Item = (IndexKey, IndexLocation)> + '_ {
(0..self.section_count).flat_map(move |i| {
let section = self.section(i);
(0..section.entry_count).map(move |j| {
let at = section.first_entry + j;
(
IndexKey::new(section.namespace, self.entry_object(at)),
self.entry_location(at, &section),
)
})
})
}
pub fn references_segment_generation(&self, generation: u64) -> bool {
for i in 0..self.section_count {
let section = self.section(i);
@ -1322,6 +1352,60 @@ impl ObjectIndex {
#[cfg(test)]
mod tests {
/// Everything `entries` yields is what `get` answers for the same key, and
/// it yields every entry the run counts.
///
/// Asserted against the run's own point lookup rather than against the
/// delta it was built from: the delta is what the encoder was given, and
/// the question is whether the decoder reads back what was written. A merge
/// built on an iterator that disagreed with `get` would produce a run whose
/// entries moved.
#[test]
fn entries_yields_every_entry_and_agrees_with_get() {
let mut delta = IndexDelta::new(1_000, 1 << 20);
let mut expected = Vec::new();
// Several namespaces, so the walk crosses section boundaries, and
// several generations within each, so the packed deltas vary.
for ns in 0..4u8 {
for object in 0..7u8 {
let key = IndexKey::new(NamespaceId([ns; 32]), ObjectId([ns * 16 + object; 32]));
let location = IndexLocation {
segment_generation: 40 + u64::from(object),
frame_offset: 512 * u64::from(object),
frame_len: 128 + u32::from(object),
object_type: 1,
shard_sequence: 900 + u64::from(object),
};
delta.insert(key, location).expect("insert");
expected.push((key, location));
}
}
let bytes = IndexRunBuilder::new(ROOT, 9, 9)
.build(&delta)
.expect("build");
let run = IndexRun::from_bytes(RunBytes::Owned(bytes), &ROOT).expect("open");
let seen: Vec<_> = run.entries().collect();
assert_eq!(
seen.len() as u64,
run.entry_count(),
"the iterator must yield exactly the entries the run counts"
);
for (key, location) in &seen {
assert_eq!(
run.get(key),
Some(*location),
"entries and get disagree for {key:?}"
);
}
expected.sort_by_key(|(key, _)| *key);
let mut sorted = seen.clone();
sorted.sort_by_key(|(key, _)| *key);
assert_eq!(sorted, expected, "every inserted entry must be read back");
}
use super::*;
const ROOT: [u8; 16] = [7u8; 16];

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

@ -2320,6 +2320,17 @@ fn recover_shard_under_lock(
// The frame's own sequence is the adoption's identity, and the only
// authority that creates one: staging has no durable position for a pin
// that transferred across a process boundary.
// The same materialization the live adopter performs, replayed. A
// process that stopped between the fence and materialization left some
// prefix of this frame's runs on disk, and possibly none; recomputing
// them from the same committed inputs either finds identical bytes or
// writes what is missing. That is what makes that window recoverable
// rather than a state recovery can only report.
resolver.materialize_committed_index_runs(
frame.facts.namespace,
descriptor,
frame.facts.shard_sequence,
)?;
let artifacts = resolver.resolve_committed(
frame.facts.namespace,
descriptor,
@ -3204,6 +3215,18 @@ mod production_session_tests {
Ok(Arc::from([self.descriptor.session_id, self.absent_session]))
}
/// The double's runs are whatever its fixture already holds, so
/// materialization has nothing to create. Recording that it *ran* is
/// what the callers under test care about; producing bytes is not.
fn materialize_committed_index_runs(
&self,
_namespace: NamespaceId,
_descriptor: &StagedProjectionInstallV1,
_adoption_shard_sequence: u64,
) -> Result<(), StoreError> {
Ok(())
}
fn resolve_committed(
&self,
namespace: NamespaceId,

View File

@ -383,6 +383,19 @@ impl RetainedGeneration {
/// shard and upper sequence so installing a sealed run can discard exactly
/// the layers that run covers instead of allowing lookup fan-out to grow once
/// per committed group forever.
///
/// # A layer is coverage, not content
///
/// `through_shard_sequence` says "every frame from this shard through here is
/// accounted for in the index". It is not a claim that this layer holds an
/// entry for any of them, and a layer with an empty delta is a well-formed
/// statement rather than a degenerate one: a group whose transactions
/// introduced no objects — an adopting transaction, whose objects live in
/// staging's artifacts, or a ref-only transaction — has genuinely nothing to
/// index and still has a sequence that must be represented. Checkpointing
/// reads these stamps to decide whether pruning would strand objects, so a
/// committed sequence that no layer reaches reads as a gap in the index rather
/// than as an absence of work.
#[derive(Clone, Debug)]
pub struct IndexDeltaLayer {
pub shard_index: u16,
@ -483,7 +496,29 @@ impl LayeredObjectIndex {
self.lookup(key).location
}
fn with_subtree(&self, subtree: &ShardSubtree) -> Self {
/// Compose one publication onto this index.
///
/// `advances_committed_sequence` is what decides whether a layer with no
/// entries is still installed. A layer records **publication coverage**,
/// not the presence of entries: it is the statement "every frame from this
/// shard through `through_shard_sequence` is accounted for in the index",
/// and a group that introduced no objects makes that statement truthfully
/// with nothing in it.
///
/// Skipping empty deltas — the earlier behaviour — left the committed
/// sequence advancing while coverage did not, so the next checkpoint
/// refused: it read the highest layer stamp as the reach of the index and
/// concluded that pruning would drop objects that never existed. Any
/// transaction introducing no objects reached it, and an adopting
/// transaction reaches it as the ordinary case, because its frame carries a
/// descriptor rather than object bytes and its membership may resolve
/// entirely into sealed runs.
///
/// The empty layer counts toward layer fan-out and may later seal into a
/// zero-entry run. That is the bounded-run model working as designed, and
/// it is the failure-safe direction: a stamped empty layer costs a run
/// slot, while an unstamped one costs the ability to checkpoint at all.
fn with_subtree(&self, subtree: &ShardSubtree, advances_committed_sequence: bool) -> Self {
let mut next = self.clone();
if let Some(sealed_through) = subtree.sealed_through_shard_sequence {
next.delta_layers_newest_first.retain(|layer| {
@ -494,7 +529,7 @@ impl LayeredObjectIndex {
let delta_is_sealed = subtree
.sealed_through_shard_sequence
.is_some_and(|sealed_through| sealed_through >= subtree.shard_committed_sequence);
if !delta_is_sealed && !subtree.index_delta.is_empty() {
if !delta_is_sealed && (!subtree.index_delta.is_empty() || advances_committed_sequence) {
next.delta_layers_newest_first
.push_front(IndexDeltaLayer::new(
subtree.shard_index,
@ -732,9 +767,19 @@ impl CommittedRoot {
retained_generations.insert(*id, Arc::clone(generation));
}
// Whether this publication moves the shard's committed frame sequence
// forward, which is what obliges it to record coverage. A maintenance
// publication appends no frame and republishes the sequence it found,
// so it owes nothing.
let advances_committed_sequence = self
.shard_committed_sequence(subtree.shard_index)
.is_none_or(|published| subtree.shard_committed_sequence > published);
Self {
repositories,
index: self.index.with_subtree(subtree),
index: self
.index
.with_subtree(subtree, advances_committed_sequence),
terminal_statuses,
shard_committed_sequences,
retained_generations,

View File

@ -7,14 +7,71 @@
//! 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, RetainedObjectSource};
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,
/// The shard this namespace routes to, which is the space its logical
/// generation numbers are unique within. Carried so
/// [`RepoSnapshot::object_source`] can resolve a location without the
/// caller re-deriving the routing.
shard_index: u16,
/// 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 +85,565 @@ 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.
///
/// `shard_index` is supplied rather than derived because deriving it needs
/// the shard count, which lives in [`crate::StoreOptions`] and not in the
/// root. It is well defined for a snapshot: a namespace routes to exactly
/// one shard, so every frame and every index entry it owns lives in that
/// shard's generations.
pub(crate) fn capture(
root: Arc<CommittedRoot>,
namespace: NamespaceId,
shard_index: u16,
) -> Result<Self, StoreError> {
let Some(state) = root.repo(&namespace) else {
return Err(StoreError::NoSuchRepository { namespace });
};
let state = Arc::clone(state);
Ok(Self {
root,
namespace,
shard_index,
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,
}))
}
/// Resolve a location's decoder and live pin.
///
/// [`Self::locate`] answers *which generation*, which is not enough to read
/// anything: a logical generation is a segment, an active journal tail, or
/// an adopted projection artifact, and the three are different files in
/// different formats. Before adoption existed every location a reader could
/// hold was a frame in this shard's journal, so the distinction was
/// invisible and the generation number alone was serviceable. An adopted
/// object is indexed at its artifact's generation and its bytes are not in
/// the frame that installed it, so answering "where" without answering
/// "what kind" is now an answer a reader cannot act on.
///
/// `Ok(None)` means no retained generation in this shard claims that
/// number. For a location this snapshot just produced that is corruption in
/// waiting, not an ordinary absence — but it is reported the same way the
/// root reports it, because the root is what decides retention and this is
/// a view onto the root, not a second opinion about it.
pub fn object_source(
&self,
location: &ObjectLocation,
) -> Result<Option<RetainedObjectSource<'_>>, StoreError> {
self.root
.object_source(self.shard_index, location.segment_generation)
}
}
#[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,
};
/// The shard every root below is built in. Nothing here asserts about
/// generation resolution, so the value only has to be the one constant
/// these hand-built roots agree on.
const SHARD: u16 = 0;
// -----------------------------------------------------------------------
// 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, SHARD).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, SHARD).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, SHARD).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, SHARD)
.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, SHARD).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, SHARD)
.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, SHARD)
.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, SHARD));
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, SHARD).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);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -19,11 +19,14 @@
//! a builder supplied.
use std::collections::BTreeSet;
use std::sync::Arc;
use levcs_core::{ObjectId, ObjectType};
use levcs_protocol::v2::{RefTarget, TransactionEvidenceV1, TypedRefCas};
use levcs_protocol::v2::{
RefTarget, StagedProjectionInstallV1, TransactionEvidenceV1, TypedRefCas,
};
use crate::staging::{ProjectionAdoptionOutcome, StagedProjectionAdoption};
use crate::staging::{ProjectionAdoption, ProjectionAdoptionOutcome, StagedProjectionAdoption};
use crate::types::{NamespaceId, OperationId, PrivilegedConstruction, StoreError};
/// The immutable output of the instance pipeline's stages 4-8 (plan §7).
@ -49,6 +52,166 @@ pub struct ValidatedTransaction {
pub(crate) expected_authority: Option<ObjectId>,
pub(crate) new_authority: Option<ObjectId>,
pub(crate) evidence: TransactionEvidenceV1,
/// The canonical descriptor of an adopted projection, kept here rather than
/// beside the pin because the frame is rebuilt from this value on **every**
/// sequencing attempt. A pre-mark deadline recheck that drops a group member
/// re-sequences, re-chains, and re-signs everything after it, and a
/// descriptor that had left with the pin would leave the retry with no
/// payload to encode.
pub(crate) projection: Option<StagedProjectionInstallV1>,
/// The live pin, and the one field of this struct that is not reproducible.
///
/// `Option` because the writer **takes** it, exactly once, before wrapping
/// this transaction in the `Arc` that survives re-sequencing — an `Arc` has
/// no move-out, so a pin left in here would be unreachable at the moment it
/// has to be settled. From that point the pin lives in an `AdoptionSlot`
/// beside the `Arc`, and `projection` above is what the retries read.
pub(crate) adoption_pin: Option<AdoptionSlot>,
}
impl ValidatedTransaction {
/// Take the slot out on its way into the writer.
///
/// Called once, before `Arc::new`. Calling it twice yields `None` rather
/// than a second capability: the pin is linear and there is only ever one.
pub(crate) fn take_adoption(&mut self) -> Option<AdoptionSlot> {
self.adoption_pin.take()
}
/// Read by the writer when it encodes the frame payload. See the note on
/// [`AppendPhase`] for why it is unreached today.
#[allow(dead_code)]
pub(crate) fn projection(&self) -> Option<&StagedProjectionInstallV1> {
self.projection.as_ref()
}
}
/// How far this transaction's frame has gone toward the device.
///
/// Only two values, because only two answers matter to a pin that has to be
/// settled without knowing why: either nothing of this frame reached the
/// journal, or something may have.
// `Appended` is constructed only by the writer's append path, and the parts of
// `AdoptionSlot` below that the writer alone calls are likewise unreached until
// `StoreEngine::submit` stops refusing an adoption. Both are exercised by this
// module's tests; the allow covers the non-test build only.
#[allow(dead_code)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum AppendPhase {
/// No byte of this transaction's frame has been written. An adoption
/// abandoned here is definitively not installed.
BeforeAppend,
/// A write has been attempted. **Not** "a fence succeeded" — the
/// transition is at the first possible frame write, so torn bytes and whole
/// unfenced frames are both on this side of it. Recovery, which can read
/// the device, is the only thing that can say which happened.
Appended,
}
/// The one-shot owner of an admitted adoption pin.
///
/// # Why this exists rather than a field on the transaction
///
/// `Prepared` retains an `Arc<ValidatedTransaction>` so a group can be
/// re-sequenced, re-chained, and re-signed when the pre-mark deadline recheck
/// drops a member. An `Arc` has no move-out, so a linear `ProjectionAdoption`
/// stored inside one could never be taken out to be settled. The pin therefore
/// travels *beside* the `Arc`, in this slot, and the descriptor the retries
/// need stays in the transaction where it can be read any number of times.
///
/// # Why the phase, rather than settling at each site
///
/// Every route out of a submit has to settle the pin, and the dangerous ones
/// are the routes nobody enumerated: a full or disconnected submission queue,
/// a pre-append error, a panic inside the writer, a deadline recheck that
/// reforms the group, an unwind. Naming them one at a time means the next
/// route added is a leak. Instead the slot settles itself on drop and reads the
/// outcome off the phase, so a route that was never considered is still
/// correct — and correct in the safe direction, because the phase advances
/// before the write rather than after the fence.
pub(crate) struct AdoptionSlot {
/// `None` only after an explicit [`Self::finish`]. A slot that still holds
/// its pin at drop time settles it from the phase.
handle: Option<ProjectionAdoption>,
phase: AppendPhase,
}
#[allow(dead_code)] // See the note on `AppendPhase`.
impl AdoptionSlot {
pub(crate) fn new(handle: ProjectionAdoption) -> Self {
Self {
handle: Some(handle),
phase: AppendPhase::BeforeAppend,
}
}
/// Advance to [`AppendPhase::Appended`], at the first possible frame write.
///
/// Deliberately idempotent and one-way: a group that re-forms and appends
/// again must not walk the phase back to `BeforeAppend`, because the
/// earlier attempt's bytes may already be on the device.
pub(crate) fn entered_append(&mut self) {
self.phase = AppendPhase::Appended;
}
pub(crate) fn phase(&self) -> AppendPhase {
self.phase
}
/// Read the pin without consuming it, for the pre-append revalidation.
///
/// `None` cannot happen before [`Self::finish`] and is not an error worth a
/// second failure mode: a caller that gets it has already settled the pin
/// and has nothing left to validate.
pub(crate) fn resolution(
&self,
) -> Option<Result<Arc<crate::staging::ProjectionAdoptionResolution>, StoreError>> {
self.handle.as_ref().map(|handle| handle.resolution())
}
/// Settle with an explicit outcome, and hand back staging's answer.
///
/// The only path that reports a settlement failure. [`Drop`] cannot, which
/// is why the writer calls this at the two points where it knows the
/// outcome — the publication that adopted, and the pre-append refusal that
/// did not.
pub(crate) fn finish(mut self, outcome: ProjectionAdoptionOutcome) -> Result<(), StoreError> {
match self.handle.take() {
Some(handle) => handle.finish(outcome),
None => Ok(()),
}
}
}
impl Drop for AdoptionSlot {
fn drop(&mut self) {
let Some(handle) = self.handle.take() else {
return;
};
let outcome = match self.phase {
AppendPhase::BeforeAppend => ProjectionAdoptionOutcome::DefinitivePreAppendFailure,
AppendPhase::Appended => ProjectionAdoptionOutcome::TransferredToRecovery,
};
// Swallowed because a `Drop` has nowhere to put it, and accounted for
// regardless: a `finish` that fails leaves `ProjectionAdoption` unfinished,
// so its own `Drop` records the pin as dropped without an outcome. The
// failure is visible in staging's counters rather than lost — it is only
// this frame's error text that cannot be carried out of here.
let _ = handle.finish(outcome);
}
}
/// Deliberately no `Debug` derive on the slot: `#[derive]` on a struct holding
/// a capability invites printing it, and what a pin identifies is a session
/// whose id belongs in staging's diagnostics rather than in a transaction dump.
impl std::fmt::Debug for AdoptionSlot {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AdoptionSlot")
.field("settled", &self.handle.is_none())
.field("phase", &self.phase)
.finish()
}
}
/// Deliberately does not print object or evidence bytes. A transaction's
@ -105,7 +268,10 @@ pub struct ValidatedTransactionBuilder {
#[allow(clippy::type_complexity)]
authority: Option<(Option<ObjectId>, Option<ObjectId>)>,
evidence: Option<TransactionEvidenceV1>,
adoption: Option<StagedProjectionAdoption>,
/// The descriptor and its pin, the pin already inside the slot that owns
/// it. Held as a pair because `build` needs the descriptor and the slot to
/// travel to different places, and neither may arrive without the other.
adoption: Option<(StagedProjectionInstallV1, AdoptionSlot)>,
}
fn incomplete(field: &str) -> StoreError {
@ -157,49 +323,53 @@ impl ValidatedTransactionBuilder {
self
}
/// Adopt one sealed staged projection. The opaque handle is the adoption
/// pin and is consumed with the canonical wire descriptor so neither half
/// can be forgotten independently.
/// Adopt one sealed staged projection.
///
/// Takes the whole [`StagedProjectionAdoption`] rather than a descriptor
/// and a pin separately. The frozen signature took two arguments so that
/// neither half could be *forgotten* independently, which this keeps — and
/// it additionally makes them impossible to *mismatch*, which two
/// arguments could not. See the type's own note. Contract review
/// 2026-08-09-A.
pub fn adopt_projection(
mut self,
descriptor: levcs_protocol::v2::StagedProjectionInstallV1,
handle: crate::staging::ProjectionAdoption,
adoption: StagedProjectionAdoption,
) -> Result<Self, StoreError> {
if let Some(previous) = self.adoption.take() {
let StagedProjectionAdoption { descriptor, handle } = adoption;
// Wrapped before anything below can fail. From here the pin is owned by
// a slot, so a builder abandoned mid-chain — `adopt_projection` called
// and `build` never reached — releases it with a pre-append outcome
// instead of dropping a live capability.
let slot = AdoptionSlot::new(handle);
if let Some((_, previous)) = self.adoption.take() {
// Both pins were admitted, so both receive a terminal outcome
// even though the builder rejects the duplicate. Returning early
// after finishing only one would turn the other drop into the
// lifecycle bug this handle exists to expose.
let previous_result = previous
.handle
.finish(ProjectionAdoptionOutcome::DefinitivePreAppendFailure);
// lifecycle bug this handle exists to expose. Settled explicitly
// rather than by drop because only this path can report a
// settlement that itself failed.
let previous_result =
previous.finish(ProjectionAdoptionOutcome::DefinitivePreAppendFailure);
let submitted_result =
handle.finish(ProjectionAdoptionOutcome::DefinitivePreAppendFailure);
slot.finish(ProjectionAdoptionOutcome::DefinitivePreAppendFailure);
previous_result?;
submitted_result?;
return Err(StoreError::Conflict(
"a transaction may adopt exactly one staged projection".into(),
));
}
self.adoption = Some(StagedProjectionAdoption { descriptor, handle });
self.adoption = Some((descriptor, slot));
Ok(self)
}
pub fn build(mut self) -> Result<ValidatedTransaction, StoreError> {
if let Some(adoption) = self.adoption.take() {
// Adoption is the B1/B3 seam of scope 6.2 item 9 and is not part
// of this slice. The pin is released with a definitive pre-append
// outcome before the refusal, because a dropped pin with no
// outcome is the leak §6.5 declares a bug — the refusal must not
// create one.
adoption
.handle
.finish(ProjectionAdoptionOutcome::DefinitivePreAppendFailure)?;
return Err(StoreError::NotImplemented(
"ValidatedTransactionBuilder::adopt_projection — B1 NamespaceTxn, \
scope 6-B1 deliverable 3 and scope 6.2 item 9",
));
}
// Held across every fallible step below rather than unwrapped at the
// end. `AdoptionSlot` settles itself when dropped, so each `?` in this
// function releases the pin with a definitive pre-append outcome
// without naming it — and a refusal that forgot to would create exactly
// the unsettled-drop §6.5 declares a bug. The slot reaches the returned
// transaction only on the success path.
let adoption = self.adoption.take();
let namespace = self.namespace.ok_or_else(|| incomplete("namespace"))?;
let (operation_id, operation_digest, retry_until_micros) =
@ -210,6 +380,23 @@ impl ValidatedTransactionBuilder {
let mut objects = self.objects.ok_or_else(|| incomplete("objects"))?;
let refs = self.refs.ok_or_else(|| incomplete("refs"))?;
// A frame carries inline objects or an install descriptor and never
// both: `FrameObjectsV1` is an enum, so there is no encoding for the
// pair. This is a refusal rather than a silent choice because both
// silent choices are wrong — dropping the inline objects loses bytes
// the caller asked to commit, and dropping the projection commits a
// frame that references artifacts nothing installs.
//
// An adopting transaction is therefore built with an *empty* object
// list, not with the list omitted. The projection is the payload.
if adoption.is_some() && !objects.is_empty() {
return Err(StoreError::Conflict(format!(
"a transaction adopting a staged projection may not also introduce {} inline \
object(s); a frame carries one payload or the other",
objects.len()
)));
}
// Canonical frame order, established once. A duplicate `ObjectId` is
// refused rather than deduplicated: two records for one object make
// `objects_new` and the index disagree about what the transaction
@ -248,6 +435,11 @@ impl ValidatedTransactionBuilder {
}
}
let (projection, adoption_pin) = match adoption {
Some((descriptor, slot)) => (Some(descriptor), Some(slot)),
None => (None, None),
};
Ok(ValidatedTransaction {
namespace,
operation_id,
@ -259,6 +451,8 @@ impl ValidatedTransactionBuilder {
expected_authority,
new_authority,
evidence,
projection,
adoption_pin,
})
}
}
@ -309,6 +503,19 @@ mod tests {
}
}
/// The pairing a caller outside this crate cannot assemble by hand, which
/// is the point of the type. These tests are inside the crate and can, so
/// they build it through one helper rather than at each call site — a
/// second construction site is a second chance to pair a descriptor with a
/// pin from somewhere else, which is exactly what production no longer
/// permits.
fn adoption(session_id: [u8; 16], lifecycle: Arc<Lifecycle>) -> StagedProjectionAdoption {
StagedProjectionAdoption {
descriptor: descriptor(session_id),
handle: ProjectionAdoption::new(lifecycle),
}
}
pub(crate) fn administrative_evidence() -> TransactionEvidenceV1 {
TransactionEvidenceV1::AdministrativeV1 {
actor: [9; 32],
@ -455,17 +662,46 @@ mod tests {
}
#[test]
fn a_projection_adoption_is_refused_and_releases_its_pin() {
fn an_adoption_reaches_the_built_transaction_still_unsettled() {
let lifecycle = Arc::new(Lifecycle::default());
let transaction = complete()
.adopt_projection(adoption([3; 16], lifecycle.clone()))
.expect("one adoption is admitted")
.build()
.expect("an adopting transaction builds");
assert_eq!(
transaction.projection().map(|install| install.session_id),
Some([3; 16]),
"the descriptor the frame is encoded from must survive `build`"
);
// Unsettled on purpose: `build` is not a decision about whether the
// projection installs. Recording an outcome here would either claim an
// append that has not happened or release artifacts a submit is about
// to reference.
assert_eq!(&*lifecycle.outcomes.lock().unwrap(), &[]);
assert_eq!(*lifecycle.dropped.lock().unwrap(), 0);
}
/// The enum has no encoding for both payloads, so the builder refuses
/// rather than choosing which one to lose.
#[test]
fn an_adoption_alongside_inline_objects_is_refused_and_releases_its_pin() {
let lifecycle = Arc::new(Lifecycle::default());
let result = complete()
.adopt_projection(
descriptor([3; 16]),
ProjectionAdoption::new(lifecycle.clone()),
)
.objects(vec![StagedObject {
id: ObjectId([9; 32]),
object_type: ObjectType::Blob,
raw: vec![9; 8],
}])
.adopt_projection(adoption([3; 16], lifecycle.clone()))
.expect("one adoption is admitted")
.build();
assert!(matches!(result, Err(StoreError::NotImplemented(_))));
let Err(StoreError::Conflict(message)) = result else {
panic!("expected a conflict, got {result:?}");
};
assert!(message.contains("one payload or the other"), "{message}");
assert_eq!(
&*lifecycle.outcomes.lock().unwrap(),
&[ProjectionAdoptionOutcome::DefinitivePreAppendFailure]
@ -473,16 +709,107 @@ mod tests {
assert_eq!(*lifecycle.dropped.lock().unwrap(), 0);
}
/// The two abandonment routes that no call site names.
///
/// Neither is reachable by an enumerated release: one drops a builder that
/// was never finished, the other drops a transaction that was never
/// submitted. Both are ordinary things for a caller to do, and both would
/// leak a live pin if settlement lived at the call sites rather than in the
/// slot's `Drop`.
#[test]
fn an_abandoned_builder_or_transaction_releases_its_pin() {
for abandon_before_build in [true, false] {
let lifecycle = Arc::new(Lifecycle::default());
let builder = complete()
.adopt_projection(adoption([4; 16], lifecycle.clone()))
.expect("one adoption is admitted");
if abandon_before_build {
drop(builder);
} else {
drop(builder.build().expect("an adopting transaction builds"));
}
assert_eq!(
&*lifecycle.outcomes.lock().unwrap(),
&[ProjectionAdoptionOutcome::DefinitivePreAppendFailure],
"abandon_before_build = {abandon_before_build}"
);
assert_eq!(
*lifecycle.dropped.lock().unwrap(),
0,
"abandon_before_build = {abandon_before_build}"
);
}
}
/// The phase, and only the phase, decides an unattended settlement.
///
/// Asserted directly on the slot because the writer's routes into
/// `Appended` are failures and panics, and a test that could only reach
/// this rule through one of them would be asserting about that failure
/// rather than about the rule.
#[test]
fn a_dropped_slot_settles_from_its_append_phase() {
let cases = [
(
false,
ProjectionAdoptionOutcome::DefinitivePreAppendFailure,
"no byte was written",
),
(
true,
ProjectionAdoptionOutcome::TransferredToRecovery,
"a write was attempted",
),
];
for (entered_append, expected, why) in cases {
let lifecycle = Arc::new(Lifecycle::default());
let mut slot = AdoptionSlot::new(ProjectionAdoption::new(lifecycle.clone()));
assert_eq!(slot.phase(), AppendPhase::BeforeAppend);
if entered_append {
slot.entered_append();
// One-way: a group that re-forms must not walk the phase back.
slot.entered_append();
assert_eq!(slot.phase(), AppendPhase::Appended);
}
drop(slot);
assert_eq!(&*lifecycle.outcomes.lock().unwrap(), &[expected], "{why}");
assert_eq!(*lifecycle.dropped.lock().unwrap(), 0, "{why}");
}
}
/// An explicit settlement wins over the phase, and happens once.
#[test]
fn an_explicitly_finished_slot_does_not_settle_again_on_drop() {
let lifecycle = Arc::new(Lifecycle::default());
let mut slot = AdoptionSlot::new(ProjectionAdoption::new(lifecycle.clone()));
slot.entered_append();
slot.finish(ProjectionAdoptionOutcome::Adopted {
committed_shard_sequence: 7,
})
.expect("staging accepts the outcome");
assert_eq!(
&*lifecycle.outcomes.lock().unwrap(),
&[ProjectionAdoptionOutcome::Adopted {
committed_shard_sequence: 7
}],
"the drop that follows `finish` must not add a second outcome"
);
assert_eq!(*lifecycle.dropped.lock().unwrap(), 0);
}
#[test]
fn duplicate_projection_adoption_releases_both_pins() {
let first = Arc::new(Lifecycle::default());
let second = Arc::new(Lifecycle::default());
let builder = ValidatedTransaction::builder(PrivilegedConstruction::internal())
.adopt_projection(descriptor([1; 16]), ProjectionAdoption::new(first.clone()))
.adopt_projection(adoption([1; 16], first.clone()))
.expect("first adoption");
let result =
builder.adopt_projection(descriptor([2; 16]), ProjectionAdoption::new(second.clone()));
let result = builder.adopt_projection(adoption([2; 16], second.clone()));
assert!(matches!(result, Err(StoreError::Conflict(_))));
for lifecycle in [first, second] {
assert_eq!(

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

@ -46,7 +46,8 @@ use levcs_store::StoreEngine;
use support::group_model::{
canonical_group_expectation, group_failpoint_expectation, oracle_recovery_outcome,
outcome_admits, physical_state_class, verify_adopted_prefix, victim_placement,
AdoptedPrefixExpectation, PhysicalStateClass, PrefixViolation, VictimPlacement,
AdoptedPrefixExpectation, AdoptionOutcomeExpectation, PayloadKind, PhysicalStateClass,
PrefixViolation, VictimPlacement,
};
use support::harness;
@ -122,6 +123,102 @@ fn the_two_independent_derivations_agree_for_every_row() {
}
}
/// The adoption expectation gets the same two-derivation treatment as the
/// recovery outcome: the fixture states it, the physical state class derives
/// it, and they must agree.
///
/// This runs on every Wave B row now, before any row lists
/// `staged_projection`, and that is the point. Turning the kind on becomes a
/// matter of listing it, not of also getting the expectation right in the same
/// commit — and if the two ever disagree, the failure names the row rather
/// than surfacing as a mis-driven adoption much later.
#[test]
fn the_adoption_expectation_agrees_with_the_physical_state_class() {
let fixture = harness::load_fixture();
let mut checked = 0;
for row in &fixture.rows {
let Some(plan) = &row.submit else { continue };
let resolved = harness::resolve(row);
let stated =
AdoptionOutcomeExpectation::from_name(&plan.adoption_outcome).unwrap_or_else(|| {
panic!(
"row {}: submit.adoption_outcome {:?} names no AdoptionOutcomeExpectation",
row.failpoint, plan.adoption_outcome
)
});
let derived = resolved.class.adoption_outcome();
assert_eq!(
stated,
derived,
"row {}: the fixture states the adoption pin ends as {}, but the \
physical state class {} derives {}. The class is the authority on \
whether a frame binding the artifacts exists, and a pin settled \
against the wrong answer either strands a session or licenses \
staging to reclaim content a committed root points into.",
row.failpoint,
stated.name(),
resolved.class.name(),
derived.name()
);
checked += 1;
}
assert_eq!(
checked,
fixture.rows.iter().filter(|r| r.submit.is_some()).count(),
"every submit row must carry an adoption expectation"
);
assert!(checked > 0, "the fixture carries no submit rows to check");
}
/// Every payload kind a row lists must name a real [`PayloadKind`], and the
/// inline kind must be present on every submit row.
///
/// Inline is required rather than merely allowed because it is the payload the
/// eight locations were characterized with. A row that dropped it while adding
/// `staged_projection` would move coverage sideways and read as having added
/// some.
#[test]
fn every_submit_row_lists_known_payload_kinds_including_inline() {
let fixture = harness::load_fixture();
for row in &fixture.rows {
let Some(plan) = &row.submit else { continue };
let kinds: Vec<PayloadKind> = plan
.payload_kinds
.iter()
.map(|name| {
PayloadKind::from_name(name).unwrap_or_else(|| {
panic!(
"row {}: submit.payload_kinds names {name:?}, which is no PayloadKind",
row.failpoint
)
})
})
.collect();
assert!(
kinds.contains(&PayloadKind::Inline),
"row {}: every submit row must keep driving the inline payload the \
location was characterized with",
row.failpoint
);
let mut seen = kinds.clone();
seen.sort_by_key(|kind| kind.name());
seen.dedup();
assert_eq!(
seen.len(),
kinds.len(),
"row {}: submit.payload_kinds repeats a kind, which would drive the \
same case twice and report it as two",
row.failpoint
);
}
}
#[test]
fn the_class_table_and_the_fixture_agree_on_every_row() {
let fixture = harness::load_fixture();
@ -1453,11 +1550,37 @@ fn wave_b_rows_drive_through_submit_to_their_full_failpoint_expectation() {
continue;
}
let expected_adoption = AdoptionOutcomeExpectation::from_name(&plan.adoption_outcome)
.unwrap_or_else(|| {
panic!(
"row {}: submit.adoption_outcome {:?} names no AdoptionOutcomeExpectation",
row.failpoint, plan.adoption_outcome
)
});
for action in &plan.actions {
let action = engine_matrix::action_from_name(action)
.unwrap_or_else(|| panic!("row {}: unknown action {action:?}", row.failpoint));
let observation = engine_matrix::drive_submit_row(&serial, resolved.point, action);
assert_full_expectation(resolved.point, resolved.class, &observation);
// The payload is a matrix axis, so every row runs once per kind it
// declares. The staged-projection pass is what makes the adoption
// outcome an observation rather than a fixture field: without it the
// column would agree with the class table and describe nothing that
// ran.
for kind in &plan.payload_kinds {
let kind = PayloadKind::from_name(kind).unwrap_or_else(|| {
panic!("row {}: unknown payload kind {kind:?}", row.failpoint)
});
let observation = engine_matrix::drive_submit_row(
&serial,
resolved.point,
action,
kind.carries_adoption(),
);
assert_full_expectation(resolved.point, resolved.class, &observation);
if kind.carries_adoption() {
assert_adoption_outcome(&observation, expected_adoption);
}
}
}
driven.push(row.failpoint.clone());
}
@ -1473,6 +1596,44 @@ fn wave_b_rows_drive_through_submit_to_their_full_failpoint_expectation() {
);
}
/// The pin's terminal outcome, observed rather than derived.
///
/// The expectation comes from the fixture, which `the_adoption_expectation_
/// agrees_with_the_physical_state_class` independently checks against the class
/// table. This compares that expectation with what staging actually counted, so
/// the two derivations the charter requires stay two: a rule about physical
/// state, and a number a run produced.
///
/// Every driven row must settle exactly one pin. Settling none is the leak §6.5
/// declares a bug, and settling two means one adoption reached two terminal
/// states — both are failures here rather than shrugs.
fn assert_adoption_outcome(
observation: &engine_matrix::RowObservation,
expected: AdoptionOutcomeExpectation,
) {
let counters = observation
.adoption
.expect("a staged-projection row records staging's pin counters");
let observed = counters
.sole_outcome()
.unwrap_or_else(|why| panic!("row {}: {why}", observation.row));
let observed = observed.unwrap_or_else(|| {
panic!(
"row {}: the adoption pin was never settled; a pin dropped without an outcome is \
the lifecycle bug the handle exists to expose ({counters:?})",
observation.row
)
});
assert_eq!(
observed,
expected.name(),
"row {}: the adoption pin settled {observed}, but this row's physical state class \
requires {} ({counters:?})",
observation.row,
expected.name()
);
}
/// The row B1 disclosed was never armed.
///
/// `DuringRootCasRetry` sits inside `publish_subtree`'s retry loop, past a

View File

@ -50,7 +50,12 @@
"actions": [
"fail"
],
"requires_root_cas_contention": false
"requires_root_cas_contention": false,
"payload_kinds": [
"inline",
"staged_projection"
],
"adoption_outcome": "DefinitivePreAppendFailure"
},
"rationale": "Marking Resolving writes no journal byte, so the physical state is identical to BeforeAppend. The row is distinguished only by immediate_status and shard_poisoned, which is why it needed an engine: the status root is the only place the difference exists."
},
@ -89,7 +94,12 @@
"actions": [
"fail"
],
"requires_root_cas_contention": false
"requires_root_cas_contention": false,
"payload_kinds": [
"inline",
"staged_projection"
],
"adoption_outcome": "DefinitivePreAppendFailure"
},
"rationale": "Fires at scope 6.3 step 2, before the group is marked Resolving and before any byte is written. Contract review 2026-07-26-A gave it the BeforeAppend shape: a routine SignerError::Unavailable is an availability event, and poisoning the shard for it would trade a real availability property for a safety property that was never at risk."
},
@ -153,7 +163,12 @@
"fail",
"panic"
],
"requires_root_cas_contention": false
"requires_root_cas_contention": false,
"payload_kinds": [
"inline",
"staged_projection"
],
"adoption_outcome": "TransferredToRecovery"
},
"rationale": "The fence returned before the subtree build begins, so the frame is durable and the transaction is committed on the device while the publication that would make it visible never happens. Recovery replays the fenced frame and publishes the receipt."
},
@ -168,7 +183,12 @@
"actions": [
"fail"
],
"requires_root_cas_contention": false
"requires_root_cas_contention": false,
"payload_kinds": [
"inline",
"staged_projection"
],
"adoption_outcome": "TransferredToRecovery"
},
"rationale": "Same physical state as DuringCommittedRootBuild and reached one step later: the group is fenced, the subtree is built, and the allocation that would carry it into the root fails. Poisoning, because it is inside steps 4-8."
},
@ -184,7 +204,12 @@
"fail",
"panic"
],
"requires_root_cas_contention": false
"requires_root_cas_contention": false,
"payload_kinds": [
"inline",
"staged_projection"
],
"adoption_outcome": "TransferredToRecovery"
},
"rationale": "The last instant at which a fenced group is still unpublished. Everything before the compare-and-swap has succeeded, so the transaction is durable; the shard is poisoned because a group that fenced and did not publish leaves the status root claiming Resolving for a committed transaction."
},
@ -200,7 +225,12 @@
"fail",
"panic"
],
"requires_root_cas_contention": true
"requires_root_cas_contention": true,
"payload_kinds": [
"inline",
"staged_projection"
],
"adoption_outcome": "TransferredToRecovery"
},
"rationale": "Reached only after a lost compare-and-swap: another shard published between this shard's load of the committed root and its swap. The re-merge is against a newer root and the subtree is unchanged, so the outcome is identical to BeforeRootCas - which is the point, because a retry that resolved differently from a first attempt would make publication order observable."
},
@ -239,7 +269,12 @@
"actions": [
"fail"
],
"requires_root_cas_contention": false
"requires_root_cas_contention": false,
"payload_kinds": [
"inline",
"staged_projection"
],
"adoption_outcome": "Adopted"
},
"rationale": "Post-publication. The fence succeeded and the root published, so the transaction is committed; a waiter that never wakes is a hung request, not an absent transaction, and the receipt stays queryable through transaction_status."
},
@ -254,7 +289,12 @@
"actions": [
"fail"
],
"requires_root_cas_contention": false
"requires_root_cas_contention": false,
"payload_kinds": [
"inline",
"staged_projection"
],
"adoption_outcome": "Adopted"
},
"rationale": "Post-publication and pre-response: the receipt is already durable and idempotently retrievable by status or retry, and no reappend is required."
}

View File

@ -0,0 +1,331 @@
//! Scope 6-B1 deliverable 7 — resubmitting an operation that already committed.
//!
//! A retry is the normal case, not an error. A client that submitted, lost the
//! connection, and submitted again must be told what happened the first time
//! rather than either appending a second frame or being refused. The operation
//! ID is the identity that makes that answerable, and the stable digest is what
//! makes the answer safe: the same ID over the same digest is the same request,
//! and the same ID over a *different* digest is two different requests claiming
//! one identity, which is the case that must be refused.
//!
//! Asserted against `oracle::coalescing_decision`, which is the frozen
//! statement of that rule, rather than against a second copy of it written
//! here. The oracle is fed the same three inputs the store has -- the durable
//! status, the in-flight digest, and the incoming digest -- and the store's
//! answer must be the one it names.
#![cfg(all(
feature = "store-privileged",
feature = "store-internals",
feature = "failpoints"
))]
use levcs_core::{ObjectId, ObjectType};
use levcs_protocol::oracle::{coalescing_decision, CoalescingDecision};
use levcs_protocol::v2::TransactionStatusV1;
use levcs_store::transaction::StagedObject;
use levcs_store::types::{NamespaceId, OperationId, PrivilegedConstruction, StoreError};
use levcs_store::ValidatedTransaction;
#[path = "support/engine_matrix.rs"]
mod engine_matrix;
use engine_matrix::{
create_transaction, deadline, evidence, genesis_id, namespace_on_shard, open_absent_root,
reopen_after_close, submit, DEFAULT_MAX_INDEX_RUNS,
};
const SHARD_COUNT: u16 = 1;
const OPERATION: u8 = 0x40;
/// One transaction, parameterized by the payload that determines its stable
/// digest, so the same operation ID can be resubmitted with the same digest or
/// a different one.
fn transaction(namespace: NamespaceId, digest_seed: u8, blob: u8) -> ValidatedTransaction {
let authority = genesis_id(&namespace);
ValidatedTransaction::builder(PrivilegedConstruction::assert_validated())
.namespace(namespace)
.operation(
OperationId([OPERATION; 16]),
ObjectId([digest_seed; 32]),
deadline(),
)
.objects(vec![StagedObject {
id: ObjectId([blob; 32]),
object_type: ObjectType::Blob,
raw: vec![blob; 64],
}])
.refs(Vec::new())
.authority(Some(authority), Some(authority))
.evidence(evidence())
.build()
.expect("a complete transaction")
}
/// The durable status the oracle is asked about, built from the receipt the
/// store actually produced. Only `operation_digest` participates in the
/// decision; the rest is carried so the value is a real status rather than a
/// shape that happens to satisfy one accessor.
fn durable_status(
receipt: &levcs_store::types::CommitReceipt,
digest: ObjectId,
) -> TransactionStatusV1 {
TransactionStatusV1::Committed(levcs_protocol::v2::CommitReceiptV1 {
operation_id: *receipt.operation_id.as_bytes(),
operation_digest: digest,
repo_sequence: receipt.repo_sequence,
current_authority: receipt.current_authority,
refs: Vec::new(),
objects_new: receipt.objects_new,
retry_until_micros: 0,
first_visible_at_micros: 0,
receipt_visible_until_micros: 0,
})
}
#[test]
fn a_resubmitted_operation_returns_its_durable_receipt() {
let directory = tempfile::TempDir::new().expect("a temporary root");
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
let engine = open_absent_root(directory.path(), SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
submit(&engine, create_transaction(namespace, 1))
.receipt()
.expect("the repository is created");
let first = submit(&engine, transaction(namespace, 0xd1, 0xb1));
let first = first
.receipt()
.unwrap_or_else(|| panic!("the first submit must commit: {:?}", first.error()))
.clone();
// The oracle's answer for these inputs, stated before the store is asked.
assert!(
matches!(
coalescing_decision(
durable_status(&first, ObjectId([0xd1; 32])),
None,
ObjectId([0xd1; 32])
),
CoalescingDecision::ReturnDurable(_)
),
"the oracle must name this a durable return, or this test is asserting the wrong rule"
);
let again = submit(&engine, transaction(namespace, 0xd1, 0xb1));
let again = again.receipt().unwrap_or_else(|| {
panic!(
"a same-digest resubmit must be answered: {:?}",
again.error()
)
});
assert_eq!(
again.repo_sequence, first.repo_sequence,
"a resubmit must return the receipt the first submit produced, not a new one; a second \
repo_sequence means a second frame was appended for one operation"
);
assert_eq!(again.objects_new, first.objects_new);
// And after a reopen, where the answer can only come from the committed
// root recovery rebuilt.
drop(engine);
let reopened = reopen_after_close(directory.path(), SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
let recovered = submit(&reopened, transaction(namespace, 0xd1, 0xb1));
let recovered = recovered.receipt().unwrap_or_else(|| {
panic!(
"a resubmit after reopen must still be answered: {:?}",
recovered.error()
)
});
assert_eq!(
recovered.repo_sequence, first.repo_sequence,
"the durable answer must survive recovery"
);
}
#[test]
fn one_operation_id_with_a_second_digest_is_refused() {
let directory = tempfile::TempDir::new().expect("a temporary root");
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
let engine = open_absent_root(directory.path(), SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
submit(&engine, create_transaction(namespace, 1))
.receipt()
.expect("the repository is created");
let first = submit(&engine, transaction(namespace, 0xd1, 0xb1));
let first = first
.receipt()
.unwrap_or_else(|| panic!("the first submit must commit: {:?}", first.error()))
.clone();
assert!(
matches!(
coalescing_decision(
durable_status(&first, ObjectId([0xd1; 32])),
None,
ObjectId([0xd2; 32])
),
CoalescingDecision::OperationIdMismatch
),
"the oracle must name this a mismatch, or this test is asserting the wrong rule"
);
// Same operation ID, different stable digest: two different requests
// claiming one identity. Returning the first receipt would tell the caller
// its transaction committed when a different one did.
let conflicting = submit(&engine, transaction(namespace, 0xd2, 0xb2));
match conflicting.error() {
Some(StoreError::Conflict(message)) => assert!(
message.contains("digest"),
"the refusal must say what disagreed: {message}"
),
other => panic!("a same-ID/different-digest resubmit must be refused, got {other:?}"),
}
// The refusal is about identity, not about the shard: an unrelated
// operation still commits.
let unrelated = submit(&engine, {
let authority = genesis_id(&namespace);
ValidatedTransaction::builder(PrivilegedConstruction::assert_validated())
.namespace(namespace)
.operation(OperationId([0x77; 16]), ObjectId([0x77; 32]), deadline())
.objects(vec![StagedObject {
id: ObjectId([0x77; 32]),
object_type: ObjectType::Blob,
raw: vec![0x77; 64],
}])
.refs(Vec::new())
.authority(Some(authority), Some(authority))
.evidence(evidence())
.build()
.expect("a complete transaction")
});
unrelated.receipt().unwrap_or_else(|| {
panic!(
"an unrelated operation must commit: {:?}",
unrelated.error()
)
});
}
/// A same-digest resubmit that arrives while the first is still in flight.
///
/// The durable path above cannot answer this one: nothing is committed yet, so
/// there is no receipt to return and no terminal entry to read. The second
/// submit has to attach to the first and receive whatever the first receives —
/// one frame, one sequence, two callers answered.
///
/// The leader is held in flight by a group that will not close on its own: the
/// transaction ceiling is high and the idle delay long, so the writer accepts
/// the leader and waits. The follower is submitted from this thread while the
/// leader's own submit is still blocked, which is the only arrangement where
/// `StatusReservation::Attached` is reachable at all.
#[test]
fn a_same_digest_resubmit_attaches_to_an_in_flight_leader() {
let directory = tempfile::TempDir::new().expect("a temporary root");
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
let mut options = engine_matrix::options_with_index_runs(
directory.path(),
SHARD_COUNT,
DEFAULT_MAX_INDEX_RUNS,
);
// Wide enough that the leader's group stays open until the follower has
// been accepted, and short enough that the test does not depend on the
// wall clock for correctness -- only for how long it waits.
options.max_group_transactions = 8;
options.max_group_idle = std::time::Duration::from_millis(250);
// Held for the whole test though nothing is armed here. The failpoint
// registry is a one-shot global, so a sibling test that arms one would
// otherwise fire it inside this engine and take the leader down -- which is
// a different test's property and this test's spurious failure.
let _serial = engine_matrix::serial();
let engine = levcs_store::StoreEngine::open(options).expect("the root initializes");
submit(&engine, create_transaction(namespace, 1))
.receipt()
.expect("the repository is created");
let (leader, follower) = std::thread::scope(|scope| {
let leading = scope.spawn(|| submit(&engine, transaction(namespace, 0xd1, 0xb1)));
// The leader has to be accepted before the follower is submitted, or
// the follower becomes the leader and the test asserts nothing.
std::thread::sleep(std::time::Duration::from_millis(50));
let follower = submit(&engine, transaction(namespace, 0xd1, 0xb1));
(leading.join().expect("the leader's thread"), follower)
});
let leader = leader
.receipt()
.unwrap_or_else(|| panic!("the leader must commit: {:?}", leader.error()))
.clone();
let follower = follower
.receipt()
.unwrap_or_else(|| panic!("the follower must be answered: {:?}", follower.error()));
assert_eq!(
follower.repo_sequence, leader.repo_sequence,
"a follower must receive the leader's outcome; a second repo_sequence means the store \
appended a second frame for one operation"
);
assert_eq!(follower.objects_new, leader.objects_new);
}
/// A follower is answered when its leader fails, not only when it commits.
///
/// This is the half of coalescing that is easy to get wrong and impossible to
/// notice: a follower whose completion is never resolved does not fail, it
/// *hangs*, with no receipt and no error. There are eight places a waiter is
/// resolved and only one of them is the happy path, so the property worth
/// asserting is that a leader taken down inside the poison window takes its
/// followers' answers with it.
///
/// `AfterMarkedResolving` is armed to fail, which poisons the shard during
/// publication -- after the follower has attached and before any receipt
/// exists.
#[test]
fn a_follower_is_answered_when_its_leader_is_poisoned() {
let directory = tempfile::TempDir::new().expect("a temporary root");
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
let mut options = engine_matrix::options_with_index_runs(
directory.path(),
SHARD_COUNT,
DEFAULT_MAX_INDEX_RUNS,
);
options.max_group_transactions = 8;
options.max_group_idle = std::time::Duration::from_millis(250);
let engine = levcs_store::StoreEngine::open(options).expect("the root initializes");
submit(&engine, create_transaction(namespace, 1))
.receipt()
.expect("the repository is created");
let serial = engine_matrix::serial();
engine_matrix::arm(
&serial,
levcs_store::failpoints::Failpoint::AfterMarkedResolving,
engine_matrix::action_from_name("fail").expect("the fail action"),
);
let (leader, follower) = std::thread::scope(|scope| {
let leading = scope.spawn(|| submit(&engine, transaction(namespace, 0xd1, 0xb1)));
std::thread::sleep(std::time::Duration::from_millis(50));
let follower = submit(&engine, transaction(namespace, 0xd1, 0xb1));
(leading.join().expect("the leader's thread"), follower)
});
engine_matrix::disarm(&serial);
assert!(
leader.receipt().is_none(),
"the armed failpoint must take the leader down, or this asserts nothing"
);
// The specific error matters less than its existence: what must not happen
// is the follower waiting forever on a leader that is never going to
// resolve it.
assert!(
follower.error().is_some(),
"the follower must be told its leader failed; a follower with neither receipt nor \
error is a hung request, which is the outcome this coalescing exists not to create"
);
}

View File

@ -0,0 +1,117 @@
//! Index coverage is a property of *publications*, not of index entries.
//!
//! A delta layer's `through_shard_sequence` states that every frame from its
//! shard through that sequence is accounted for in the index. Checkpointing
//! reads those stamps to decide whether pruning the journal would strand
//! objects, so a committed sequence that no layer reaches is indistinguishable
//! from a gap — the index simply does not reach that far.
//!
//! A group that introduces no objects has nothing to put in a layer and still
//! advances the committed sequence. Skipping its layer, on the reasonable-looking
//! grounds that an empty delta is not worth installing, left exactly that gap:
//! the shard could never checkpoint again, and the error said objects would be
//! dropped when there were none. An empty layer is the smallest honest way to
//! record that the sequence happened and carried nothing.
//!
//! This is asserted with a plain object-less transaction rather than through the
//! path that found it. An adopting transaction reaches the same state — its
//! frame carries a descriptor rather than object bytes — but it reaches it for a
//! reason specific to projections, and a regression that only fails when staging
//! is involved would not name what actually broke.
#![cfg(all(
feature = "store-privileged",
feature = "store-internals",
feature = "failpoints"
))]
use levcs_core::ObjectId;
use levcs_store::types::{NamespaceId, OperationId, PrivilegedConstruction};
use levcs_store::ValidatedTransaction;
#[path = "support/engine_matrix.rs"]
mod engine_matrix;
use engine_matrix::{
create_transaction, deadline, evidence, genesis_id, namespace_on_shard, open_absent_root,
reopen_after_close, submit, DEFAULT_MAX_INDEX_RUNS,
};
const SHARD_COUNT: u16 = 2;
/// Authority-only: it moves the repository's state forward and introduces no
/// object, so its frame contributes nothing to the index.
fn objectless_transaction(namespace: NamespaceId, operation: u8) -> ValidatedTransaction {
let authority = genesis_id(&namespace);
ValidatedTransaction::builder(PrivilegedConstruction::assert_validated())
.namespace(namespace)
.operation(
OperationId([operation; 16]),
ObjectId([operation; 32]),
deadline(),
)
.objects(Vec::new())
.refs(Vec::new())
.authority(Some(authority), Some(authority))
.evidence(evidence())
.build()
.expect("an object-less transaction is complete")
}
#[test]
fn a_transaction_introducing_no_objects_still_lets_the_shard_checkpoint_and_reopen() {
let directory = tempfile::TempDir::new().expect("a temporary root");
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
{
let engine = open_absent_root(directory.path(), SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
// Sequence 0 carries the genesis authority object, so it stamps a layer
// with an entry. Without it the shard would have no coverage at all and
// the check under test would be skipped rather than exercised.
let created = submit(&engine, create_transaction(namespace, 1));
created
.receipt()
.unwrap_or_else(|| panic!("the repository must be created: {:?}", created.error()));
// Sequence 1 carries nothing. This is the frame whose coverage had no
// representation.
let empty = submit(&engine, objectless_transaction(namespace, 2));
empty.receipt().unwrap_or_else(|| {
panic!(
"an object-less transaction must commit: {:?}",
empty.error()
)
});
engine.checkpoint().unwrap_or_else(|error| {
panic!(
"the shard must checkpoint through a sequence that introduced no objects, but: \
{error:?}"
)
});
}
// Through production recovery, because the checkpoint above is only correct
// if what it wrote can be opened. A run sealed from the empty layer holds
// fewer entries than the sequences it covers, and a reopen is what proves
// that is a shape this store reads back rather than one it only writes.
let reopened = reopen_after_close(directory.path(), SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
let snapshot = reopened
.snapshot(namespace)
.expect("the repository survives");
assert!(
snapshot
.locate(genesis_id(&namespace))
.expect("locate")
.is_some(),
"the genesis authority object must still be findable after checkpoint and reopen; if the \
empty layer sealed away the entries beside it, this is where that shows"
);
assert_eq!(
snapshot.current_authority(),
genesis_id(&namespace),
"the object-less transaction's own effect must survive the round trip too — it is the \
frame whose coverage was missing, so a checkpoint that lost it would look like success"
);
}

View File

@ -0,0 +1,301 @@
//! Scope 3.4 — sealing the active journal and continuing in a fresh one.
//!
//! A journal is preallocated. When a forming group no longer fits it, the shard
//! has to seal what it has into `segments/`, install the manifest generation
//! that names it, and keep going in a new file. Until that exists the shard
//! simply stops accepting work, which is the one failure mode a store cannot
//! have: nothing about the transactions being refused is wrong, and no amount
//! of retrying makes room.
//!
//! # What this asserts beyond "the submit succeeded"
//!
//! Rotation is easy to do in a way that passes a liveness test and loses data.
//! The frames sealed into the segment are still the only copy of everything
//! committed before the rotation, and every `IndexLocation` naming them was
//! written against the *tail's* logical generation — so a rotation that
//! installs the segment under a different generation, or that fails to retain
//! it, leaves those objects indexed at a generation nothing resolves. The
//! assertions here therefore span the rotation in both directions: objects
//! written before it must still locate after it, and the whole thing must
//! survive a reopen through production recovery.
//!
//! §3.4's link-then-unlink ordering exists for the crash window in the middle
//! of that sequence. This file does not crash the process — that is the crash
//! matrix's job — but it does prove the non-crash path leaves a root recovery
//! reopens without replaying the sealed prefix twice.
#![cfg(all(
feature = "store-privileged",
feature = "store-internals",
feature = "failpoints"
))]
use levcs_core::{ObjectId, ObjectType};
use levcs_store::transaction::StagedObject;
use levcs_store::types::{NamespaceId, OperationId, PrivilegedConstruction};
use levcs_store::{StoreEngine, StoreOptions, ValidatedTransaction};
#[path = "support/engine_matrix.rs"]
mod engine_matrix;
use engine_matrix::{
create_transaction, deadline, evidence, genesis_id, namespace_on_shard, submit,
DEFAULT_MAX_INDEX_RUNS,
};
const SHARD_COUNT: u16 = 1;
/// Small enough that a handful of transactions fills it, and still at least
/// `max_group_bytes`, which `StoreOptions::validate` requires.
const JOURNAL_BYTES: u64 = 64 * 1024;
const GROUP_BYTES: u64 = 8 * 1024;
/// Four kilobytes per transaction, so the journal fills in about a dozen
/// commits rather than a thousand.
const OBJECT_BYTES: usize = 4 * 1024;
fn options(root: &std::path::Path) -> StoreOptions {
let mut options =
engine_matrix::options_with_index_runs(root, SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
options.journal_preallocate_bytes = JOURNAL_BYTES;
options.segment_max_bytes = JOURNAL_BYTES;
options.max_group_bytes = GROUP_BYTES;
options
}
fn blob_id(seed: u16) -> ObjectId {
let mut bytes = [0u8; 32];
bytes[..2].copy_from_slice(&seed.to_le_bytes());
ObjectId(bytes)
}
/// A transaction carrying one sizeable object, so the journal fills quickly.
fn filling_transaction(namespace: NamespaceId, seed: u16) -> ValidatedTransaction {
let authority = genesis_id(&namespace);
let mut operation = [0u8; 16];
operation[..2].copy_from_slice(&seed.to_le_bytes());
ValidatedTransaction::builder(PrivilegedConstruction::assert_validated())
.namespace(namespace)
.operation(
OperationId(operation),
ObjectId([seed as u8; 32]),
deadline(),
)
.objects(vec![StagedObject {
id: blob_id(seed),
object_type: ObjectType::Blob,
raw: vec![seed as u8; OBJECT_BYTES],
}])
.refs(Vec::new())
.authority(Some(authority), Some(authority))
.evidence(evidence())
.build()
.expect("a complete filling transaction")
}
fn segment_count(root: &std::path::Path) -> usize {
let segments = root.join("shards").join("00").join("segments");
std::fs::read_dir(&segments)
.map(|entries| entries.filter_map(Result::ok).count())
.unwrap_or(0)
}
/// `segment_max_bytes` is a rotation trigger in its own right.
///
/// `Journal::should_rotate` names two boundaries — the preallocated length and
/// the configured segment size, whichever comes first — and only the first one
/// is a question about whether the next group *fits*. A shard that rotates
/// solely on fit runs to the end of its preallocation however small the segment
/// ceiling is, and produces segments many times the configured maximum.
///
/// The two limits are deliberately far apart here. The main test above sets
/// them equal, which is the realistic default and also the one arrangement
/// where this defect is invisible.
#[test]
fn a_segment_ceiling_below_the_preallocation_rotates_first() {
let directory = tempfile::TempDir::new().expect("a temporary root");
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
let mut options = options(directory.path());
// Room for many groups in the file, but a segment ceiling reached after a
// handful of them.
options.journal_preallocate_bytes = 1024 * 1024;
options.segment_max_bytes = 24 * 1024;
let engine = StoreEngine::open(options).expect("the root initializes");
let created = submit(&engine, create_transaction(namespace, 1));
created
.receipt()
.unwrap_or_else(|| panic!("the repository must be created: {:?}", created.error()));
const WRITES: u16 = 24;
for seed in 1..=WRITES {
let outcome = submit(&engine, filling_transaction(namespace, seed));
outcome
.receipt()
.unwrap_or_else(|| panic!("write {seed} must commit: {:?}", outcome.error()));
}
// Roughly 96 KiB written against a 24 KiB ceiling, so several segments.
// Asserted as "more than one" rather than an exact count: a segment may
// overshoot the ceiling by up to one group, because the boundary is checked
// before a group is added and not in the middle of one.
let sealed = segment_count(directory.path());
assert!(
sealed > 1,
"with segment_max_bytes far below journal_preallocate_bytes the shard must seal on \
the segment ceiling, but only {sealed} segment(s) exist after {WRITES} writes"
);
for seed in 1..=WRITES {
assert!(
engine
.snapshot(namespace)
.expect("snapshot")
.locate(blob_id(seed))
.expect("locate")
.is_some(),
"object {seed} must survive a segment-ceiling rotation"
);
}
}
/// A frame that no rotation can ever seat is a ceiling, not a rotation.
///
/// The distinction is off by exactly the journal header. A fresh journal's
/// cursor starts past its own header, so the space a rotation actually offers
/// is the preallocation *less* that header. A frame in between — small enough
/// for the preallocation, too large once the header is there — was classified
/// as rotatable, and the retry against the fresh journal failed the same test
/// again. The caller was promised `LimitExceeded` and got a second rotation
/// instead.
#[test]
fn a_frame_larger_than_a_fresh_journal_is_refused_rather_than_rotated() {
let directory = tempfile::TempDir::new().expect("a temporary root");
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
// The object is sized to land in the gap: its frame fits
// `journal_preallocate_bytes` outright but not once the header is
// accounted for. `max_group_bytes` is raised past it so the group ceiling
// is not what refuses first -- this must be the journal's answer.
const PREALLOCATED: u64 = 32 * 1024;
let mut options = options(directory.path());
options.journal_preallocate_bytes = PREALLOCATED;
options.segment_max_bytes = PREALLOCATED;
options.max_group_bytes = PREALLOCATED;
let engine = StoreEngine::open(options).expect("the root initializes");
let created = submit(&engine, create_transaction(namespace, 1));
created
.receipt()
.unwrap_or_else(|| panic!("the repository must be created: {:?}", created.error()));
// Just under the preallocation, so the frame around it is over the usable
// space by roughly the header.
let oversized = ValidatedTransaction::builder(PrivilegedConstruction::assert_validated())
.namespace(namespace)
.operation(OperationId([0xee; 16]), ObjectId([0xee; 32]), deadline())
.objects(vec![StagedObject {
id: blob_id(0xeee),
object_type: ObjectType::Blob,
raw: vec![0xee; PREALLOCATED as usize - 768],
}])
.refs(Vec::new())
.authority(Some(genesis_id(&namespace)), Some(genesis_id(&namespace)))
.evidence(evidence())
.build()
.expect("the transaction itself is well formed");
let outcome = submit(&engine, oversized);
match outcome.error() {
Some(levcs_store::types::StoreError::LimitExceeded {
limit,
observed,
allowed,
}) => {
assert_eq!(
*limit, "journal_preallocate_bytes",
"the refusal must name the ceiling that stopped it"
);
// The reported ceiling is what makes this test see the off-by-one
// rather than merely see a refusal. A frame can be far enough over
// to be refused either way; what distinguishes the two is *which*
// ceiling the store believes it has. A fresh journal's cursor
// starts past its header, so the space a rotation can offer is the
// preallocation less that header, and reporting the whole
// preallocation means frames in the gap were called rotatable.
assert_eq!(
*allowed,
PREALLOCATED - levcs_store::format::JOURNAL_HEADER_LEN as u64,
"the ceiling must be the space a fresh journal actually offers, not the raw \
preallocation"
);
assert!(
*observed > *allowed,
"the refusal must report a frame that genuinely exceeds the ceiling"
);
}
other => panic!(
"a frame no fresh journal can hold must be refused as a ceiling, not retried as a \
rotation, but submit answered {other:?}"
),
}
// And the shard is still usable: a ceiling is a refusal of one transaction,
// not a wedged writer.
let after = submit(&engine, filling_transaction(namespace, 1));
after
.receipt()
.unwrap_or_else(|| panic!("an ordinary write must still commit: {:?}", after.error()));
}
#[test]
fn a_full_active_journal_seals_and_the_shard_keeps_committing() {
let directory = tempfile::TempDir::new().expect("a temporary root");
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
// Enough transactions to overrun the preallocated journal several times
// over, so the test exercises repeated rotation rather than one boundary.
const WRITES: u16 = 48;
{
let engine = StoreEngine::open(options(directory.path())).expect("the root initializes");
let created = submit(&engine, create_transaction(namespace, 1));
created
.receipt()
.unwrap_or_else(|| panic!("the repository must be created: {:?}", created.error()));
for seed in 1..=WRITES {
let outcome = submit(&engine, filling_transaction(namespace, seed));
outcome.receipt().unwrap_or_else(|| {
panic!(
"write {seed} of {WRITES} must commit; the journal filling is a rotation, \
not a ceiling: {:?}",
outcome.error()
)
});
}
// Before the reopen, because a rotation that lost the segment would
// still answer correctly from a root that has not been rebuilt yet.
let snapshot = engine.snapshot(namespace).expect("snapshot");
for seed in [1u16, WRITES / 2, WRITES] {
assert!(
snapshot.locate(blob_id(seed)).expect("locate").is_some(),
"object {seed} was committed before a rotation and must still be located \
after it"
);
}
}
// Through production recovery: the sealed segments are now the only copy of
// everything but the last journal's frames, and a reopen is what proves the
// manifest names them.
let reopened = StoreEngine::open(options(directory.path())).expect("the root reopens");
let snapshot = reopened.snapshot(namespace).expect("snapshot");
for seed in 1..=WRITES {
assert!(
snapshot.locate(blob_id(seed)).expect("locate").is_some(),
"object {seed} did not survive the reopen; a sealed prefix the manifest does not \
name is a fenced, acknowledged transaction that recovery cannot find"
);
}
}

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

@ -0,0 +1,364 @@
//! Scope 6-B1 deliverable 3 and scope 6.2 item 9 — `adopt_projection` through
//! `StoreEngine::submit`, asserted through the public surface.
//!
//! The property under test is **ownership**, not visibility. An adopted
//! projection's objects are not in the frame that installed them: the frame
//! carries a descriptor, and the bytes stay in the artifacts staging sealed. So
//! two things have to be true at once, and each has a distinct way of being
//! wrong:
//!
//! 1. Each object is indexed at its **artifact's** logical generation, not at
//! the frame's. Indexing at the frame location would still make `locate`
//! return `Some`, and a reader following it would land at an offset inside a
//! journal frame that contains a descriptor rather than the object — a
//! plausible-looking answer that decodes into nothing.
//! 2. Those artifact generations survive the next retained generation. A
//! checkpoint publishes a successor and releases the predecessor's pins; an
//! adoption whose artifacts are not carried forward reads correctly exactly
//! once and then resolves to nothing, with no error at the moment the
//! mistake is made.
//!
//! One test catches both, which is why it is the first one written: locate and
//! resolve every object, checkpoint into a successor generation, then locate
//! and resolve all of them again. Failure 1 shows up in the first resolve,
//! failure 2 only in the second.
//!
//! **At least two chunks, always.** A single-chunk projection has exactly one
//! artifact, so "the index resolved every object to the right artifact" and
//! "the index resolved every object to the only artifact there is" are the same
//! observation, and B3 defines and resolves one `IndexLocation` per chunk. Two
//! chunks is the smallest arrangement where per-chunk ownership is load-bearing.
// The same three-feature gate as `namespace_snapshot.rs`, for the same reason:
// `engine_matrix` arms failpoints and reaches `drive.rs`, so it compiles only
// under the full set, and the Phase 1 gate runs exactly this combination.
#![cfg(all(
feature = "store-privileged",
feature = "store-internals",
feature = "failpoints"
))]
use levcs_core::{blake3_hash, ObjectHeader, ObjectId, ObjectType, FORMAT_VERSION};
use levcs_protocol::v2::{
ProjectionMode, ProjectionStageChunkV1, ProjectionStageManifestV1, ProjectionStageSessionV1,
StageSourceKindV1, StagedChunkObjectV1, StagedObjectV1,
};
use levcs_store::staging::{ProjectionStageBinding, StagedProjectionAdoption};
use levcs_store::types::NamespaceId;
use levcs_store::{RetainedObjectSource, StoreEngine, ValidatedTransaction};
#[path = "support/engine_matrix.rs"]
mod engine_matrix;
use engine_matrix::{
create_transaction, deadline, evidence, genesis_id, namespace_on_shard, now_micros,
open_absent_root, submit, DEFAULT_MAX_INDEX_RUNS,
};
const SHARD_COUNT: u16 = 2;
const CHUNKS: u32 = 2;
const PER_CHUNK: usize = 2;
const HOUR_MICROS: i64 = 3_600_000_000;
// ---------------------------------------------------------------------------
// A projection bound to a repository the engine actually created
// ---------------------------------------------------------------------------
struct Staged {
binding: ProjectionStageBinding,
chunks: Vec<ProjectionStageChunkV1>,
}
impl Staged {
/// Every object across every chunk, in no particular order — the test
/// asserts about all of them and never about their arrangement.
fn object_ids(&self) -> Vec<ObjectId> {
self.chunks
.iter()
.flat_map(|chunk| chunk.objects.iter())
.map(|object| object.descriptor.object_id)
.collect()
}
}
fn blob(body: &[u8]) -> StagedChunkObjectV1 {
let mut raw = ObjectHeader {
object_type: ObjectType::Blob,
format_version: FORMAT_VERSION,
body_len: body.len() as u64,
}
.encode()
.to_vec();
raw.extend_from_slice(body);
let id = blake3_hash(&raw);
StagedChunkObjectV1 {
descriptor: StagedObjectV1 {
object_id: id,
object_type: ObjectType::Blob as u8,
raw_len: raw.len() as u64,
raw_digest: id,
},
raw_bytes: raw,
}
}
/// A self-consistent projection bound to `namespace`.
///
/// The binding's `destination_repo`, `destination_genesis`, and
/// `expected_authority` are taken from the repository the engine created rather
/// than from constants. A projection bound to a repository that does not exist
/// is a different refusal entirely, and one bound to the wrong authority is
/// another; neither is what this file is about.
fn staged_projection(namespace: NamespaceId, session_id: [u8; 16], actor: [u8; 32]) -> Staged {
let total = CHUNKS as usize * PER_CHUNK;
let mut objects: Vec<StagedChunkObjectV1> = (0..total)
.map(|index| blob(format!("adopted-{}-{index}", hex::encode(session_id)).as_bytes()))
.collect();
// The manifest is the ordered concatenation of the chunks and must be
// strictly sorted, so the sort happens before the split, not inside each
// chunk.
objects.sort_by(|left, right| left.descriptor.cmp(&right.descriptor));
let total_object_bytes = objects
.iter()
.map(|object| object.descriptor.raw_len)
.sum::<u64>();
let descriptors: Vec<StagedObjectV1> = objects
.iter()
.map(|object| object.descriptor.clone())
.collect();
let chunks: Vec<ProjectionStageChunkV1> = (0..CHUNKS)
.map(|ordinal| {
let start = ordinal as usize * PER_CHUNK;
ProjectionStageChunkV1 {
session_id,
ordinal,
chunk_count: CHUNKS,
objects: objects[start..start + PER_CHUNK].to_vec(),
}
})
.collect();
let chunk_digests: Vec<ObjectId> = chunks
.iter()
.map(|chunk| chunk.chunk_digest().expect("chunk digest"))
.collect();
// The instance layer's commitment. B3 stores it and proves the manifest
// digest binds it; nothing in the store evaluates it.
let membership_root = blake3_hash(&session_id[..]);
let manifest = ProjectionStageManifestV1 {
session_id,
chunk_digests,
objects: descriptors,
membership_root,
};
let manifest_digest = manifest.manifest_digest().expect("manifest digest");
let authority = genesis_id(&namespace);
Staged {
binding: ProjectionStageBinding {
session: ProjectionStageSessionV1 {
session_id,
destination_repo: ObjectId(*namespace.as_bytes()),
destination_genesis: authority,
expected_authority: authority,
projection: ProjectionMode::Full,
source_kind: StageSourceKindV1::Mirror,
actor,
actor_key_epoch: 3,
source_generation_digest: ObjectId([12; 32]),
fork_proof: None,
final_operation_id: [13; 16],
final_operation_digest: ObjectId([14; 32]),
final_evidence_digest: ObjectId([15; 32]),
total_object_count: total as u64,
total_object_bytes,
chunk_count: CHUNKS,
manifest_digest,
expires_at_micros: now_micros() + HOUR_MICROS,
},
membership_root,
},
chunks,
}
}
/// Stage the projection through the engine's own staging and take the pin.
///
/// Through `engine.staging()` and not a second `ProjectionStaging`: staging's
/// ceilings are root-global and its constructor demands the root lock, so a
/// test that opened its own would be a second accountant for one root and would
/// prove the property against a staging the store does not use.
fn stage_and_finalize(engine: &StoreEngine, staged: &Staged) -> StagedProjectionAdoption {
let session = engine
.staging()
.begin(staged.binding.clone(), now_micros())
.expect("staging admits the session");
for chunk in &staged.chunks {
session.put_chunk(chunk, now_micros()).expect("chunk lands");
}
session.seal(now_micros()).expect("the session seals");
session
.finalize(now_micros())
.expect("the sealed session yields an adoption pin")
}
fn adopting_transaction(
namespace: NamespaceId,
operation: u8,
adoption: StagedProjectionAdoption,
) -> ValidatedTransaction {
let authority = genesis_id(&namespace);
ValidatedTransaction::builder(levcs_store::types::PrivilegedConstruction::assert_validated())
.namespace(namespace)
.operation(
levcs_store::types::OperationId([operation; 16]),
ObjectId([operation; 32]),
deadline(),
)
// Empty, and not a convenience: `FrameObjectsV1` is an enum, so a frame
// carries inline objects or an install descriptor and never both. The
// projection *is* this transaction's payload.
.objects(Vec::new())
.refs(Vec::new())
.authority(Some(authority), Some(authority))
.evidence(evidence())
.adopt_projection(adoption)
.expect("the builder admits one adoption")
.build()
.expect("a complete adopting transaction")
}
// ---------------------------------------------------------------------------
// The test
// ---------------------------------------------------------------------------
#[test]
fn an_adopted_projection_is_owned_by_its_artifacts_and_survives_a_checkpoint() {
let directory = tempfile::TempDir::new().expect("a temporary root");
let engine = open_absent_root(directory.path(), SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
let namespace = namespace_on_shard(0, SHARD_COUNT, 1);
submit(&engine, create_transaction(namespace, 1))
.receipt()
.expect("the repository is created");
let staged = staged_projection(namespace, [25; 16], [21; 32]);
let ids = staged.object_ids();
assert_eq!(
ids.len(),
CHUNKS as usize * PER_CHUNK,
"the fixture must produce one object per chunk slot"
);
// Nothing staged is visible before a submit adopts it. Asserted before the
// adoption so a later `Some` is evidence the adoption produced it, rather
// than something that was always there.
let before = engine.snapshot(namespace).expect("snapshot");
for id in &ids {
assert_eq!(
before.locate(*id).expect("locate"),
None,
"a sealed but unadopted object must not be visible"
);
}
let adoption = stage_and_finalize(&engine, &staged);
let committed = submit(&engine, adopting_transaction(namespace, 2, adoption));
committed
.receipt()
.unwrap_or_else(|| panic!("the adoption must commit: {:?}", committed.error()));
let adopted = engine.snapshot(namespace).expect("snapshot");
let generations = resolve_all(&adopted, &ids, "immediately after adoption");
// A checkpoint publishes a successor retained generation and releases the
// predecessor's pins. This is the moment an adoption that was never carried
// forward stops resolving.
engine.checkpoint().expect("the store checkpoints");
let after = engine.snapshot(namespace).expect("snapshot");
let survived = resolve_all(&after, &ids, "after a checkpoint");
assert_eq!(
generations, survived,
"an adopted object's generation must not move when a successor generation is published; \
the artifacts are the same files either side of the checkpoint"
);
}
/// Staging outlives the engine, so the root lock has to outlive it too.
///
/// `StoreEngine::staging` hands out a reference, but a reference is not a
/// lifetime bound on what a caller may keep: `Arc::clone` escapes it, and so do
/// a `ProjectionStageSession` and an adoption pin, each of which owns a clone.
/// If `LOCK` were released when the engine dropped, any of those could still
/// write into a root another process had since opened.
///
/// Asserted against `RecoverySession::open` rather than against a second
/// `StoreEngine::open`, deliberately. Staging keeps an in-process registry of
/// open roots, so an engine open would be refused by *that* whether or not the
/// lock were held — a passing test proving nothing about the lock. Taking the
/// lock directly is the only form of this assertion that fails when the lease
/// is not retained.
#[test]
fn a_retained_staging_holds_the_root_lock_past_the_engine() {
let directory = tempfile::TempDir::new().expect("a temporary root");
let engine = open_absent_root(directory.path(), SHARD_COUNT, DEFAULT_MAX_INDEX_RUNS);
let staging = std::sync::Arc::clone(engine.staging());
drop(engine);
let contested = levcs_store::recovery::RecoverySession::open(directory.path());
assert!(
contested.is_err(),
"the root lock must still be held while staging is retained, but a second session took \
it: {contested:?}"
);
drop(staging);
levcs_store::recovery::RecoverySession::open(directory.path())
.expect("the lock is released once the last staging reference is gone");
}
/// Locate every id, require an artifact source for each, and hand back the
/// generations so the caller can compare them across a checkpoint.
///
/// Requiring `ProjectionArtifact` and not merely `Some` is the whole point:
/// an object indexed at the installing frame's location resolves to a tail or a
/// segment and would satisfy a `Some` assertion perfectly.
fn resolve_all(
snapshot: &levcs_store::RepoSnapshot,
ids: &[ObjectId],
when: &str,
) -> Vec<(ObjectId, u64)> {
let mut generations = Vec::with_capacity(ids.len());
for id in ids {
let location = snapshot
.locate(*id)
.expect("locate")
.unwrap_or_else(|| panic!("object {} is not visible {when}", hex::encode(id.0)));
let source = snapshot
.object_source(&location)
.expect("resolving a location this snapshot produced")
.unwrap_or_else(|| {
panic!(
"object {} resolves to no retained source {when}: generation {} is not \
retained, so its artifact was not carried forward",
hex::encode(id.0),
location.segment_generation
)
});
match source {
RetainedObjectSource::ProjectionArtifact(_) => {}
other => panic!(
"object {} resolves to {:?} {when}, not a projection artifact: it was indexed at \
the installing frame's location rather than at its chunk's",
hex::encode(id.0),
std::mem::discriminant(&other)
),
}
generations.push((*id, location.segment_generation));
}
generations
}

View File

@ -42,7 +42,7 @@ const HOUR_MICROS: i64 = 3_600_000_000;
/// a constructor production cannot reach.
struct Root {
directory: TempDir,
lock: RecoverySession,
lock: Arc<RecoverySession>,
options: StoreOptions,
}
@ -64,7 +64,7 @@ impl Root {
&DurabilityCounters::default(),
)
.expect("v2 root layout");
let lock = RecoverySession::open(directory.path()).expect("root lock");
let lock = Arc::new(RecoverySession::open(directory.path()).expect("root lock"));
Self {
directory,
lock,
@ -83,7 +83,7 @@ impl Root {
}
fn open_with(&self, durability: Arc<DurabilityCounters>) -> Arc<ProjectionStaging> {
ProjectionStaging::open(&self.lock, self.options.clone(), durability)
ProjectionStaging::open(Arc::clone(&self.lock), self.options.clone(), durability)
.expect("staging root opens")
}
@ -1091,7 +1091,7 @@ fn one_root_admits_exactly_one_staging_accountant() {
let (_first, _durability) = root.open();
let result = ProjectionStaging::open(
&root.lock,
Arc::clone(&root.lock),
root.options.clone(),
Arc::new(DurabilityCounters::default()),
);
@ -1108,7 +1108,7 @@ fn a_root_lock_held_on_another_root_is_not_proof() {
let other = Root::new();
let result = ProjectionStaging::open(
&other.lock,
Arc::clone(&other.lock),
owner.options.clone(),
Arc::new(DurabilityCounters::default()),
);
@ -1189,20 +1189,21 @@ fn session_of(
/// that the staged objects are invisible, and that they become visible only
/// after a `submit` adopts the descriptor.
///
/// Still blocked on B1 NamespaceTxn, and blocked in one more place than before.
/// `RepoSnapshot::locate`, `StoreEngine::snapshot`, and the submit path are all
/// `NotImplemented`/`unimplemented!` today. On top of that, the P1-4 fix moved
/// staging inside the locked engine lifetime: this test can no longer open its
/// own `ProjectionStaging` beside a `StoreEngine`, because holding two root
/// locks is exactly what was made unrepresentable. It needs an accessor on the
/// engine — recorded as an interface request to B1 — to reach the staging the
/// engine owns.
/// Still blocked on B1 NamespaceTxn, but in one place now rather than three.
/// `RepoSnapshot::locate` and `StoreEngine::snapshot` are implemented (scope
/// 6-B1 deliverable 8), and the interface request this comment used to record —
/// an engine accessor for the staging the engine owns, needed because the P1-4
/// fix made holding two root locks unrepresentable — was granted as
/// `StoreEngine::staging()` under contract review 2026-08-09-A. What remains is
/// the submit path: it refuses an adopting transaction, settling the pin before
/// anything is queued, until deliverable 3 wires the descriptor payload, the
/// pre-append revalidation, and the post-fence artifact merge.
///
/// Asserting the property against staging's own state instead would be charter
/// item 8 exactly — a property proved against the helper rather than the path
/// that runs — so it is marked blocked rather than satisfied the wrong way.
#[test]
#[ignore = "blocked on B1 NamespaceTxn: needs RepoSnapshot::locate, StoreEngine::snapshot/submit (scope 6.4 deliverables 1, 3-8), and a StoreEngine accessor for the engine-owned ProjectionStaging"]
#[ignore = "blocked on B1 NamespaceTxn: StoreEngine::submit refuses an adopting transaction until scope 6-B1 deliverable 3 wires it (the descriptor payload, the pre-append revalidation, and the post-fence artifact merge)"]
fn sealed_objects_stay_invisible_until_a_submit_adopts_them() {
let root = Root::new();
let fixture = projection([25; 16], [21; 32], 1, 2, HOUR_MICROS);
@ -1228,8 +1229,8 @@ fn sealed_objects_stay_invisible_until_a_submit_adopts_them() {
}
unimplemented!(
"B1: expose the engine-owned ProjectionStaging, begin/put/seal a session through it, \
re-assert locate() is None for every staged object, then build a ValidatedTransaction \
with adopt_projection(install, handle), submit it, and assert locate() returns Some"
"B1: begin/put/seal a session through engine.staging(), re-assert locate() is None for \
every staged object, then finalize() it and build a ValidatedTransaction with \
adopt_projection(adoption), submit it, and assert locate() returns Some"
);
}

View File

@ -133,29 +133,22 @@ impl CommitEvidenceSigner for CountingSigner {
}
}
/// `StoreOptions::max_index_runs`, the ceiling this slice reaches soonest.
/// `StoreOptions::max_index_runs`, at the value a deployment runs.
///
/// Publishing a group adds one in-memory index delta layer, and B1's slice
/// seals none of them into an `IndexRun` — deliverable 1 refuses by name. So
/// `submit` refuses `NotImplemented` after exactly `max_index_runs` group
/// publications for the life of an engine, root-wide, whatever the workload.
/// At the shipping default of 64 that is 64 commits.
/// **The weakening this note used to disclose is gone.** It said that B1's
/// slice sealed no delta layer into an `IndexRun`, so `submit` refused
/// `NotImplemented` after exactly `max_index_runs` group publications and a
/// driver needing more had to raise the ceiling to run at all. Sealing is
/// implemented: the ceiling is drained by sealing rather than reached by
/// refusing, no `NotImplemented` refusal remains in the engine's production
/// paths, and a 45-second soak at this value published 5,192 groups — 81× the
/// ceiling — at a flat rate.
///
/// The single-row driver leaves the default alone: three submits per row is
/// nowhere near it, and a row driven under a non-default ceiling would be a row
/// driven against a store nobody ships. The contention driver raises it,
/// because it must publish groups until two of them collide and the collision
/// is not on a schedule. **That is a disclosed weakening**: the raised value is
/// a real configuration, not a bypass, but it configures around a missing
/// deliverable rather than around a tuning choice, and it is the same
/// unimplemented seal that caps the benchmark.
/// So the default is what every driver here uses, which is the point: a row
/// driven under a non-default ceiling is a row driven against a store nobody
/// ships.
pub const DEFAULT_MAX_INDEX_RUNS: u32 = 64;
/// Enough that the collision search is never the thing that ends the run.
/// Measured: the failpoint fires within 6 to 64 group publications, and the
/// eight repository creations consume eight of them.
pub const CONTENTION_MAX_INDEX_RUNS: u32 = 4096;
pub fn options(root: &Path, shard_count: u16) -> StoreOptions {
options_with_index_runs(root, shard_count, DEFAULT_MAX_INDEX_RUNS)
}
@ -183,7 +176,13 @@ pub fn options_with_index_runs(root: &Path, shard_count: u16, max_index_runs: u3
/// The client principal, deliberately not the signer's public key.
const EVIDENCE_ACTOR: [u8; 32] = [0x7e; 32];
fn evidence() -> TransactionEvidenceV1 {
/// Public because an adopting transaction cannot be assembled from
/// [`create_transaction`] or [`push_transaction`]: it carries no inline
/// objects, so B1's projection-adoption tests build their own and need the same
/// evidence, deadline, and genesis authority these two produce. Two independent
/// notions of "the authority for this namespace" in one test binary would make
/// a mismatched-authority refusal look like a harness bug.
pub fn evidence() -> TransactionEvidenceV1 {
TransactionEvidenceV1::AdministrativeV1 {
actor: EVIDENCE_ACTOR,
actor_key_epoch: 11,
@ -192,18 +191,20 @@ fn evidence() -> TransactionEvidenceV1 {
}
}
fn now_micros() -> i64 {
/// Public for the same reason as [`evidence`]: staging's `begin`, `put_chunk`,
/// `seal`, and `finalize` all take a caller-supplied clock reading.
pub fn now_micros() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as i64)
.unwrap_or(0)
}
fn deadline() -> i64 {
pub fn deadline() -> i64 {
now_micros() + 600_000_000
}
fn genesis_id(namespace: &NamespaceId) -> ObjectId {
pub fn genesis_id(namespace: &NamespaceId) -> ObjectId {
let mut bytes = [0u8; 32];
let mut hasher = blake3::Hasher::new();
hasher.update(b"levcs-store/b4/genesis-authority/v1\0");
@ -273,6 +274,163 @@ pub fn push_transaction(namespace: NamespaceId, operation: u8, blob: u8) -> Vali
.expect("a complete push transaction")
}
/// Stage and finalize a two-chunk projection through the engine's own staging.
///
/// Two chunks rather than one for the same reason the B1 acceptance test uses
/// two: it is the smallest projection where per-chunk ownership is load-bearing,
/// and a crash row that only ever adopted one chunk would not exercise the
/// partitioned run set at all.
pub fn stage_projection_for(
engine: &StoreEngine,
namespace: NamespaceId,
seed: u8,
) -> levcs_store::staging::StagedProjectionAdoption {
let (binding, chunks) = projection_binding(namespace, seed);
let session = engine
.staging()
.begin(binding, now_micros())
.expect("staging admits the row's session");
for chunk in &chunks {
session
.put_chunk(chunk, now_micros())
.expect("the row's chunk lands");
}
session.seal(now_micros()).expect("the row's session seals");
session
.finalize(now_micros())
.expect("the row's sealed session yields an adoption pin")
}
/// The victim transaction when the row is driving the staged-projection axis.
pub fn adopting_transaction(
namespace: NamespaceId,
operation: u8,
adoption: levcs_store::staging::StagedProjectionAdoption,
) -> ValidatedTransaction {
let authority = genesis_id(&namespace);
ValidatedTransaction::builder(privileged())
.namespace(namespace)
.operation(
OperationId([operation; 16]),
ObjectId([operation; 32]),
deadline(),
)
// Empty: a frame carries inline objects or an install descriptor, never
// both, so the projection is this transaction's whole payload.
.objects(Vec::new())
.refs(Vec::new())
.authority(Some(authority), Some(authority))
.evidence(evidence())
.adopt_projection(adoption)
.expect("one adoption is admitted")
.build()
.expect("a complete adopting transaction")
}
fn projection_binding(
namespace: NamespaceId,
seed: u8,
) -> (
levcs_store::staging::ProjectionStageBinding,
Vec<levcs_protocol::v2::ProjectionStageChunkV1>,
) {
use levcs_core::{blake3_hash, ObjectHeader, FORMAT_VERSION};
use levcs_protocol::v2::{
ProjectionMode, ProjectionStageChunkV1, ProjectionStageManifestV1,
ProjectionStageSessionV1, StageSourceKindV1, StagedChunkObjectV1, StagedObjectV1,
};
const CHUNKS: u32 = 2;
const PER_CHUNK: usize = 1;
let session_id = [seed; 16];
let staged = |index: usize| -> StagedChunkObjectV1 {
let body = format!("row-staged-{seed:02x}-{index}");
let mut raw = ObjectHeader {
object_type: ObjectType::Blob,
format_version: FORMAT_VERSION,
body_len: body.len() as u64,
}
.encode()
.to_vec();
raw.extend_from_slice(body.as_bytes());
let id = blake3_hash(&raw);
StagedChunkObjectV1 {
descriptor: StagedObjectV1 {
object_id: id,
object_type: ObjectType::Blob as u8,
raw_len: raw.len() as u64,
raw_digest: id,
},
raw_bytes: raw,
}
};
let mut objects: Vec<StagedChunkObjectV1> =
(0..CHUNKS as usize * PER_CHUNK).map(staged).collect();
// The manifest is the ordered concatenation of the chunks and must be
// strictly sorted, so the sort happens before the split.
objects.sort_by(|left, right| left.descriptor.cmp(&right.descriptor));
let total_object_bytes = objects
.iter()
.map(|object| object.descriptor.raw_len)
.sum::<u64>();
let descriptors: Vec<StagedObjectV1> = objects
.iter()
.map(|object| object.descriptor.clone())
.collect();
let chunks: Vec<ProjectionStageChunkV1> = (0..CHUNKS)
.map(|ordinal| ProjectionStageChunkV1 {
session_id,
ordinal,
chunk_count: CHUNKS,
objects: objects[ordinal as usize * PER_CHUNK..][..PER_CHUNK].to_vec(),
})
.collect();
let chunk_digests: Vec<ObjectId> = chunks
.iter()
.map(|chunk| chunk.chunk_digest().expect("chunk digest"))
.collect();
let membership_root = blake3_hash(&session_id[..]);
let manifest = ProjectionStageManifestV1 {
session_id,
chunk_digests,
objects: descriptors,
membership_root,
};
let manifest_digest = manifest.manifest_digest().expect("manifest digest");
let authority = genesis_id(&namespace);
(
levcs_store::staging::ProjectionStageBinding {
session: ProjectionStageSessionV1 {
session_id,
destination_repo: ObjectId(*namespace.as_bytes()),
destination_genesis: authority,
expected_authority: authority,
projection: ProjectionMode::Full,
source_kind: StageSourceKindV1::Mirror,
actor: [0x21; 32],
actor_key_epoch: 3,
source_generation_digest: ObjectId([12; 32]),
fork_proof: None,
final_operation_id: [13; 16],
final_operation_digest: ObjectId([14; 32]),
final_evidence_digest: ObjectId([15; 32]),
total_object_count: objects.len() as u64,
total_object_bytes,
chunk_count: CHUNKS,
manifest_digest,
expires_at_micros: now_micros() + 3_600_000_000,
},
membership_root,
},
chunks,
)
}
fn privileged() -> levcs_store::types::PrivilegedConstruction {
levcs_store::types::PrivilegedConstruction::assert_validated()
}
@ -580,6 +738,56 @@ pub struct RowObservation {
/// against a comment about where the fence sits.
pub fences_before: u64,
pub fences_after: u64,
/// Staging's four adoption-pin terminal counters, read on the live engine
/// **before** it is dropped. A pin settled by an unwind or by a slot's
/// `Drop` is recorded here and nowhere else: the counters live in the
/// engine-owned staging and go with it, and reopening builds fresh ones.
///
/// `None` for an inline row, which takes no pin. That is a different fact
/// from "took a pin and settled it zero times", and collapsing the two
/// would let a row that silently stopped adopting keep passing.
pub adoption: Option<AdoptionCounters>,
}
/// The four ways an admitted pin can end, as staging counts them.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct AdoptionCounters {
pub adopted: u64,
pub released: u64,
pub transferred: u64,
pub dropped: u64,
}
impl AdoptionCounters {
/// Which terminal outcome this row's pin actually reached.
///
/// `None` when no pin settled at all, and an error when more than one did:
/// a single adoption has exactly one outcome, and two counters moving means
/// the pin was settled twice or two pins were admitted where one was
/// expected.
pub fn sole_outcome(&self) -> Result<Option<&'static str>, String> {
let mut seen: Vec<&'static str> = Vec::new();
for (count, name) in [
(self.adopted, "Adopted"),
(self.released, "DefinitivePreAppendFailure"),
(self.transferred, "TransferredToRecovery"),
(self.dropped, "DroppedWithoutOutcome"),
] {
if count > 1 {
return Err(format!(
"{name} was recorded {count} times for one adoption"
));
}
if count == 1 {
seen.push(name);
}
}
match seen.as_slice() {
[] => Ok(None),
[one] => Ok(Some(one)),
many => Err(format!("one adoption reached {many:?}")),
}
}
}
impl RowObservation {
@ -617,8 +825,18 @@ pub fn drive_submit_row(
serial: &Serial,
point: Failpoint,
action: FailpointAction,
adopts: bool,
) -> RowObservation {
let row = format!("{} [{}]", point.name(), action_name(action));
let row = format!(
"{} [{}] [{}]",
point.name(),
action_name(action),
if adopts {
"staged_projection"
} else {
"inline"
}
);
let directory = tempfile::tempdir().expect("tempdir");
let root = directory.path().join("root");
@ -641,8 +859,16 @@ pub fn drive_submit_row(
.expect("shard 0 has a writer")
.fdatasync;
// Staged *before* the failpoint is armed. Staging writes chunks through
// maintenance workers, and arming first would let the row's fault fire on
// staging's own I/O rather than on the submit under test.
let staged = adopts.then(|| stage_projection_for(&engine, namespace, 0x22));
arm(serial, point, action);
let victim = submit(&engine, push_transaction(namespace, 0x22, 0xb2));
let victim = match staged {
Some(adoption) => submit(&engine, adopting_transaction(namespace, 0x22, adoption)),
None => submit(&engine, push_transaction(namespace, 0x22, 0xb2)),
};
let fences_after = engine
.durability_counters(0)
.expect("shard 0 has a writer")
@ -651,6 +877,19 @@ pub fn drive_submit_row(
.transaction_status(namespace, victim_operation)
.expect("transaction_status is a read and never fails on an open engine");
// Read while the engine is alive: staging's counters are engine-owned and a
// reopen builds fresh ones, so a pin settled by an unwind is only visible
// here.
let adoption = adopts.then(|| {
let counters = engine.staging().counters().snapshot();
AdoptionCounters {
adopted: counters.sessions_adopted,
released: counters.adoption_pins_released,
transferred: counters.adoption_pins_transferred,
dropped: counters.adoption_pins_dropped,
}
});
let probe = submit(&engine, push_transaction(namespace, 0x33, 0xb3));
// Whatever happened, nothing may stay armed for the next row: the registry
// is a one-shot global and a row that did not fire would otherwise hand its
@ -677,6 +916,7 @@ pub fn drive_submit_row(
prior_recovered,
fences_before,
fences_after,
adoption,
}
}
@ -798,7 +1038,7 @@ pub fn drive_root_cas_retry_row(
.map(|shard| namespace_on_shard(shard, CONTENTION_SHARDS, 0x0CA5_0000 + shard as u64))
.collect();
let engine = open_absent_root(&root, CONTENTION_SHARDS, CONTENTION_MAX_INDEX_RUNS);
let engine = open_absent_root(&root, CONTENTION_SHARDS, DEFAULT_MAX_INDEX_RUNS);
for (index, namespace) in namespaces.iter().enumerate() {
let created = submit_plain(&engine, create_transaction(*namespace, 0x40 + index as u8));
assert!(
@ -913,7 +1153,7 @@ pub fn drive_root_cas_retry_row(
disarm(serial);
drop(engine);
let reopened = reopen_after_close(&root, CONTENTION_SHARDS, CONTENTION_MAX_INDEX_RUNS);
let reopened = reopen_after_close(&root, CONTENTION_SHARDS, DEFAULT_MAX_INDEX_RUNS);
let recovered = reopened
.transaction_status(namespace, victim_operation)
.expect("status read");
@ -938,6 +1178,10 @@ pub fn drive_root_cas_retry_row(
prior_recovered,
fences_before,
fences_after,
// The contention driver races shards against each other for the
// root CAS and never adopts; the payload axis is driven by
// `drive_submit_row`.
adoption: None,
}),
}
}

View File

@ -143,6 +143,128 @@ impl PhysicalStateClass {
PhysicalStateClass::WholeFrameFencedAndPublished => true,
}
}
/// Which terminal outcome a `StagedProjection` payload's adoption pin must
/// carry when this class is produced (scope 6.2 item 9).
///
/// This is the class-derived half of the adoption expectation, and the
/// fixture's stated `adoption_outcome` is the other. The matrix requires
/// them to agree, exactly as it already does for `required_outcome`.
///
/// The rule is the physical one and nothing else: **whether a frame
/// binding these artifacts exists on the device.**
///
/// * No bytes reached the file, so no frame names the artifacts. They are
/// unreferenced and reclaiming them is correct, which is what
/// `DefinitivePreAppendFailure` licenses.
/// * A torn or unfenced frame is `AbsentRetriable` for the transaction,
/// but it is **not** `DefinitivePreAppendFailure` for the pin. Bytes are
/// on the device and recovery decides what they mean; telling staging to
/// reclaim now would race that decision. Only recovery can resolve it.
/// * A fenced frame is durable and binds the manifest, so the pin outlives
/// the process and recovery notifies staging of the resolution.
/// * Fenced *and* published means the committed root already references
/// the artifacts and the shard sequence is known, so the writer settled
/// `Adopted` before this failpoint could fire. A failure after that
/// point cannot un-adopt what a published root points into.
///
/// Note the asymmetry against `required_outcome`: `PartialFrame` and
/// `WholeFrameUnfenced` are `AbsentRetriable` there and
/// `TransferredToRecovery` here. A transaction that will not commit and a
/// pin whose artifacts may be referenced are different questions, and
/// collapsing them is how a retriable refusal would come to delete
/// content.
pub const fn adoption_outcome(self) -> AdoptionOutcomeExpectation {
match self {
PhysicalStateClass::NoBytes => AdoptionOutcomeExpectation::DefinitivePreAppendFailure,
PhysicalStateClass::PartialFrame => AdoptionOutcomeExpectation::TransferredToRecovery,
PhysicalStateClass::WholeFrameUnfenced => {
AdoptionOutcomeExpectation::TransferredToRecovery
}
PhysicalStateClass::WholeFrameFenced => {
AdoptionOutcomeExpectation::TransferredToRecovery
}
PhysicalStateClass::WholeFrameFencedAndPublished => AdoptionOutcomeExpectation::Adopted,
}
}
}
/// The terminal outcome expected of an adoption pin, as the matrix names it.
///
/// A test-side mirror of `ProjectionAdoptionOutcome` without the payload:
/// `Adopted` carries a committed shard sequence the matrix cannot predict, and
/// an expectation that had to guess it would assert less, not more.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum AdoptionOutcomeExpectation {
Adopted,
DefinitivePreAppendFailure,
TransferredToRecovery,
}
impl AdoptionOutcomeExpectation {
pub const ALL: &'static [AdoptionOutcomeExpectation] = &[
AdoptionOutcomeExpectation::Adopted,
AdoptionOutcomeExpectation::DefinitivePreAppendFailure,
AdoptionOutcomeExpectation::TransferredToRecovery,
];
/// Name as it appears in `tests/fixtures/phase1-failpoints.json`.
pub const fn name(self) -> &'static str {
match self {
AdoptionOutcomeExpectation::Adopted => "Adopted",
AdoptionOutcomeExpectation::DefinitivePreAppendFailure => "DefinitivePreAppendFailure",
AdoptionOutcomeExpectation::TransferredToRecovery => "TransferredToRecovery",
}
}
/// Parsed by linear search over `ALL`, for the reason
/// `PhysicalStateClass::from_name` is: adding a variant must not silently
/// acquire a default.
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|o| o.name() == name)
}
}
/// Which payload a Wave B row drives the failpoint's location with.
///
/// The third independent axis of a submit row, alongside the location the
/// failpoint names and the action the driver chooses. It is an axis rather
/// than a second list of failpoints because the locations do not change: a
/// staged-projection adoption reaches every one of the eight Wave B locations,
/// and giving it its own rows would produce a parallel list that drifts from
/// this one the first time a location is added to either.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum PayloadKind {
/// Objects carried in the frame, which is every Wave B row today.
Inline,
/// A `StagedProjectionInstallV1` descriptor adopting sealed artifacts,
/// with an adoption pin whose terminal outcome is asserted.
StagedProjection,
}
impl PayloadKind {
pub const ALL: &'static [PayloadKind] = &[PayloadKind::Inline, PayloadKind::StagedProjection];
/// Name as it appears in `tests/fixtures/phase1-failpoints.json`.
pub const fn name(self) -> &'static str {
match self {
PayloadKind::Inline => "inline",
PayloadKind::StagedProjection => "staged_projection",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|k| k.name() == name)
}
/// Whether driving this kind requires an adoption pin whose outcome the
/// row must assert.
pub const fn carries_adoption(self) -> bool {
match self {
PayloadKind::Inline => false,
PayloadKind::StagedProjection => true,
}
}
}
/// The physical state class each failpoint produces.

View File

@ -54,6 +54,20 @@ pub struct SubmitPlan {
/// True for exactly one row, and the matrix asserts that rather than
/// letting a second row quietly acquire it.
pub requires_root_cas_contention: bool,
/// Which payloads this location is driven with — the third independent
/// axis, for the reason given on [`PayloadKind`]. Required: a row that
/// omitted it would acquire a default, and a default here is a silent
/// claim about coverage.
pub payload_kinds: Vec<String>,
/// The terminal outcome an adoption pin must carry at this location, as
/// the fixture states it.
///
/// Stated even on rows that do not yet list `staged_projection`, and
/// checked against `PhysicalStateClass::adoption_outcome` on every row.
/// That agreement check is what makes turning the kind on a matter of
/// listing it rather than of also getting the expectation right at the
/// same moment.
pub adoption_outcome: String,
}
#[derive(Clone, Debug)]
@ -196,6 +210,10 @@ pub fn load_fixture() -> Fixture {
.get("requires_root_cas_contention")
.and_then(|v| v.as_bool())
.expect("submit.requires_root_cas_contention"),
payload_kinds: string_list(
plan.get("payload_kinds").expect("submit.payload_kinds"),
),
adoption_outcome: string_field(plan, "adoption_outcome"),
})
}),
rationale: string_field(row, "rationale"),

View File

@ -1839,6 +1839,194 @@ 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-09-C
`crates/levcs-store/src/index.rs``IndexRun::entries`. A frozen Wave A interface, so
requested rather than emitted. Additive and read-only; no byte of the run format moves.
**Nothing in the store has ever removed an index run.** `seal_index` pushes one per seal and
the adoption path pushes the runs staging materialized; the successor generation carries the
predecessor's forward. That was invisible while nothing checkpointed on a cadence. Wiring the
P2 harness to take checkpoints inside its measured window made it immediate: a 125-second run
died with `max_open_index_runs (observed: 33, allowed: 32)`.
**The frozen workload is arithmetically unreachable without compaction.** 300 s warmup plus
900 s measured against a hard ceiling of 32 open runs allows at most one checkpoint per ~37 s,
and ends at the ceiling with no headroom for a fan-out-triggered seal. The alternatives were
raising the ceiling — the disclosed-weakening pattern retired the same day by 2026-08-09-B, and
it would make `index_maintenance = runs_sealed` describe a run whose fan-out grows unbounded —
or shortening the measured window, which is a Phase 0 contract. Merging runs is the only option
that does not trade what the number means for the ability to produce one.
**A merge cannot be written against the existing API**, which answers point lookups only:
`get`, `may_contain`, `entry_count`, `references_segment_generation`,
`references_only_segment_generation`. `entries` yields `(IndexKey, IndexLocation)` in the order
already on the device — sections ascending by namespace, entries ascending within a section — so
a caller that re-encodes what it reads produces the layout it consumed. It returns no `Result`
per entry: every value is decoded from bytes the run validated at `open`, and a run that could
not be trusted entry by entry should not have opened.
`entries_yields_every_entry_and_agrees_with_get` asserts the walk against the run's own `get`
rather than against the delta it was built from, because the question is whether the decoder
reads back what the encoder wrote.
**Compaction partitions by generation domain, and that is a rule rather than a preference.** A
run section is per-namespace with a 16-bit generation span, and adopted-projection generations
sit in the band at `1 << 63`. Journal-backed runs may merge with each other and staging-owned
artifact runs with each other; a merge across the two is unencodable. Today's `seal_index` is
safe only because adopted entries never enter a shard delta, so compaction is the first
operation that could mix them.
Scope is deliberately narrower than Phase 4's `CompactionBackup`: sealed index runs only, no
segment compaction, no event-floor retention, no backup interaction.
##### Contract review 2026-08-09-B
`bench/result-schema.json``run_conditions.index_run_ceiling`. Requested after index-delta
sealing landed, because the enum's only raised value names a cause that is no longer true and
that nothing ever verified. One value is added, none is removed.
**The declaration was never a claim about *why*.** `store-bench.rs` derives it by comparing the
configured ceiling with the store default; that comparison cannot know what motivated a raise.
It nevertheless emitted `raised_because_index_sealing_unimplemented`, so the bundle asserted a
cause on the strength of a subtraction. That was accurate while the only reason to raise the
ceiling was the unimplemented seal. It is not accurate now, and a future run raising the ceiling
for any other reason — deliberately measuring fan-out, say — would have emitted a false
explanation that the schema called valid.
**`raised_above_store_default` is added and is what the emitter now writes.** It states the fact
the comparison establishes and stops there.
**The old value is retained and deprecated, not removed.** `schema_version` is `const: 1`, so
there is no later version to move archived bundles to, and this repository has never held a
bundle to check against — the reference machine may hold ones that declare it. Removing it would
invalidate evidence already produced, which is a worse outcome than carrying a spelling nothing
emits. It is retained **solely so archived v1 bundles validate, and is not a current causal
claim**; the enum's description says so.
**Deprecated does not mean unchecked.** The `allOf` rule that floors a declared raise at 65
accepts either spelling, so a bundle using the old string is still cross-checked against
`resources.configured_ceilings.max_index_runs`.
`both_raised_spellings_are_accepted_and_bound_the_same_way` asserts both halves — that each
validates, and that each is refused when it records the store default. Verified by mutation:
narrowing the rule to the new spelling alone makes the deprecated one silently unchecked and
fails that test.
The `configured_ceilings.max_index_runs` description carried the same causal claim and now
records what is true — the run seals against that ceiling, and its fan-out trigger is what
imposes the steady state a P2 number must be measured in.
##### Contract review 2026-08-09-A
B1 deliverable 3 — `adopt_projection` through submit. Requested by B1 on starting the wiring,
because the public entry point the deliverable is named after could not be called. Three
visibility changes, two accessors, one lifetime correction, and one signature amendment —
`adopt_projection` takes the adoption as a single value, for the reason recorded below. No wire
format moves.
**`ValidatedTransactionBuilder::adopt_projection` was a public function no external caller
could invoke.** It takes a `ProjectionAdoption`, whose only issuer is
`ProjectionStageSession::finalize`, which was `pub(crate)` and returned the `pub(crate)`
`StagedProjectionAdoption`. The pin has no other constructor by design — that unforgeability is
the point — so the two together made the signature readable and uncallable. **`finalize` and
`StagedProjectionAdoption` become `pub`.** `StagedProjectionAdoption` sits in D0-B's frozen
adoption seam; this changes who may name it and nothing about what it is.
**`adopt_projection` now takes the whole `StagedProjectionAdoption`, and its fields stay
private.** Review found that publishing the two fields and keeping the two-argument signature
admits a pairing that is *wrong* rather than merely incomplete: finalize session A, take the
descriptor from session B, submit `(descriptor_B, handle_A)`. Every field of `descriptor_B` is
internally valid, so nothing looks damaged — the frame would simply name B's artifacts while
A's are the ones held against deletion. The frozen two-argument form existed so neither half
could be *forgotten* independently; taking one value keeps that and additionally makes the
mismatch unrepresentable. A pre-append revalidation against the pin's canonical resolution
still runs, because staging's state can change between `finalize` and submit — but it is now a
recheck of a pairing the type guarantees rather than the only thing standing between a caller
and a mispaired frame. `descriptor()` is added for inspection; it borrows, because taking the
descriptor out is what the type exists to prevent.
**`StoreEngine::staging()` is added**, returning `&Arc<ProjectionStaging>`. Since the P1-4 fix
moved staging inside the locked engine lifetime, no caller can construct one beside a
`StoreEngine` — holding two root locks is what that fix made unrepresentable — so without an
accessor there is no reachable way to stage the projection an adoption adopts. This closes the
interface request recorded in the ignored test at `tests/staging_sessions.rs`.
**`ProjectionStaging` now retains the `RecoverySession` lease, and the first version of this
accessor was unsound without it.** Its justification claimed that returning a reference kept
staging from outliving the lock. It does not: `Arc::clone` escapes the borrow, and so do a
`ProjectionStageSession` and an adoption pin, each of which owns an `Arc<ProjectionStaging>`.
With `LOCK` released when the engine dropped, any of those could still write into a root
another process had since opened — a second accountant for one root, which is the exact
condition staging's construction rules exist to prevent. **`ProjectionStaging::open` therefore
takes `Arc<RecoverySession>` and keeps it**, so the lock is released when the last holder is
dropped rather than when the engine is. `EngineShared` shares the same lease.
`a_retained_staging_holds_the_root_lock_past_the_engine` asserts it by taking the lock
directly, not by attempting a second engine open — staging's in-process root registry would
refuse that whether or not the lock were held, so only the direct form fails when the lease is
not retained. Verified by mutation: replacing the retained `Arc` with a `Weak` makes the test
fail with a second session holding the root.
**`RepoSnapshot` gains `object_source()` and carries its shard index.** `locate` answers *which
generation*, which stopped being sufficient the moment adoption existed: a logical generation
is a segment, an active tail, or a projection artifact, and an adopted object's bytes are not in
the frame that installed it. Before adoption every location a reader could hold was a frame in
this shard's journal, so the distinction was invisible; now a reader that cannot ask "what
kind" cannot act on the answer at all. The shard index is passed to `capture` rather than
derived because deriving it needs the shard count, which lives in `StoreOptions` and not in the
root. `RetainedObjectSource` is already re-exported, so `lib.rs` does not move.
**One finding recorded and not acted on.** `checkpoint.rs` has no notion of projection
artifacts: a retained generation's `projection_artifacts` are in-memory pins, and their
durability comes from the journal frame plus staging's adoption marker, with recovery
rebuilding them by replaying the frame through `resolve_committed`. The successor-generation
paths clone them forward correctly within a process. Whether a manifest that outlives the
frames it was derived from needs to record them is a B3/checkpoint question, not a B1 one, and
is raised here rather than answered.
##### 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
@ -2282,7 +2470,7 @@ there.
**`max_index_runs` is now a named required member of `resources.configured_ceilings`.** It was
reachable only through that block's free-form `additionalProperties`, so an emitter could omit
the one ceiling this workload actually reaches — `submit` refuses `NotImplemented` at it — and
the one ceiling this workload actually reaches — the run seals against it — and
the bundle stayed valid. It must be the value the run configured, read back from the options the
store opened with. The schema cannot see the process's options, so the bite is a cross-check:
`index_run_ceiling: "store_default"` bounds the recorded value at 64 and

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
@ -1982,22 +2003,23 @@ Owns the crash driver, the benchmark, the matrix, and the recovery script.
`bench/result-schema.json` now requires a `run_conditions` block and a named
`resources.configured_ceilings.max_index_runs`, and requires two verification claims on the
submit path. The emitter does not produce any of them, so `store-bench emit-skeleton` currently
writes a bundle that fails validation on exactly two fields:
submit path.
```
[]: 'run_conditions' is a required property
['resources', 'configured_ceilings']: 'max_index_runs' is a required property
```
**Closed by `062797d`, "Condition benchmark claims on the run that produced them".** The
state this section recorded — the emitter producing none of them, so `store-bench
emit-skeleton` wrote a bundle failing validation on `run_conditions` and
`resources.configured_ceilings.max_index_runs`, with `bundle=schema-invalid`, `VERIFY_EXIT=1`
and `check-phase1.sh` red alongside — is history, and is kept here because the *mechanism* is
the point: the gate was red for a schema the store satisfied, because it runs `store-bench`'s
unit tests and three of them validate the emitted bundle against the schema rather than
against substrings. Schema conformance is inside the gate. That is the property review
2026-07-24-B was after, working, and it is the reason landing a contract first is allowed to
turn the gate red.
`scripts/verify-store-recovery.sh --cycles 2` reports `bundle=schema-invalid`, `VERIFY_EXIT=1`
with everything else green (`matrix=pass`, `acknowledged_loss=0`, `torn_transactions=0`).
**`scripts/check-phase1.sh` is red as well**, which is not what landing a schema alone would
normally do: the gate runs `store-bench`'s unit tests, and three of them validate the emitted
bundle against the schema rather than against substrings, so schema conformance is inside the
gate. That is the property review 2026-07-24-B was after, working. All three failures are in
`store-bench.rs` and none is a defect in the store; they are expected collateral of landing the
contract first and they close with item 5.
Verified green on 2026-08-07 at `b4e4c7e`: `scripts/check-phase1.sh` reports `GATE_EXIT=0`,
and `scripts/verify-store-recovery.sh --cycles 2` reports `bundle=schema-valid` on both
`--path submit` and `--path drive`, `matrix=pass`, `acknowledged_loss=0`,
`torn_transactions=0`, `VERIFY_EXIT=0`.
5. **Emit the run conditions, the index-run ceiling, and the two earned claims.** All of it in
`store-bench.rs`; no other file is involved. Nothing here may be a constant this file
@ -2013,9 +2035,9 @@ contract first and they close with item 5.
|---|---|---|
| `initialization_path` | `store_engine_open` (see **f**) | `shard_drive_create` |
| `mutation_path` | `store_engine_submit` | `journal_drive` |
| `checkpointing` | `unimplemented` | `unimplemented` |
| `checkpointing` | `enabled_not_reached` (see §7) | `unimplemented` |
| `index_maintenance` | `deltas_retained_in_memory` | `no_index_in_path` |
| `index_run_ceiling` | `raised_because_index_sealing_unimplemented` | `store_default` |
| `index_run_ceiling` | `store_default` (see the carry-forward below) | `store_default` |
| `receipt_reconciliation` | `acceptance_of_any_committed_status` | `no_receipts_in_path` |
| `objects_new_source` | `summed_from_receipts` | `derived_from_transaction_count` |
| `commit_id_uniqueness` | `checked_globally_across_ack_records` (after **d**) | `not_checked` |
@ -2068,6 +2090,71 @@ contract first and they close with item 5.
required field the negative control never removes is a field the suite cannot notice the loss
of.
*Met* at `062797d`, verified 2026-08-07 at `b4e4c7e`.
#### Carry-forward: `ENGINE_MAX_INDEX_RUNS` outlived its reason
Recorded 2026-08-07. `store-bench.rs` opens its measured store with
`ENGINE_MAX_INDEX_RUNS = 1_000_000` against a store default of 64, carrying the comment
"Raised because index-delta sealing is unimplemented". Sealing landed in `fef8520` and was
bounded by `e03ca2b`, so that reason has lapsed, and two things follow that did not follow
before:
1. **The emitted `run_conditions.index_run_ceiling` names a reason that is no longer true.**
The derivation is correct — it compares the configured ceiling to the store default and
reports a raise, so it cannot drift from what the run configured — but the only enum value
the schema offers for a raise is `raised_because_index_sealing_unimplemented`. (Amended
2026-08-09-B: the schema now also offers `raised_above_store_default`, which is what a
comparison can honestly report.)
2. **The raise now suppresses a behaviour the store has.** At a ceiling of a million, the
fan-out trigger never fires, so every seal in a measured run is triggered by entry pressure.
`index_maintenance = runs_sealed` is therefore true and narrower than it reads, and a P2 run
at this ceiling is not measuring the fan-out steady state the ceiling exists to impose.
**This was a decision, not an edit, and it is not B4's to make alone.** Two ways could close it
and they were not equivalent:
- **Drop the raise** — set the bench to the store default, and `index_run_ceiling` emits
`store_default` with no schema change at all. The outcome to prefer *if the measured run
survives the real ceiling*, because it is the configuration the exit criterion is about. A
change to what the benchmark measures, to be demonstrated rather than assumed.
- **Amend the enum** — if some raise were still genuinely required, the value would need a name
stating the surviving reason. `bench/result-schema.json` is lead-owned; per item 4 above,
**request it, do not emit it**, which would make it a contract review rather than a commit.
**Closed 2026-08-07 by dropping the raise, ruled by the lead and demonstrated rather than
assumed.** The store is opened at its own default and `ENGINE_MAX_INDEX_RUNS` is retired; the
schema enum is untouched, so no contract review was needed.
**Amended 2026-08-09 by contract review 2026-08-09-B, for the other reason.** Finding 1 above
was closed by no longer *emitting* the raised value, which left the value itself still naming a
lapsed cause — harmless while nothing raised the ceiling, and a false explanation the moment
anything did, since the emitter derives the declaration from a comparison and cannot know why
two numbers differ. `raised_above_store_default` is added and is what the emitter writes;
`raised_because_index_sealing_unimplemented` is retained, deprecated, and still cross-checked,
solely so archived v1 bundles validate. That is an enum amendment, so it *was* a contract
review — requested rather than emitted, per item 4.
The demonstration is the part that mattered, because the whole question was whether a run
survives the ceiling it had been raised to escape. A 45-second submit-path run at
`max_index_runs = 64` published **5,192 groups** — 81× the ceiling — with no `NotImplemented`
refusal, and its rate was flat against a 5-second run at the same ceiling (113.2/s vs 117.7/s,
debug build, diagnostic hardware; the figures are for survival, not for throughput). The
emitted bundle declares `index_run_ceiling: store_default` against
`resources.configured_ceilings.max_index_runs = 64`, with `index_maintenance: runs_sealed`
alongside. `check-phase1.sh` reports `GATE_EXIT=0` and `verify-store-recovery.sh --cycles 2`
reports `matrix=pass`, `bundle=schema-valid`, `VERIFY_EXIT=0`.
`runs_sealed` now means what it reads: seals come from fan-out pressure as well as entry
pressure, which is the steady state the ceiling exists to impose. §7's count is unchanged at
three of four — this was never one of the four — but the third of them is now earned under the
ceiling a deployment would actually run.
The raised branch of the derivation has not become dead: `bench`'s
`a_run_at_the_store_default_declares_store_default` covers the branch a real run now takes, and
the existing fixture keeps the raised branch covered, because a comparison needs both sides
exercised.
#### Carry-forward: the SIGKILL cycles still drive the journal seam
Recorded here because until now it existed only as a comment in the harness, and **a harness
@ -2228,15 +2315,35 @@ achieved with checkpointing disabled.
the conditions the number was obtained under travel inside the bundle as values, and a bundle
that met none of them cannot encode a pass.
The harness as it stands satisfies **two of the four**: since B1 landed startup state 1, the
submit path both creates its root through `StoreEngine::open` and mutates it through
`StoreEngine::submit`. It satisfies neither `checkpointing` nor `index_maintenance`, and those
are the two that decide whether a P2 figure describes a steady state or a burst — a run holding
every index delta in memory, with a lookup fan-out that grows for its whole duration and no
checkpoint ever taken, is measuring a system that has not yet reached the condition the number
is supposed to characterize. Two of four is the accurate reading of how much of the P2 exit
criterion is currently earned, and the remaining two are the expensive ones — the same
disclosure the SIGKILL carry-forward above makes about the crash-recovery row.
The harness as it stands satisfies **three of the four**, measured on 2026-08-07 at `b4e4c7e`.
Since B1 landed startup state 1, the submit path both creates its root through
`StoreEngine::open` and mutates it through `StoreEngine::submit`. Index sealing (`fef8520`,
bounded by `e03ca2b`) earned the third: the submit path now declares
`index_maintenance = runs_sealed`, derived from the `IndexRun` files read back off the device
rather than from a label.
`checkpointing` is the one outstanding, and it has moved from `unimplemented` to
**`enabled_not_reached`** — `StoreEngine::checkpoint` exists and answers (`5462952`, corrected
by `bff8e8a`), and the short verification runs simply never accumulate enough work to trip it.
That distinction matters for planning: what remains is a *run long enough to take a
checkpoint*, not a mechanism to build. It is the last of the four, and it is the one that
decides whether a P2 figure describes a steady state or a burst — a run with a lookup fan-out
that grows for its whole duration and no checkpoint ever taken is measuring a system that has
not yet reached the condition the number is supposed to characterize.
**A fourth condition is not on the exit list and bore on the same question, and it is now
closed.** The submit path used to declare `index_run_ceiling =
raised_because_index_sealing_unimplemented`, because `store-bench.rs` opened its store with
`ENGINE_MAX_INDEX_RUNS = 1_000_000` against a store default of 64 — so no seal in a measured
run was ever triggered by fan-out pressure and `index_maintenance = runs_sealed` was earned by
entry pressure alone. The raise was dropped on 2026-08-07 after a run demonstrated survival at
the real ceiling; the path now declares `store_default`, and `runs_sealed` above is earned
under the ceiling a deployment would run. The carry-forward under §6.6 item 5 records the
evidence.
Three of four is the accurate reading of how much of the P2 exit criterion is currently earned,
and the remainder is the expensive one — the same disclosure the SIGKILL carry-forward above
makes about the crash-recovery row.
## 8. Capacity analysis for P2 on the frozen reference hardware

View File

@ -75,12 +75,15 @@ else
fi
echo "== crash matrix has no pending rows at Phase 1 exit =="
# During Waves A and B the fixture legitimately carries pending-wave-b rows.
# This check only fires once B1 has landed engine.rs, at which point a pending
# row means a forgotten failpoint rather than a sequenced one.
# During Waves A and B the fixture legitimately carried pending-wave-b rows, so
# this check was gated on B1 having landed engine.rs -- proxied by engine.rs no
# longer mentioning `NotImplemented`. That proxy stopped holding: B1 landed, and
# the only mentions left are a module doc and two tests asserting an error is
# *not* one, so the grep matched and the check silently stopped running. B1's
# deliverables are complete, so the transitional guard is gone and a pending row
# is now always a forgotten failpoint.
matrix=crates/levcs-store/tests/fixtures/phase1-failpoints.json
if [ -f "$matrix" ] && grep -q 'fn submit' crates/levcs-store/src/engine.rs \
&& ! grep -q 'NotImplemented' crates/levcs-store/src/engine.rs; then
if [ -f "$matrix" ]; then
if grep -q 'pending-wave-b' "$matrix"; then
echo "engine.rs is implemented but the crash matrix still has pending rows:" >&2
grep -n 'pending-wave-b' "$matrix" >&2

228
scripts/close-phase1.sh Executable file
View File

@ -0,0 +1,228 @@
#!/usr/bin/env bash
# Run every piece of Phase 1 exit evidence and report it as a checklist.
#
# Scope §7 lists seven exit criteria and two stop-condition clauses. Most are
# already asserted by tests that run in the ordinary gate; what this script adds
# is the part that cannot run anywhere except on reference hardware -- the P2
# campaign -- and a single place that says, in one screen, which criteria are
# met and which are not.
#
# It is deliberately a *reporter*, not a promoter. It exits non-zero unless
# every criterion passes, and it never edits a bundle to make one pass. A run on
# a machine that does not match a frozen hardware profile will correctly report
# the P2 rows as failed, which is what makes it safe to run here as a rehearsal.
#
# Usage:
# scripts/close-phase1.sh [--work DIR] [--seconds N] [--reps N] [--cycles N]
# [--skip-recovery] [--skip-gate]
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
work="$repo_root/target/phase1-closure"
seconds=120
reps=3
cycles=100
run_recovery=1
run_gate=1
usage() {
sed -n '2,17p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
}
while [ $# -gt 0 ]; do
case "$1" in
--work) work="$2"; shift 2 ;;
--seconds) seconds="$2"; shift 2 ;;
--reps) reps="$2"; shift 2 ;;
--cycles) cycles="$2"; shift 2 ;;
--skip-recovery) run_recovery=0; shift ;;
--skip-gate) run_gate=0; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
evidence="$repo_root/evidence/phase1-closure-$stamp"
# Every row this script can report on. `record` appends one; nothing else
# writes to the checklist, so a criterion with no row is a criterion nobody
# measured rather than one that silently passed.
checklist=()
record() { checklist+=("$1|$2|$3"); }
# ---------------------------------------------------------------------------
# Preflight
# ---------------------------------------------------------------------------
echo "== preflight ==" >&2
target_dir="$(cargo metadata --no-deps --format-version 1 \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')"
if [ -z "$target_dir" ]; then
echo "could not resolve cargo's target directory from cargo metadata" >&2
exit 70
fi
if ! python3 -c 'import jsonschema' 2>/dev/null; then
echo "jsonschema is not installed; bundles could not be validated" >&2
exit 70
fi
mkdir -p "$work" "$evidence"
work="$(cd -- "$work" && pwd)"
# The frozen profile requires nodatacow on the journal and segment directories,
# and store-bench refuses on mismatch rather than recording it. Setting the
# attribute on an empty parent and creating each store root inside it is the
# only way an unprivileged run can satisfy that.
bench_parent="$work/bench"
rm -rf "$bench_parent"
mkdir -p "$bench_parent"
chattr +C "$bench_parent" 2>/dev/null || true
fs_type="$(stat -f -c %T "$work" 2>/dev/null || echo unknown)"
if [ "$fs_type" = "tmpfs" ]; then
echo "the work directory is on tmpfs; no durability claim survives that." >&2
echo "Pass --work with a persistent filesystem." >&2
exit 70
fi
echo "work=$work fs=$fs_type evidence=$evidence" >&2
# ---------------------------------------------------------------------------
# 1. The ordinary gate
# ---------------------------------------------------------------------------
if [ "$run_gate" -eq 1 ]; then
echo "== phase 1 gate ==" >&2
if bash "$repo_root/scripts/check-phase1.sh" >"$evidence/check-phase1.log" 2>&1; then
record "Gate (fmt, workspace tests, crash matrix, whitespace)" PASS "check-phase1.log"
else
record "Gate (fmt, workspace tests, crash matrix, whitespace)" FAIL "check-phase1.log"
fi
else
record "Gate (fmt, workspace tests, crash matrix, whitespace)" SKIP "--skip-gate"
fi
# ---------------------------------------------------------------------------
# 2. Acknowledged crash recovery
# ---------------------------------------------------------------------------
if [ "$run_recovery" -eq 1 ]; then
echo "== crash recovery campaign ($cycles cycles) ==" >&2
if bash "$repo_root/scripts/verify-store-recovery.sh" \
--cycles "$cycles" --work "$work/recovery" \
>"$evidence/verify-store-recovery.log" 2>&1; then
record "Acknowledged crash recovery ($cycles SIGKILL cycles)" PASS "verify-store-recovery.log"
else
record "Acknowledged crash recovery ($cycles SIGKILL cycles)" FAIL "verify-store-recovery.log"
fi
else
record "Acknowledged crash recovery" SKIP "--skip-recovery"
fi
# ---------------------------------------------------------------------------
# 3. The P2 campaign
# ---------------------------------------------------------------------------
#
# Release build, because `environment_fidelity` refuses `reference_profile`
# without one -- a debug bundle is a diagnostic bundle whatever the hardware.
echo "== building store-bench (release) ==" >&2
cargo build -q --release -p levcs-store \
--features bench-harness,store-internals,store-privileged --bin store-bench >&2
p2_pass=1
for rep in $(seq 1 "$reps"); do
echo "== P2 repetition $rep of $reps (${seconds}s) ==" >&2
root="$bench_parent/p2-$rep"
out="$evidence/storage-primitive-$rep.json"
rm -rf "$root"
if ! "$target_dir/release/store-bench" run \
--root "$root" --out "$out" --path submit \
--seconds "$seconds" >"$evidence/p2-$rep.log" 2>&1; then
record "P2 repetition $rep" FAIL "p2-$rep.log"
p2_pass=0
continue
fi
verdict="$(python3 - "$repo_root/bench/result-schema.json" "$out" <<'PY'
import json, sys
import jsonschema
schema = json.load(open(sys.argv[1]))
bundle = json.load(open(sys.argv[2]))
errors = sorted(
jsonschema.Draft202012Validator(schema).iter_errors(bundle),
key=lambda e: list(e.path),
)
if errors:
print("INVALID " + "; ".join(f"{list(e.path)}: {e.message}" for e in errors[:3]))
raise SystemExit(0)
rc = bundle.get("run_conditions", {})
# The four conditions a passing storage_primitive bundle must declare, checked
# by name so the reason a repetition did not close is the field rather than a
# schema error twenty levels down.
missing = [
f"{k}={rc.get(k)!r}"
for k, want in (
("initialization_path", "store_engine_open"),
("mutation_path", "store_engine_submit"),
("checkpointing", "exercised"),
("index_maintenance", "runs_sealed"),
)
if rc.get(k) != want
]
fidelity = rc.get("environment_fidelity")
if fidelity != "reference_profile":
missing.append(f"environment_fidelity={fidelity!r}")
outcome = bundle.get("outcome")
if outcome != "pass":
missing.append(f"outcome={outcome!r}")
print("PASS" if not missing else "SHORT " + ", ".join(missing))
PY
)"
case "$verdict" in
PASS) record "P2 repetition $rep (schema-valid, conditions met)" PASS "$(basename "$out")" ;;
*) record "P2 repetition $rep" FAIL "$(basename "$out"): $verdict"; p2_pass=0 ;;
esac
done
if [ "$p2_pass" -eq 1 ]; then
record "P2 >=75k commits/s, p99 <=50ms, $reps repetitions" PASS "storage-primitive-*.json"
else
record "P2 >=75k commits/s, p99 <=50ms, $reps repetitions" FAIL "see repetitions above"
fi
# ---------------------------------------------------------------------------
# Checklist
# ---------------------------------------------------------------------------
echo >&2
echo "===========================================================" >&2
echo " Phase 1 exit checklist ($stamp)" >&2
echo "===========================================================" >&2
failed=0
for row in "${checklist[@]}"; do
IFS='|' read -r what verdict artifact <<<"$row"
printf ' %-6s %-56s %s\n' "$verdict" "$what" "$artifact" >&2
[ "$verdict" = "FAIL" ] && failed=1
done
echo "-----------------------------------------------------------" >&2
echo " evidence archived in: $evidence" >&2
{
echo "# Phase 1 closure run $stamp"
echo
printf '| verdict | criterion | artifact |\n|---|---|---|\n'
for row in "${checklist[@]}"; do
IFS='|' read -r what verdict artifact <<<"$row"
printf '| %s | %s | `%s` |\n' "$verdict" "$what" "$artifact"
done
} >"$evidence/CHECKLIST.md"
if [ "$failed" -eq 1 ]; then
echo "CLOSURE_EXIT=1 (at least one criterion did not pass)" >&2
exit 1
fi
echo "CLOSURE_EXIT=0" >&2

View File

@ -108,9 +108,26 @@ trap cleanup EXIT
features="failpoints,store-internals,store-privileged"
# Where cargo puts binaries is not necessarily `$repo_root/target`:
# `CARGO_TARGET_DIR`, a `build.target-dir` in a config.toml, and a shared
# workspace target directory all move it. Assuming the default turns a host
# that sets one into a build that succeeds followed by a "was not built"
# refusal, which reads as a compilation failure and is not one. Ask cargo.
#
# This resolves the *build output* directory only. The working directory above
# deliberately stays under `$repo_root/target`: it holds crash roots whose
# filesystem is part of what the campaign measures, and a shared target
# directory may be on a different one.
target_dir="$(cargo metadata --no-deps --format-version 1 \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')"
if [ -z "$target_dir" ]; then
echo "could not resolve cargo's target directory from cargo metadata" >&2
exit 70
fi
echo "== building the crash driver ==" >&2
cargo build -q -p levcs-store --features "$features" --bin store-crash-driver
driver="$repo_root/target/debug/store-crash-driver"
driver="$target_dir/debug/store-crash-driver"
if [ ! -x "$driver" ]; then
echo "store-crash-driver was not built at $driver" >&2
exit 70
@ -270,7 +287,7 @@ if [ "$run_bundle" = "1" ]; then
bundle_out="$work/storage-primitive-skeleton-$bundle_variant.json"
rm -rf "$bundle_root" "$bundle_out"
if "$repo_root/target/debug/store-bench" emit-skeleton \
if "$target_dir/debug/store-bench" emit-skeleton \
--root "$bundle_root" --out "$bundle_out" --path "$bundle_variant" \
--allow-unsigned --seconds 2 --group-len 16 >&2; then
if python3 - "$repo_root/bench/result-schema.json" "$bundle_out" >&2 <<'PY'
@ -319,7 +336,7 @@ PY
zero_work_root="$bundle_parent/zero-work-root"
zero_work_out="$work/storage-primitive-zero-work.json"
rm -rf "$zero_work_root" "$zero_work_out"
if "$repo_root/target/debug/store-bench" emit-skeleton \
if "$target_dir/debug/store-bench" emit-skeleton \
--root "$zero_work_root" --out "$zero_work_out" --path submit \
--allow-unsigned --seconds 2 --group-len 16 \
--submitters-per-shard 0 >&2; then
@ -348,7 +365,7 @@ PY
unaccounted_root="$bundle_parent/unaccounted-root"
unaccounted_out="$work/storage-primitive-unaccounted.json"
rm -rf "$unaccounted_root" "$unaccounted_out"
if "$repo_root/target/debug/store-bench" emit-skeleton \
if "$target_dir/debug/store-bench" emit-skeleton \
--root "$unaccounted_root" --out "$unaccounted_out" --path submit \
--allow-unsigned --seconds 2 --group-len 4 \
--shards 1 --submitters-per-shard 1 \