Skip to content

node: promotion enforces Raft's election restriction — a majority must vouch for the candidate (#240) - #342

Merged
allamiro merged 5 commits into
mainfrom
feat/240-election-restriction
Aug 11, 2026
Merged

node: promotion enforces Raft's election restriction — a majority must vouch for the candidate (#240)#342
allamiro merged 5 commits into
mainfrom
feat/240-election-restriction

Conversation

@allamiro

@allamiro allamiro commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Part (a) of #240's remainder — the §5.4.1 election restriction. Does NOT close the issue: item 5 (the signed transition record) remains open by design — it is evidence, not safety, and the ROADMAP frames it as its own design conversation (the #265 lesson: design first).

The open question, answered in the negative

The #265 postmortem left one safety question stated rather than closed: "Whether the fence plus the acknowledged-records bound fully substitute for the election restriction is the open question." They do not, and the postmortem's own replay shows it: A acknowledges a record on {A, B} and dies; B answers 101, candidate C answers 100. The k-th-largest floor computes to 100, C holds it, every existing gate passes — and B's acknowledged record is reconciled away under C's published boundary. (#266 narrowed this by making HWM updates non-droppable, but a leader can die before propagation; the follower-side bound cannot be made reliable against death.)

The restriction, in Raft's per-voter form

establish now requires a majority of the fenced, answering replicas to be at or below the candidate's own offset — each such replica is one that would have granted the vote. Why this and not candidate-holds-the-maximum:

  • Safety: any acknowledged record's quorum intersects every vote quorum, so a candidate a majority can vouch for holds every acknowledged record. The replay above is refused (votes: 1, required: 2), naming B as the replica that must win.
  • Availability: at RF 5, a candidate with a majority at or below it may lead even though one fenced replica is ahead — the record making it ahead was never acknowledged (its quorum would have needed three), and the stricter form would refuse a legitimate leader for nothing. Pinned by test.

The new Promotion::CandidateBehindVoters refusal carries the vote arithmetic and the most complete replica — the remedy pointer. The lease agent handles it exactly as LeaderBehind: suspend, not demote, so the epoch stays grantable to the replica the refusal names.

