# grandine-fc-order — VERDICT CONFIRMED (part b only; part a REFUTED)

The combined candidate survives in a narrower form: **Grandine publishes a post-import head before applying the block's PTC attestations**, although these attestations are part of the specified `on_block` operation. This can expose a different branch until the separate task result is handled. **The claim that a delayed index-1 attestation must never subsequently count is refuted**: the inherited attestation handler expressly allows later processing, and Grandine revalidates with the block-origin flag intact. No whole-block rejection follows from the separate attestation handler's initial failure. These conclusions concern Gloas/EIP-7732 at the supplied pins, not an observed devnet split. Sources: [spec handlers][FC], [inherited handler][P0], [block import/publication][M], [task implementation][T].

## Scope and versions

Reviewed public raw source at Grandine **52cd677117b5d93bf477fe4843148070b0303444**, consensus-specs **8a80ffa8**, and EIPs **d4c57552**. The [pinned Glamsterdam meta EIP][META] schedules EIP-7732; the [pinned EIP][E7732] is Last Call, with deadline 2026-11-01. The normative comparison here is the supplied Gloas fork-choice revision, including inherited Phase 0 behavior. This bounded item does not repeat the parent job's all-EIP/all-client inventory or investigate EL gas rules.

Gloas is not activated on mainnet in the pinned [mainnet configuration][MAINNET] (`GLOAS_FORK_EPOCH` is the far-future value), and the pinned meta EIP leaves mainnet activation blank. The surviving path depends on Gloas block payload attestations; this review does not identify a live-mainnet candidate requiring withheld details. Review date: 2026-10-27. No node, RPC, devnet connection, client execution, or transaction submission was used.

## Strongest refutation attempt: a spec assertion is not a permanent discard

The supplied index-1 check is real. In `specs/gloas/fork-choice.md#L938-L944`:

```python
if attestation.data.index == 1:
    assert is_payload_verified(store, attestation.data.beacon_block_root)
```

It applies for block-origin attestations too. But the inherited `on_attestation` at `specs/phase0/fork-choice.md#L1013-L1030` says:

> An `attestation` that is asserted as invalid may be valid at a later time, consider scheduling it for later processing in such case.

This directly defeats the candidate's inference that the spec *never* records the vote. A failed call cannot update the fork-choice store (Phase 0 lines 97–99); a later call after verification is a different, potentially valid call. Both handlers and the retry guidance are at the same consensus-specs pin. [Gloas validation][FC-validation]; [Phase 0 handler and exception rule][P0].

### Actual retry path and the block-origin rule

All following Grandine locations are at **52cd677117b5d93bf477fe4843148070b0303444**:

1. `fork_choice_control/src/tasks.rs#L390-L403` wraps included LMD attestations as `AttestationOrigin::Block(block_root)` and invokes validation. `fork_choice_store/src/store.rs#L2881-L2900` derives `is_from_block` from that origin and returns `DelayUntilPayload` if appropriate; lines 3206–3218 contain the cited TODO and delay branch. [Tasks][T]; [store validation][S-validation].
2. `fork_choice_control/src/mutator.rs#L1647-L1654` queues these attestations for ordinary imported blocks, while dropping them for `from_test_block`. This is an explicit test-harness distinction, not a production rejection. The helper at lines 4262–4282 stores the original attestation item in `delayed_until_envelope`, or immediately retries if verification already happened. [Mutator][M].
3. Envelope acceptance applies the envelope and publishes a snapshot, then removes and retries waiting objects: `mutator.rs#L3812-L3852`. `retry_delayed` forwards the original attestation at lines 4729–4730. `retry_attestation` at lines 4787–4803 does not replace its origin. [Envelope/retry path][M-retry].
4. `AttestationTask::run`, `tasks.rs#L338-L358`, calls `validate_attestation(attestation, false)` on the new snapshot. Here `false` means **do not skip signature verification**, not `is_from_block=false`. The block-origin flag is independently recomputed in `store.rs#L2886-L2887`; `misc.rs#L780-L789` identifies `Block(_)` as block-origin. The index-1 verification check runs again and now succeeds if the envelope is verified. Other applicable checks still run. Accepted votes reach `apply_attestations` (`mutator.rs#L1587-L1597`, 1732–1757) and `apply_attestation_batch` (`store.rs#L4758-L4768`). [Tasks][T]; [origin definition][ORIGIN]; [store][S]; [mutator][M].

