M6: BFT consensus in onxd (ADR-0049 locking) — NO-GO: 1 critical + 112 pts, fix design ADR-0052 proposed #3

Open
amethyst wants to merge 45 commits from m6/bft-consensus into main
Owner

Carries the old pr59 branch (M6: wire onx-consensus into onxd, BFT locking per ADR-0049) onto the Forgejo main, and adds tests for the last review round.

Verdict under the CONTRIBUTING point system: NO-GO (re-scored at 04f0558). One critical finding (a chain halt) is outstanding and proven by a failing test. The non-critical findings add 40 points from the proven findings (under the 50-point line) and 112 including the four found while designing the fix (over it). Per the "design before code" rule, the findings below are reported, not fixed. A fix design for all of them, ADR-0052, is now on this branch as a proposal. It is not implemented, so no finding has been removed from the score.

Consensus impact

  • breaking
  • adjacent
  • none

This PR introduces BFT consensus (proposal → PreVote → PreCommit → Commit, with Tendermint-style locking) into the node. Nodes with this PR and nodes without it do not interoperate.

Could this behave differently on two honest nodes given the same inputs? Yes. Gossip order and timing decide which votes a node has seen before its round timeout, and so which block (if any) it locks on. That's inherent to BFT, but finding 1 below shows that today it can leave honest nodes locked on different blocks permanently.

Specification and decision record

  • I identified the applicable docs/specification/ section: docs/specification/consensus.md (updated in this PR).
  • If no specification existed, this PR adds it before implementation. (The spec existed. Locking was added to it in 3f2b7e5, before the engine change.)
  • I added an ADR for a significant interpretation or deviation: ADR-0049, BFT consensus protocol with locking (written 2026-10-09).
  • I implemented the complete specified structure, or documented each tracked exception. Not complete. See Known gaps (STF dry-run, set_head).
  • I described any ambiguity or deviation explicitly below.

Checks

Run locally (rustc 1.98.1, pinned toolchain), since the server has no CI. Scoped to the two crates this PR changes (-p onx-consensus -p onxd), not --workspace.

  • cargo fmt --all -- --check: clean
  • cargo clippy -p onx-consensus -p onxd --all-targets -- -D warnings: clean
  • cargo build -p onx-consensus -p onxd --all-targets: ok
  • cargo test -p onx-consensus -p onxd: all pass
    • onx-consensus: wire 8, election 6, engine_integration 4 (+1 ignored finding)
    • onxd: lib 23 (+2 ignored findings), follower_sync 11, producer_loop 15, four_validator_consensus 1, bin 5
  • cargo test ... -- --ignored: all 3 finding tests FAIL, as expected (see below)
  • python3 scripts/site.py check, check-doc-links.py, check-spec-citations.py, version.py check: pass
  • Re-run at 04f0558 (ADR-0052, docs only): the four doc checks pass. The Rust checks were not re-run because 04f0558 changes no code; the results above are from c1867bd.

Test-environment notes:

  • The build machine's disk filled up, so the onxd test binaries were linked and run one target at a time.
  • producer_loop::daemon_binary_refuses_to_start_without_key hardcodes <workspace>/target/debug/onxd. With a shared CARGO_TARGET_DIR it fails to spawn (NotFound). With the target dir in place it passes. This is a latent fragility in the test, not a product bug.

Notes

Outstanding findings (not fixed, owner decides)

Scored with CONTRIBUTING.md's point system. Severity follows docs/claude-review-prompt.md ("critical: consensus break, chain halt, …"). Findings 1–3 are each proven by an #[ignore]d test that fails today. Run them with cargo test -p onx-consensus -p onxd -- --ignored. Findings 4–7 were found while designing the fix (ADR-0052 Context). Each was checked against the code at 04f0558, but none has a failing test yet. Under the proof gate the two high ones should be proven before they block, so the score is given both with and without them.

# Finding Severity Points Proof Fix design
1 POL unlock path is unreachable, so split locks halt the height (and a locked leader cannot re-propose) critical NO-GO observed_pol_for_other_block_unlocks ADR-0052 §2, §3, §5
2 Unauthenticated future-round votes are buffered without bound high 30 forged_future_round_votes_are_not_buffered ADR-0052 §6
3 A stale buffered vote makes a valid vote return Err and drops its broadcast medium 10 stale_buffered_vote_does_not_poison_valid_vote ADR-0052 §7
4 Two more unauthenticated, unbounded buffer paths (vote before proposal; Commit vote before its block), and no height check before buffering high 30 code reading at 04f0558, no test yet ADR-0052 §2, §6
5 Proposal blocks are stored before the proposal is authenticated, and blocks is never pruned high 30 code reading at 04f0558, no test yet ADR-0052 §6
6 A locked validator rejecting an honest leader's block is logged as EQUIVOCATION evidence medium 10 code reading at 04f0558, no test yet ADR-0052 §3.3, §6
7 consensus.md §3.7 disagrees with the engine: it locks on a PreVote quorum (engine: PreCommit) and unlocks on a proposal-carried QC (engine: local POL) low 2 spec text vs engine.rs ADR-0052 open decision 4

Score (04f0558):

  • Proven findings only: critical outstanding → NO-GO, plus 40 points (2 + 3).
  • All outstanding findings: critical outstanding → NO-GO, plus 112 points (30 + 10 + 30 + 30 + 10 + 2). That alone crosses the 50-point threshold, so the PR would still be NO-GO with finding 1 fixed.
  • ADR-0052 is a proposal, not a fix. Per CONTRIBUTING, a finding leaves the score only when fixed or refuted with evidence, so every finding above still counts.

