Skip to content

fix(validator_store): sign the decided AggregatorCommittee worklist once per slot - #1229

Merged
mergify[bot] merged 2 commits into
sigp:unstablefrom
shane-moore:fix/1227-aggregator-committee-decided-worklist
Aug 13, 2026
Merged

fix(validator_store): sign the decided AggregatorCommittee worklist once per slot#1229
mergify[bot] merged 2 commits into
sigp:unstablefrom
shane-moore:fix/1227-aggregator-committee-decided-worklist

Conversation

@shane-moore

@shane-moore shane-moore commented Aug 8, 2026

Copy link
Copy Markdown
Member

Problem, Evidence, and Context (Required)

At Boole, one QBFT decision can carry both attestation aggregates and sync contributions. Anchor
signed these from two separate Lighthouse callbacks. Each callback signed only its own object type.
But both sized the shared partial-signature batch from the full decided value.

If the decided value held an object type that the local Lighthouse never asked for, the batch never
filled. The operator then never sent its one post-consensus message. The duty failed for the whole
cluster, not just for that operator.

We first saw this on a mixed Anchor and go-ssv network. On ssv-mini it breaks about 1.2% of
post-fork AggregatorCommittee duties, so it is a steady loss on any mixed network.

Closes #1227.

Change Overview (Required)

Anchor now builds the signing list from the decided value instead of from what Lighthouse asked
for. Local state (share, cluster status, metadata) only filters that list.

The slot pipeline starts one signing job per committee when it publishes the decided value. This is
the only place that starts a job, so one committee sends exactly one message per slot. The two
callbacks now only read the result.

The batch size is the number of entries Anchor actually submits, so local filtering can always
reach it.

Read aggregator_post_consensus.rs first, then the trigger in update_aggregation_assignments,
then the two callbacks.

The signature collector is untouched, and collect_signature is byte-identical to before, so
pre-Boole paths cannot change.

Risks, Trade-offs, and Mitigations (Required)

Two behavior changes to be aware of:

  • QBFT for AggregatorCommittee now starts at 2/3 slot from the pipeline. Before, it started when
    the first callback arrived.
  • A per-root timeout now counts as OTHER_ERROR instead of TIMEOUT. This matches the existing
    BeaconVote path, and it is visible on dashboards.

Blast radius is validator_store plus one deleted helper in ssv_types.

Anchor signs every decided entry that it holds a share for. go-ssv also filters by local duty and
signs fewer. Signing extra roots is safe here, because peers do not check completeness for this
duty, and one validator stays inside the 5-root cap.

Validation (Required)

66 unit tests, plus make lint, make cargo-fmt-check, and a full release make test, all clean.

Live A/B on ssv-mini with 2 Anchor and 2 go-ssv operators at a 3-of-4 threshold. Everything was
identical between the two arms except the Anchor build.

Old code This PR
Post-fork AggregatorCommittee duties 667 1996
Failed duties 8 0

Every one of the 8 failures looks the same. Only the two go-ssv operators sent a post-consensus
message. Both Anchor operators sent nothing, which left 2 of 4 partials against a threshold of 3.
On this branch that never happened once.

We also restarted an operator after the fork, because this PR keeps the signing jobs in memory. The
node misses 4 slots and then recovers on its own, with no stuck batch.

Rollback (Required for behavior or runtime changes; optional otherwise)

Revert the commits. There are no config, CLI, database, or wire-format changes, and nothing new is
written to disk.

Blockers / Dependencies (Optional)

None.

🤖 Generated with Claude Code

@codecov-commenter

codecov-commenter commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.56284% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.64%. Comparing base (82bac14) to head (09b0210).

Files with missing lines Patch % Lines
...r/validator_store/src/aggregator_post_consensus.rs 93.15% 21 Missing ⚠️
anchor/validator_store/src/lib.rs 89.24% 10 Missing ⚠️
...tor_store/src/testing/aggregator_post_consensus.rs 99.81% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           unstable    #1229      +/-   ##
============================================
+ Coverage     65.42%   66.64%   +1.21%     
============================================
  Files           164      166       +2     
  Lines         28075    28798     +723     
