Invariants / DC-EPOCH-36

DC-EPOCH-36

DC derived enforced

After the epoch-boundary decision for a block at slot, the ledger's epoch MUST equal the venue era schedule's epoch for that slot. A disagreement in EITHER direction is a durable-state contradiction and fails closed as a typed LedgerError::EpochAgreement, halting the apply with ZERO mutation. This is strictly STRONGER than the detection it guards: detect_epoch_transition fires only on schedule > ledger, so it is structurally blind to a ledger AHEAD of the schedule. The check is a POST-condition (the epoch legitimately changes during the crossing), and an UNLOCATABLE slot -- before the schedule's first era, which the mainnet corpus contains -- makes the invariant unverifiable, NOT violated, preserving pre-P5 behaviour exactly. Enforcement is structural rather than remembered: detect_epoch_transition is pub(crate) with EXACTLY ONE non-test caller, rules::cross_epoch_boundary_for_slot, which pairs detection with the check.

Source

docs/clusters/PREPROD-ENTRY-AUTHORITY/SLICE-P5-epoch-agreement-and-venue-constant-containment.md

Introduced in
PREPROD-ENTRY-AUTHORITY-P5

Enforcement trace

Tests 4

  • epoch_agreement_rejects_a_stale_ledger_epoch
  • epoch_agreement_rejects_a_ledger_ahead_of_the_schedule
  • epoch_agreement_accepts_agreement
  • epoch_agreement_is_silent_on_an_unlocatable_slot

Cross-references

Evidence notes

Written directly from the P4 root cause (e1de7a2e), which measured what having NEITHER check costs: a preview store ran its entire life with ledger_epoch=1375 against schedule_epoch=1378 while a third authority (the epoch-accumulator) advanced correctly to 1378, and nothing compared them. The drift surfaced only three epochs later, as an opaque recovery FingerprintMismatch, when a binary that computed the epoch correctly replayed the store and applied the three skipped boundaries on the first replayed block. This invariant would have caught the originating P3 defect on the FIRST block on BOTH venues -- preview (473 vs 1375, frozen forever) and preprod (498 vs 304, phantom boundary) -- where a mainnet-shaped test corpus structurally could not. The gate was negative-tested three ways: making detect_epoch_transition bare pub, adding a second unpaired caller, and removing the check from the crossing point were each mutated and caught.