**Whole-block question:** the spec's `on_block` does not invoke `on_attestation` for included LMD attestations. Its `state_transition` uses state-transition attestation validity instead. The Gloas `process_attestation` at `beacon-chain.md#L2396-L2480` and participation helper at lines 1339–1386 do not require a locally received envelope for every index-1 vote. A payload mismatch can affect head participation without invalidating the block. Therefore an initially unprocessable LMD fork-choice vote does **not** imply that `on_block` should reject the containing block. [Gloas block processing][BC]; [fork-choice on_block][FC-block].

**Other-check/invariant route:** `store.rs#L2189-L2196` delays a block that extends its own parent's FULL node until that parent's envelope is verified. This only constrains that parent. An included attestation may name a different or older block R; there is no general invariant that every such R's envelope was received locally. Thus this check alone does not refute the described arrival condition. The decisive refutation is the spec's permission to retry and Grandine's actual revalidation, not impossibility of the condition. [Parent check][S-parent]; [state-transition attestation rules][BC].

**Result for (a): REFUTED as a spec divergence.** It is true that production Grandine can queue and later count the vote; it is false that the specified handler necessarily forbids doing so. The dormant `bail!` and TODO do not override the inherited handler text.

## Corrected surviving record: b — block PTC votes applied after head publication

**Client / EIP:** Grandine / EIP-7732, Gloas fork choice.

**Spec requirement and correction to the original allegation.** `specs/gloas/fork-choice.md#L1084-L1098` explicitly computes the **old head before applying the block**, then inserts the block/state, initializes that block's PTC arrays, executes:

```python
notify_ptc_messages(store, state, block.body.payload_attestations)
```

and records timeliness and proposer boost. The original phrase “before any get_head” was too broad: the old-head query is intentional. The discrepancy is exposing the **new** post-block view without the included PTC update. [Pinned on_block][FC-block]. The PTC notification helper is at lines 268–288, with the vote updates at lines 1139–1187 of the same [spec file][FC].

### Exact code and visibility proof

| Stage | Pinned location | Observed behavior |
| --- | --- | --- |
| Import | `fork_choice_control/src/mutator.rs#L3440-L3444` | Calls `self.store_mut().apply_block(chain_link)?`. |
| Head computation | `fork_choice_store/src/store.rs#L4548-L4685` | Records timeliness/boost/checkpoints, inserts the block at 4657, and recomputes head at 4675. No block PTC tally is applied here or in `insert_block` at 5069–5142. |
| Spawn later work | `fork_choice_control/src/mutator.rs#L3629-L3630`, `#L5261-L5284` | Spawns `BlockPayloadAttestationsTask` on an owned post-import store snapshot. |
| Task result | `fork_choice_control/src/tasks.rs#L424-L465` | Validates included payload attestations and sends `MutatorMessage::BlockPayloadAttestations`; it does not mutate the store. |
| Publish incomplete view | `fork_choice_control/src/mutator.rs#L3669-L3672`, `#L3707-L3741` | Publishes a snapshot and can emit head events, `P2pMessage::HeadChanged`, `ValidatorMessage::Head`, and forkchoice-updated notifications based on `apply_block`'s result. |
| Apply PTC afterward | `fork_choice_control/src/mutator.rs#L287-L290`, `#L1692-L1725`, `#L1762-L1789`; `fork_choice_store/src/store.rs#L4787-L4835` | A subsequent mutator message tallies the PTC votes, recomputes head, publishes another snapshot, and may notify a reorganization. |