1. POL unlock path is unreachable (critical, chain halt). 2b87615 replaced the leader-claimed qc_round unlock with a local Proof-of-Lock: a locked validator unlocks only if it has itself seen 2/3+ PreVotes for the new block at a higher round (pol[B] > locked_round). But pol is only written in advance_after_quorum(PreVote), for the current proposal. A validator locked on A rejects every proposal for B (ConflictingProposal), so it has no current proposal for B. receive_vote then rejects every PreVote for B (ConflictingProposal, since votes must match the current proposal). pol[B] can never be written, and the unlock branch is dead code. If validators end up locked on different blocks (for example, a Byzantine node delivers PreCommits selectively, so some honest nodes reach the lock quorum for A in round r and others for B in round r'), neither side can ever unlock. The height stalls forever.
Test result: panicked at engine_integration.rs:245: locked validator never unlocks despite observing a POL for B at a higher round.
Possible direction (not implemented): count PreVotes for any block per round (as Tendermint does), independent of whether the local node accepted that round's proposal, and record pol from those counts.

2. Unbounded buffering of unauthenticated future-round votes (high, memory DoS). The engine returns InvalidRound before it verifies the signature. The driver (69f1ae1, "buffer future-round votes") pushes every such vote into pending with no bound and no authentication. Any peer can grow node memory without limit.
Test result: 1000 forged votes (claimed validator 0, signed with validator 3's key), all buffered: 1000 forged votes buffered.
Possible direction: verify the signature (and validator membership) before buffering, and cap the buffer per validator and per round.

3. A stale buffered vote poisons a valid vote (medium, liveness). 2b87615 keeps pending across view changes. A round-0 vote buffered before the view change is stale in round 1. The retry loop in receive_vote propagates its error with ?. So a valid round-1 vote that the engine already counted returns Err, the PreCommit it triggered is never broadcast (the driver has already marked the phase as voted), and the rest of the drained buffer is dropped.
Test result: a valid vote must not fail because of a stale buffered vote: "consensus: vote rejected: InvalidRound".
Possible direction: drop stale-round entries on view change, and handle retry errors per vote instead of with ?.

4. More unauthenticated, unbounded buffer paths (high, memory DoS). Fixing finding 2 alone would leave two more ways in. (a) The engine checks that a vote matches the current proposal before it checks the signature, and the driver buffers every ConflictingProposal vote while no proposal is held (consensus_driver.rs, "Vote arrived before the proposal"). (b) A Commit vote whose block has not arrived is buffered on the blocks.get miss, before its header signature or vote signature is checked. Neither path has a bound. Also, the engine returns InvalidRound for a wrong height as well as a wrong round, and the driver buffers whenever vote.round > round, so votes for other heights are buffered too.
Possible direction: one admission pipeline (height, round window, signature and membership, then buffer) for every path, with a keyed and capped buffer.

5. Blocks stored before the proposal is authenticated (high, memory DoS). receive_proposal inserts the block into blocks before engine.receive_proposal checks the leader and signature. Nothing ever removes entries from blocks. The only checks before the insert are the hash match and seqno (the prev_hash check is dead because set_head is never called), so any peer can make the node store any number of blocks of up to 8 MiB each.
Possible direction: verify the proposal (leader and signature) before storing, and prune blocks on view change and height change.

6. Locked rejection mislabeled as equivocation (medium, false evidence). The engine returns ConflictingProposal both for a real double proposal and for "locked on a different block". The driver logs every ConflictingProposal as EQUIVOCATION evidence: leader … double-proposed. An honest leader proposing a different block to a locked validator is recorded as slashable misconduct, which ADR-0050 (accountability) treats as evidence.
Possible direction: a separate LockedOnOtherBlock error, logged as a normal rejection.

7. Spec and engine disagree on the lock rule (low, spec drift). consensus.md §3.7 rule 2 locks on a >2/3 PreVote quorum; ADR-0049 §1 and engine.rs lock on a >2/3 PreCommit quorum. Rule 1 unlocks on a "proposal [that] carries a QC from round > locked_round"; since 2b87615 the engine ignores the proposal's QC and uses a locally observed POL. CONTRIBUTING asks for spec deviations to be stated in the PR; this records it.
Possible direction: keep the engine's rule and rewrite §3.7 (ADR-0052 open decision 4).

Fix design: ADR-0052 (proposed, not implemented)

04f0558 adds docs/adr/0052-pol-certificates-and-bounded-vote-admission.md, amending ADR-0049 §1 and §5. Design only: no implementation code, and the three #[ignore] tests are unchanged.

  • Finding 1: the engine logs every signed vote in the current round, whatever block it names, so a POL for another block can form. A locked validator accepts block B only on a verified POL for B above its lock. The lock is never cleared, only replaced. A proposal it cannot accept yet is deferred and re-checked when a POL arrives. Signed POL certificates are relayed (new PolCertificate message, KIND_POL envelope) because a Byzantine validator can deliver its PreVote selectively. A locked leader re-proposes its best-certified block.
  • Findings 2, 4, 5: one admission pipeline (height, round window of 2, signature and membership, then buffer). The buffer is keyed by (round, validator, phase) and capped at 7 per validator, 3·N per round and N·7 overall; new entries are rejected and existing ones never evicted. Proposals are verified before their block is stored, and blocks is pruned to at most three.
  • Finding 3: stale entries are purged on view change, each buffered vote is retried on its own, a phase is marked voted only after the engine accepts the node's own vote, and an outbox keeps queued broadcasts through an Err.
  • Finding 6: new LockedOnOtherBlock error; only a real double proposal is logged as equivocation.
  • Safety and liveness: the ADR proves agreement with Byzantine stake under 1/3 for every unlock path (local, relayed, current-round and future-round POL), and that split locks resolve at the first round after GST with an honest leader. It lists the liveness gaps it does not close.
  • Open decisions (owner): relay message vs POL-in-proposal; constants (horizon 2, 64 POL entries, 10 s propose delay); block bytes in the relay vs fetch-by-hash; lock point and §3.7 rewrite; one new match arm in existing tests; qc_* field names.

Review history (from the original PR #59 on GitHub)

  • 052d9a6: regression tests for review probe 2 (votes_before_proposal_are_buffered) and probe 4 (proposal_with_bad_seqno_is_rejected). Both pass.
  • 0ddbe44: sign the QC fields. qc_round/qc_block are included in proposal_signing_bytes, so a proposer can't alter them after signing. fmt and site rebuild. Recorded the known gap: set_head is never called.
  • 69f1ae1 (re-audit): store the commit-vote header signature only after the engine accepts the vote. Buffer Commit votes whose block hasn't arrived yet. Buffer future-round votes instead of dropping them (see finding 2). A locked leader refuses to propose a different block.
  • 2b87615 (critical safety fix): unlock only on a locally observed POL instead of the leader-claimed qc_round, which a Byzantine leader could forge to unlock honest validators and fork the chain. Reject Commit votes without a header signature (they counted toward finality but added no SigEntry). Keep the pending buffer across round timeouts (see finding 3).
  • c1867bd (this PR): regression tests for 2b87615 (forged_qc_round_does_not_unlock, commit_vote_without_header_sig_is_rejected, both pass) and the three finding tests above (all fail).
  • 04f0558 (this PR): ADR-0052, the fix design for findings 1–6 (docs only; ADR-0049 amendment note; site ADR list regenerated). Drafted in a cloud Claude session on a copy of the repo and applied here byte for byte (same file hashes as that session's patch a4c7212).

Known gaps

  • set_head is never called (recorded in 0ddbe44). The producer doesn't set block.header.prev_hash yet, so producer.rs leaves driver.set_head() as a TODO(ADR-0049). The driver's "proposal builds on local head" check is dead code until the producer is fixed.
  • STF dry-run validation is a TODO (consensus_driver.rs, consensus.md §3.4). Validators check seqno and prev_hash (dead, see above) but don't re-execute the block before voting. The driver has no access to the state at the head yet.

History note

The branch history contains the original M6 series twice, a duplicate from a rebase. 15 M6: commits appear twice (the two series 4321eb5…b5a7115 and 2040d6e…c2a70f1). 10 of the pairs have identical patches, and 5 differ only in context from the rebase. The net diff is correct (19 files, +2731/−152 against main). Squash-merging would give a clean history. A plain merge keeps the duplicate commits.

Merge

091c61a merges main (the Forgejo migration, PR #1) into the branch. site/index.html was resolved by regenerating it (scripts/site.py build). ADR numbering: this branch keeps ADR-0049, because it was written first (2026-10-09). The council ADR from pr60 became ADR-0051 (PR #2).

Prompts

Operator prompt, verbatim, for the session that produced this PR and its sibling. Redactions are marked inline.

  Resume ONX PR work in /root/The-Open-Network-X. Self-hosted Forgejo only:
  https://git.on-x.live/amethyst/the-open-network-x (origin). NO GitHub, ever.
  Zero CI on the server. Commit locally freely; push and open PRs only as
  part of this task (already authorized). Send a Telegram done report at the
  end (memory rule).

  ## State
  - PR #1 (migration/kill-actions) is MERGED into main (dbd4c1a). Run
    `git fetch origin` first. Local `main` is stale; fast-forward it.
  - Two local branches resolve the old pr59/pr60 branches. They are NOT
    pushed and have NO PRs yet:
    1. m6/bft-consensus = origin/pr59 + merge of main (091c61a, site/index.html
       regenerated) + WIP commit 082677e that ADDS TESTS NOT YET COMPILED/RUN.
    2. docs/accountability-council = origin/pr60 + merge of main (8f52976).
       User chose "keep principles, archive council": council ADR renumbered
       0049 -> 0051 and marked superseded, ai-council.yml workflow and
       scripts/ai-council/{health,liveness}.py deleted, PHILOSOPHY.md and
       ADR-0050 unchanged. Doc checks pass. Ready to push and PR.
  - Both may still be checked out in old worktrees under
    /tmp/claude-0/.../scratchpad/wt-m6 and wt-council. If those dirs are gone,
    run `git worktree prune` and re-add the worktrees from the branches.
  - Rust: system rustc is 1.85.1 (too old). Use the pinned toolchain:
    export PATH=$HOME/.cargo/bin:$PATH  (rustup 1.98.1 installed).
    Machine has 3 GB RAM: build with -j3. A prior `cargo test --no-run` was
    interrupted mid-build. Reuse CARGO_TARGET_DIR=/root/The-Open-Network-X/target.

  ## Tests in 082677e (verify they compile; fix test code only)
  onx-consensus/tests/engine_integration.rs:
  - forged_qc_round_does_not_unlock: regression for the critical fix
    2b87615, expected to PASS.
  - observed_pol_for_other_block_unlocks: #[ignore]. FINDING: the POL unlock
    path is unreachable. A validator locked on A rejects B's proposal, and the
    engine only counts votes for the current proposal, so pol[B] is never
    written. Split locks (Byzantine selective PreCommit delivery) stall the
    height forever (liveness). Expected to FAIL with --ignored.
  onxd/src/consensus_driver.rs tests:
  - commit_vote_without_header_sig_is_rejected: regression, should PASS.
  - stale_buffered_vote_does_not_poison_valid_vote: #[ignore]. FINDING: the
    pending buffer survives the view change. A stale round-0 vote hits the
    `?` in receive_vote's retry loop, so a valid vote returns Err, its
    triggered broadcast is lost, and the rest of the buffer is dropped.
  - forged_future_round_votes_are_not_buffered: #[ignore]. FINDING: the engine
    returns InvalidRound before verifying the signature, and the driver
    buffers future-round votes with no bound (memory DoS from any peer).

  ## Steps
  1. In the m6 worktree: cargo fmt --all; clippy -p onx-consensus -p onxd
     --all-targets -D warnings; cargo test -p onx-consensus -p onxd; then
     `-- --ignored` to confirm the 3 findings FAIL as described. If a finding
     test passes, the finding is wrong: delete the test and drop the claim.
     Also run python3 scripts/site.py check, check-doc-links.py,
     check-spec-citations.py, version.py check. Reword the WIP commit with
     the real results.
  2. Do NOT fix the consensus findings (design before code, user decides).
     Report them in the PR body, scored with the CONTRIBUTING point system
     (critical = NO-GO; high 30, medium 10). The POL stall is a chain halt,
     so critical by docs/claude-review-prompt.md.
  3. Push both branches and open 2 PRs to main via the Forgejo API.
     Auth: the user's password in [redacted: credential file path]. Use it
     ONLY via a temp GIT_ASKPASS script (git) and a temp `curl -K` config
     file (API) in the scratchpad, and delete both right after. Never put it
     in a URL, config or log.
     PR bodies must carry the evidence from the original PRs:
     - pr59: review rounds 0ddbe44 (sign QC fields), 69f1ae1 (re-audit:
       header-sig ordering, buffering, locked leader), 2b87615 (critical:
       local POL unlock, reject sig-less commits, keep buffer on timeout),
       probes 2 and 4 regression tests; the 13 M6 commits appear TWICE in
       history (rebase duplicate, net diff fine); known gap from 0ddbe44:
       set_head is never called, so the prev_hash check is dead; STF dry-run
       is a TODO; the new test results.
     - pr60: rounds 395f42e, 24a6893, ff02012, c573e10, f85a699; ADR
       renumbering rationale (pr59's ADR-0049 was written first,
       2026-10-09 vs 2026-10-10); what was archived vs removed and why.
     End each PR body with: 🤖 Generated with [Claude Code](https://claude.com/claude-code)
  4. Note in the final report: my migration commit e3d3bd9 is authored as
     "quickerup" (global git identity). This conflicts with MIGRATION's "one
     human identity, one bot identity" rule. Ask the user which identity
     this repo should use.

Prompts for the 04f0558 update (ADR-0052), verbatim:

  • The cloud session that wrote ADR-0052: its prompt is not available in this session. The commit records it as Claude-Session: https://claude.ai/code/session_01E3fK4bHkZ2YbqXZbGZsSh6.
  • This session, after pasting that session's patch and report:
push it and make a pr
the password is [redacted: credential file path]
update the existing pr as well as the whole score

Review relay

N/A. The GitHub review relay was removed in the Forgejo migration. Review follows CONTRIBUTING.md#review-process. Current self-assessment at 04f0558: NO-GO. Critical finding 1 is outstanding, and the non-critical findings add 40 points (proven) or 112 (all outstanding). ADR-0052 awaits the owner's decisions before any fix is implemented.

🤖 Generated with Claude Code

Carries the old `pr59` branch (M6: wire `onx-consensus` into `onxd`, BFT locking per ADR-0049) onto the Forgejo `main`, and adds tests for the last review round. > **Verdict under the CONTRIBUTING point system: NO-GO** (re-scored at 04f0558). One critical finding (a chain halt) is outstanding and proven by a failing test. The non-critical findings add 40 points from the proven findings (under the 50-point line) and 112 including the four found while designing the fix (over it). Per the "design before code" rule, the findings below are **reported, not fixed**. A fix design for all of them, **ADR-0052**, is now on this branch as a proposal. It is not implemented, so no finding has been removed from the score. ## Consensus impact - [x] breaking - [ ] adjacent - [ ] none This PR introduces BFT consensus (proposal → PreVote → PreCommit → Commit, with Tendermint-style locking) into the node. Nodes with this PR and nodes without it do not interoperate. Could this behave differently on two honest nodes given the same inputs? Yes. Gossip order and timing decide which votes a node has seen before its round timeout, and so which block (if any) it locks on. That's inherent to BFT, but finding 1 below shows that today it can leave honest nodes locked on different blocks permanently. ## Specification and decision record - [x] I identified the applicable `docs/specification/` section: `docs/specification/consensus.md` (updated in this PR). - [x] If no specification existed, this PR adds it before implementation. (The spec existed. Locking was added to it in 3f2b7e5, before the engine change.) - [x] I added an ADR for a significant interpretation or deviation: **ADR-0049, BFT consensus protocol with locking** (written 2026-10-09). - [ ] I implemented the complete specified structure, or documented each tracked exception. **Not complete.** See *Known gaps* (STF dry-run, `set_head`). - [x] I described any ambiguity or deviation explicitly below. ## Checks Run locally (rustc 1.98.1, pinned toolchain), since the server has no CI. Scoped to the two crates this PR changes (`-p onx-consensus -p onxd`), not `--workspace`. - [x] `cargo fmt --all -- --check`: clean - [x] `cargo clippy -p onx-consensus -p onxd --all-targets -- -D warnings`: clean - [x] `cargo build -p onx-consensus -p onxd --all-targets`: ok - [x] `cargo test -p onx-consensus -p onxd`: **all pass** - onx-consensus: wire 8, election 6, engine_integration 4 (+1 ignored finding) - onxd: lib 23 (+2 ignored findings), follower_sync 11, producer_loop 15, four_validator_consensus 1, bin 5 - [x] `cargo test ... -- --ignored`: **all 3 finding tests FAIL**, as expected (see below) - [x] `python3 scripts/site.py check`, `check-doc-links.py`, `check-spec-citations.py`, `version.py check`: pass - [x] Re-run at **04f0558** (ADR-0052, docs only): the four doc checks pass. The Rust checks were not re-run because 04f0558 changes no code; the results above are from c1867bd. Test-environment notes: - The build machine's disk filled up, so the onxd test binaries were linked and run one target at a time. - `producer_loop::daemon_binary_refuses_to_start_without_key` hardcodes `<workspace>/target/debug/onxd`. With a shared `CARGO_TARGET_DIR` it fails to spawn (`NotFound`). With the target dir in place it passes. This is a latent fragility in the test, not a product bug. ## Notes ### Outstanding findings (not fixed, owner decides) Scored with CONTRIBUTING.md's point system. Severity follows `docs/claude-review-prompt.md` ("critical: consensus break, chain halt, …"). Findings 1–3 are each proven by an `#[ignore]`d test that fails today. Run them with `cargo test -p onx-consensus -p onxd -- --ignored`. Findings 4–7 were found while designing the fix (ADR-0052 *Context*). Each was checked against the code at 04f0558, but none has a failing test yet. Under the proof gate the two high ones *should* be proven before they block, so the score is given both with and without them. | # | Finding | Severity | Points | Proof | Fix design | |---|---|---|---|---|---| | 1 | POL unlock path is unreachable, so split locks halt the height (and a locked leader cannot re-propose) | **critical** | NO-GO | `observed_pol_for_other_block_unlocks` | ADR-0052 §2, §3, §5 | | 2 | Unauthenticated future-round votes are buffered without bound | high | 30 | `forged_future_round_votes_are_not_buffered` | ADR-0052 §6 | | 3 | A stale buffered vote makes a valid vote return Err and drops its broadcast | medium | 10 | `stale_buffered_vote_does_not_poison_valid_vote` | ADR-0052 §7 | | 4 | Two more unauthenticated, unbounded buffer paths (vote before proposal; Commit vote before its block), and no height check before buffering | high | 30 | code reading at 04f0558, **no test yet** | ADR-0052 §2, §6 | | 5 | Proposal blocks are stored before the proposal is authenticated, and `blocks` is never pruned | high | 30 | code reading at 04f0558, **no test yet** | ADR-0052 §6 | | 6 | A locked validator rejecting an honest leader's block is logged as `EQUIVOCATION evidence` | medium | 10 | code reading at 04f0558, **no test yet** | ADR-0052 §3.3, §6 | | 7 | `consensus.md` §3.7 disagrees with the engine: it locks on a PreVote quorum (engine: PreCommit) and unlocks on a proposal-carried QC (engine: local POL) | low | 2 | spec text vs `engine.rs` | ADR-0052 open decision 4 | **Score (04f0558):** - **Proven findings only:** critical outstanding → **NO-GO**, plus 40 points (2 + 3). - **All outstanding findings:** critical outstanding → **NO-GO**, plus **112 points** (30 + 10 + 30 + 30 + 10 + 2). That alone crosses the 50-point threshold, so the PR would still be NO-GO with finding 1 fixed. - ADR-0052 is a proposal, not a fix. Per CONTRIBUTING, a finding leaves the score only when fixed or refuted with evidence, so every finding above still counts. **1. POL unlock path is unreachable (critical, chain halt).** 2b87615 replaced the leader-claimed `qc_round` unlock with a local Proof-of-Lock: a locked validator unlocks only if it has *itself* seen 2/3+ PreVotes for the new block at a higher round (`pol[B] > locked_round`). But `pol` is only written in `advance_after_quorum(PreVote)`, for the *current proposal*. A validator locked on A rejects every proposal for B (`ConflictingProposal`), so it has no current proposal for B. `receive_vote` then rejects every PreVote for B (`ConflictingProposal`, since votes must match the current proposal). `pol[B]` can never be written, and the unlock branch is dead code. If validators end up locked on different blocks (for example, a Byzantine node delivers PreCommits selectively, so some honest nodes reach the lock quorum for A in round r and others for B in round r'), neither side can ever unlock. The height stalls forever. Test result: `panicked at engine_integration.rs:245: locked validator never unlocks despite observing a POL for B at a higher round`. Possible direction (not implemented): count PreVotes for any block per round (as Tendermint does), independent of whether the local node accepted that round's proposal, and record `pol` from those counts. **2. Unbounded buffering of unauthenticated future-round votes (high, memory DoS).** The engine returns `InvalidRound` before it verifies the signature. The driver (69f1ae1, "buffer future-round votes") pushes every such vote into `pending` with no bound and no authentication. Any peer can grow node memory without limit. Test result: 1000 forged votes (claimed validator 0, signed with validator 3's key), all buffered: `1000 forged votes buffered`. Possible direction: verify the signature (and validator membership) before buffering, and cap the buffer per validator and per round. **3. A stale buffered vote poisons a valid vote (medium, liveness).** 2b87615 keeps `pending` across view changes. A round-0 vote buffered before the view change is stale in round 1. The retry loop in `receive_vote` propagates its error with `?`. So a valid round-1 vote that the engine *already counted* returns `Err`, the PreCommit it triggered is never broadcast (the driver has already marked the phase as voted), and the rest of the drained buffer is dropped. Test result: `a valid vote must not fail because of a stale buffered vote: "consensus: vote rejected: InvalidRound"`. Possible direction: drop stale-round entries on view change, and handle retry errors per vote instead of with `?`. **4. More unauthenticated, unbounded buffer paths (high, memory DoS).** Fixing finding 2 alone would leave two more ways in. (a) The engine checks that a vote matches the current proposal *before* it checks the signature, and the driver buffers every `ConflictingProposal` vote while no proposal is held (`consensus_driver.rs`, "Vote arrived before the proposal"). (b) A Commit vote whose block has not arrived is buffered on the `blocks.get` miss, before its header signature or vote signature is checked. Neither path has a bound. Also, the engine returns `InvalidRound` for a wrong *height* as well as a wrong round, and the driver buffers whenever `vote.round > round`, so votes for other heights are buffered too. Possible direction: one admission pipeline (height, round window, signature and membership, then buffer) for every path, with a keyed and capped buffer. **5. Blocks stored before the proposal is authenticated (high, memory DoS).** `receive_proposal` inserts the block into `blocks` before `engine.receive_proposal` checks the leader and signature. Nothing ever removes entries from `blocks`. The only checks before the insert are the hash match and `seqno` (the `prev_hash` check is dead because `set_head` is never called), so any peer can make the node store any number of blocks of up to 8 MiB each. Possible direction: verify the proposal (leader and signature) before storing, and prune `blocks` on view change and height change. **6. Locked rejection mislabeled as equivocation (medium, false evidence).** The engine returns `ConflictingProposal` both for a real double proposal and for "locked on a different block". The driver logs every `ConflictingProposal` as `EQUIVOCATION evidence: leader … double-proposed`. An honest leader proposing a different block to a locked validator is recorded as slashable misconduct, which ADR-0050 (accountability) treats as evidence. Possible direction: a separate `LockedOnOtherBlock` error, logged as a normal rejection. **7. Spec and engine disagree on the lock rule (low, spec drift).** `consensus.md` §3.7 rule 2 locks on a >2/3 **PreVote** quorum; ADR-0049 §1 and `engine.rs` lock on a >2/3 **PreCommit** quorum. Rule 1 unlocks on a "proposal [that] carries a QC from round > `locked_round`"; since 2b87615 the engine ignores the proposal's QC and uses a locally observed POL. CONTRIBUTING asks for spec deviations to be stated in the PR; this records it. Possible direction: keep the engine's rule and rewrite §3.7 (ADR-0052 open decision 4). ### Fix design: ADR-0052 (proposed, not implemented) 04f0558 adds [`docs/adr/0052-pol-certificates-and-bounded-vote-admission.md`](https://git.on-x.live/amethyst/the-open-network-x/src/branch/m6/bft-consensus/docs/adr/0052-pol-certificates-and-bounded-vote-admission.md), amending ADR-0049 §1 and §5. Design only: no implementation code, and the three `#[ignore]` tests are unchanged. - **Finding 1:** the engine logs every signed vote in the current round, whatever block it names, so a POL for another block can form. A locked validator accepts block B only on a verified POL for B above its lock. The lock is never cleared, only replaced. A proposal it cannot accept yet is deferred and re-checked when a POL arrives. Signed **POL certificates** are relayed (new `PolCertificate` message, `KIND_POL` envelope) because a Byzantine validator can deliver its PreVote selectively. A locked leader re-proposes its best-certified block. - **Findings 2, 4, 5:** one admission pipeline (height, round window of 2, signature and membership, then buffer). The buffer is keyed by (round, validator, phase) and capped at 7 per validator, 3·N per round and N·7 overall; new entries are rejected and existing ones never evicted. Proposals are verified before their block is stored, and `blocks` is pruned to at most three. - **Finding 3:** stale entries are purged on view change, each buffered vote is retried on its own, a phase is marked voted only after the engine accepts the node's own vote, and an outbox keeps queued broadcasts through an `Err`. - **Finding 6:** new `LockedOnOtherBlock` error; only a real double proposal is logged as equivocation. - **Safety and liveness:** the ADR proves agreement with Byzantine stake under 1/3 for every unlock path (local, relayed, current-round and future-round POL), and that split locks resolve at the first round after GST with an honest leader. It lists the liveness gaps it does not close. - **Open decisions (owner):** relay message vs POL-in-proposal; constants (horizon 2, 64 POL entries, 10 s propose delay); block bytes in the relay vs fetch-by-hash; lock point and §3.7 rewrite; one new match arm in existing tests; `qc_*` field names. ### Review history (from the original PR #59 on GitHub) - **052d9a6**: regression tests for review **probe 2** (`votes_before_proposal_are_buffered`) and **probe 4** (`proposal_with_bad_seqno_is_rejected`). Both pass. - **0ddbe44**: sign the QC fields. `qc_round`/`qc_block` are included in `proposal_signing_bytes`, so a proposer can't alter them after signing. fmt and site rebuild. Recorded the known gap: `set_head` is never called. - **69f1ae1** (re-audit): store the commit-vote header signature only *after* the engine accepts the vote. Buffer Commit votes whose block hasn't arrived yet. Buffer future-round votes instead of dropping them (see finding 2). A locked leader refuses to propose a different block. - **2b87615** (critical safety fix): unlock only on a *locally observed* POL instead of the leader-claimed `qc_round`, which a Byzantine leader could forge to unlock honest validators and fork the chain. Reject Commit votes without a header signature (they counted toward finality but added no `SigEntry`). Keep the pending buffer across round timeouts (see finding 3). - **c1867bd** (this PR): regression tests for 2b87615 (`forged_qc_round_does_not_unlock`, `commit_vote_without_header_sig_is_rejected`, both pass) and the three finding tests above (all fail). - **04f0558** (this PR): ADR-0052, the fix design for findings 1–6 (docs only; ADR-0049 amendment note; site ADR list regenerated). Drafted in a cloud Claude session on a copy of the repo and applied here byte for byte (same file hashes as that session's patch `a4c7212`). ### Known gaps - **`set_head` is never called** (recorded in 0ddbe44). The producer doesn't set `block.header.prev_hash` yet, so `producer.rs` leaves `driver.set_head()` as a `TODO(ADR-0049)`. The driver's "proposal builds on local head" check is dead code until the producer is fixed. - **STF dry-run validation is a TODO** (`consensus_driver.rs`, consensus.md §3.4). Validators check seqno and `prev_hash` (dead, see above) but don't re-execute the block before voting. The driver has no access to the state at the head yet. ### History note The branch history contains the original M6 series **twice**, a duplicate from a rebase. 15 `M6:` commits appear twice (the two series 4321eb5…b5a7115 and 2040d6e…c2a70f1). 10 of the pairs have identical patches, and 5 differ only in context from the rebase. The **net diff is correct** (19 files, +2731/−152 against `main`). Squash-merging would give a clean history. A plain merge keeps the duplicate commits. ### Merge 091c61a merges `main` (the Forgejo migration, PR #1) into the branch. `site/index.html` was resolved by regenerating it (`scripts/site.py build`). ADR numbering: this branch keeps **ADR-0049**, because it was written first (2026-10-09). The council ADR from pr60 became ADR-0051 (PR #2). ## Prompts Operator prompt, verbatim, for the session that produced this PR and its sibling. Redactions are marked inline. ```text Resume ONX PR work in /root/The-Open-Network-X. Self-hosted Forgejo only: https://git.on-x.live/amethyst/the-open-network-x (origin). NO GitHub, ever. Zero CI on the server. Commit locally freely; push and open PRs only as part of this task (already authorized). Send a Telegram done report at the end (memory rule). ## State - PR #1 (migration/kill-actions) is MERGED into main (dbd4c1a). Run `git fetch origin` first. Local `main` is stale; fast-forward it. - Two local branches resolve the old pr59/pr60 branches. They are NOT pushed and have NO PRs yet: 1. m6/bft-consensus = origin/pr59 + merge of main (091c61a, site/index.html regenerated) + WIP commit 082677e that ADDS TESTS NOT YET COMPILED/RUN. 2. docs/accountability-council = origin/pr60 + merge of main (8f52976). User chose "keep principles, archive council": council ADR renumbered 0049 -> 0051 and marked superseded, ai-council.yml workflow and scripts/ai-council/{health,liveness}.py deleted, PHILOSOPHY.md and ADR-0050 unchanged. Doc checks pass. Ready to push and PR. - Both may still be checked out in old worktrees under /tmp/claude-0/.../scratchpad/wt-m6 and wt-council. If those dirs are gone, run `git worktree prune` and re-add the worktrees from the branches. - Rust: system rustc is 1.85.1 (too old). Use the pinned toolchain: export PATH=$HOME/.cargo/bin:$PATH (rustup 1.98.1 installed). Machine has 3 GB RAM: build with -j3. A prior `cargo test --no-run` was interrupted mid-build. Reuse CARGO_TARGET_DIR=/root/The-Open-Network-X/target. ## Tests in 082677e (verify they compile; fix test code only) onx-consensus/tests/engine_integration.rs: - forged_qc_round_does_not_unlock: regression for the critical fix 2b87615, expected to PASS. - observed_pol_for_other_block_unlocks: #[ignore]. FINDING: the POL unlock path is unreachable. A validator locked on A rejects B's proposal, and the engine only counts votes for the current proposal, so pol[B] is never written. Split locks (Byzantine selective PreCommit delivery) stall the height forever (liveness). Expected to FAIL with --ignored. onxd/src/consensus_driver.rs tests: - commit_vote_without_header_sig_is_rejected: regression, should PASS. - stale_buffered_vote_does_not_poison_valid_vote: #[ignore]. FINDING: the pending buffer survives the view change. A stale round-0 vote hits the `?` in receive_vote's retry loop, so a valid vote returns Err, its triggered broadcast is lost, and the rest of the buffer is dropped. - forged_future_round_votes_are_not_buffered: #[ignore]. FINDING: the engine returns InvalidRound before verifying the signature, and the driver buffers future-round votes with no bound (memory DoS from any peer). ## Steps 1. In the m6 worktree: cargo fmt --all; clippy -p onx-consensus -p onxd --all-targets -D warnings; cargo test -p onx-consensus -p onxd; then `-- --ignored` to confirm the 3 findings FAIL as described. If a finding test passes, the finding is wrong: delete the test and drop the claim. Also run python3 scripts/site.py check, check-doc-links.py, check-spec-citations.py, version.py check. Reword the WIP commit with the real results. 2. Do NOT fix the consensus findings (design before code, user decides). Report them in the PR body, scored with the CONTRIBUTING point system (critical = NO-GO; high 30, medium 10). The POL stall is a chain halt, so critical by docs/claude-review-prompt.md. 3. Push both branches and open 2 PRs to main via the Forgejo API. Auth: the user's password in [redacted: credential file path]. Use it ONLY via a temp GIT_ASKPASS script (git) and a temp `curl -K` config file (API) in the scratchpad, and delete both right after. Never put it in a URL, config or log. PR bodies must carry the evidence from the original PRs: - pr59: review rounds 0ddbe44 (sign QC fields), 69f1ae1 (re-audit: header-sig ordering, buffering, locked leader), 2b87615 (critical: local POL unlock, reject sig-less commits, keep buffer on timeout), probes 2 and 4 regression tests; the 13 M6 commits appear TWICE in history (rebase duplicate, net diff fine); known gap from 0ddbe44: set_head is never called, so the prev_hash check is dead; STF dry-run is a TODO; the new test results. - pr60: rounds 395f42e, 24a6893, ff02012, c573e10, f85a699; ADR renumbering rationale (pr59's ADR-0049 was written first, 2026-10-09 vs 2026-10-10); what was archived vs removed and why. End each PR body with: 🤖 Generated with [Claude Code](https://claude.com/claude-code) 4. Note in the final report: my migration commit e3d3bd9 is authored as "quickerup" (global git identity). This conflicts with MIGRATION's "one human identity, one bot identity" rule. Ask the user which identity this repo should use. ``` Prompts for the 04f0558 update (ADR-0052), verbatim: - The cloud session that wrote ADR-0052: its prompt is not available in this session. The commit records it as `Claude-Session: https://claude.ai/code/session_01E3fK4bHkZ2YbqXZbGZsSh6`. - This session, after pasting that session's patch and report: ```text push it and make a pr ``` ```text the password is [redacted: credential file path] ``` ```text update the existing pr as well as the whole score ``` ## Review relay N/A. The GitHub review relay was removed in the Forgejo migration. Review follows `CONTRIBUTING.md#review-process`. Current self-assessment at 04f0558: **NO-GO**. Critical finding 1 is outstanding, and the non-critical findings add 40 points (proven) or 112 (all outstanding). ADR-0052 awaits the owner's decisions before any fix is implemented. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
- config/genesis.toml: 4 validators (test keys 0x11-0x44)
- producer_sig_section_bytes(n): budgets 4+N*68 for the genesis set
- onx-consensus wire.rs: proposal/vote encode/decode + tests
- docs/planning/m6-task-log.md: plan + design decisions
Transport-layer adaptation (engine untouched): commit votes carry the
validator's block-header signature so a finalized quorum doubles as the
block's multi-sig section. Decoder rejects header sigs on non-commit
phases.
- New consensus_driver.rs: owns ConsensusEngine per height, translates
  engine messages to wire bytes, emits broadcast/finalize events.
- Commit votes carry header signatures (transport layer); finalization
  assembles SigEntries from the quorum.
- Out-of-order votes buffered and retried (gossip is unordered).
- Engine gains height()/proposal()/stakes() accessors.
- Tests: 4-validator full round finalizes; 3-of-4 (one offline) still
  reaches quorum.
- run_tick drives the ConsensusDriver: timeouts, inbound messages,
  leader proposal, finalization -> commit with quorum sigs.
- run_producer_loop builds the driver from genesis + signing key;
  local loopback stub for single-validator (P4 replaces with net).
- Producer tests updated to BFT ticks; 16/16 green.
- Timeouts 60s (block building can take seconds; timeout is for dead
  leaders). Clippy clean.
- New consensus_net.rs: length-prefixed TCP gossip for proposals/votes,
  envelope encode/decode with tests.
- ProducerConfig gains consensus_bind/consensus_peers; OnxdConfig parses
  them from config file.
- run_producer_loop spawns the net when configured, else loopback.
- 20/20 onxd lib tests green.
- Driver logs EQUIVOCATION evidence when the leader double-proposes
  (conflicting proposal at same height+round).
- Fork-choice rule documented: first quorum-certified wins; BFT safety
  guarantees no two conflicting blocks finalize without >1/3 Byzantine.
- tests/four_validator_consensus.rs: 4 real onxd nodes on localhost
  with TCP gossip, same genesis, finalize the same block with >=3/4
  signatures in <3s.
- Fixed: tx pubkey must be zero (matches working test pattern).
- Remove the old single-signature sign_block (replaced by BFT driver).
- Fix Clone-on-Copy and sort_by_key lints.
- All tests green: 20 onxd lib + 16 consensus.
- config/genesis.toml: 4 validators (test keys 0x11-0x44)
- producer_sig_section_bytes(n): budgets 4+N*68 for the genesis set
- onx-consensus wire.rs: proposal/vote encode/decode + tests
- docs/planning/m6-task-log.md: plan + design decisions
Transport-layer adaptation (engine untouched): commit votes carry the
validator's block-header signature so a finalized quorum doubles as the
block's multi-sig section. Decoder rejects header sigs on non-commit
phases.
- New consensus_driver.rs: owns ConsensusEngine per height, translates
  engine messages to wire bytes, emits broadcast/finalize events.
- Commit votes carry header signatures (transport layer); finalization
  assembles SigEntries from the quorum.
- Out-of-order votes buffered and retried (gossip is unordered).
- Engine gains height()/proposal()/stakes() accessors.
- Tests: 4-validator full round finalizes; 3-of-4 (one offline) still
  reaches quorum.
- run_tick drives the ConsensusDriver: timeouts, inbound messages,
  leader proposal, finalization -> commit with quorum sigs.
- run_producer_loop builds the driver from genesis + signing key;
  local loopback stub for single-validator (P4 replaces with net).
- Producer tests updated to BFT ticks; 16/16 green.
- Timeouts 60s (block building can take seconds; timeout is for dead
  leaders). Clippy clean.
- New consensus_net.rs: length-prefixed TCP gossip for proposals/votes,
  envelope encode/decode with tests.
- ProducerConfig gains consensus_bind/consensus_peers; OnxdConfig parses
  them from config file.
- run_producer_loop spawns the net when configured, else loopback.
- 20/20 onxd lib tests green.
- Driver logs EQUIVOCATION evidence when the leader double-proposes
  (conflicting proposal at same height+round).
- Fork-choice rule documented: first quorum-certified wins; BFT safety
  guarantees no two conflicting blocks finalize without >1/3 Byzantine.
- tests/four_validator_consensus.rs: 4 real onxd nodes on localhost
  with TCP gossip, same genesis, finalize the same block with >=3/4
  signatures in <3s.
- Fixed: tx pubkey must be zero (matches working test pattern).
- Remove the old single-signature sign_block (replaced by BFT driver).
- Fix Clone-on-Copy and sort_by_key lints.
- All tests green: 20 onxd lib + 16 consensus.
Addresses the critical review finding: the engine lacks locking, allowing
honest validators to finalize conflicting blocks. Specifies Tendermint-style
locks, unified >2/3 threshold, block validation before voting, signed-records,
view changes, and catch-up.
- Add locked_block/locked_round to engine (ADR-0049)
- Lock on PreCommit quorum (>2/3)
- Reject conflicting proposals unless QC from higher round
- Change threshold from >=2/3 to >2/3 (unified)
- Add qc_round/qc_block to proposal wire format
- Add regression test for lock behavior
- Check block.header.seqno == proposal.height
- Check block.header.prev_hash == local head (if set)
- Add set_head() to ConsensusDriver
- TODO: full STF dry-run (requires state access)
- Count own votes locally (don't rely on network echo)
- Buffer votes that arrive before the proposal
- Don't abort tick on bad messages (prevents dropping Finalized)
- Vote all phases the engine advances through (loop)
- proposal_with_bad_seqno_is_rejected (probe 4)
- votes_before_proposal_are_buffered (probe 2)
- Include qc_round/qc_block in proposal_signing_bytes (safety: prevents
  Byzantine proposer from lying about QC to unlock validators)
- cargo fmt clean
- site/index.html regenerated via scripts/site.py build
- Note: set_head not called (producer doesn't set prev_hash yet); the
  prev_hash check remains dead until the producer is fixed.
- Store header sig AFTER engine accepts vote (not before)
- Buffer commit votes for missing blocks (don't reject)
- Buffer future-round votes (don't drop)
- Locked leader refuses to propose different block
- Engine: replace proposer-claimed qc_round unlock with local Proof-of-Lock.
  Validators now unlock only when THEY have seen 2/3+ PreVotes for the
  proposed block at a round higher than their lock round. A Byzantine
  leader can no longer forge qc_round to unlock honest validators.
- Driver: reject Commit votes without header signatures. Previously they
  counted toward engine finality but contributed no SigEntry, leaving
  blocks under-authenticated.
- Driver: don't clear pending vote buffer on round timeout. Buffered
  future-round votes are now retried instead of dropped.
Resolves the site/index.html conflict by regenerating it with
scripts/site.py build on the merged tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Regression tests (pass):
- onx-consensus engine_integration::forged_qc_round_does_not_unlock:
  a validator locked on A at round 0 refuses a round-1 proposal for B
  even when the leader signs qc_round = 1000.
- onxd consensus_driver::commit_vote_without_header_sig_is_rejected:
  a Commit vote without a header signature is rejected by the driver.

Finding tests (#[ignore]; each FAILS under --ignored, proving the bug):
- engine_integration::observed_pol_for_other_block_unlocks (liveness,
  critical): the POL unlock path is unreachable. A validator locked on
  A rejects B's proposal and only counts votes for the current
  proposal, so pol[B] is never written. Fails at the final assert:
  "locked validator never unlocks despite observing a POL for B at a
  higher round".
- consensus_driver::stale_buffered_vote_does_not_poison_valid_vote:
  a round-0 vote buffered before a view change hits the `?` in the
  receive_vote retry loop. Fails with: "a valid vote must not fail
  because of a stale buffered vote: consensus: vote rejected:
  InvalidRound".
- consensus_driver::forged_future_round_votes_are_not_buffered: the
  engine returns InvalidRound before verifying the signature and the
  driver buffers future-round votes with no bound. Fails with: "1000
  forged votes buffered".

Results (rustc 1.98.1): cargo fmt --check clean; clippy -p onx-consensus
-p onxd --all-targets -D warnings clean; cargo test -p onx-consensus
-p onxd: all pass (onx-consensus 18 + 1 ignored, onxd lib 23 + 2
ignored, follower_sync 11, producer_loop 15, four_validator_consensus
1, bin 5). site/doc-links/spec-citations/version checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve site/index.html by regenerating it on the merged tree
(scripts/site.py build). No Rust changes come in from main; ADR-0049
(this branch), ADR-0050 and ADR-0051 now coexist. site, doc-link,
spec-citation and version checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Design for the three PR #3 findings (critical POL unlock unreachable,
high unbounded unauthenticated future-round buffer, medium stale buffered
vote poisons retry loop). Adds ADR-0052 amending ADR-0049 §1/§5, with
safety and liveness arguments, admission limits, view-change cleanup,
per-vote error isolation, test plan and open decisions.

No implementation code; the three #[ignore] regression tests are
unchanged. Site ADR list regenerated (scripts/site.py build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3fK4bHkZ2YbqXZbGZsSh6
amethyst changed title from M6: BFT consensus in onxd (ADR-0049 locking) — NO-GO: 3 open findings to M6: BFT consensus in onxd (ADR-0049 locking) — NO-GO: 1 critical + 112 pts, fix design ADR-0052 proposed 2026-10-11 07:43:07 +00:00
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin m6/bft-consensus:m6/bft-consensus
git switch m6/bft-consensus

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff m6/bft-consensus
git switch m6/bft-consensus
git rebase main
git switch main
git merge --ff-only m6/bft-consensus
git switch m6/bft-consensus
git rebase main
git switch main
git merge --no-ff m6/bft-consensus
git switch main
git merge --squash m6/bft-consensus
git switch main
git merge --ff-only m6/bft-consensus
git switch main
git merge m6/bft-consensus
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amethyst/the-open-network-x!3
No description provided.