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>
This commit is contained in:
Levi Neuwirth 2026-08-07 22:27:37 +02:00
parent d36f6a86f7
commit e0c599c432
No known key found for this signature in database
4 changed files with 278 additions and 9 deletions

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();

View File

@ -50,7 +50,11 @@
"actions": [
"fail"
],
"requires_root_cas_contention": false
"requires_root_cas_contention": false,
"payload_kinds": [
"inline"
],
"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 +93,11 @@
"actions": [
"fail"
],
"requires_root_cas_contention": false
"requires_root_cas_contention": false,
"payload_kinds": [
"inline"
],
"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 +161,11 @@
"fail",
"panic"
],
"requires_root_cas_contention": false
"requires_root_cas_contention": false,
"payload_kinds": [
"inline"
],
"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 +180,11 @@
"actions": [
"fail"
],
"requires_root_cas_contention": false
"requires_root_cas_contention": false,
"payload_kinds": [
"inline"
],
"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 +200,11 @@
"fail",
"panic"
],
"requires_root_cas_contention": false
"requires_root_cas_contention": false,
"payload_kinds": [
"inline"
],
"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 +220,11 @@
"fail",
"panic"
],
"requires_root_cas_contention": true
"requires_root_cas_contention": true,
"payload_kinds": [
"inline"
],
"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 +263,11 @@
"actions": [
"fail"
],
"requires_root_cas_contention": false
"requires_root_cas_contention": false,
"payload_kinds": [
"inline"
],
"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 +282,11 @@
"actions": [
"fail"
],
"requires_root_cas_contention": false
"requires_root_cas_contention": false,
"payload_kinds": [
"inline"
],
"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

@ -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"),