Sources for this table: [store][S], [mutator][M], [task][T]. All paths above are at the full Grandine pin stated above; raw URL anchors retain the specified line ranges.

**It is not awaited before publication.** The mutator handles messages serially (`mutator.rs#L245-L290`), and spawning is `self.thread_pool.spawn(task)` at lines 5614–5616. Even if the worker finishes quickly, its message cannot be handled until the current block message finishes. `update_store_snapshot` at lines 5608–5612 performs an `ArcSwap` store immediately. The controller's public `head()` / `head_with_payload_status()` at `queries.rs#L308-L325` load that snapshot directly, with **no wait-group wait**. Thus a head query can observe the intermediate view; no validator-duty scheduling assumption is needed to establish visibility. [Mutator][M]; [queries][Q]; [wait abstraction][WAIT]. `WaitGroup` clones carried in messages do not introduce an implicit barrier in these functions.

A possible alternative defense was the current-slot block-processing marker. It does not cover this task: `BlockTask` unmarks a block before sending its import result (`tasks.rs#L183-L198`), whereas the PTC task is spawned later during acceptance. The recent marker fix therefore does not close this gap. [Tasks][T]; [marker-fix commit][MARKER].

### Condition and hand-traced branch consequence

This is a code/spec inference, **not an executed reproducer**:

- Let viable R be the only competitive head at slot s, with its envelope already locally verified and valid, away from epoch/fork boundaries. At local slot s+1, its PTC tally has not yet received enough affirmative votes through gossip.
- Import timely B, the first boosted block at s+1, with parent R but choosing R's **EMPTY** execution branch. Include a valid PTC aggregate for R at slot s covering more than half of the PTC positions, with both `payload_present` and `blob_data_available` true (257 of 512 positions in the mainnet preset). No competing checkpoint change or invalid payload is needed.
- This is not ruled out by block validity. The state-transition parent-payload function allows EMPTY with empty parent execution requests (`beacon-chain.md#L1841-L1849`); payload-attestation validity checks parent root, previous slot and signature (`#L2488-L2499`), not agreement between the positive PTC vote and B's choice of EMPTY. Local payload receipt is not an additional validity condition on an EMPTY child. [State-transition rules][BC]. A proposer may choose a branch contrary to fork-choice guidance while still producing a state-transition-valid block.
- With no affirmative quorum yet and B boosted on EMPTY, Grandine's `should_extend_payload(R)` is false (`store.rs#L1084-L1116`); the EMPTY child B is eligible. Once the included votes are applied, both positive thresholds pass (`#L1188-L1242`), `should_extend_payload(R)` becomes true, and `ignore_child` excludes that EMPTY child (`#L5997-L6004`). Under the stated single-branch assumptions this changes head from B back to R/FULL. [Payload decision][S-decision]; [child filtering][S-filter].
- The spec processes those included votes inside `on_block`. Its previous-slot payload decision has zero weight on both variants and uses the payload-status tiebreaker; a verified payload with positive timeliness and availability quorums wins FULL even when the boosted child chose EMPTY. Thus the first post-`on_block` head is R/FULL, rather than the temporarily published B. [Spec decision/weight/children][FC-decision]. A query between the two Grandine publications distinguishes these outcomes. If the votes are already known, fail to cross the thresholds, or the relevant slot has passed, this example need not diverge.

**Confidence:** high for the missing publication barrier and observable intermediate fork-choice view; medium-high for the branch-changing scenario, established by source tracing only. **Severity category: fork-choice divergence**, transient until the task result is applied, with possible externally consumed wrong-head decisions. This is not evidence of a permanent consensus split, invalid-block acceptance, valid-block rejection, or gas/fee error. The duration and an actual signed-duty consequence were not measured.

**Other client:** Nimbus at **0abc0dd0**, `beacon_chain/fork_choice/fork_choice.nim#L519-L529`, synchronously writes included PTC tally positions inside `process_block`, before subsequent timeliness/boost/checkpoint processing (lines 534–540) and block integration. This supports the ordering distinction; it does not establish every aspect of Nimbus conformance. Its historical-block guard at lines 521–522 does not exclude the current s+1 scenario above. [Pinned Nimbus source][N].

## Known checks and later-fix route

Public GitHub API checks covered both open and closed issues/PRs, without a state filter, using the search URLs below and fetching the relevant metadata/comments. **No exact public issue or merged fix for the surviving post-import PTC publication gap was found.** This is a limited search result, not proof that the team is unaware.

| Record | State observed | Relevance / disposition |
| --- | --- | --- |
| [#673][PR673] | Open PR, unmerged | Explicitly proposes delaying index-1 votes until the envelope, for gossip. Related to (a); not an acknowledgement of the alleged permanent-discard rule or a fix for (b). |
| [#537][PR537] | Closed, merged | Implements block payload attestations. Review comments inspected; no acknowledgement of the publication-order problem found. |
| [#831][PR831] | Closed, merged | Fork-choice compliance tests. Does not establish production atomicity. |
| [#919][PR919] | Closed, merged | Includes commit `0d7aba5cd4c375c15a8336f475834810472aa1ed`, which adds test-only dropping for unverified-payload votes. The [commit diff][TESTFIX] explains the harness behavior; it preserves production retry and does not fix (b). |
| [#745][I745] / [#776][PR776] | Closed issue / closed, merged PR | Related EMPTY/FULL fork-choice selection issue. The existing selection logic is precisely what makes a delayed tally capable of changing the branch; this is not evidence the asynchronous publication gap was fixed. |
| [#895][PR895] / [#899][PR899] | Closed, unmerged / closed, merged | Preparing the next payload again after PTC votes and reveal. Related downstream handling, not synchronous application of block PTC votes before publication. |
| [#1010][PR1010] | Closed, merged | Current-slot blocks waiting for data remain marked as in processing. The [patch][MARKER] does not extend that marker across the block PTC task. |
| [#886][I886] / [#887][PR887] | Open issue / open, unmerged PR | Restart replay of stored payload envelopes; different problem. |
| [#989][PR989] | Open, unmerged PR | Gloas clock fork-boundary timing; different problem. |

Also inspected [#591][PR591] (closed, unmerged), its issue/review comments, [#705][PR705] (closed, unmerged; payload-attestation-message tests), [#752][PR752] (closed, merged; PTC counts), and [#713][I713] (closed; fully validated head waiting). None supplied a barrier before the publication demonstrated above. Searches for the exact delay TODO phrase and `DelayUntilPayload` found no dedicated matching issue; the former returned #591. Source TODOs were treated as evidence of implementation intent, not normative requirements.

For the later-commit route, the [develop branch API][BRANCH] returned **the supplied pin itself** at review time. Path histories for [store.rs][H-S], [mutator.rs][H-M], and [tasks.rs][H-T] were checked (30 latest commits each); no newer develop commit exists relative to this pin. This is stronger than concluding from commit titles alone that a fix is absent. Open PRs were not treated as merged fixes.

**Public test-results check:** attempted the [Hive result portal][HIVE] and [Glamsterdam result listing][HIVE-L]. Direct HTTP retrieval returned **403 Forbidden** for both; the web reader exposed no result rows. Consequently no Hive pass/fail claim or known-bug match is made. The [EEST repository notice][EEST] says tests migrated to execution-specs. Inspected the [Glamsterdam devnet-8 v8.1.0 release metadata][RELEASE], which describes EL repricing/coverage changes, not a Grandine asynchronous fork-choice regression result. No public result disproving or reproducing this particular CL ordering window was found; neither EL fixture availability nor Grandine's harness-specific compliance behavior is evidence that production snapshot publication is atomic.

## URLs read / source ledger

The links below identify the raw files and API responses used above. Grandine source was read from raw.githubusercontent.com, never GitHub source HTML. Additional discovery queries: [payload+attestation][SEARCH-A], [PTC][SEARCH-P], [fork choice][SEARCH-F], [payload+order][SEARCH-O], [payload+task][SEARCH-T], [exact notification helper][SEARCH-N], [DelayUntilPayload][SEARCH-D], and [exact TODO phrase][SEARCH-TODO]. For #537, #673, #831 and #591, both `/issues/{number}/comments?per_page=100` and `/pulls/{number}/comments?per_page=100` under the Grandine API repository were read. Commit-to-PR endpoints for the test fix and marker fix were also read. No inaccessible page was used as affirmative technical evidence.

[FC]: https://raw.githubusercontent.com/ethereum/consensus-specs/8a80ffa8/specs/gloas/fork-choice.md
[FC-validation]: https://raw.githubusercontent.com/ethereum/consensus-specs/8a80ffa8/specs/gloas/fork-choice.md#L914-L953
[FC-block]: https://raw.githubusercontent.com/ethereum/consensus-specs/8a80ffa8/specs/gloas/fork-choice.md#L1044-L1105
[FC-decision]: https://raw.githubusercontent.com/ethereum/consensus-specs/8a80ffa8/specs/gloas/fork-choice.md#L497-L670
[P0]: https://raw.githubusercontent.com/ethereum/consensus-specs/8a80ffa8/specs/phase0/fork-choice.md
[BC]: https://raw.githubusercontent.com/ethereum/consensus-specs/8a80ffa8/specs/gloas/beacon-chain.md
[META]: https://raw.githubusercontent.com/ethereum/EIPs/d4c57552/EIPS/eip-7773.md
[E7732]: https://raw.githubusercontent.com/ethereum/EIPs/d4c57552/EIPS/eip-7732.md
[MAINNET]: https://raw.githubusercontent.com/ethereum/consensus-specs/8a80ffa8/configs/mainnet.yaml
[S]: https://raw.githubusercontent.com/grandinetech/grandine/52cd677117b5d93bf477fe4843148070b0303444/fork_choice_store/src/store.rs
[S-validation]: https://raw.githubusercontent.com/grandinetech/grandine/52cd677117b5d93bf477fe4843148070b0303444/fork_choice_store/src/store.rs#L2881-L3240
[S-parent]: https://raw.githubusercontent.com/grandinetech/grandine/52cd677117b5d93bf477fe4843148070b0303444/fork_choice_store/src/store.rs#L2189-L2196
[S-decision]: https://raw.githubusercontent.com/grandinetech/grandine/52cd677117b5d93bf477fe4843148070b0303444/fork_choice_store/src/store.rs#L1084-L1242
[S-filter]: https://raw.githubusercontent.com/grandinetech/grandine/52cd677117b5d93bf477fe4843148070b0303444/fork_choice_store/src/store.rs#L5977-L6004
[M]: https://raw.githubusercontent.com/grandinetech/grandine/52cd677117b5d93bf477fe4843148070b0303444/fork_choice_control/src/mutator.rs
[M-retry]: https://raw.githubusercontent.com/grandinetech/grandine/52cd677117b5d93bf477fe4843148070b0303444/fork_choice_control/src/mutator.rs#L3812-L3852
[T]: https://raw.githubusercontent.com/grandinetech/grandine/52cd677117b5d93bf477fe4843148070b0303444/fork_choice_control/src/tasks.rs
[ORIGIN]: https://raw.githubusercontent.com/grandinetech/grandine/52cd677117b5d93bf477fe4843148070b0303444/fork_choice_store/src/misc.rs#L780-L789
[Q]: https://raw.githubusercontent.com/grandinetech/grandine/52cd677117b5d93bf477fe4843148070b0303444/fork_choice_control/src/queries.rs#L308-L325
[WAIT]: https://raw.githubusercontent.com/grandinetech/grandine/52cd677117b5d93bf477fe4843148070b0303444/fork_choice_control/src/wait.rs
[N]: https://raw.githubusercontent.com/status-im/nimbus-eth2/0abc0dd0/beacon_chain/fork_choice/fork_choice.nim#L519-L540
[BRANCH]: https://api.github.com/repos/grandinetech/grandine/branches/develop
[H-S]: https://api.github.com/repos/grandinetech/grandine/commits?sha=develop&path=fork_choice_store/src/store.rs&per_page=30
[H-M]: https://api.github.com/repos/grandinetech/grandine/commits?sha=develop&path=fork_choice_control/src/mutator.rs&per_page=30
[H-T]: https://api.github.com/repos/grandinetech/grandine/commits?sha=develop&path=fork_choice_control/src/tasks.rs&per_page=30
[TESTFIX]: https://api.github.com/repos/grandinetech/grandine/commits/0d7aba5cd4c375c15a8336f475834810472aa1ed
[MARKER]: https://api.github.com/repos/grandinetech/grandine/commits/8bc84d045ac76bf974dab5717544ba72f0bf5183
[PR673]: https://api.github.com/repos/grandinetech/grandine/pulls/673
[PR537]: https://api.github.com/repos/grandinetech/grandine/pulls/537
[PR831]: https://api.github.com/repos/grandinetech/grandine/pulls/831
[PR919]: https://api.github.com/repos/grandinetech/grandine/pulls/919
[I745]: https://api.github.com/repos/grandinetech/grandine/issues/745
[PR776]: https://api.github.com/repos/grandinetech/grandine/pulls/776
[PR895]: https://api.github.com/repos/grandinetech/grandine/pulls/895
[PR899]: https://api.github.com/repos/grandinetech/grandine/pulls/899
[PR1010]: https://api.github.com/repos/grandinetech/grandine/pulls/1010
[I886]: https://api.github.com/repos/grandinetech/grandine/issues/886
[PR887]: https://api.github.com/repos/grandinetech/grandine/issues/887
[PR989]: https://api.github.com/repos/grandinetech/grandine/issues/989
[PR591]: https://api.github.com/repos/grandinetech/grandine/pulls/591
[PR705]: https://api.github.com/repos/grandinetech/grandine/pulls/705
[PR752]: https://api.github.com/repos/grandinetech/grandine/pulls/752
[I713]: https://api.github.com/repos/grandinetech/grandine/issues/713
[SEARCH-A]: https://api.github.com/search/issues?q=repo%3Agrandinetech/grandine%20%22payload%22%20%22attestation%22&per_page=100
[SEARCH-P]: https://api.github.com/search/issues?q=repo%3Agrandinetech/grandine%20%22PTC%22&per_page=100
[SEARCH-F]: https://api.github.com/search/issues?q=repo%3Agrandinetech/grandine%20%22fork%20choice%22&per_page=100
[SEARCH-O]: https://api.github.com/search/issues?q=repo%3Agrandinetech/grandine%20%22payload%22%20%22order%22&per_page=100
[SEARCH-T]: https://api.github.com/search/issues?q=repo%3Agrandinetech/grandine%20%22payload%22%20%22task%22&per_page=100
[SEARCH-N]: https://api.github.com/search/issues?q=repo%3Agrandinetech/grandine%20%22notify_ptc_messages%22&per_page=100
[SEARCH-D]: https://api.github.com/search/issues?q=repo%3Agrandinetech/grandine%20%22DelayUntilPayload%22&per_page=100
[SEARCH-TODO]: https://api.github.com/search/issues?q=repo%3Agrandinetech/grandine%20%22block%20should%20be%20delayed%22&per_page=100
[HIVE]: https://hive.ethpandaops.io/
[HIVE-L]: https://hive.ethpandaops.io/glamsterdam/listing.jsonl
[EEST]: https://raw.githubusercontent.com/ethereum/execution-spec-tests/main/README.md
[RELEASE]: https://api.github.com/repos/ethereum/execution-specs/releases/tags/tests-glamsterdam-devnet%40v8.1.0