============================================
+ Hits          18367    19191     +824     
+ Misses         9708     9607     -101     
Flag Coverage Δ
rust 66.64% <96.56%> (+1.21%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…nce per slot

Both Boole post-consensus callbacks sized the committee partial-signature
batch from the full decided value but each signed only its own object class,
so any decided entry whose class the local Lighthouse did not request left the
batch permanently under-filled and the operator's single post-consensus
message was never sent. The whole duty then failed cluster-wide.

Drive the signing from the decided value instead. The slot pipeline starts one
execution per committee when it publishes that value at 2/3 slot, so exactly
one message per (committee, slot) holds by construction rather than by locking,
and the two Lighthouse callbacks become lookups that filter the shared outcome
for the roots they asked for. The batch size is the number of entries actually
submitted, so it is always reachable by local filtering.

This reuses the crate's existing signing pipeline rather than duplicating it:
SigningRequest and resolve now key results by (validator index, signing root)
so one validator can hold five distinct roots, and collect_prepared_signatures
shares the drain helper with the new path. collect_signature is unchanged.

Refs sigp#1227
@shane-moore
shane-moore force-pushed the fix/1227-aggregator-committee-decided-worklist branch from 02c334a to 444be35 Compare August 8, 2026 14:32
@shane-moore

Copy link
Copy Markdown
Member Author

@claude, please take a look

@claude-code-actions-sigp

claude-code-actions-sigp Bot commented Aug 8, 2026

Copy link
Copy Markdown

Claude finished @shane-moore's task in 11m 38s —— View job


Review: PR #1229

  • Gather context (diff vs origin/unstable)
  • Review aggregator_post_consensus.rs (new subsystem)
  • Review lib.rs changes (trigger + callbacks + drain_signatures)
  • Review ssv_types/consensus.rs deletion
  • Review tests + metrics
  • Post review feedback

The core fix is well-reasoned and the diagnosis is convincing: deriving the signing worklist from the decided value (filtered only by local share/liquidation/metadata) and sizing the committee batch from what this operator actually submits directly removes the "shared batch sized from the full union, filled by only one class" bug. Keying resolve/CollectedSignatures by (index, root) is the right change to support a validator holding multiple roots. drain_signatures correctly isolates a non-quorum root to itself. The AggregatorCommitteeConsensusData::post_consensus_signature_count deletion is clean — no dangling references remain, and SpecificError/Error derive Clone, so post_consensus_outcome's (*e).clone() is sound. The target.epochslot.epoch() domain change is spec-correct and equivalent given the enforced aggregate_slot == slot check.

Two points, both non-blocking:

1. insert replaces on re-publish — the "exactly once by construction" invariant depends on one write per (committee, slot) (aggregator_post_consensus.rs:129). If Phase 3 ever publishes twice for the same slot (clock catch-up/drift, re-publish), the second insert overwrites the entry and spawns a fresh QBFT round plus a second set of detached signing tasks; the first set is already detached via spawn_handle and keeps running, so two committee messages could go out. If a duplicate same-slot publish is genuinely impossible, a one-line comment stating that would lock in the invariant; otherwise making registration idempotent (vacant-entry-only) closes it. Inline suggestion posted.

2. Stale field doc (lib.rs:246-252). The aggregator_post_consensus field still documents the old callback-triggered design ("the first Lighthouse callback ... spawns the execution"), which this PR replaces with deterministic start from update_aggregation_assignments. Worth updating so the documented invariant matches the code. Inline suggestion posted.

Test coverage is strong for the module (both callback orders, dropped-future, five-root validator, missing/duplicate/conflicting/slot-mismatched decided entries). Neither point above is exercised by a test; #1 in particular would only surface at runtime.

I did not run make lint / make test in this review (read-only analysis); the PR reports both clean.

Comment on lines +121 to +130
for (&committee_id, decided_data) in &assignments.consensus_data_by_ssv_committee {
let store = Arc::clone(self);
let decided_data = Arc::clone(decided_data);
let execution = self.spawn_shared("aggregator_post_consensus", async move {
store
.run_aggregator_post_consensus(committee_id, slot, decided_data)
.await
});
executions.insert((committee_id, slot), execution);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The "exactly one committee message per (committee, slot) by construction" invariant leans on this being the sole writer and on each (committee, slot) being written once. insert replaces any existing execution, so a second publish for the same slot (Phase 3 firing twice under clock catch-up/drift, a re-publish, etc.) spawns a fresh QBFT round and a second full set of detached signing tasks — the old tasks are already detached via spawn_handle and keep running, so you'd get two committee messages, defeating the "by construction" guarantee.

If a duplicate publish for the same slot is genuinely impossible, worth a one-line comment saying so. Otherwise consider making the registration idempotent so a re-publish is a no-op:

Suggested change
for (&committee_id, decided_data) in &assignments.consensus_data_by_ssv_committee {
let store = Arc::clone(self);
let decided_data = Arc::clone(decided_data);
let execution = self.spawn_shared("aggregator_post_consensus", async move {
store
.run_aggregator_post_consensus(committee_id, slot, decided_data)
.await
});
executions.insert((committee_id, slot), execution);
}
for (&committee_id, decided_data) in &assignments.consensus_data_by_ssv_committee {
if let Entry::Vacant(slot_entry) = executions.entry((committee_id, slot)) {
let store = Arc::clone(self);
let decided_data = Arc::clone(decided_data);
let execution = self.spawn_shared("aggregator_post_consensus", async move {
store
.run_aggregator_post_consensus(committee_id, slot, decided_data)
.await
});
slot_entry.insert(execution);
}
}

Comment thread anchor/validator_store/src/lib.rs Outdated
Comment on lines +246 to +252
/// Once-only post-consensus signing executions for `AggregatorCommittee` duties (Boole+).
///
/// One entry per `(committee, slot)`. The first Lighthouse callback (aggregate or
/// contribution class) spawns the execution as a detached task; concurrent and later
/// callbacks of either class join the same `Shared` future, so the complete decided
/// worklist is signed and batched exactly once no matter which callbacks fire, in what
/// order, or whether their futures are dropped.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This doc still describes the old callback-triggered design ("The first Lighthouse callback ... spawns the execution as a detached task; concurrent and later callbacks of either class join the same Shared future"). Per the PR itself, executions are now started deterministically in update_aggregation_assignments before the watch publish, and callbacks only look up results — they never spawn. Suggest updating so the invariant readers rely on matches the code:

Suggested change
/// Once-only post-consensus signing executions for `AggregatorCommittee` duties (Boole+).
///
/// One entry per `(committee, slot)`. The first Lighthouse callback (aggregate or
/// contribution class) spawns the execution as a detached task; concurrent and later
/// callbacks of either class join the same `Shared` future, so the complete decided
/// worklist is signed and batched exactly once no matter which callbacks fire, in what
/// order, or whether their futures are dropped.
/// Once-only post-consensus signing executions for `AggregatorCommittee` duties (Boole+).
///
/// One entry per `(committee, slot)`, started by `start_aggregator_post_consensus` from
/// `update_aggregation_assignments` before the assignments are published. Lighthouse
/// callbacks only look results up via the shared future; they never spawn work, so the
/// complete decided worklist is signed and batched exactly once no matter which callbacks
/// fire, in what order, or whether their futures are dropped.

Registering an AggregatorCommittee execution overwrote any existing entry for
the same (committee, slot). The slot pipeline publishes once per slot today, so
this was not reachable, but it moved the exactly-once guarantee out of this
module and into another module's timing loop. Overwriting would spawn a second
QBFT round and a second set of detached signing tasks while the first set kept
running, putting two post-consensus messages on the wire for one slot, which
peers reject with a gossip penalty.

Register only into a vacant entry so exactly-once holds here regardless of how
often the pipeline publishes, and correct the field doc, which still described
the superseded callback-triggered design.

Raised in review of sigp#1229.
@shane-moore

shane-moore commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

@claude, both points addressed in 09b0210.

1. Idempotent registration. Fixed. I traced the Phase 3 loop (`metadata_service.rs:334-352`): it sleeps `duration_to_next_slot + 2/3 slot` each iteration, so a same-slot double publish is not reachable today, and an overrun skips a slot rather than repeating one. But the objection holds regardless of reachability: insert moved the exactly-once guarantee out of this module and into another module's timing loop, where a future re-publish path would break it silently and expensively. Registration is now vacant-entry-only.

Worth noting this was a regression I introduced: the earlier callback-triggered version of this code used or_insert_with, and the property was lost when the trigger moved to the slot pipeline.

2. Stale field doc. Fixed, it now describes the pipeline-triggered design and states that callbacks only read the map.

On the missing test coverage, which was the most useful part of the review: added republished_assignments_do_not_resubmit. I verified it actually catches the regression rather than merely passing, by reverting to insert and confirming it fails, then restoring. 66 tests pass, fmt and lint clean.

@claude-code-actions-sigp

claude-code-actions-sigp Bot commented Aug 8, 2026

Copy link
Copy Markdown

Claude finished @shane-moore's task in 1m 8s —— View job


Re-review: PR #1229 (commit 09b0210)

  • Verify idempotent registration fix (aggregator_post_consensus.rs)
  • Verify stale doc fix (lib.rs)
  • Verify new test republished_assignments_do_not_resubmit
  • Post verdict

Both points are resolved correctly. Verified against the commit:

1. Idempotent registrationaggregator_post_consensus.rs:128 now registers only into a Entry::Vacant, so a same-(committee, slot) re-publish is a no-op rather than overwriting a live entry and detaching a second QBFT round + signing set. Entry is already imported (aggregator_post_consensus.rs:15), and no overwriting executions.insert(...) remains in the module. The inline comment captures why (two committee messages → gossip penalty) and correctly notes the single-publish behavior lives in another module's timing loop, so keeping this vacant-only is the right place to enforce the invariant. Good catch on this being a regression from the or_insert_with original — the commit message records that.

2. Stale field doclib.rs:245-253 now describes the pipeline-triggered design: registration by update_aggregation_assignments before publish, vacant-only single writer, callbacks read-only via the Shared future. Matches the code.

3. Test coveragerepublished_assignments_do_not_resubmit is a real regression guard, not a tautology. It routes through seed_decided_valueupdate_aggregation_assignments at the same TEST_SLOT (testing/aggregator_post_consensus.rs:119-135), i.e. the actual registration path, then asserts distinct_roots == MIXED_WORKLIST_SIZE and batch identity after the second publish. Your reported check — reverting to insert makes it fail, restoring makes it pass — is exactly the right way to confirm it bites.

Note: I confirmed all three by source inspection; I was not able to execute cargo test / make lint in this session (command approval denied), so I'm relying on your reported "66 tests pass, fmt and lint clean" for the runtime result.

No further concerns. LGTM.
· fix/1227-aggregator-committee-decided-worklist

@petarjuki7 petarjuki7 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good!

@mergify

mergify Bot commented Aug 13, 2026

Copy link
Copy Markdown

Queued — the merge queue status continues in this comment ↓.

@mergify

mergify Bot commented Aug 13, 2026

Copy link
Copy Markdown

Merge Queue Status

This pull request spent 30 minutes 59 seconds in the queue, including 28 minutes 34 seconds running CI.

Required conditions to merge

@mergify mergify Bot added the queued label Aug 13, 2026
@mergify
mergify Bot merged commit c332d6a into sigp:unstable Aug 13, 2026
24 checks passed
@mergify mergify Bot removed the queued label Aug 13, 2026
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.

3 participants