Tests

  • a_candidate_a_fenced_replica_would_refuse_the_vote_is_not_promoted — the node,broker: prove a boundary under the new epoch before publishing it (#240) #265 hazard replay, refused (REGRESSION-shape doc).
  • the_replica_the_refusal_names_promotes_over_the_same_probes — the remedy works on identical probes.
  • a_majority_at_or_below_the_candidate_promotes_despite_a_more_complete_minority — the per-voter form's availability guarantee at RF 5.
  • All pre-existing promotion tests pass unchanged — the gate is strictly additional.
  • Scenario 09 live: PASS — its promotion of the most caught-up follower is precisely the candidate the new gate blesses, and the module docs now name both gates.

63 vtop-node tests, clippy -D warnings, fmt — all green.


Summary by cubic

Enforces Raft §5.4.1 during promotion and adds a stand-aside policy after eligibility refusals. This prevents truncating acknowledged records and lets the most complete replica win the lease (part of #240); the hold-off now clears as soon as another replica holds the range.

  • Bug Fixes
    • Apply per-voter election restriction in establish: require a majority of fenced replicas at or below the candidate’s offset.
    • Add Promotion::CandidateBehindVoters with votes, required, and most_complete; votes use post-reconciliation offsets; docs explain why that’s correct and why DivergesAt cannot delete acknowledged records (by induction). The below-HWM guard remains for pre-restriction histories. The lease agent stands aside for two lease lifetimes on this and LeaderBehind; quorum misses do not trigger the hold-off; one verdict funds one hold-off; the hold-off clears when another replica is observed holding the lease.
    • Pin tests for the hazard replay, its remedy, RF=5 availability, stand-aside requests, and hold-off clearing.

Written for commit a29ec1c. Summary will update on new commits.

Review in cubic

…t vouch for the candidate (#240)

The #265 postmortem left one safety question open, stated rather than
closed: whether the fence plus the acknowledged-records bound fully
substitute for Raft §5.4.1, given that the lease is granted with no
log-completeness condition and LeaderBehind only refuses a candidate
behind the proven floor — which can itself sit below an acknowledged
record. They do not substitute, and the postmortem's own replay shows
it: A acknowledges on {A,B} and dies; B answers 101, candidate C
answers 100; the k-th largest floor computes to 100, C holds it, every
existing gate passes, and B's acknowledged record is reconciled away
under C's boundary.

establish now applies the restriction in Raft's PER-VOTER form: only
replicas at or below the candidate's own offset would have granted it
the vote, and a majority of grants is what makes promotion safe — any
acknowledged record's quorum intersects every vote quorum, so a
candidate a majority can vouch for holds every acknowledged record.
Deliberately not candidate-holds-the-maximum: at RF 5 a candidate with
a majority at or below it may lead even though one fenced replica is
ahead, because the record making it ahead was never acknowledged, and
the stricter form would trade availability for nothing. The refusal
names the most complete replica — the one the lease should go to — and
the lease agent suspends rather than demotes, exactly as LeaderBehind
does, so the epoch stays grantable to that replica.

The hazard replay is pinned as a test, alongside its remedy (the named
replica promotes over the identical probes) and the availability
guarantee. Scenario 09 passes live — its promotion of the most
caught-up follower is precisely the candidate the new gate blesses.

Item 5 of #240 — the signed transition record — remains open by
design: it is evidence, not safety, and the ROADMAP frames it as its
own design conversation.
@cursor

cursor Bot commented Aug 11, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc75b206db

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +245 to +248
let votes = answered
.values()
.filter(|offset| **offset <= candidate_offset)
.count();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Evaluate voters before destructive reconciliation

In the real ReplicaPlaneProbe::probe path, these offsets are not the voters' pre-election log positions: InProcessFollower::fence calls reconcile_with before reading and returning local_committed_offset. In the motivating case where A acknowledged offset 100 on {A, B} but died before propagating the high-water mark to B, candidate C's fence can therefore truncate B from 101 to 100 because B does not know that record was acknowledged; this filter then counts both B and C as votes and returns Established after the acknowledged record has already been deleted. The election restriction must evaluate the follower's position before candidate-driven reconciliation can alter it, or the follower must explicitly refuse/truncate only after voting.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified against the code, and the premise does not hold for the motivating scenario. The fence does reconcile before answering (#263, confirmed at replication/mod.rs — adopt, reconcile, then read offsets) — but reconciliation cannot truncate B in the A-acks-on-{A,B}-and-dies case, because B and C hold IDENTICAL epoch vectors there: compare_lineage explicitly treats a same-epoch prefix relationship as NOT divergence ('the shorter replica has simply recorded less, and nothing here proves anything about the records beyond its last entry'), answers Agreed, and reconcile_with returns without touching the log. B answers 101 untruncated, and the new gate refuses C — the pinned test models exactly the post-fence numbers that path produces. Where truncation-before-answering DOES happen — genuine lineage divergence — a pre-truncation offset would be the WRONG vote input: offsets are only comparable within an agreed lineage, and counting bytes from a contradicted leadership line as log-completeness is the exact mistake epoch qualification (#258) exists to prevent; Raft's own (term,index) comparison encodes the same judgment. The residual interplay — an acknowledged record above a stale HWM at a genuinely diverged position — is the #264/#266 HWM-propagation arc, guarded by TruncationBelowAcknowledged failing the fence loudly, and orthogonal to the election restriction. The rationale is now in the restriction's comment (12a7a4a) so the next reader doesn't have to re-derive it.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/vtop-node/src/promotion.rs
Comment thread crates/vtop-node/src/lease_agent.rs Outdated
…e point

Review round one (Codex + cubic, same P1): the concern was that the
fence reconciles before it answers, so the replica ahead of the
candidate could be truncated down before its vote is counted — passing
the restriction in the exact scenario it exists to refuse. Verified
against the code, the premise does not hold for that scenario: a
same-epoch prefix relationship is not divergence — compare_lineage
answers Agreed and reconciliation touches nothing — so the replica
ahead answers with its full offset and the candidate is refused. And
where truncation before answering DOES happen, genuine lineage
divergence, a pre-truncation offset would be the wrong vote input:
offsets are only comparable within an agreed lineage, and counting
bytes from a contradicted leadership line as log-completeness is the
mistake epoch qualification exists to prevent. The rationale now lives
in the restriction's comment. The warn line's embedded indentation is
fixed alongside (cubic P3).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12a7a4a346

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +246 to +251
// These are POST-RECONCILIATION offsets — the fence reconciles before it
// answers (#263) — and that is correct, not a leak in the restriction.
// A same-epoch prefix relationship is not divergence (`compare_lineage`
// answers Agreed and reconciliation touches nothing), so the replica
// ahead of the candidate still answers with its full offset and refuses
// the candidate here. Truncation before answering happens only on

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Evaluate divergent voters before reconciling them

When the surviving voter and candidate have genuinely divergent epoch histories, this post-reconciliation vote can still delete an acknowledged record: InProcessFollower::fence maps Lineage::DivergesAt to truncate_to before reading the returned offset, and the truncation guard only knows the follower's possibly stale high-water mark. Thus, if the old leader acknowledged offset 100 on itself and this voter but died before propagating the HWM, a divergent candidate can truncate the voter to 100, after which both replicas satisfy this predicate and establish a quorum despite the acknowledged record being gone. The newly added rationale only establishes that a prefix relationship is not truncated; the explicit DivergesAt path is fresh evidence that the destructive ordering remains. Divergent replicas must evaluate log freshness before candidate-driven reconciliation, or any replica truncated during probing must not grant the candidate a vote.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that the DivergesAt path truncates before the vote is read, and that its guard only knows a possibly-stale HWM — verified. What makes it safe is not the guard but reachability, and 3fc7d07 commits the argument into the module doc: a record acknowledged on a quorum intersects every later epoch's fence-majority, so with this gate in force at EVERY promotion, the intersecting voter refuses any candidate whose log lacks the record. By induction, every granted epoch's leader holds every previously acknowledged record, every subsequent leadership line contains them, and any suffix a voter loses to DivergesAt was written under a superseded line and never acknowledged. Divergence below an acknowledged record therefore requires a promotion that already violated the restriction — the pre-#263 bugs, or a history predating this gate — which is exactly what the below-HWM guard remains the loud last resort for. Your proposed reordering (a fence that votes before it reconciles) would make the property LOCAL rather than inductive, and it is the right shape for that — but it is a replica-plane protocol change, and it belongs to #240's remaining design conversation rather than riding on this bounded slice; the doc says so explicitly, and I'll record it on the issue so it isn't lost.

…cord — by induction

Review round two (Codex): the DivergesAt path does truncate before the
vote is read, and its guard only knows a possibly-stale high-water
mark. What makes that safe is not the guard — it is that the hazard
state is unreachable through restricted promotions. A record
acknowledged on a quorum intersects every later epoch's fence-majority,
so with this gate at every promotion the intersecting voter refuses any
candidate whose log lacks the record; every granted epoch's leader
therefore holds every previously acknowledged record, every subsequent
leadership line contains them, and any suffix a voter loses to
DivergesAt was written under a superseded line and never acknowledged.
Divergence below an acknowledged record requires a promotion that
already violated the restriction — the pre-#263 bugs, or a history
predating this gate — and the below-HWM guard remains the last resort
for exactly those. The local (non-inductive) form — a fence that votes
before it reconciles — is a protocol reordering for #240's remaining
design conversation, stated in the doc rather than smuggled into this
slice.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3fc7d07efb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

);
// Retryable for the same reason as LeaderBehind: the
// right fix is a different candidate, and suspending
// leaves the epoch grantable to it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent an ineligible candidate from reacquiring the lease

When this stable refusal occurs because a majority is genuinely ahead, suspending only stops renewal; after the lease expires, the same node immediately returns to the ordinary acquisition race and can repeatedly reacquire before the most_complete replica. Since a suspended candidate receives no replication with which to become eligible, consistent poll timing or lower metadata latency can leave the range unavailable indefinitely despite a valid candidate being online. Suppress this node's reacquisition until another holder/epoch is observed, or otherwise give the identified eligible replica an opportunity to acquire.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, and fixed in cbd1c6c — with the note that the starvation was pre-existing for LeaderBehind and this variant merely made it visible, so the fix covers both. An eligibility refusal (either behind-variant, NOT a quorum miss — that is a verdict nobody reached) now requests a stand-aside: the agent skips the campaign for two lease lifetimes of poll rounds, giving the replica the refusal named uncontested acquisitions however the two agents' polls interleave. Deliberately BOUNDED rather than suppressed-until-another-holder: if the eligible replica is down, someone must keep probing, and the refusal repeating on a duty cycle is the honest unavailability signal — a suppression waiting for a holder that never comes would wedge the range on a second failure. Pinned by an_eligibility_refusal_requests_a_stand_aside_and_a_quorum_miss_does_not.

…nnot serve

Review round three (Codex P2), verified real — and pre-existing for
LeaderBehind, which the new variant merely made visible: suspend only
stops renewal, so a refused candidate re-enters the acquisition race,
can keep beating the replica its own refusal named to the CAS, holds a
lease it cannot serve, lapses it, and wins again — while receiving no
replication with which to become eligible. Consistent poll timing can
starve an eligible, online replica indefinitely.

An eligibility refusal — LeaderBehind or the §5.4.1 vote check, NOT a
quorum miss, which is a verdict nobody reached — now requests a
stand-aside: the agent skips the campaign for two lease lifetimes of
poll rounds, giving the named replica uncontested acquisitions however
the two agents' polls interleave. BOUNDED, deliberately, not
until-another-holder-is-observed: if the eligible replica is down,
someone must keep probing, and the refusal repeating on a duty cycle is
the honest unavailability signal — a suppression that waits for a
holder that never comes would wedge the range on a second failure.

Pinned: the verdict requests the stand-aside and a quorum miss does
not; one verdict funds one hold-off.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cbd1c6c500

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +846 to +847
if self.campaign_hold_off_rounds > 0 {
self.campaign_hold_off_rounds -= 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the hold-off after another replica acquires

When the eligible replica successfully acquires during this hold-off, subsequent Wait rounds leave the counter untouched because it is decremented only in Acquire. If that holder later releases or fails—even long afterward—this node still skips the next two lease lifetimes of acquisition rounds (30 seconds with the defaults), unnecessarily delaying an otherwise immediate failover. Clear the hold-off once a different holder/epoch is observed, since the stand-aside has then achieved its purpose.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid — fixed in the pushed commit: the Wait arm (exactly where this node observes a rival holding the range) now clears the hold-off, since a rival's acquisition is the stand-aside's purpose achieved. A later failure of that holder is answered by an immediate campaign; if this node is still behind at that point, the refusal and a fresh hold-off will say so.

…chieved

Review round four (Codex P2): the hold-off decremented only in Acquire
rounds, so a node that stood aside successfully — the eligible replica
took the lease — kept the residual counter through every Wait round,
and a failure of that holder even long afterward was answered by
serving out up to two lease lifetimes of leftover wait before
campaigning. The Wait arm, which is exactly where this node observes a
rival holding the range, now clears the hold-off: the stand-aside
exists to let an eligible replica win, and once one has, an immediate
campaign on the next vacancy is the correct posture — if this node is
still behind, the refusal and a fresh hold-off will say so.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant