|
| 1 | +# ADR 0061: The version-seven ABCI adapter, and why replay needs a guard |
| 2 | + |
| 3 | +- Status: Accepted |
| 4 | +- Date: 2026-09-01 |
| 5 | + |
| 6 | +## Context |
| 7 | + |
| 8 | +[ADR 0060](0060-the-version-seven-node-process.md) made a version-seven |
| 9 | +application a **process**: a store, an application, and a private Unix socket |
| 10 | +that starts, serves, and shuts down. Nothing on the other end of that socket |
| 11 | +spoke version seven. `adapter/cometbft` — the frame client, the ABCI |
| 12 | +application over it, and the four binaries — was written for version one, so no |
| 13 | +consensus engine could drive a version-seven node and requirement 13 of |
| 14 | +[`first-goal.md`](../project/first-goal.md), the adversarial four-node economic |
| 15 | +scenarios, still had nothing to run. |
| 16 | + |
| 17 | +[ADR 0059](0059-the-version-seven-transport.md) settled what differs on the |
| 18 | +wire. This ADR records the adapter that reads it, and the one question the |
| 19 | +adapter had to answer that is not encoding: **the replay handshake ADR 0058 |
| 20 | +recorded as owed to this slice.** |
| 21 | + |
| 22 | +## Decision |
| 23 | + |
| 24 | +### The Go client is version one's client and one different answer |
| 25 | + |
| 26 | +`ClientV7` embeds `Client` and declares `FinalizeBlock`. Nothing else. |
| 27 | + |
| 28 | +The connection, the request-identifier discipline, the terminal latch, the |
| 29 | +twenty-octet frame header, the seven kinds, and all five request encoders carry |
| 30 | +no ledger-version meaning — a height, a transaction list, a byte budget, an app |
| 31 | +state, a raw transaction. A second copy of them would be a second place for a |
| 32 | +framing rule to be wrong, and the two would have to be kept in step by |
| 33 | +discipline alone. It is the same decision ADR 0059 made on the C++ side, in the |
| 34 | +same shape, and it is what makes the two sides legible together. |
| 35 | + |
| 36 | +Three fields are genuinely version-specific and all three are in the finalized |
| 37 | +block: the **block identifier** after the state root, a receipt of fifty-six |
| 38 | +octets under a version field of 7 with its result byte at offset 39, and a |
| 39 | +result range of thirty-three rather than eight. |
| 40 | + |
| 41 | +### The decoder validates rather than trusts |
| 42 | + |
| 43 | +A declared code and its receipt's own result byte must be the same fact; an |
| 44 | +admission failure must carry no receipt; a receipt must be version seven's |
| 45 | +length under version seven's prefix; a result byte at or above the code count |
| 46 | +means the two sides disagree about the contract. The C++ encoder already checks |
| 47 | +each of these on the way out. Checking them again here is not redundancy: it is |
| 48 | +the only place a **corrupted or mismatched peer** on the socket is caught, and |
| 49 | +the adapter has no ledger, no kernel, and no vectors with which to catch it any |
| 50 | +other way. |
| 51 | + |
| 52 | +**The two shapes fail closed against each other.** Version seven's decoder |
| 53 | +refuses version one's finalized block and version one's refuses version |
| 54 | +seven's, so a client dialled at the wrong version cannot silently misread a |
| 55 | +block — which is what makes `-protocol-version` a safe flag rather than a |
| 56 | +trap. |
| 57 | + |
| 58 | +### One `Application`, parameterized, rather than two |
| 59 | + |
| 60 | +Six of the seven ABCI conversions name no ledger version: the signed height |
| 61 | +range, the chain-identity decoding, the app-state bound, the proposal prefix, |
| 62 | +the block bounds, the committed head. Duplicating them for version seven would |
| 63 | +be about a hundred and fifty lines whose only difference is which copy a later |
| 64 | +fix reaches. |
| 65 | + |
| 66 | +So `New` and `NewV7` differ by two things: the codespace that names the result |
| 67 | +codes an executed transaction can carry, and whether a finalized block arrives |
| 68 | +with an identifier. The bridge's own `FinalizedBlock` carries that identifier |
| 69 | +as a **pointer**, because absent must be unmistakable — a zero hash would be |
| 70 | +indexed as though it named something. |
| 71 | + |
| 72 | +### The block identifier becomes a block event |
| 73 | + |
| 74 | +ABCI has no field for a second block identifier. CometBFT computes its own |
| 75 | +block hash over the transactions and the previous application hash; version |
| 76 | +seven's identifier commits to the protocol's own header, including the |
| 77 | +transaction root and both state roots, which is a different statement about the |
| 78 | +same block. |
| 79 | + |
| 80 | +It is emitted as a `protocol_block` event with an indexed `id` attribute. The |
| 81 | +alternative was to decode it and discard it, and the argument against that is |
| 82 | +not aesthetic: **a value that crosses a process boundary and is then discarded |
| 83 | +is a value the next simplification deletes**, and the C++ encoder's own check |
| 84 | +would then be the only thing keeping it on the wire. |
| 85 | + |
| 86 | +**It is observable rather than consensus-visible.** A block event is not hashed |
| 87 | +into anything CometBFT agrees on; only a transaction result's code and data |
| 88 | +reach `LastResultsHash`. |
| 89 | + |
| 90 | +### The replay handshake is a guard, and the engine never trips it |
| 91 | + |
| 92 | +This is the question ADR 0058 left open, and the answer turned out to be a fact |
| 93 | +about the pinned engine rather than a design. |
| 94 | + |
| 95 | +`ApplicationV7` refuses, terminally, a `finalize_block` at any height that is |
| 96 | +not its current plus one — **including one it has already committed**. The |
| 97 | +worry was the crash window between the application's commit and CometBFT saving |
| 98 | +its own state, which leaves the application one block ahead of the engine's |
| 99 | +state. |
| 100 | + |
| 101 | +CometBFT v0.39.4 handles that window itself. In `consensus/replay.go`, with the |
| 102 | +block store one ahead of the state and the application at the block store's |
| 103 | +height, it loads its **own saved** `FinalizeBlock` response and replays that |
| 104 | +height against a mock application built from it — the source says, in as many |
| 105 | +words, that it does not want to call `Commit` twice for the same block on the |
| 106 | +real app. Every other branch either replays from `appBlockHeight + 1`, which is |
| 107 | +exactly `current + 1` at each step because each replayed block is committed |
| 108 | +before the next is sent, or refuses at the handshake without sending a request |
| 109 | +at all when the application is ahead of the block store. |
| 110 | + |
| 111 | +So the adapter reconciles nothing, and **inventing a reconciliation would have |
| 112 | +been worse than useless**: to answer a repeat honestly it would have to |
| 113 | +reproduce the per-transaction receipts of a block whose results the application |
| 114 | +no longer holds and the store never recorded, and any answer it synthesised |
| 115 | +instead would be exactly the fabricated agreement this layer exists to prevent. |
| 116 | + |
| 117 | +What it does instead is refuse to *forward* such a request. The height comes |
| 118 | +from the application's own answers to `Info` and `Commit` and is never counted |
| 119 | +here, so it can never exceed the height the application would accept and can |
| 120 | +never refuse a legitimate `current + 1`. The cost is one comparison per block. |
| 121 | +The benefit is that if any engine ever does ask — a version change, a |
| 122 | +misconfiguration, an operator pointing two engines at one node — the failure is |
| 123 | +a legible error the engine stops on rather than a node bricked on a |
| 124 | +contradiction it did not commit. |
| 125 | + |
| 126 | +### The genesis application state is version-specific |
| 127 | + |
| 128 | +`ApplicationV7` requires `"protocol-stack-v7"` at `init_chain`, and **that is |
| 129 | +what stops a node started against a version-one genesis and a version-seven |
| 130 | +engine**: it refuses at `init_chain` rather than at the first block. |
| 131 | +`protocol-cometbft-init` therefore takes `-protocol-version`, and the parser |
| 132 | +compares as the wider type so that 257 is not admitted as version one. |
| 133 | + |
| 134 | +The devnet stays on version one explicitly rather than by omission. A |
| 135 | +version-seven local network needs its genesis and the `-protocol-version` its |
| 136 | +supervisor passes each bridge to be one choice, and that belongs with the |
| 137 | +four-node slice. |
| 138 | + |
| 139 | +## Evidence |
| 140 | + |
| 141 | +**All thirty-six results a version-seven block can report** — three admission |
| 142 | +failures and thirty-three execution results — are decoded and each field |
| 143 | +checked against the receipt that produced it. Eight refusals are exercised: a |
| 144 | +wrong result count, a truncated identifier, trailing octets, a version-one |
| 145 | +receipt version under a version-seven code, a result byte at the code count, a |
| 146 | +declared code disagreeing with its receipt, a version-one receipt length, and |
| 147 | +an admission failure carrying data. |
| 148 | + |
| 149 | +**The version-seven client sends version one's request byte for byte**, read |
| 150 | +off a real socket pair and compared against the shared encoder's output, and it |
| 151 | +answers `Info` through the embedded client with no second copy of anything. A |
| 152 | +protocol failure in its response is terminal for the whole connection. |
| 153 | + |
| 154 | +**The bridge is driven through `Info`, `CheckTx`, `Query`, and a finalized |
| 155 | +block carrying all three result shapes.** The identity event's type, key, index |
| 156 | +flag, and value are each checked, and version one must emit no event at all. |
| 157 | + |
| 158 | +**The guard is exercised from both sides of a commit.** A height below the one |
| 159 | +`Info` reported and the reported height itself are refused *without reaching the |
| 160 | +local application*, which the fake asserts by counting its own calls; the next |
| 161 | +height succeeds; the same height repeated after its own commit is refused; and |
| 162 | +the height after that succeeds. A separate case proves the guard counts nothing |
| 163 | +itself: an adapter that never asked `Info` still forwards the first block of a |
| 164 | +chain, and height zero — which is not a block height — is refused by the same |
| 165 | +comparison. |
| 166 | + |
| 167 | +**A home initialized for version seven carries the version-seven application |
| 168 | +state**, and re-initializing that home for version one is refused rather than |
| 169 | +adopted, because the initializer exact-validates an existing genesis. |
| 170 | + |
| 171 | +## Owed, and recorded rather than implied |
| 172 | + |
| 173 | +- **The end-to-end run.** Nothing here has spoken to a real CometBFT engine. |
| 174 | + The single-node integration test drives version one; driving version seven |
| 175 | + needs the recorded blocks' **raw inputs**, which no accepted vector file |
| 176 | + carries today — the version-seven vectors record each block's roots, |
| 177 | + identifier, and receipts but not the transactions that produced them. That is |
| 178 | + the next slice, and only four of the five recorded blocks are reachable |
| 179 | + through it: `carried.block4` is at height 1,152,000. |
| 180 | +- **The four-node devnet.** Its genesis and its bridges must choose one version |
| 181 | + together, and it stays on version one until they do. |
| 182 | +- **The uptime schedule is still `nullptr`.** A chain driven through this |
| 183 | + adapter executes correctly and pays nobody, which ADR 0058 already records. |
| 184 | + Four nodes agreeing on blocks that pay nobody would satisfy the word |
| 185 | + "four-node" and not the word "economic". |
| 186 | + |
| 187 | +## Alternatives considered |
| 188 | + |
| 189 | +**A second frame codec and a second connection for version seven.** Rejected |
| 190 | +for the reason ADR 0059 rejects a second wire: the framing rules carry no |
| 191 | +ledger-version meaning, so the copies would differ in nothing but their names |
| 192 | +while doubling the places one can be wrong. |
| 193 | + |
| 194 | +**Two `Application` types in the bridge.** Rejected: six of seven conversions |
| 195 | +are identical, and the copies would drift the first time one was fixed. |
| 196 | + |
| 197 | +**Making the application answer a repeated finalize instead of latching.** |
| 198 | +Rejected. The response it would have to reproduce contains per-transaction |
| 199 | +receipts that the stage no longer holds after `commit` and that the store never |
| 200 | +records, so answering would mean either re-executing a block against a head |
| 201 | +that has moved past it — a different question — or synthesising a reply. The |
| 202 | +engine does not ask, so nothing is bought for the risk. |
| 203 | + |
| 204 | +**A single `FinalizedBlock` with a zero identifier for version one.** Rejected: |
| 205 | +a zero hash is a value, and it would have been emitted, indexed, and eventually |
| 206 | +compared. |
0 commit comments