Mutation controls for the #2517 migration tests
Status: current · Scope: icn-net — replay_guard, signing_sequence
A test that passes on broken code is worse than no test, because it reports safety it has not established. This file records which deliberate defect each #2517 test actually catches, so a later change that quietly makes one of them vacuous is visible as a gap in this table rather than as a still-green suite.
Companion to protocol-state-migration-invariants.md.
Method
Apply one defect to origin/main + the #2517 change, run
cargo test -p icn-net --lib -- migration_tests signing_sequence::tests, record which tests fail,
revert. Every mutation must kill at least one named test; a mutation that kills nothing means
the property is unguarded.
The final row is the control: with every mutation reverted the suite is green, which is what makes the failures above it meaningful rather than a broken checkout.
Results
| # | defect introduced | tests killed |
|---|---|---|
| 1 | Legacy state treated as current — the version branch always selects the current arm | 10 killed, re-measured post-rebase: legacy_high_water_does_not_reject_a_legitimate_lower_sequence_forever, migration_completes_at_the_envelope_validity_horizon, migration_runs_once_and_does_not_re_trigger_on_restart, crash_before_migration_completes_re_quarantines_rather_than_trusting_legacy, unknown_future_version_stays_fail_closed_past_the_legacy_migration_horizon, a_known_version_without_a_migration_does_not_borrow_the_legacy_path, pre_2514_inflated_floor_does_not_survive_migration, state_rewritten_by_an_older_binary_is_treated_as_legacy, receiver_first_upgrade_migrates_the_sender_regime_end_to_end, sender_first_upgrade_costs_two_sequential_holds_and_no_sender_restart |
| 2 | Sender resets the watermark when stamping an unversioned store (recreates #2510) | unversioned_durable_watermark_is_stamped_without_disturbing_the_sequence, corrupt_watermark_is_rejected_rather_than_silently_reset |
| 3 | Legacy state dropped immediately, with no fail-closed hold | captured_legacy_envelope_stays_rejected_across_the_whole_migration, legacy_high_water_does_not_reject_a_legitimate_lower_sequence_forever, migration_completes_at_the_envelope_validity_horizon, migration_runs_once_and_does_not_re_trigger_on_restart, crash_before_migration_completes_re_quarantines_rather_than_trusting_legacy, state_rewritten_by_an_older_binary_is_treated_as_legacy |
| 4 | Current version never stamped on write, so migration re-runs forever | migration_runs_once_and_does_not_re_trigger_on_restart, current_semantic_state_is_restored_exactly_and_not_migrated |
| 5 | Sender accepts an unknown future regime instead of refusing to open | unknown_future_semantic_version_refuses_to_start, corrupt_semantic_version_is_rejected |
| 6 | Receiver routes unknown-future through the bounded legacy migration | unknown_future_version_stays_fail_closed_past_the_legacy_migration_horizon, a_known_version_without_a_migration_does_not_borrow_the_legacy_path |
| 7 | Receiver treats an unknown future regime as a current-semantic floor | unknown_future_version_stays_fail_closed_past_the_legacy_migration_horizon, a_known_version_without_a_migration_does_not_borrow_the_legacy_path |
| — | (all reverted — control) | none; 23 passed, 0 failed |
Mutation 3 is the security control: it is what proves the fail-closed hold is load-bearing rather
than decorative. Discarding a legacy high-water without holding the peer makes a captured legacy
envelope acceptable again, and captured_legacy_envelope_stays_rejected_across_the_whole_migration
is the test that notices.
Mutations 6 and 7 are the downgrade-safety controls, and they are the reason known-legacy and unknown-future do not share a branch. Both express "an unknown regime becomes current" — one by countdown, one immediately — and both must be caught. They kill exactly the two unknown-future tests and nothing else, which is what shows those tests discriminate the unsupported path from the bounded one rather than merely observing that something was rejected.
Recorded gap
Mutation 5 originally carried a receiver-side half that edited a single
entry.semantic_version != REPLAY_STATE_SEMANTIC_VERSION branch. When that branch was replaced by
an enumerated match, the edit stopped matching any source text and the receiver half became a silent
no-op — the mutation still "passed" because its sender half killed tests. Mutation 7 exists because
of that.
The general hazard: a mutation applied by text substitution can stop applying when the code is restructured, and reports success either way. A mutation that kills fewer tests than it did before is the signal, which is why the counts above are recorded rather than just the pass/fail.
A second instance, caught in review rather than by the harness. Row 1 named a test
unknown_future_semantic_version_fails_closed that does not exist — the test had been renamed to
unknown_future_version_stays_fail_closed_past_the_legacy_migration_horizon and the table was not
updated. Nothing failed: a kill list is prose, so a stale name in it is invisible to every gate.
The row was corrected by re-running the mutation rather than by guessing which test the old name
referred to, which is how the true kill count turned out to be 10 rather than the 5 recorded. This
is the same failure mode as the no-op above, one level up: the harness can drift from the code, and
the record can drift from the harness.
Sender sequence regime (§§8–11 of the invariants)
The second axis. Each defect below is applied alone to the merged branch, the suite is run, and the files are restored. 8 of 8 killed.
Re-run in full after the branch was rebased onto 3c430ccb (#2521), because both the production and
test code changed underneath the branch. Counts below are from that run.
| # | defect introduced | outcome | kills | tests killed |
|---|---|---|---|---|
| M1 | a missing DURABLE_SIGNING_SEQUENCE capability resolves to DurableV1 |
KILLED | 6 | missing_durable_capability_resolves_to_unproven_not_durable, test_replay_attack_rejected, test_multiple_senders_independent, test_out_of_order_messages_forwarded, test_sequential_messages_forwarded, test_valid_signature_forwarded |
| M2 | an accepted sequence is stamped DurableV1 regardless of the window's established regime |
KILLED | 7 | legacy_sender_traffic_is_never_recorded_as_durable_v1, a_sender_that_stays_legacy_keeps_working_and_stays_tagged_legacy, cleanup_of_an_inactive_peer_does_not_prove_the_legacy_namespace_never_existed, crash_before_the_transition_marker_resumes_from_legacy_state, receiver_first_upgrade_migrates_the_sender_regime_end_to_end, current_semantic_state_is_restored_exactly_and_not_migrated, migration_runs_once_and_does_not_re_trigger_on_restart |
| M3 | LegacyOrUnproven → DurableV1 establishes directly, skipping the retirement hold |
KILLED | 15 | all of sender_regime_tests that depend on the hold, incl. receiver_first_upgrade_migrates_the_sender_regime_end_to_end, a_captured_legacy_envelope_must_not_poison_a_fresh_durable_namespace, first_contact_with_a_durable_sender_costs_exactly_one_hold, established_regime_survives_replay_state_cleanup, repeated_restarts_during_transition_never_shorten_the_hold |
| M4 | promotion no longer requires current authenticated DurableV1 evidence |
KILLED | 1 | transition_does_not_promote_when_the_peer_returns_without_the_capability |
| M5 | a downgrade resets the peer to unproven and clears its high-water | KILLED | 2 | a_stale_legacy_connection_cannot_downgrade_established_durable_state, receiver_first_upgrade_migrates_the_sender_regime_end_to_end |
| M6 | the transition is not written to the durable provenance record | KILLED | 1 | the_transition_is_recorded_in_the_durable_provenance_record |
| M7 | an unrecognised provenance value is read as LegacyOrUnproven |
KILLED | 1 | unknown_provenance_value_fails_closed_and_never_expires |
| M8 | the Hello current-certificate check is removed, so capabilities are not bound to the connection | KILLED | 3 | forged_hello_does_not_corrupt_established_peer_state, hello_replayed_onto_a_different_current_cert_is_rejected, weak_binding_verifier_is_confined_to_authorised_sites |
| M9 | the migration hold is no longer classified as a local fault, so held traffic is scored | KILLED | 1 | a_peer_held_through_the_migration_is_never_scored_quarantined_or_banned |
| M10 | DURABLE_SIGNING_SEQUENCE is redefined to alias POSTCARD_ENCODING's bit |
KILLED | 1 | capability_bits_are_pairwise_disjoint |
| — | control (pre and post) | — | 0 | green: 339 lib + 7 Hello + 7 signing_sequence_replay + 7 accept_handshake_cancellation |
10 of 10 killed, 0 survivors, 0 no-ops. M4–M10 kill exactly the property named and nothing else, which is what shows those tests discriminate rather than merely observing that something failed.
M9 was added late, because the property had no test at all. The handler classified
SenderRegimeTransition as a local fault and excluded it from scoring, and the reasoning for that
was written down — but nothing asserted it, so no mutation could fail and the gap was invisible to
the suite. This is the operational safety property of the whole migration: a hold refuses every
message for a full retirement horizon, which is strictly more traffic than the #2514 defect refused,
and #2514 turned 2060 violation series into 2333 bans against legitimate traffic. Had the hold
scored, the migration would have been worse than the bug it fixes.
The test carries its own non-vacuity control — a genuine replay from a second peer, on the same context and the same detector, must still score. Without it, "no violations were recorded" passes identically on a build that never consults the detector and on one that correctly classifies the hold, which are not the same build.
M10 was added because review found the aliasing test asserting a tautology. It read
others = all() & !bit; assert!((others & bit).is_empty()) — but & !bit removes bit from
others, so the intersection is empty whether or not the flag aliases anything. The old assertion
passes under M10 unchanged; it never tested the property in its own name.
The replacement lists the capabilities explicitly and checks them pairwise, which is deliberate and
not laziness avoided. iter_names() cannot be the oracle: if two constants shared a bit, the union
would carry that bit once and the iterator would yield only the first-declared name, so the defect
under test would hide itself from the test. A union == all() assertion keeps the hand-maintained
list honest — adding a capability without listing it fails rather than silently narrowing coverage.
The general lesson is narrower than "write better assertions": an assertion built by subtracting the thing under test from the set it is checked against can only ever be true. That shape is worth recognising directly, because it reads as thorough.
Harness self-correction: a restore that preserves mtime does not rebuild
The post-run control came back RED on exactly M8's two integration kills, against a tree git diff
confirmed was byte-identical to HEAD. The mutation results were not wrong; the control was.
The harness restored its snapshot with shutil.copy2, which preserves mtime. Cargo fingerprints
by mtime, so a file restored with its original — older — timestamp looks unchanged, and the crate is
not recompiled. The control therefore ran M8's still-mutated binary against restored sources. It
reproduced 10/10 at 41.95 s (two 20 s wait_for_peer timeouts); touching the three files with no
content change recompiled and returned 7 passed in 1.96 s.
M1–M8 are unaffected, and the reason is worth stating rather than assuming: applying a mutation
writes new content with a current mtime, which forces a recompile of the whole icn-net crate from
whatever is on disk at that moment — including the restored copies of the other two files. Only the
final control, where nothing was written at all, could go stale.
The general hazard generalises the one recorded above: a mutation harness can fail by not applying a
mutation, and equally by not un-applying one. Both report success. Snapshot restore must either
drop mtime (shutil.copy + os.utime(path, None)) or the control must be forced with an explicit
touch, and a control that fails deserves a diagnosis before the kills above are trusted.
Three of these survived the first pass, and that is the useful part
Recorded because the survivals located real gaps rather than proving the mutations wrong.
M1 survived because every ReplayGuard unit test supplies the sender regime as a parameter, so
none of them exercised the one place that derives it from peer_capabilities. The single point
where a missing capability could be read as durable had no coverage at all. Closed by
missing_durable_capability_resolves_to_unproven_not_durable and its positive twin — the twin
matters, because without it the negative test would pass on a build that hardcoded
LegacyOrUnproven and never read capabilities.
M7 survived because the unknown-regime test wrote its unrecognised value into the high-water
entry, which has its own catch-all. The provenance load path was untested. Closed by
unknown_provenance_value_fails_closed_and_never_expires, plus
corrupt_provenance_quarantines_rather_than_reading_as_absent for the adjacent case — an unreadable
record must not be read as an absent one, since absent is permissive enough to establish a fresh
durable namespace after a hold.
M6 survived twice. The transition is recorded in two places, so removing either left a restart
still entering a 600-second hold — and a fresh hold is behaviourally identical to a resumed one.
The first replacement test asserted "restarting holds" and so passed with the provenance write
deleted entirely. The property is only pinnable structurally: assert the record exists. The
redundancy is deliberate — the high-water entry is the one cleanup() deletes — but redundancy is
exactly what makes a behavioural mutation control vacuous, and that is worth remembering the next
time two records carry the same fact.