Summary
The hprop_ledger_events_propose_new_constitution test is flaky because CLI query proposals reads from the pulsing snapshot, which lags the live ledger state by one epoch.
Combined with a 0% dvtUpdateToConstitution threshold, any vote triggers immediate ratification, creating a tiny window where vote assertions fail.
Root cause
Two facts interact to produce the race:
- Pulsing snapshot lag -
query proposals reads from the DRep pulsing snapshot, which is set once at the epoch boundary and not modified during pulsing. New proposals and votes only appear in this snapshot after the next epoch transition.
- Zero ratification threshold -
dvtUpdateToConstitution defaults to 0 %! 2 in Testnet.Defaults, meaning the proposal is ratified as soon as pulsing completes, regardless of votes.
On master, the test submits all 9 votes in a single transaction, then checks for ratification via foldEpochState, and only afterwards queries CLI proposals.
By this point the proposal may already have been ratified and removed from gov-state, causing length proposalsArray === 1 to fail on an empty array.
The vote count assertions (length proposalsDRepVotes === numVotes) similarly fail when the snapshot has not yet refreshed or the proposal is gone.
Current workaround (PR #6575)
PR #6575 reworked the test ordering - moving the CLI proposal-structure query before voting (race-free) and adding unless guards around the post-vote CLI assertions.
Reviewers (@Jimbo4350, @palas) correctly flagged the unless guards as weakening the test - they silently skip the vote-count checks whenever the snapshot is stale or the proposal is already ratified.
Proposed fix
A. Override Conway genesis per-test
Follow the pattern from the NoConfidence test: split createAndRunTestnet into createTestnetEnv + H.rewriteJsonFile + cardanoTestnet.
Override two parameters:
dvtUpdateToConstitution = 51 %! 100 - a 51% threshold, ensuring a minority of yes-votes cannot trigger ratification.
ucppGovActionLifetime = EpochInterval 4 - 80s lifetime at genesisEpochLength = 200, giving ample time for snapshot refresh. The master default of EpochInterval 1 (20s) is too tight.
No committee setup is needed.
With ucppCommitteeMinSize = 0 (the default), committeeAccepted returns True vacuously.
B. Stage votes in two rounds
- Round 1: Submit 3 yes votes out of 9 DReps = 33.3% of active stake. This is below the 51% threshold, so the proposal stays in gov-state indefinitely.
- Round 2: Submit +1 yes, +3 no, +2 abstain. The new ratio is 4 yes out of 7 participating (abstain DReps are excluded from the denominator) = 57.1%, which exceeds 51% and triggers ratification.
C. Deterministic assertions - no unless guards
- After round 1: Use
retryUntilJustM until the pulsing snapshot shows the proposal with exactly 3 DRep votes. Assert vote counts unconditionally. The proposal cannot be ratified at 33.3% < 51%, so it will always be present.
- After round 2: Use
retryUntilJustM until the snapshot shows the full vote counts (4 yes, 3 no, 2 abstain). Assert unconditionally, then verify ratification via the existing foldEpochState check.
D. Technical notes
- Vote ratio is stake-weighted: non-voting DReps count in the denominator, abstain DReps are excluded.
- The pulsing snapshot's proposals map is set once at the epoch boundary and not modified mid-epoch - only the vote tallies are updated as pulsing progresses.
- The
retryUntilJustM guard condition must match the expected postcondition exactly (proposal present AND vote count matches), otherwise the retry exits early and the assertion fails.
Acceptance criteria
Summary
The
hprop_ledger_events_propose_new_constitutiontest is flaky because CLIquery proposalsreads from the pulsing snapshot, which lags the live ledger state by one epoch.Combined with a 0%
dvtUpdateToConstitutionthreshold, any vote triggers immediate ratification, creating a tiny window where vote assertions fail.Root cause
Two facts interact to produce the race:
query proposalsreads from the DRep pulsing snapshot, which is set once at the epoch boundary and not modified during pulsing. New proposals and votes only appear in this snapshot after the next epoch transition.dvtUpdateToConstitutiondefaults to0 %! 2inTestnet.Defaults, meaning the proposal is ratified as soon as pulsing completes, regardless of votes.On master, the test submits all 9 votes in a single transaction, then checks for ratification via
foldEpochState, and only afterwards queries CLI proposals.By this point the proposal may already have been ratified and removed from gov-state, causing
length proposalsArray === 1to fail on an empty array.The vote count assertions (
length proposalsDRepVotes === numVotes) similarly fail when the snapshot has not yet refreshed or the proposal is gone.Current workaround (PR #6575)
PR #6575 reworked the test ordering - moving the CLI proposal-structure query before voting (race-free) and adding
unlessguards around the post-vote CLI assertions.Reviewers (@Jimbo4350, @palas) correctly flagged the
unlessguards as weakening the test - they silently skip the vote-count checks whenever the snapshot is stale or the proposal is already ratified.Proposed fix
A. Override Conway genesis per-test
Follow the pattern from the
NoConfidencetest: splitcreateAndRunTestnetintocreateTestnetEnv+H.rewriteJsonFile+cardanoTestnet.Override two parameters:
dvtUpdateToConstitution = 51 %! 100- a 51% threshold, ensuring a minority of yes-votes cannot trigger ratification.ucppGovActionLifetime = EpochInterval 4- 80s lifetime atgenesisEpochLength = 200, giving ample time for snapshot refresh. The master default ofEpochInterval 1(20s) is too tight.No committee setup is needed.
With
ucppCommitteeMinSize = 0(the default),committeeAcceptedreturnsTruevacuously.B. Stage votes in two rounds
C. Deterministic assertions - no
unlessguardsretryUntilJustMuntil the pulsing snapshot shows the proposal with exactly 3 DRep votes. Assert vote counts unconditionally. The proposal cannot be ratified at 33.3% < 51%, so it will always be present.retryUntilJustMuntil the snapshot shows the full vote counts (4 yes, 3 no, 2 abstain). Assert unconditionally, then verify ratification via the existingfoldEpochStatecheck.D. Technical notes
retryUntilJustMguard condition must match the expected postcondition exactly (proposal present AND vote count matches), otherwise the retry exits early and the assertion fails.Acceptance criteria
unlessguards or other conditional skips around assertions.query proposalsvote counts are checked unconditionally in both rounds.Testnet.Defaults- all overrides are local to this test.