Skip to content

Commit ecd3fcf

Browse files
committed
docs(decisions): record the version-seven ABCI adapter
ADR 0061 records what the adapter decided, and the interesting half is the question ADR 0058 left open rather than the encoding. **The replay handshake turned out to be a fact about the pinned engine.** `ApplicationV7` refuses a `finalize_block` at any height that is not its current plus one, including one it has already committed, and that refusal is terminal — so the worry was the crash window that leaves the application one block ahead of CometBFT's own state. CometBFT v0.39.4 closes that window itself: it replays exactly that height against a mock application built from its own saved response, with the source saying in as many words that it will not call `Commit` twice for one block on the real app, and every other branch sends only `current + 1` because each replayed block is committed before the next is sent. So the adapter reconciles nothing, and the ADR records why inventing a reconciliation would have been worse than useless: answering a repeat honestly means reproducing per-transaction receipts the stage no longer holds after `commit` and the store never recorded, so any answer it synthesised would be the fabricated agreement this layer exists to prevent. What it adds instead is a guard that refuses to forward such a request, using a height taken only from the application's own answers. The rest is recorded with its reasoning: why the Go client is version one's client and one different answer, why one parameterized `Application` rather than two, why the block identifier is a pointer and becomes an indexed block event rather than being decoded and discarded, and why the genesis application state is the thing that refuses a mismatched pair. Three items are owed rather than implied: the end-to-end run against a real engine, which needs the recorded blocks' raw inputs that no accepted vector file carries; the four-validator devnet, whose genesis and bridges must choose one version together; and the uptime schedule, which is still `nullptr`, so a chain driven through this adapter executes correctly and pays nobody.
1 parent 27b163d commit ecd3fcf

3 files changed

Lines changed: 262 additions & 4 deletions

File tree

adapter/cometbft/README.md

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
This Go module contains the replaceable adapter between CometBFT `v0.39.4`
44
and the headless C++ application. It implements the accepted version-one
55
contract in
6-
[`consensus-application-v1.md`](../../docs/specifications/consensus-application-v1.md).
6+
[`consensus-application-v1.md`](../../docs/specifications/consensus-application-v1.md),
7+
and, under `-protocol-version 7`, the version-seven responses recorded in
8+
[ADR 0059](../../docs/decisions/0059-the-version-seven-transport.md) and
9+
[ADR 0061](../../docs/decisions/0061-the-version-seven-abci-adapter.md).
710

811
The module provides four cgo-free commands:
912

@@ -17,14 +20,21 @@ The adapter:
1720

1821
- serves the official CometBFT ABCI `2.0.0` socket interface;
1922
- serializes all supported calls onto one persistent local Unix connection;
20-
- translates only the seven version-one application methods and their exact
21-
result fields;
23+
- translates only the seven application methods and their exact result fields;
24+
- reads a version-seven finalized block with the same frames and one different
25+
decoder, and refuses each version's finalized block under the other, so a
26+
client started at the wrong version fails closed rather than misreading a
27+
block;
28+
- refuses to forward a `FinalizeBlock` at a height the application has already
29+
committed, using a height taken only from the application's own answers;
2230
- fails unsupported application-mempool and state-sync operations closed;
2331
- holds no canonical ledger state and makes no admission or execution
2432
decision;
2533
- uses no cgo or Cosmos SDK;
2634
- fixes the CometBFT genesis time, initial height, application identity,
27-
validator, and supported M1 configuration;
35+
validator, and supported M1 configuration, with the genesis application state
36+
naming the ledger version so that a mismatched pair is refused at `InitChain`
37+
rather than at the first block;
2838
- refuses to overwrite an existing genesis with different semantics.
2939

3040
The repository verifier bootstraps the integrity-pinned Go 1.25.10 Linux
@@ -98,6 +108,27 @@ default. Set
98108
block is occupied. Every repeated start refuses partial homes, changed keys,
99109
changed genesis, or changed configuration.
100110

111+
## Version seven
112+
113+
A version-seven node is the same three processes with the version-seven
114+
application binary and `-protocol-version 7` on both the initializer and the
115+
bridge. The two must agree: the genesis application state the initializer
116+
writes is what `ApplicationV7` requires at `InitChain`.
117+
118+
```sh
119+
protocol-application-v7 --genesis-identity /absolute/path/protocol.genesis
120+
121+
protocol-cometbft-init -protocol-version 7 ...
122+
123+
protocol-cometbft-bridge -protocol-version 7 \
124+
-application-socket /absolute/path/application.sock \
125+
-abci-listen tcp://127.0.0.1:26658
126+
```
127+
128+
The four-validator devnet is version one only. Its genesis and the
129+
`-protocol-version` its supervisor passes each bridge must be one choice, and
130+
that is not yet wired.
131+
101132
## Single-node lifecycle
102133

103134
First derive the deployment identity from the same canonical genesis that the

docs/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,27 @@ connected to, shut down — against the recorded chain identity and a genesis ro
553553
read out of a recorded block header, since a header commits to its previous state
554554
root and at height 1 that is the genesis root.
555555

556+
ADR 0061 records the version-seven ABCI adapter, and its one real question was
557+
not encoding. **`ApplicationV7` refuses a `finalize_block` at any height that is
558+
not its current plus one — including one it has already committed — and that
559+
refusal is terminal**, so ADR 0058 owed this slice an answer about what the
560+
adapter does when a consensus engine replays a block. The answer turned out to
561+
be a fact about the pinned engine: CometBFT v0.39.4 replays exactly that case
562+
against a **mock** application built from its own saved response, saying in as
563+
many words that it will not call `Commit` twice for one block on the real app,
564+
and every other branch sends only `current + 1`. So the adapter reconciles
565+
nothing — and inventing a reconciliation would have been worse than useless,
566+
since answering a repeat honestly means reproducing receipts the application no
567+
longer holds and the store never recorded. What it adds is a **guard**: a
568+
finalize at an already-committed height is refused before it is forwarded, using
569+
a height taken only from the application's own answers, so it can never refuse a
570+
legitimate block. On the Go side the client is version one's client and one
571+
different answer, the two finalized-block shapes refuse each other so a client
572+
dialled at the wrong version fails closed, and the block identifier — which ABCI
573+
has no field for — is emitted as an indexed block event rather than decoded and
574+
discarded, because a value that crosses a process boundary and is then thrown
575+
away is the one a later simplification deletes.
576+
556577
## Engineering
557578

558579
- `engineering/continuation.md`: the cross-session `proceed` protocol.
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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

Comments
 (0)