Skip to content

Feat/poa admission - #237

Open
lordbutterfly-hive wants to merge 45 commits into
mainfrom
feat/poa-admission
Open

Feat/poa admission#237
lordbutterfly-hive wants to merge 45 commits into
mainfrom
feat/poa-admission

Conversation

@lordbutterfly-hive

@lordbutterfly-hive lordbutterfly-hive commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

feat(poa): validator-admission (POA) — inert, gated behind consensus 0.7.0

Repo: go-vsc-node (only repo touched) · Head: feat/poa-admission @ 0f86c600
Base: feat/btc-vault-rotation-v2 @ c21610f9 · 13 commits · 35 files · ~4,500 insertions / 5 deletions
Branch not yet published. The 0.7.0 announce (DIFF-1) is committed (folded into the top close RG-1c commit 0f86c600).

Summary

Ships the POA (proof-of-authority) validator-admission machinery for Magi:
a seat registry, seat-gated candidacy, vsc.admit_vote (2/3-of-seats admission),
flat seat-weight, a per-election churn cap, and a collateral exit-halt. Everything
lands behind the 0.7.0 consensus-version batch and is completely inert on every
network
— no behaviour changes until the election version floor is raised to 0.7.0
in a separate step (deliberately not in this PR).

This PR also bumps currentConsensus 3 → 7 so the shipped binary announces 0.7.0.
That announcement is itself inert (gates stay off while the floor is 3) but is the
mandatory precondition for a later floor rise — see below.

How activation works (read before reviewing the version bump)

  • POA is gated by the election version floor reaching 0.7.0
    (Version0_7_0Active, feature_gates.go). Until then every POA rule is dead code
    and the chain is byte-identical to base.
  • When the floor rises, the election proposer deletes any witness announcing a
    version below the floor
    (election-proposer.go, PinnedVersionFloor filter).
    So the fleet must be running a binary that announces 0.7.0 before the floor
    is raised, or the committee empties and the vault freezes. That is the entire reason
    for the currentConsensus 3 → 7 bump here — it is the safe, inert first step.
  • Raising the floor is not part of this PR (that's the trigger; it happens at
    deploy time once witnesses are upgraded).

⚠ Fleet-wide version-line safety gate

The version line is shared across branches: 0.4/0.5 = develop's delegated-stake
batch, 0.6 = feat/vault-protection. POA is at 0.7.0 specifically to avoid
collision
. Raising the floor to 7 activates every batch ≤ 0.7.0 present on the
deployed branch. On this lineage only 0.2/0.3/0.7 exist (develop not merged), so
a future 3→7 floor rise activates only POA. Before raising the floor on whatever
branch testnet actually runs, this must be empty:

grep -rn "V0_4_0\|V0_5_0\|V0_6_0" modules/ --include=*.go | grep -v _test

What's included (build map sections A+B only)

  • Consensus line V0_7_0 + 5 feature gates flipping as one batch.
  • poa_seats registry — append-only, height-addressed; records seating/exit
    bookkeeping (the "when did this account leave the set" fact that existed nowhere
    before). Bootstrap seeds it from the election in force at activation.
  • Seat gate on candidacy (with a starvation guard so a degenerate set can't wedge).
  • vsc.admit_vote — admission by ceil(2/3) of seats, cloned from the audited
    witness-vote governance engine.
  • Flat seat-weight (2/3 = 14-of-20 seats, not 2/3 of stake).
  • Churn capPoaMaxNewMembersPerElection = 1.
  • Collateral exit-halt — armed at admission; binds at both unstake-submission and
    payout-release (~6h hold on a departing seat).

What's explicitly NOT in this PR (by design)

  • The floor rise (system-config.go TestnetConfig 765/3 → <epoch>/7) — the
    activation trigger, landed separately after the fleet announces 0.7.0.
  • Economic slashing / proven-theft → auto-slash — POA's deterrent is
    identity + vetting + governance removal, not slashing. The exit-halt is a
    forward-compat hook.
  • Governance/signing split, BTC-custody-tracked collateral, multi-asset coverage.

Audit / open-items ledger

Built under a full PRUNED adversarial pass (23/0) + an extended pass (invariant miner,
exploiter council, failure-state suite). 12 defects in this build were found and
fixed here
(4 critical — the batch was dead code via a $lt self-collision; a
missing omitempty starved the committee; two value-queried height fields had
omitempty so the halt never armed; the seat gate had no starvation guard). These are
squashed into the commits below.

Deliberately-open design items (not code bugs, not blocking this inert merge):

ID What Status
Ratification-gap: exit-halt armed at ratification but membership fixed at generation → unstake slipped the gap CLOSED here (3 iters: arm-from-admission → hold-while-electable → hold-on-recent-activity), adversarially exhausted
Exit-halt "defeats a slash that doesn't exist" INFO under vetting-not-slashing; becomes a design note only if an economic layer is added later
ceil(2/3) admission self-protecting for one round only Retracted from HIGH — holding 13/20 seats is 13 vetted operators colluding, not a cheap ladder
No organic 0.7.0 attestation path → activation is a hand-picked height Resolved by this PR's RunningVersion bump + Route B guarded rise
Bootstrap founds the permanent registry from the committee elected at the chosen moment (unvetted, no un-seed) Operator choice — pin at a vetted/healthy committee
makeDIDs uses floor(2/3) vs ceil(2/3) elsewhere Downgraded — mempool-admission threshold only (consensus auth is BLS ceil-2/3), pre-existing, not bundled
compareIndexOptions nil-compat could defeat registry uniqueness Unreachable today (new collection); noted

Tests

~90 POA tests, verified green at HEAD:

  • state-processing POA 52/52 (incl. both RG-1 guards)
  • election-proposer 13/13
  • poaseats 16 pass (+1 intentional POA_MINE skip)
  • consensusversion gates + params suites pass
  • go build on all touched packages exits 0, go vet clean

Pre-existing unrelated failure TestH6GatewayPoPGate (H-6 gate disabled) is identical
at base. No new failures introduced.

The consensus-version bump carries in-commit tripwire updates the codebase's own error
messages demand: version_test.go source-pin → 0.7.0, and the "binary must announce
the highest batch" tripwire relocated to poa_gates_test.go
(TestRunningVersionImplementsPoa).

Deploy order (do not raise the floor early)

  1. Merge to the branch testnet runs; re-run the V0_4/5/6 grep — must be empty.
  2. Land this PR (announces 0.7.0), build, deploy to all testnet witnesses; confirm
    each announces 0.7.0. Inert — nothing activates.
  3. Confirm ≥ MinMembers(=3) upgraded witnesses will be in the committee at the target
    epoch (bootstrap refuses below 3 and does not retry).
  4. Land the floor rise (separate change) with the activation epoch set past the rollout.
  5. At the epoch: gates flip → seats seed from that election → POA active; seat gate
    bites one epoch later (built-in slack).

Commits (13, off c21610f9)

0f86c600 fix(poa): close RG-1c — hold on recent witness activity  [+ 0.7.0 announce bump folded in]
44db7185 fix(poa): complete RG-1 close — hold the bond while the operator is electable
6638da1a fix(poa): close RG-1 — arm the collateral exit-halt from admission
fbebeb32 test(poa): failure-state suite + RG-1 characterization
8afb3bf0 refactor(poa): code-quality pass — typed dup errors, drop dead return
f48ff97f audit(poa): retract the inflated threshold finding; add the miner harness
a83ebae0 fix(poa): batch was permanently dead code; churn cap could stall the epoch
c06cf553 fix(poa): normalise account case before stripping the hive: prefix
a318259c fix(poa): two more silent bson/query mismatches — the halt never armed
02c3e172 fix(poa): eight defects found by the PRUNED pass on this build
1021f8eb feat(poa): vsc.admit_vote — seat admission by 2/3 of seats (S4)
c53bb564 feat(poa): seat gate, flat seat-weight, churn cap, exit-halt (S3,S5,S6,S7)
0167c6ba feat(poa): seat registry + consensus-version gates (S0-S2)

Note: the currentConsensus 3→7 announce ("DIFF-1") was folded into the top
0f86c600 commit by an amend, so its message doesn't call it out separately. The
bump is present and in the PR; splitting it into its own commit is optional cleanup.

Files changed (35, vs c21610f9)

consensusversion (gate + version line)

modules/common/consensusversion/feature_gates.go
modules/common/consensusversion/feature_gates_test.go
modules/common/consensusversion/poa_gates_test.go
modules/common/consensusversion/version.go
modules/common/consensusversion/version_test.go

state-processing (admission engine + seats)

modules/state-processing/poa_admission.go
modules/state-processing/poa_admission_internal_test.go
modules/state-processing/poa_seats.go
modules/state-processing/poa_seats_internal_test.go
modules/state-processing/poa_failure_states_test.go
modules/state-processing/state_engine.go
modules/state-processing/state_engine_test.go
modules/state-processing/system_txs.go
modules/state-processing/transactions.go
modules/state-processing/audit_fix_c4_gvl12_test.go

election-proposer (floor filter + POA election)

modules/election-proposer/election-proposer.go
modules/election-proposer/poa_election_test.go
modules/election-proposer/audit_unfixed_37_test.go
modules/election-proposer/gateway_pop_gate_test.go
modules/election-proposer/review2_election_consensuskey_test.go

poaseats DB (registry)

modules/db/vsc/poaseats/poaseats.go
modules/db/vsc/poaseats/types.go
modules/db/vsc/poaseats/poaseats_test.go
modules/db/vsc/poaseats/poaseats_storage_test.go
modules/db/vsc/poaseats/minerharness_test.go
modules/db/vsc/governance/governance.go

params / config / governance / wiring

modules/common/params/params.go
modules/common/params/poa_params_test.go
modules/common/system-config/system-config.go
modules/common/common_types/types.go
modules/governance/governance.go
modules/e2e/node.go
cmd/vsc-node/main.go
lib/test_utils/mock_poaseats.go
lib/test_utils/contract_test_utils.go

lordbutterfly and others added 30 commits July 7, 2026 21:52
…recovery snapshot)

Recovery snapshot of the v6 TSS vault-rotation build after the originating
session bricked mid-M1.3-council. Bundles three dark-launched milestones,
all inert until VaultRotationV2ActivationHeight / MaxNewMembersPerElection
are set (0 on every network today):

- M1.1a: deterministic governance FLAG halt for BTC keysign (vsc.tss_halt
  custom_json + solvency_gate.go, gate at tss.go SignAction choke). Council-
  hardened to FLAG-only; SIGNAL scaffolded/fail-open. Mainnet-safe, inert.
- M1.2: churn RATE cap MaxNewMembersPerElection 0->1 mainnet only.
- M1.3: reshare->keygen rotation, PER-KEYID gate-off (U-1) + fresh keyId per
  gen + keygen reward-sourcing G15 (ScoreTssKeygenExclusion). Dark-launched
  behind VaultRotationV2Enabled(bh). 5 mandatory TSS checks authored.

Builds green (magid + host pkgs); unit tests pass. Full state in
/mnt/o/MAGI-TSS-BLOCKTRADES-AUDIT-2026-07-07/BUILD-LEDGER.md.
Deferred/outstanding: M1.3 council (lost in brick, re-run), M1.4, S2 devnet
rotation proof, M1.3b NR-9 check-sig ceremony.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…f tests, deploy guardrail

Re-ran the M1.3 council (4 Opus lenses) after the originating session bricked
mid-review. M1.3 PASSES (shipped diff is correct, deterministic, byte-identically
inert at flag=0, honest dark-launch). These are the hardening fixes from the
adjudication (none says M1.3 is broken); all inert until VaultRotationV2Enabled.

- corr-F1 split-brain guard (state_engine.go): reject a "keygen" commitment whose
  keyId is already active — contract keyId-reuse would keep the stale on-chain
  PublicKey while the DKG wrote new shares, freezing the vault. Reshare (same
  pubkey) unaffected. Gated on VaultRotationV2Enabled -> byte-identical when inert.
- compl-F1 tests: extracted shouldSkipReshareForVaultRotation (behaviour-preserving)
  + TestShouldSkipReshareForVaultRotation (7 cases) + TestVaultRotationV2Enabled.
- adv-F1/corr-F3/compl-F3 deploy guardrail (convergent, 3 lenses): enabling the flag
  with only M1.3 shipped freezes the vault. Hard go-live precondition doc at the
  flag def (params.go) + explicit VaultRotationV2ActivationHeight: 0 in all 4 configs.
- corr-F2 documented global keygen scoring (intended); corr-F4 record accuracy.

Builds green (magid + host pkgs); new + existing tss/params/rewards tests pass.
4 pre-existing state-processing failures (pendulum-oracle/review2, DB-harness) fail
identically at base 69d4493 — flagged for team, not a regression. Synthesis:
/mnt/o/MAGI-TSS-BLOCKTRADES-AUDIT-2026-07-07/methodology-run/M1.3-council-synthesis.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Last Month-1 containment item. Adds zeroizeKeystoreEntry — best-effort secure
erase (overwrite stored value with same-length zeros + clear in-memory copy)
of a retired TSS key share, wired into the keystore-cleanup delete path.

DORMANT TWICE OVER: (1) gated on VaultRotationV2Enabled(bh) -> byte-identical to
the bare delete on every network today (flag 0); (2) the cleanup block is under
KeyRetirementEnabled (false on mainnet, never flipped) -> does not run until S5's
fund-gated retire path calls the same helper. Wiring secure-erase to the current
TIME-gated retirement would turn a stalled-migration freeze into unrecoverable
loss, so dormancy is safety-critical, not cosmetic.

Honest limitation documented: shares are already AES-256-GCM at rest (the primary
control); on flatfs a Put is temp-file+rename, so this is a logical overwrite +
unlink, not a guaranteed physical erase. Defence-in-depth, not a substitute.

Builds green; TestZeroizeKeystoreEntry + full tss package pass. 5 TSS checks
authored (local keystore op, no party-list/CID/consensus output). Light-gated
(self-verified, like M1.2) — not a full 4-lens council. Record:
/mnt/o/MAGI-TSS-BLOCKTRADES-AUDIT-2026-07-07/methodology-run/M1.4-implementation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applied the fix batch from the pruned audit methodology over Month-1 (7
decorrelated lenses). Month-1 shipped diff was SOUND as-shipped; these are
hardening + one framing correction. All build green + unit tests pass.

- Churn-cap activation height [4 lenses -> HIGH]: add MaxNewMembersActivationHeight
  + EffectiveMaxNewMembers gate; election-proposer reads the effective cap. The
  0->1 cutover is now an atomic single-block flip (dark-launchable), so a rolling
  binary upgrade can't split old(cap=0)/new(cap=1) nodes into divergent election
  member sets. Mainnet value stays 1 but INERT until governance pins a future
  height. +TestEffectiveMaxNewMembers.
- Un-wire M1.4 zeroize [key-lifecycle L1 -> MED]: removed the time-gated zeroize
  caller; kept zeroizeKeystoreEntry+test as the primitive S5's fund-gated retire
  wires. The time-gate had zero fund check -> could destroy a still-funded key's
  share (recoverable freeze -> permanent loss) if KeyRetirementEnabled flipped pre-S5.
- Solvency-math overflow [money-math F1]: observeBtcSolvencyInsolvent computed in
  uint64 with underflow guard + reject implausibly-large Supply (was int64 cast,
  inverted for uint64 >= 2^63). Dead M1.1b code; fix-before-wiring.
- Halt fail-open, read path [3 lenses]: refreshChainConsensusCache retains prior
  cache on a consensus-state Get() error (fail closed for BtcKeysignHalted/
  ProcessingSuspended) instead of resetting to zero-value.
- Halt fail-open, write path [error-handling]: ERROR (was Warn) on SetBtcKeysignHalt
  write failure - the node did NOT apply the emergency halt; re-broadcast the op.
- Manual reshare fail-open [2 lenses]: KeyReshare BTC refusal fails CLOSED before
  the first BlockTick (lastBlockHeight==0) when v2 is configured.

Deferred (tracked): V-8 evacuation whitelist (S3), zeroize-all-epochs (S5),
btcclient URL-escape/non-negative (M1.1b), pre-existing retire-status race (team).
The one election-proposer test failure (TestH6GatewayPoPGate) is pre-existing
(gateway-PoP, MocknetConfig cap=0 -> gate is a no-op; fails identically at base).
Synthesis: methodology-run/MONTH1-PRUNED-METHODOLOGY.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… V-8

S3.0-S3.3: the fund-safety heart of the spine's node layer. A retiring/
draining vault generation's TSS key may sign ONLY a migration sweep whose
every output pays the committed successor vault's P2WSH — anything else is
refused (ends "sign blind"; closes the theft-oracle the reconstructable
old key would otherwise be).

- lib/btcvault: dependency-light lib-lift (like lib/btcclient) — vault
  registry + TxSpends decode, msgp SigningData decode (tolerant of the
  optional amount field), successor P2WSH derivation and BIP143 (segwit-v0)
  sighash recompute, all byte-for-byte mirrors of the contract.
- modules/tss/output_scoping.go: deterministic pre-issuance gate at the
  SignAction choke (tss.go:1036). Reads the vault list + pending spends from
  consensus contract state (readContractStateKey, pinned to bh) and the
  successor PRIMARY from COMMITTED tss_keys (never a contract field — the C-C
  guard). Binds the untrusted template to the signature by recomputing the
  sighash (a SigHashAll digest commits to the outputs). Fails CLOSED on any
  uncertainty. Inert until VaultRotationV2Enabled (activation height 0).
- V-8: a proven successor-scoped sweep is exempt from the M1.1a solvency
  halt so the honest evacuation to the new vault can still complete.

Determinism: pure local skip-or-issue before any session/party-list/CID work
(keysign results are never CID'd/BLS-collected) — Constraints 1-3 safe by
construction; every input is consensus-state/committed, so all nodes reach
the identical verdict. 5 mandatory TSS checks authored (S3-DESIGN.md).

Tests: lib/btcvault (4) + output_scoping (9) green — successor sweep allow,
active-gen unrestricted, inert pre-fold, non-sweep digest refuse, non-
successor output refuse, lying-decoy recompute-catch, missing-amount fail-
closed, unknown/non-fund-holding refuse, corrupt-registry fail-safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry (HIGH)

Council converged (3 lenses: adversarial, fail-safe, correctness) on a fail-OPEN
in the output-scoping gate: evaluateScope mapped resolveVaultView->not-ok to
scopeAllow, but not-ok conflated a genuinely ABSENT registry (pre-fold, allow
correct) with a PRESENT-but-corrupt one (malformed / 0-active / 2+-active). The
latter fell open, letting a lying/compromised contract disable NN#1 by writing a
bad "v" and get the reconstructable retiring key to sign an attacker output.
Inert until the flag, but must-fix before pinning VaultRotationV2ActivationHeight.

- resolveVaultView is now TRI-STATE: vaultAbsent (allow ONLY gen-0 "main" — a
  higher-gen keyId cannot exist without a registry -> refuse), vaultUnresolvable
  (registry present but corrupt/ambiguous -> refuse ALL BTC-vault keysigns, fail
  closed + recoverable), vaultResolved (the status switch). Once "v" is present,
  any failure is unresolvable, never absent.
- Extracted btcSignGateDecision(verdict, halted) (pure) so the V-8 halt exemption
  is unit-testable without a live datalayer.

Tests: TestEvaluateScope_UnresolvableRegistryFailsClosed (two/zero-active +
malformed -> refuse, incl. active-gen keyId frozen on a corrupt registry),
absent-non-main -> refuse, TestBtcSignGateDecision_V8 (proven sweep issues during
a halt; all else freezes). Full tss + btcvault suites green, vet clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…try (HIGH) + resolve-once (F-1)

Two node fixes from the pruned methodology (both inert until the flag; must land
before pinning VaultRotationV2ActivationHeight).

- money-math HIGH: resolveVaultView keyed byKeyId by VaultKeyName(Generation) and
  counted activeCount over the raw slice, so two entries sharing a Generation
  (one Retiring, one Active) aliased onto one map key — last-write-wins could hide
  the reconstructable retiring key behind an Active entry, returning scopeAllow
  (a fail-open worse than the council's, since it never reaches the sighash/output
  check). Now reject any duplicate Generation -> vaultUnresolvable (fail closed).
  Honest state never reuses a generation (monotonic).
- assumptions F-1: the gate re-ran GetLastOutput once per key ("v"/"va"/"p" + one
  "d-<txid>" per pending spend) under the global TSS lock. Added
  contractStateReaderAt: resolve the committed output + state databin ONCE, then
  read every key from that one databin. Cuts N+3 GetLastOutput to 1, bounding work
  under the lock. (A context-bounded GetRaw is a tracked hardening — needs a
  datalayer API that takes a context; contract-state leaves at a processed bh are
  local, so a network stall is an edge.) btcScopeDeps.readKey is now a bound
  1-arg closure; evaluateScope/resolveVaultView/retiringSignPaysSuccessor drop bh.

Tests: TestEvaluateScope_DuplicateGenerationFailsClosed (dup-gen -> refuse) added;
all output_scoping + lib/btcvault tests green (reader closure + evaluateScope
signatures updated); magid builds; vet clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ions at the flag def

WARDEN flagged that the deploy-ordering constraints lived only in O-drive
markdown, not in the code where the other go-live preconditions are. Add to the
VaultRotationV2ActivationHeight doc: (d) pin the churn cap too (else the V-A
liveness mitigation is vacuous), (e) verify ContractId("BTC") is populated (empty
= all three BTC gates go inert together), (f) reorg-harden the migration confirm
flow + pause-exempt the confirm path (tracked S2). Comment-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s into the deploy gate

The 5-lens failure-state/brick council found NO permanent brick reachable today
(shares never deleted + CSV backstop + inert), but named the constraints that keep
it that way. Record them at the flag def as hard pin preconditions: (g) build the
check-signature-before-activate ceremony (FS3-1: else funds route to an unsignable
vault); (h) ★ S5 retirement must gate on SPV-proven ZERO L1 balance, NEVER contract
registry-emptiness (FS5-1: the one forward-coupling to PERMANENT LOSS — a stranded
delete-at-build gen reads registry-empty while funds are on L1); (i) raise contract
MaxBlockRetention >= CSV timelock + reorg; (j) don't let ProcessingSuspended
deprecate a fund-holding gen while renewKey is blocked. Comment-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uspend + reshare extends expiry

Two node key-lifecycle fixes from the failure-state/brick council (both prevent a
recoverable freeze from becoming reachable, both deterministic on-chain state).

- BRK-5 (FS-1/FS-2): while the chain is processing-suspended, FREEZE the
  deprecation/retirement clock. A suspend blocks renewKey (a non-recovery contract
  op), so the unconditional epoch clock would deprecate a fund-holding gen's key
  with no in-suspend cure (freeze until unsuspend). The chain is halted anyway, so
  no lifecycle should advance; it resumes normally when the suspend lifts. Gated on
  the on-chain ProcessingSuspended flag (refreshed just above).
- BRK-8 (FS3-2 / L-1): a RESHARE now extends the key's ExpiryEpoch (like activation
  does), so an actively-reshared in-use key does NOT deprecate mid-life on the fixed
  ~3-month epoch clock (the manual-renew time-bomb). A key that stops resharing still
  deprecates at its last-reshare expiry; reshare only; legacy no-expiry keys
  (Epochs==0) unchanged. Deterministic (commitment.Epoch + Epochs are on-chain).

Builds green + vet clean; consensus_version/suspend tests pass; full
state-processing package shows only the 4 pre-existing Mongo-harness failures
(unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…k (i)/(j) done

The 4-lens methodology on the brick fixes verified them sound (deterministic, fail-
safe, abuse-resistant, accounting-neutral). Fold in the determinism lens's deploy-
ordering caveat: BRK-5/BRK-8 change consensus key-lifecycle timing once Epochs>0
keys exist, so the fleet must run the binary uniformly before v2 keys are minted
(inert on today's legacy Epochs==0 keys). Mark (i) MaxBlockRetention + (j) suspend-
deprecation-freeze as implemented (BRK-4a / BRK-5). Comment-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… + fee-funding

BRK-1 (delete-at-confirm migration) is built + council-proven on the contract
(feat/vault-rotation-s1 @ a5cf0a0), so precondition (f) — reorg-hardened
confirm flow + pause-exempt migration confirm — is now DONE.

Add precondition (l): BRK-1's registry-based NN#3 makes rotation-liveness
depend on every superseded gen fully draining. A sub-sweep-fee dust deposit to
a superseded gen's still-matchable address is credited but un-sweepable
(V-1/V5-4 fee abort) → NN#3 freezes rotation (funds-safe, liveness DoS). Ship
the V-1 dust escape (or a min-deposit floor / S5 SPV-zero prune) before pinning.
Also: FeeSupply must be funded (calcVscFee=0 today rejects every sweep at the
build-time reserve check — fail-safe can't-start, not stuck-on-L1).

Comment-only; VaultRotationV2ActivationHeight stays 0 (inert).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… node slice)

A fresh vault generation activates today on keygen AGREEMENT alone — nothing
proves the new committee can PRODUCE a signature with the agreed key. Funds
could route into an agreed-but-UNSIGNABLE vault and custody collapses to the
single CSV backup key (brick council FS3-1, deploy precondition g).

Option A' reuses the EXISTING deterministic vsc.tss_sign secp256k1 verify (no
new BLS ceremony, no new WASM crypto): the moment a v2 BTC-vault key flips
created->active, the node enqueues a canonical, domain-separated check-message
M(keyId,gen,committed-pubkey); it is signed through the unchanged sign path,
admitted by a new S3 scopeCheckSig verdict (a Pending gen signs NOTHING but M),
and on the verified landing sets tss_keys.SignatureVerified. The contract's
attestPrimaryKey (next slice) requires the flag before activating the vault.

- lib/btcvault/checksig.go: CheckSigMessage + VaultGenFromKeyId (one source of
  truth for both node sites; exact inverse of VaultKeyName).
- modules/tss/output_scoping.go: scopeCheckSig verdict + pendingSignIsCheckSig
  (recompute M from the gen's OWN committed pubkey; halt-exempt, moves no funds).
- modules/db/vsc/tss: TssKey.SignatureVerified (omitempty; never CID-hashed) +
  SetSignatureVerified dedicated single-field updater (+ mock).
- modules/state-processing: enqueue on the created->active flip + set-on-verified
  in the vsc.tss_sign handler; shared vaultCheckSigDigest binds identical bytes.
- D1 (fork-safe): TssGetKey's 4th field appended ONLY when vault-rotation-v2 is
  chain-active, via WithVaultRotationV2 (mirrors WithTryCatch); byte-identical
  legacy 3-field return when off.

All inert behind VaultRotationV2Enabled (deploy gate unchanged, flag stays 0).
5/5 mandatory TSS checks PASS (determinism pre-mortem + re-run vs actual code).
New unit tests green; full node builds. NOT pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e GQL simulate path

The contract-call SIMULATION resolver (schema.resolvers.go, read-only, reverted)
was the one exec-context New() site besides transactions.go and did not pass
WithVaultRotationV2 (same pattern as its pre-existing WithTryCatch omission). So
under an active v2 chain a simulated activateKey saw the legacy 3-field TssGetKey
and optimistically reported activation succeeding before a check-signature had
landed. NOT a consensus issue (the simulate result is never hashed into the state
root — the real activation goes through the correctly-gated transactions.go
path); simulate-accuracy only, but a misleading preview during a rotation
ceremony. Fix: reflect the chain-active VaultRotationV2Enabled flag in the
simulate ctxOpts (nil-guarded). Found by the BRK-2 council determinism lens.

Council verdict: SOUND, this one LOW finding only — no consensus-fork, no
fund-loss, no brick; every failure state fails safe. Adjudication:
methodology-run/BRK-2-COUNCIL-ADJUDICATION.md. Builds green. NOT pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…igibility

The migration-sign party is built from the retiring gen's own committee (correct),
but readiness is emitted only by current-election members (isMember gate), verified
on receipt only against the current election (p2p.go), and version-floored to the
current floor. So once >1/3 of the old committee churns OUT of the current election
(ordinary churn, NO attacker) they are filtered out → the migration sweep can never
reach threshold → the retiring vault stays funded → the next rotation is blocked
(NN#3) → permanent BTC freeze (C-A / V-A). CSV backup (30d) is the only escape.

Fix: widen the CONVERGENT gossip set with an on-chain-DETERMINISTIC addition —
fund-holding retiring/draining-gen committee members become first-class readiness
participants for the migration-sign target, regardless of current-election
membership. This is the OPPOSITE of the reverted GV-H8 mistake (which replaced the
gossip set with a stale snapshot): each member's readiness stays a single
BLS-signed, settle-window-converged claim; we only widen WHO may emit / be verified,
from a set every honest node computes identically from consensus state.

- retiring_eligibility.go: pure computeRetiringSignerSet(deps) + the TssManager
  method (reads the vault registry -> retiring/draining gens -> each gen's
  commitment bitset ∩ epoch election). Unit-tested.
- tss.go emission gate: isMember || retiring-eligible; retiring signer exempt from
  the CURRENT version floor (V5-6).
- p2p.go receive: verify a churned-out retiring member's attestation against its
  gen's commitment epoch election; widen the member pre-filter + bundle cap.
- tss.go sign-party filter: version-floor exemption scoped to retiring-gen signs.

5/5 mandatory TSS checks authored in BRK-3-VA-DESIGN.md (PASS by construction,
contingent on the deterministic receive-election union + DEVNET validation of the
version-floor exemption). Byte-identical / inert when VaultRotationV2Enabled==false.
Builds green; new unit tests pass. Council is the next step. NOT pushed.

SCOPE: node-side eligibility only. The ECONOMIC bond-lock (#11 — prevent unstake
until drained) and withholding DETERRENCE (V5-1) are separate tracked slices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nt (GV-H8 PASS)

3-lens council (determinism/GV-H8, adversarial, fail-safe) verdict: V-A is SOUND
and fund-safe — GV-H8 TEST PASS (a legitimate widening of the convergent gossip
set, NOT a snapshot-replaces-gossip; monotonic-additive so it can't manufacture a
new freeze), no theft, S3 output-scoping not bypassed, reconstruction window
REDUCED not increased, every failure state fails safe. No CRIT/HIGH/MED-correctness.

One actionable finding fixed:
- A3 (MEDIUM, DoS-amplification, inert until v2 pinned): the UNTRUSTED ready_gossip
  receive path called retiringGenSignerSet(targetBlock) on EVERY message; once v2
  is live that is an un-timeouted contract-state datalayer read per message,
  sharing the pubsub semaphore with consensus-critical ask_sigs/res_sig/round
  messages → a cheap unauthenticated flood could starve signature collection. Fix:
  retiringGenSignerSetCached — memoize per target height (dedicated mutex, never
  held across the read; short-circuits + allocation-parity while the flag is off;
  bounded cache). The emission (once/block) and sign-filter (once/sign) paths are
  not per-message and stay uncached.
- INFO: corrected the "highest-gen election" comment to the actual deterministic
  last-writer-wins-in-registry-order behaviour.

Tracked (LOW/liveness, devnet-gated, fail-and-retry — on the design's devnet list):
A2b compromised-since-rotated old BLS key → offline-member stall; A2c honest
churned+rotated member unverifiable → CSV; A4 retiring-sign version-heterogeneity.
Also tracked (pre-existing): the un-timeouted GetRaw on contractStateReaderAt
(M1.1b), and gating ready_gossip acceptance to members (optional pre-pin).

Builds green; vet clean; tss tests pass. Adjudication:
methodology-run/BRK-3-VA-COUNCIL-ADJUDICATION.md. Node ... 15aee81 -> HEAD. NOT pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…te) + shared predicate

V-A kept a fund-holding retiring/draining committee signing-ELIGIBLE after churn so
the migration can sign; this is the ECONOMIC counterpart: those same members cannot
UNSTAKE their consensus bond until their generation is drained. Otherwise the freely-
churning committee that holds the reconstructable old key's shares banks its keygen
reward, unstakes, and leaves — dropping the retiring key below threshold so the
migration can never complete (C-A/V-A: permanent BTC freeze, no attacker). Locking
the bond gives a rational member the incentive to stay online and finish the sweep.

- NEW modules/vaultrotation: the deterministic "who is in a fund-holding
  retiring/draining committee" predicate (ComputeRetiringSignerSet), extracted so
  BOTH consumers key off the IDENTICAL set — a member is signing-eligible IFF it is
  bond-locked, never one without the other. Lives in a leaf package because
  modules/tss imports state-processing (so state-processing can't import tss).
- modules/tss (V-A): re-wired to the shared core (behaviour unchanged; V-A tests +
  the A3 cache preserved).
- state-processing: *StateEngine.IsBondLockedRetiringMember (via the shared predicate
  + a contract-state reader mirroring the tss one) + the common_types.StateEngine
  interface method; TxConsensusUnstake rejects an unstake by a bond-locked member.

Deterministic (consensus state at height). Byte-identical / INERT while
VaultRotationV2Enabled is off (returns false before any DB read) — inert test proves
it; the functional locked→reject path is devnet-gated like the rest of the spine.
Full node builds; vet clean; V-A + inert tests pass. NOT pushed.

KNOWN GAP (deferred, honest): this blocks NEW unstakes while locked, but does not
hold an ALREADY-pending unstake that a member front-ran before its gen went retiring
(narrow, speculative, Epoch+5-bounded) — a hold-pending-release follow-up, or covered
by the V5-1 withholding deterrence. Council is the next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/F4 front-run pin-gate

3-lens council found #11-as-built was DEAD-ON-ARRIVAL and FORK-PRONE-WHEN-PINNED
(both HIGH, both inert today). Adjudication: methodology-run/BOND11-COUNCIL-ADJUDICATION.md.

- F1 (HIGH, dead code) FIXED: account-namespace mismatch — TxConsensusUnstake passes
  tx.From ("hive:alice"), but the retiring-committee set is keyed by BARE election
  account names, so the map lookup never hit → the gate returned false for every real
  witness (unstake freely). Fix: bondLockMatches strips the "hive:" prefix before the
  lookup + a positive-path unit test (the original only covered the flag-off inert
  path, which couldn't tell "inert" from "matches nothing"). The tss/V-A consumer is
  unaffected (bare↔bare).
- F2 (HIGH/CRIT-when-pinned) DESIGNED + GATED (params.go (m)): the bond-lock reads
  fail-OPEN on transient infra errors and drive a CONSENSUS tx outcome, so two nodes
  reading differently commit different state roots → fork — the exact class review4
  #96 fixed for the co-located election read. The correct fix is a multi-API fail-STOP
  refactor (GetLastOutput + GetElection both swallow errors → need fail-stop variants +
  blockingRetry); not rushed at the tail of a marathon. INERT today (gate off before
  any read + pin hard-gated on this) → zero live fork risk; unreachable until pin.
- F4 (front-run RELIABLE), F3 (perf/cache), F5 (clean negative + contract "v" status
  audit cross-ref), F6 (permanent-lock correctly S5-gated) — tracked in the pin gate.

Also marked precondition (g) DONE (BRK-2 check-sig ceremony, this session). Builds
green; F1 positive-path + inert tests pass. #11 is functional + inert-safe; fork-safety
+ front-run are tracked HARD-PRE-PIN. NOT pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…close the fork)

Council determinism HIGH: IsBondLockedRetiringMember drives a CONSENSUS tx outcome
(TxConsensusUnstake success/reject + the ledger mutation), but its reads fail-OPENED
on transient infra errors, so two nodes reading differently would commit different
state roots -> fork. Now fail-STOP, mirroring the co-located review4 #96 election read.

Scope (proven): the fork surface was exactly the two Mongo SWALLOWS. GetRaw/DagServ.Get
use the online bitswap blockservice + context.Background(), so the datalayer reads
BLOCK-and-fetch (fork-safe; a non-local leaf blocks, never silently fails open).

- GetLastOutputStrict (contracts) + GetElectionStrict (elections): error-surfacing
  variants of the two swallowing reads (added, not modified — the 11 existing
  GetLastOutput callers + GetElection's nil-on-error contract are untouched; mocks +
  3 test stubs updated).
- bond_lock.go: IsBondLockedRetiringMember wraps the compute in blockingRetry; a
  TRANSIENT read error (isTransientReadErr = non-nil AND not mongo.ErrNoDocuments)
  blocks/retries until infra recovers; ErrNoDocuments = DETERMINISTIC absence (skip
  gen); parse/decode of committed content-addressed state = deterministic (fail-open,
  all nodes agree). "not-locked" is concluded ONLY from reads that SUCCEEDED.
- isTransientReadErr is the load-bearing halt-safety invariant (mis-classifying
  ErrNoDocuments as transient = infinite block = network halt) — unit-tested directly.

Byte-identical / INERT while VaultRotationV2Enabled off (returns false before any
read). Full node builds; vet clean; halt-safety + namespace + inert tests pass
(elections Mongo-integration test TestGetElectionByHeight is a pre-existing infra dep,
unrelated). params.go (m): fail-stop DONE; (m1) front-run hold-release + (m2) S5
lock-release still tracked before pin. NOT pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…its gen drains

Council F4: the block-new-unstake gate did NOT hold an ALREADY-pending unstake that
a member front-ran before its gen went retiring (reliable, not speculative) — the
bond is debited at unstake time and the payout matures at Epoch+5 regardless, so a
member could cash out ahead of finishing the migration while still holding the
retiring key's TSS share (signing-eligible via V-A). This closes it.

- ledger-system/utils.go: the consensus_unstake ActionRecord now carries the BONDED
  account as Params["from"] (the record's To is only the payout destination, which
  may differ).
- state-processing (the matured-unstake RELEASE path): HOLD a payout whose From is
  still a bond-locked retiring committee member — skip the payout and don't mark it
  complete, so it stays pending and is re-attempted next slot; it releases
  automatically once the gen leaves the fund-holding set (S5). Funds are DEFERRED,
  never lost (standard pending-unstake accounting: the #in debit already happened;
  only the #out credit is delayed). Consensus-safe: reuses the SAME F2 fail-stop
  IsBondLockedRetiringMember (all nodes hold/release the identical set). INERT when
  vault-rotation-v2 is off (predicate returns false → normal release); a
  pre-this-change record has no "from" → treated as not-locked (inert).

params.go (m1) front-run hold-release: DONE; (m2) S5 lock-release still tracked (a
lock/hold releases only when a gen leaves the fund-holding set = S5's job). Node
builds; vet clean; bond-lock tests pass. NOT pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t) in lock-step

lib/btcvault.UnmarshalVaultRegistry decodes the contract's "v" state key (used by
modules/vaultrotation eligibility / the #11 bond-lock signer set). S5 grew the contract's
VaultEntrySize 87→91 to carry InactiveHeight; this mirror must move with it or the node
misaligns every entry past the first (and len%stride rejects the blob). Cross-repo
deploy-order: the 91-byte contract and this reader ship together.

Found by the S5 brick council (schema/blast-radius lens, cross-repo F9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the keysign gate

Design C (as robust as the governance flag): each block, refreshBtcTheftHalt reads the BTC
mapping contract's deterministic "th" theft flag (set by the SPV-proven reportUnauthorizedSpend
auto-trip), mirrors it into consensus_state.btc_theft_halted, and the keysign gate freezes on
BtcKeysignHalted() || BtcTheftHalted(). Reuses #11's btcContractStateReaderAtStrict; keep-last-
on-transient + change-detected-write; deterministic + fail-safe. The V-8 successor-sweep + BRK-2
checksig exemptions apply to the theft halt too (an output-bound evacuation to the protocol's
fresh vault rescues funds from a thief — verified not an escape).

2-lens council (determinism/consensus-path + fail-safe/liveness): VERDICT SOUND — fork-impossible
(BtcTheftHalted is a per-node derived view, never hashed into consensus/CID; only the local gate
reads it), divergence = fail-safe stall (btss needs 100% of parties), not stuck-on-able (clean-
absence clears; only a self-healing infra stall in the safe direction), no permanent miss (self-
healing writes), inert-safe (change-gated, no BTC contract = no-op). Fixes folded: H-1 the 3
GetScheduler/ConsensusState mocks (fakeSolvencyScheduler, MockElectionSystem, MockConsensusState)
+ a theft-flag gate test (the OR branch was untested); L-3 cross-repo clear-⇒-key-absent note.

Node builds; state-processing/consensus_state vet clean; tss test pkg compiles (needs WasmEdge
to RUN — infra-gated, CI). Inert until a report/flag exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…anism

Per the U-3 decision — do NOT build a new TSS auto-ban (membership removal is PoA's job; a
naive ban is a proven death-spiral, see the disabled election-proposer banSystemEnabled=false)
— but fix the two real latent bugs the recon found in the EXISTING live mechanism.

BUG 1 (consensus fork risk) — BlameScore read GetElectionByHeight(MaxInt64-1) ("the latest
election, right now") instead of the processed height bh. Under the async block-consumer race
(consumer.go:45-57) two nodes could resolve a just-landed election differently -> divergent
BannedNodes -> divergent party lists -> SSID mismatch (a silent consensus fork, Constraint 2/3).
Fix: BlameScore takes the currentElection RunActions already resolved via
GetElectionByHeight(bh), so the ban set is deterministic and byte-consistent with the party
lists it filters. No behavior change in the common (non-race) case. Removes the now-unused
"math" import.

BUG 2 (correctness) — keygen's blame exclusion decoded the blame bitset against currentElection
instead of the blame's OWN epoch election ("Known Bug on Main #1", already fixed on
reshare/sign). setToCommitment encodes bit positions via GetElection(epoch).Members, so
decoding against currentElection excludes the WRONG members once membership drifts between the
blame's epoch and now. Fix: mirror the shipped reshare/sign decode — decode each blame in the
BLAME_EXPIRE window against GetElection(blame.Epoch), require >33% (TSS_BLAME_THRESHOLD_PERCENT)
of the window's blame commitments to name an account (so a single manufactured blame can't drop
a healthy node), then exclude by account. A fresh keyId has no blames -> inert.

The 5 mandatory TSS CHECKS (repo .claude/CLAUDE.md) PASS for both: each change only makes the
party-list inputs MORE deterministic (removes a non-deterministic election read; decodes
against the correct on-chain election), reducing SSID/CID divergence, never increasing it.

Verified: node builds, go vet clean, all 13 BlameScore unit tests pass, modules/tss green,
modules/tss/tests compiles. Test-debt: the keygen blame-decode path has no dedicated runtime
unit test (needs a RunActions/libp2p harness); it mirrors the proven reshare logic — flagged
for the devnet/integration phase.

NOTE: the repo .claude/CLAUDE.md "Example C" (ban trips PrepareForSigning len(ks)!=pax panic) is
STALE — tss-lib v3 (and v2) pre-filters keydata via BuildLocalSaveDataSubset before
PrepareForSigning, so subset-exclusion does not panic. Doc correction tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-cleared)

FULL-PRUNED methodology run (2026-07-09) over the 73-file build map, adjudicated
against SECURITY-TRUST-MODEL_v3, verified by two 3-lens councils (determinism /
correctness / fail-safe). All changes INERT behind VaultRotationV2Enabled (mainnet
activation height still 0). Councils: no fork / brick / fund-loss introduced.

Consensus / TSS:
- L2-1  BlameScore ban-cap: add Account tiebreak -> total order (closes a real
        map-range boundary-tie fork; tss.go).
- L9-1  reshare loop skips ONLY superseded (retiring/draining/inactive) BTC gens;
        the ACTIVE gen now reshares to follow committee churn (fixes a withdrawal
        FREEZE). Reuses the shared retiring predicate; deterministic; fail-safe on
        corrupt "v" (solvency_gate.go + test).
- L2-2  V-A readiness receive pre-filter reads election + retiring set at the
        settled currentBh, not the future targetBlock (p2p.go).
- L8-01 ComputeRetiringSignerSet fails CLOSED (Unresolvable) on a corrupt "v";
        #11 bond-lock locks all rather than release on unverifiable state
        (eligibility.go, bond_lock.go).
- L4-C1 shared retiring predicate + node IsFundHoldingStatus mirror include
        Inactive, lock-step with the contract (eligibility.go, vault.go + test).

Config / tooling:
- L8-02 correct the mainnet churn-cap "LIVE now" comment (dead code until
        MaxNewMembersActivationHeight is pinned; system-config.go).
- L8-03 gate -sysconfig to devnet/mocknet in mapping-bot / contract-deployer /
        genesis-elector, matching cmd/vsc-node (footgun removal on the oracle role).

Tracked, NOT in this commit: L7-01 RBF stuck-tx re-drive (fund-critical, devnet-
gated - next); L10-2 pre-existing base fail-open reads (don't-fix-mainnet); L8-04
slash-window validation (slashing deferred); L9-1 session-id idx stability +
FindEpochKeys no-sort (pre-pin liveness hardening).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fix(tss): admit the genesis generation's own check-signature so a fresh
  vault-rotation-v2 deployment can activate gen-0 (output_scoping.go). The
  single-Pending-zero-Active genesis state was unresolvable, so the activation
  check-sig was refused and genesis deadlocked. Fail-closed for every other
  state (>=2-gen / corrupt / non-Pending). Runtime-proven on devnet + unit test.
- fix(tss-db): SetSignedRequest uses ReturnDocument(After) so a first check-sig
  enqueue no longer logs a spurious "failed to enqueue" warning on a successful
  upsert-insert.
- devnet: full BTC money + rotation/drain + retire/purge + pause/theft +
  money-edge integration harness (tests/devnet/vault_stage1..7, genesisfix,
  batched, l7_redrive) with parallel-lane support (tss_helpers_test.go).
- devnet: raise RC_HIVE_FREE_AMOUNT to 1M for the devnet/mocknet networks only
  (system-config.FromNetwork) so ephemeral test accounts afford SPV-heavy op
  gas; mainnet/testnet keep the 10_000 production default (params.go).
- build: HOME=/tmp in the devnet contract Makefiles so the TinyGo build works
  under the test UID.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The contract is pull-driven: it only builds a migration sweep when someone calls
migrateVault, and getMigrationInputs returns at most MaxMigrationInputs UTXOs per
call ("the caller drains it in successive tranches"). Nothing in the bot drove
that loop, so a rotated-out generation kept most of its BTC forever — the sweep
was never built, the generation never drained, retireVault could not advance it
(fund-gated), and the committee's bond stayed locked. Broadcasting and settling
are already generic over pending spends (HandleUnmap -> HandleConfirmations ->
confirmSpend), so a sweep rides that pipeline once it exists; only the driving
side was missing.

- vault_rotation.go: per block, build the next tranche (migrateVault), fall back
  to writeOffDust when a residual is provably un-sweepable, and retireVault once
  a generation is drained. Rate-limited (every op costs RC), never stacks a
  second sweep on an in-flight one (that would draw the fee reserve down twice
  and race the same UTXO set), fails CLOSED on an unreadable vault registry, and
  is a no-op on any contract without a vault registry (dash/ltc/... and a
  pre-rotation BTC deploy). The whole policy is a pure decideVaultAction() so it
  is unit-testable without a chain.
- vault_state.go: batched vault- and UTXO-registry reads (one query for every
  UTXO object, not one per UTXO); reuses the node's UnmarshalVaultRegistry so the
  91-byte stride cannot drift from the contract.
- contract-interface/const.go: the "v" / "ms-" / "us-" state keys, mirrored from
  the contract.
- call_contract_l2.go: a per-action RC limit (rcLimitFor). The bot attaches one
  global RcLimit (default 10000) to every L2 tx, but the vault ops are
  SPV/secp256k1/P2WSH-derivation heavy (~8M); at the default every migrateVault
  would abort with a cost-limit-exceeded. Raised for the four vault ops ONLY —
  not globally, because a high rc_limit reserves HBD against RC and surfaces as a
  spurious insufficient-balance on ops that move HBD (the vault ops move none).

The vault ops are owner-or-operator gated; until governance appoints this bot via
setVaultOperator the driver latches on the not-owner rejection and reports it
once, rather than burning RC on guaranteed-failing retries.
… mixed-version

Devnet coverage for the paths the PR left unproven or soft-asserted. Every
assertion reads committed contract state (the vault "v" and UTXO "r" registries),
not transaction status — several existing cases passed while transitioning
nothing.

- vault_drain_complete_test.go: settles EVERY migration tranche and proves a
  multi-UTXO generation drains fully to zero, then genuinely Inactive, then
  Purged. Replaces the vacuous Stage-5 retire asserts (which only checked that
  the retireVault tx confirmed, while retireVault succeeds transitioning nothing).
- vault_operator_drain_test.go: a non-owner appointed via setVaultOperator drives
  the full drain + retire; a stranger is refused; a non-owner cannot appoint; a
  revoked operator is refused again. The delegation end-to-end on a live chain.
- vault_writeoffdust_test.go: the deadlock escape hatch — a sub-dust residual
  that migrateVault refuses as uneconomic is cleared by writeOffDust so the
  generation drains and retires.
- vault_redrive_test.go: the L7 re-drive at the contract level — a premature
  re-drive is refused (staleness gate), a stranger is refused (operator-scoped),
  and the operator re-drives a stale sweep into a higher-fee replacement.
- vault_mixed_version_test.go: rolling-upgrade proof — PR nodes + merge-base
  nodes in one fleet, v2 flag off (the post-merge mainnet state), asserting
  block-for-block cross-node convergence so the ungated per-block theft-halt read
  and the new consensus_unstake Params["from"] neither fork nor stall a
  half-upgraded network.
- vault_batched_test.go: register/activate now retry the BRK-2 check-sig instead
  of a single racing read — without which the VL-PEN-15 set-once and VL-PEN-10
  two-keygens negative cases passed VACUOUSLY (a second op "rejected" only because
  the first never landed).
- vault_stage4_test.go: factor migrateAndSettleAs(opNode) out of migrateAndSettle
  so a sweep can be driven from an appointed operator's identity.
A migration sweep that is broadcast but never confirms (built at too low a fee for
the live BTC market) leaves the driver's in-flight check true forever, so the drain
wedges: the generation never empties, retireVault cannot advance it, and the
committee's bond stays locked. The existing bot only REPORTS staleness via /health
(manual operator action); nothing drove redriveSpend.

Track the contract height at which each in-flight sweep was first seen and, once a
sweep has been in flight past the stale window, call redriveSpend to replace it with
a higher-fee tx. The contract's own staleness gate (RedriveStaleBlocks) is the final
arbiter, so an early call is simply refused; the driver only avoids issuing calls it
knows will be rejected. Settled sweeps are forgotten so a recycled txid starts a fresh
clock rather than inheriting a stale one. redriveSpend also gets the raised vault-op RC
limit (it builds a fresh signed sweep — as heavy as migrateVault).

HasMigrationSweepInFlight becomes InFlightSweepTxIds (returns the sweep txids the
re-drive works off); FetchContractHeight reads the contract "h" for the staleness clock.
Unit-tested (TestNoteSweepsInFlightStaleness): too-early does not redrive, stale does,
a settled sweep is forgotten, a recycled txid is not instantly stale.
…nnot steal

Proves the flip side of TestVaultOperatorDrivenDrain: the scoped operator's blast
radius is exactly the four self-validating ops and nothing more. Appoints an operator,
then confirms that operator CANNOT:

- writeOffDust a generation holding a large, still-sweepable UTXO (no fund
  destruction — the op runs but the un-sweepability gate deletes nothing)
- retireVault a still-funded generation (no premature purge / orphaning)
- setVaultOperator (no privilege escalation / self re-appointment)
- pause the contract (no governance seizure)

and that gen-0's funds are intact after every attack. 5/5 on devnet.
Devnet proof of the bond-lock: a committee member whose generation holds a
retiring, not-yet-drained BTC vault key cannot unstake its consensus bond, and can
once that generation is drained.

The SAME consensus_unstake op is issued twice — refused while gen-0 is
retiring+funded, accepted after gen-0 is fully drained — so the difference isolates
the bond-lock and its release (an insufficient-stake refusal would fail BOTH; a
bond-lock fails only the first). Observed via the ledger_actions pending
consensus_unstake amount (the same predicate GetAccountPendingConsensusUnstake uses):
0 while locked, > 0 once released. 3/3 on devnet.
tibfox and others added 15 commits July 14, 2026 17:02
…ection

Proves the ungated, live-on-merge governance halt end-to-end: a gateway-multisig
vsc.tss_halt(true) sets consensus_state.btc_keysign_halted on EVERY node, and
tss_halt(false) clears it — the op-plumbing integration the mixed-version test could
not reach (it broadcast once, before vsc.gateway's authority was populated). This
version retries the op until the flag actually propagates, which also confirms VSC
accepted it (a Hive-level broadcast success is not acceptance), using the known-working
deterministic-BLS gateway-multisig setup. The flag->freeze logic itself is unit-tested
(TestBtcKeysignFrozen_FlagAndScope).

Also fixes countHaltFlag: it queried the "consensus_state" collection, but the halt
flag lives in "chain_consensus_state" — so it always read 0 regardless of the real
state. Verified against a live devnet (node logs "vsc.tss_halt applied" on all nodes +
chain_consensus_state.singleton.btc_keysign_halted=true). This latent bug also affected
the mixed-version helper.
The reshare-skip set (retiring/draining/inactive) excluded PURGED — the terminal
retired state. And nothing deactivates a gen's tss_key at purge: FindEpochKeys
selects on status:"active" (blind to vault status), no purge handler touches
tss_keys, and KeyRetirementEnabled is false. So a purged generation's key stayed
active, kept being returned by FindEpochKeys, and — not being in the skip set — was
RESHARED every cycle, resurrecting a retired key's shares in the current committee.
That defeats the PR's headline property (a reconstructed key becomes worthless
within one rotation): the purged key stayed live and reshared forever.

Add ReshareSkipKeyIds — every NON-active generation (retiring/draining/inactive AND
purged) — and point the reshare loop at it. Kept SEPARATE from KeyIds on purpose:
the #11 bond-lock and V-A signing consumers must RELEASE a purged gen's members (it
is drained + past grace, nothing to sign, no reason to stay bond-locked), so Purged
must NOT enter KeyIds. Only the single ACTIVE gen ever reshares. Inert until
VaultRotationV2Enabled, like the rest of the skip.

TestComputeRetiringSignerSet_PurgedSkipsReshareButReleasesBond: a purged gen is in
ReshareSkipKeyIds but NOT KeyIds; the active gen is in neither.
POA admission batch, part 1 of the build map at
MAGI-POA-BUILD-2026-07-20/00-BUILD-MAP-v2-IMPLEMENTABLE.md.

S0 - activation surface. New consensus line V0_7_0 with five resolvers
(PoaAdmissionOps/SeatGate/FlatWeight/ChurnCap/ExitHalt), all flipping as
one batch. 0.7.0 rather than 0.4.0 because 0.4/0.5 are develop's
delegated-stake batch and 0.6 is vault-protection's: a shared line means
one floor rise silently activates two unrelated batches after a merge.
Version-gated, not height-gated, so a laggard is excluded from the
committee rather than forking across a height gap. New ConsensusParams:
PoaAdmitVoteWindowBlocks, PoaExitHaltBlocks, PoaMaxNewMembersPerElection,
each with a non-zero fallback (a zero window expires proposals instantly,
a zero halt is no halt, a zero cap wedges admission permanently).

S1 - poa_seats collection. Append-only by construction: the interface has
no delete, because voting is entry-only (signers must not be able to vote
each other out - a smaller set is a cheaper capture, and the admit
threshold is self-protecting only while the set cannot shrink).
Height-addressed reads so a reindex reproduces the same elections as a
live node. Accounts normalised through one helper: a "hive:" prefix
mismatch does not error, it matches nothing, and in the election path
matching nothing means an empty committee.

S2 - seat maintenance at the ratified-election consensus point. Two jobs:
bootstrap seeding, and the seating/exit bookkeeping that creates the "when
did this account leave the set" fact - which exists nowhere in the
codebase today (elections carry no reason, no status, no departure
height), and which the collateral exit-halt is counted from.

Bootstrap seeding is the load-bearing safety property. The seat gate is an
allowlist over candidacy; activated against an empty registry it deletes
every candidate. That is not hypothetical - the structurally identical H-6
key-admission gate starved the mainnet committee below the floor at epoch
1699, halted elections, and is still disabled today. So the first ratified
election after activation seeds the registry from its own member set: no
operator action, no flag day. Empty registry keeps the gate inert; a
failed registry read skips maintenance entirely rather than reading as
"nobody is seated" (which would arm the halt against every operator at
once).

Exit recording is idempotent: once an exit height is set, later elections
that also exclude the account cannot push it forward. Without that guard
an operator who exits and stays out has their 3-day clock reset every
election interval, and a temporary lock becomes a permanent seizure.

Inert below 0.7.0 - no registry writes at all, so this binary produces
byte-identical state to the current one until the floor rises.

Tests: 11 seat-maintenance (whitebox, local doubles - test_utils imports
this package so an in-package test cannot import it back), 10 registry,
5 gate/param. Note: cmd/vsc-node needs `make generate` first on this
branch; gqlgen's generated.go/models.go are gitignored and absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…6,S7)

S3 - seat gate. Election candidacy is restricted to ratified seats, so
stake alone no longer buys a place in the committee.

Placement is load-bearing: the gate runs BEFORE the stake loop, the churn
cap and the bond floor guard, so the floor guard remains the LAST
membership-shrinking step and can still backfill incumbents. Three
independent anti-starvation layers, because this is the same gate shape
that halted mainnet at epoch 1699: version-gated on the PRIOR election's
version (one epoch of slack after the floor rises), inert while the
registry is empty (bootstrap seeding fills it), and fail-stop on a read
error (abort the attempt, retry next slot) rather than building a
committee from an unknown seat set.

S5 - flat seat-weight. Every seat carries params.PoaSeatWeight (1). Stake
keeps every other role - MinStake floor, maturity window, established
grace, slashable bond - but stops being consensus weight, so 2/3 means 14
seats of 20 rather than two-thirds of stake. Not a novel path: pType
"initial" already flattens weights today, and every downstream consumer is
a ratio rule over whatever weights the election carries. TSS thresholds
are member-count based and untouched.

Two secondary sites flatten too, and both matter. The bond floor guard's
backfill would otherwise re-seat an incumbent at its stake-derived weight,
putting one stake-proportional member into an otherwise flat committee -
restoring the exact capture vector through the liveness patch. distWeight
is inert today (REQUIRED_ELECTION_MEMBERS is empty) but is computed as a
share of the total, so flattening it stops one-seat-one-vote being quietly
undone later by adding a required member.

S6 - churn cap. The existing cap is dead code everywhere:
EffectiveMaxNewMembers needs MaxNewMembersActivationHeight pinned and it
is 0 on every network, mainnet included. (Four devnet tests set the cap
value and assert on it while the height stays 0.) POA activates it off the
version gate rather than pinning a height, which removes the mis-pin
footgun documented on the field itself - a bare value lets old-binary
cap=0 and new-binary cap=N nodes compute different member sets for one
election and stall the epoch on failed BLS aggregation.

S7 - collateral exit-halt. A seat's bond is unwithdrawable while it is in
the set, and for PoaExitHaltBlocks after it leaves. This is what makes the
bond a deterrent: theft cannot be prevented (a threshold signature
confirms on Bitcoin in ~10 minutes regardless of Magi), so it is deterred
by collateral the thief cannot extract before detection.

Enforced at BOTH the unstake submission and the matured-payout release,
and the second is what makes it real - the submission check alone is
bypassable by ordering (submit while comfortably seated, then leave and
let the 5-epoch maturity elapse, and the bond pays out with no halt ever
applied). Fail-closed on an unreadable registry: a delayed withdrawal is
bounded, a thief's collateral leaving during the detection window is not.

Termination is proven, not assumed: unstaking drops weight, the next
election records an exit, and the bond releases a bounded time later. An
operator who stays seated stays held - by their own choice, and the
refusal message names the release height.

Tests: 7 election-path (gate excludes unseated / inert when empty /
fail-stop / inert below activation, flat weight ignores a 10,000x whale,
weight still tracks stake below activation, churn cap defers newcomers but
never incumbents), 18 seat + exit-halt.

Pre-existing failures unchanged by this work, verified against c21610f:
TestH6GatewayPoPGate (the H-6 gate it asserts is disabled for the epoch-
1699 liveness fix), and 4 in state-processing (pendulum-oracle x2,
review2 x2). The election-proposer suite cannot run from /mnt/o at all -
NewContractTest chmods data/config and DrvFs refuses; run it from an ext4
checkout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The only way a new operator ever enters the set. A clone of the existing
witness-vote governance mechanism (vsc.reserve_payout / vsc.reserve_vote):
propose-is-first-vote, ceil(2/3) beneficiary-excluded threshold, expiry
window, one-shot terminality. It reuses that engine's pure core and its
proposal store rather than re-implementing the tally, so the determinism
argument is the already-reviewed one.

Two deliberate differences from the governance trio:

The electorate is SEATS, not the elected committee, one vote each. A seat
temporarily out of the committee (dropped for a liveness fault) still
votes, because it is still an admitted operator; and no amount of stake
buys a second vote.

There is no create op. Every vote for the same (candidate, ubo) converges
on one proposal id derived from that pair, so the first vote opens the
proposal. Folding a tx id into the id - as reserve_payout does, correctly,
for its chosen amounts - would scatter votes across per-tx proposals and
make the threshold unreachable by construction.

Why the threshold IS the theft threshold: admission at ceil(2/3) of seats
is self-protecting. A coalition below 2/3 cannot vote accomplices in to
REACH 2/3, and one already at 2/3 gains nothing - it already controls the
vault. That holds only while the set cannot SHRINK, which is why there is
no removal op anywhere in this build (A6, asserted structurally) and why
the seat gate is placed where it cannot starve the committee.

The UBO is part of the proposal id, not loose payload, so a coalition
cannot gather approvals for one beneficial owner and then seat a
different one. A blank ubo_id is refused rather than defaulted: every
blank collides under the sparse unique index, so the per-owner cap would
silently stop binding.

The electorate is snapshotted at proposal creation. Re-reading it per vote
would move the denominator under an open proposal - seats admitted
mid-window raising the bar for a vote already cast, and a shrinking set
LOWERING it. A failed registry read refuses to tally at all, for the same
reason: a partial electorate is a lower 2/3 bar.

WHAT THIS DOES NOT DO: it does not vet anybody. The chain enforces that a
UBO string is present and unique; it cannot verify the string is TRUE.
KYC/UBO vetting is an off-chain precondition to casting a vote, and
reading the uniqueness check as vetting would be a serious misreading.

Tests: 14. Threshold (2 of 4 refused, 3 of 4 admits), no vote stacking,
non-seats ignored, second seat per owner refused, blank ubo refused,
expiry closes and stays terminal, applied is terminal, already-seated
candidate is a no-op, electorate snapshot pinned, read failure refuses to
tally, wrong net_id ignored, and two structural checks that no removal
path exists in the interface or the payload.

Suite state on this branch, all verified identical at base c21610f:
4 state-processing + 1 election-proposer (H6, whose gate is disabled) fail
pre-existing; 6 modules/db packages fail on a 30s timeout because no
MongoDB is running. The Mongo-backed poaseats implementation is therefore
NOT covered by a live-DB test - only the semantics-mirroring mock is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Found by the seven Phase-0 component models run against S0-S4. Ordered by
consequence.

CRITICAL - bootstrap seeded ONE seat, then halted the chain. UboId was the
only optional Seat field without bson omitempty, so every bootstrap seat
wrote an explicit empty ubo_id. A Mongo SPARSE unique index only exempts
ABSENT fields - an explicitly stored empty string is indexed - so seat #1
inserted and #2..N failed with duplicate-key errors that the loop logged
and continued past. The registry then held one seat, which is not empty,
so the seat gate's inert-while-empty guard did not fire; the gate deleted
every other candidate, the committee fell below MinMembers, and
HoldElection aborted. That is the epoch-1699 committee-starvation halt,
reintroduced by the mechanism written to prevent it - and the comment
above the index confidently asserted the opposite. No mock could catch it:
it is a property of the struct tag and the storage engine, so the
regression test asserts on bson marshalling instead.

CRITICAL - the seat gate had no starvation guard, only an ordering
argument. It now computes the gated list speculatively and DECLINES to
apply it if that would drop the committee below MinMembers, logging at
ERROR. One epoch of permissionless candidacy is recoverable; a stalled
chain is not. MinMembers rather than bondCommitteeFloor: the latter folds
in the hardcoded 8-key gateway floor, which exceeds a whole 3-node devnet
and would make POA untestable, and mainnet already pins MinMembers to 8
because of that same gateway floor.

HIGH - what got seated was not what was voted on. The seat write used the
crossing vote's freshly-parsed payload rather than the proposal's stored
fields, so the last voter, not the electorate, determined the admission.
Now seats from prop.Candidate/prop.UboId - and, because that made the
store load-bearing, SaveProposal was extended to actually persist those
two fields. It hand-rolls its set map and had silently dropped both, which
would have made every admission write an empty account and fail. The two
fixes are only correct together; either alone is worse than neither.

HIGH - flat weight applied without the seat gate. Only the candidacy block
checked poaSeats != nil; flat-weight, churn, backfill and distWeight gated
on version alone. A node with no registry would admit any staked witness
AND flatten everyone to weight 1 - committee weight bought at MinStake per
seat, cheaper than the stake-weighting POA replaces and free of the
vetting it adds. All POA election rules now share one predicate, so they
apply as a set or not at all.

HIGH - proposal-id delimiter was attackable. The id is sha256 over
candidate, a NUL byte, and the owner id - but both fields were raw JSON
strings, and a JSON u0000 escape decodes to a real NUL that passes through
both normalisers, so a crafted pair shifts the field boundary and two
different (candidate, owner) pairs collide into one proposal. Candidates
are now bounded to Hive's account charset and owner ids to printable
non-space ASCII. Case and surrounding whitespace are still NORMALISED
rather than rejected, and before the id is derived, so differently-spelled
votes converge on one proposal instead of splitting the electorate.

HIGH - registry writes were best-effort on a consensus path. ExitHeight
decides whether an unstake is refused or paid; a write failing on one node
while succeeding on peers diverges the ledger result for an identical tx,
and SetExit's idempotency means no later election can repair it. Now
fail-stop via blockingRetry, matching bond_lock and the safety-slash
paths.

MEDIUM - bootstrap could enshrine a degraded committee, permanently. Seats
are append-only and widening the set needs 2/3 OF THE SEATS, so whatever
the first post-activation election happened to contain became a permanent
floor with the survivors holding a veto. Bootstrap now refuses to seed
below MinMembers and retries next election. It also only fires at the
actual transition (previous election below the POA line) rather than
whenever the registry looks empty - otherwise a node that merely LOST its
poa_seats collection would re-seed from the current committee and diverge
silently, since poa_seats is not merklized and the reindex trigger does
not cover it.

MEDIUM - non-deterministic bootstrap order and a releasable hold.
Bootstrap iterated a Go map, so under any partial failure WHICH seats
survived was node-dependent. Now sorted. SetSeating gained a monotonic
guard: clearing exit_height is the one write that RELEASES a collateral
hold, so an older election reprocessed by a replay or reorg must not be
able to drive it.

Also: poaseats.NormalizeAccount now case-folds, so the write path
(governance.NormalizeAccount, which folds) and the read paths agree by
construction rather than by relying on Hive's account charset, which
nothing here stated or tested.

72 POA tests, all passing. Unchanged pre-existing failures, verified
identical at c21610f: 4 in state-processing, TestH6GatewayPoPGate, and
the modules/db packages that need a live MongoDB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same class as the omitempty bug fixed in 02c3e17, found by checking the
rest of the struct for it. Both silent, both invisible to every in-memory
test, and one of them was INTRODUCED by that commit is own monotonic guard.

last_seated_height and exit_height are QUERIED BY VALUE: SetSeating filters
last_seated_height $lte, SetExit filters exit_height == 0 together with
last_seated_height $gt 0. A MongoDB value query does not match a document
where the field is ABSENT - only an explicit null or $exists:false does.
Both fields carried bson omitempty, so on a freshly admitted seat, where
both values are 0 and therefore omitted entirely:

  - SetSeating matched nothing, so a voted-in seat was never recorded as
    seated - it never acquired a LastSeatedHeight, so it could never
    acquire an exit either;
  - SetExit matched nothing, so NO seat ever had an exit recorded, which
    silently disables the entire collateral exit-halt.

The halt would have looked implemented, passed all 72 tests, and protected
nothing. A Go map has no notion of an absent field, so no in-memory double
can express this failure; the regression test asserts on bson marshalling,
which needs no database.

Note the two fields now differ deliberately from UboId directly above
them: UboId MUST omit (its unique index is sparse, and a sparse index does
not exempt an explicit empty string), these two MUST NOT (they are
filtered by value). Both directions are now stated at the field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Found by the collateral-bypass hunt. poaseats.NormalizeAccount stripped
the prefix and THEN lowercased; governance.NormalizeAccount (the write
path) lowercases and then strips. TrimPrefix is case-sensitive, so
"HIVE:alice" kept its prefix on the read path and normalised to
"hive:alice" while the write path produced "alice" - the two helpers
disagreeing on exactly the mixed-case input that case-handling exists to
cover. Same silent-membership-mismatch class as the rest: an excluded
operator in the election path, a wrongly-armed collateral halt in the
maintenance path.

Guarded out on the live security path today (tx.From requires a
case-sensitive lowercase hive: prefix at submission) and it failed closed
where reachable, so this is defence in depth rather than a live break -
but the whole point of routing every comparison through one helper is that
its correctness must not depend on which caller reaches it.

The same hunt found no exit-halt bypass: all seven attempted escapes
traced to BLOCKED, including the strongest lead (consensus_stake credits
To rather than From) - that path mints new bond from liquid HIVE and never
moves an existing hive_consensus balance, and hive_consensus has no
transfer path anywhere in the ledger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…epoch

Two findings from the Phase-1 adversarial hunt, both in fixes made earlier
in this same audit.

CRITICAL - the entire POA batch could never activate. The re-seed guard
added in 02c3e17 resolved its activation check via
ActiveConsensusVersion(blockHeight), while its transition check read
prevElection. GetElectionByHeight filters block_height with a strict less
than, and the election being processed is stored AT that height, so the
first call returns the PREVIOUS election - the exact row the second call
tests. The gate demanded that row be at/above the POA line while the
transition check demanded it be below: a contradiction no chain state can
satisfy. bootstrapPoaSeats could therefore never fire, on any node, at any
epoch.

The consequence was not a fork - every node computes the same wrong answer
- but a silent, permanent defeat of the batch: the registry stays empty
forever, so the seat gate stays inert and vsc.admit_vote is a no-op, while
flat seat-weight and the churn cap DO still fire. That is precisely the
"worse than either regime" hybrid this build guards against elsewhere:
candidacy open to anyone with MinStake, but every such candidate carrying
the same weight as a vetted operator. A full reindex reproduces it
identically, so nothing would have surfaced it.

Now resolved from the election being processed, which is both correct and
the more honest reading of the question being asked. The hunt agent
compiled a proof of the dead path before I fixed it; the regression test
models the real shape - a previous election below the line and a current
one at it - which the old test doubles could not express, because they
returned one fixed version for every query. That is the third time in this
audit a double hid a defect by being more forgiving than reality.

HIGH - the churn cap could stall the epoch. The seat gate's starvation
guard simulates only the seat-gate step, but the churn cap runs afterwards
and defers new entrants, so it could re-shrink a committee the guard had
just approved back under MinMembers - turning the guard's promised "one
epoch of open candidacy" into the halt it exists to prevent. Deferring an
entrant is always safe to skip, so the cap now yields when it would breach
the floor. It is a rate limit, not a safety property.

Also: AdmitSeat was left best-effort at both call sites while every
sibling registry write was made fail-stop in 02c3e17. Now fail-stop too,
with deterministic duplicate-key refusals surfaced rather than retried -
blockingRetry on a deterministic error would wedge block processing
forever.

77 POA tests. Unchanged pre-existing failures verified at c21610f.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ness

RETRACTION. I reported that the ceil(2/3) admission threshold is only
"self-protecting for one round" and that a 13-of-20 coalition reaches
permanent control for one external vote, rated HIGH and folded into a
CRITICAL chain. The operator challenged it and the challenge is right:
holding 13 of 20 seats means 13 vetted, KYC'd, UBO-capped operators are
already colluding, which IS the catastrophe the vetting exists to
prevent. Framing "one more vote" as a ladder was close to restating that
the threshold is 14.

I also under-weighted that the "one external vote" is a vote to admit a
candidate who must first pass off-chain vetting with a distinct
beneficial owner - the actual load-bearing control, which I glossed.

Re-rated LOW/INFO. What survives is narrow and worth keeping only as a
note: required(W) = ceil(2W/3) increases by 0 for W congruent to 2 mod 3,
so at W=20->21 admitting a seat does not raise the bar. A property of
specific set sizes, not a capture path.

The code comment is corrected in both directions. It previously asserted
a claim that was too strong, then I replaced it with a warning that was
also too strong. It now states plainly what the threshold buys (a
minority acting alone can never grow itself; the set can never shrink)
and where the trust actually sits (vetting, plus no-shrink).

The chain that finding was part of does NOT collapse - it relocates onto
the half that never needed it: bootstrap founds the permanent,
append-only registry from whichever committee the OLD permissionless
stake-only regime elected, at a moment chosen by a hand-pinned version
floor. Those seats never pass vetting and there is no un-seed. That needs
no ratchet and no admission vote. It is the finding I should have led
with.

Also adds minerharness_test.go (POA_MINE=1): drives the real seat state
machine through 40 randomised runs to emit a {fn,state_before,state_after}
history for the lever3 trace-miner. This retires the earlier
"not-forkable" waiver, which was simply wrong - the miner needs a
transition history, not a fork. 1,115 transitions mined 30 laws (29
novel), including an empirically re-derived append-only property; the
break-loop then ran 2,944 campaigns with mutation-control proven on
30/30 targets and broke none of them.

Caveat recorded in phase1/miner-README.md: the break-loop ran against a
JS model mirroring the Go guards, not against production Go, and the
mined laws are aggregate counters - they say nothing about the
cross-component properties that matter most.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Quality review of what PRUNED (security) does not grade. Two findings, both
fixed; behavior-preserving.

1. isDuplicateSeatErr classified duplicate-seat failures by matching error
   MESSAGE SUBSTRINGS ("already holds a seat", "E11000"). That is the fragile
   pattern the repo already avoids elsewhere (mongo.IsDuplicateKeyError). A
   mis-classification here is not cosmetic: a duplicate is a DETERMINISTIC
   refusal, and if a future wrapper changed the message text the classifier
   would silently treat it as transient and blockingRetry would wedge block
   processing forever — the exact failure the classifier exists to prevent.
   poaseats now exports typed sentinels (ErrSeatExists, ErrUboExists);
   AdmitSeat wraps them and maps the storage-layer E11000 (insert race) back to
   the same sentinel; isDuplicateSeatErr uses errors.Is. Both mocks now return
   the same typed errors so a future test cannot pass against classification
   the real store would fail.

2. poaSeatElectorate returned a []string seatAccounts that BOTH callers
   discarded (one with an explicit _ = seatAccounts). Dropped it to a 2-value
   return.

POA tests green after the change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Seven failure-state tests covering "what happens when something is already
wrong" — the conditions three of the four criticals in this build hid in.

- BootstrapDoesNotHangOnADeterministicDuplicate: proves blockingRetry
  SURFACES a deterministic ErrSeatExists instead of looping on it (would
  wedge block processing). Timeout-guarded so a regression fails, not hangs.
- ExitHaltFailsClosedWithoutConfig: nil sconf -> HOLD, no panic.
- AdmitVoteIgnoresAForeignProposalType + IdsNeverCollide: the admit handler
  refuses a foreign-typed row in the SHARED governance_proposals store, and
  admit/reserve-payout ids are type-prefixed so they cannot collide.
- RG1_NeverSeatedMemberIsNotHalted_KNOWN_GAP: a CHARACTERIZATION test that
  pins the current (buggy) ratification-gap behaviour so a future fix is
  detectable; labelled to be INVERTED when RG-1 is closed.
- AdmitVoteNoPanicOnNilDependencies: every partial-wiring combination is a
  safe no-op.
- SeatMaintenanceToleratesDegenerateElectionMembers: duplicate/empty/prefix-
  only members are sanitised; writing the test surfaced that the MinMembers
  floor guard correctly refuses a sub-floor distinct set (my first
  expectation was wrong, the code was right).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The exit-halt armed only from first SEATING (SetSeating at ratification),
but a seat is elected at GENERATION height and marked seated only at
RATIFICATION. In the window between, LastSeatedHeight is still 0, and the
old never-seated branch returned false — so a member could unstake in that
window, drain hive_consensus (the slashable pool) to ~0, and still become a
committee member one block later with an unslashable bond. The council
verified the defeated slash is already wired and testnet-live (double-sign
+ invalid-block), so this was a HIGH, not a latent finding.

Under POA a seat is ELECTABLE the moment it is admitted (the seat gate
admits any seat holder), so "holds a seat" is the correct trigger for the
halt, not "has been seated once". IsPoaExitHalted now holds any admitted
seat, including the pre-seating window an RG-1 attacker occupies.

TRADEOFF, flagged for team review: a seat admitted but never elected has
its bond held with no timed release (ExitHeight is only set on a
seated->absent transition). This errs toward holding collateral (safe)
over convenience; a governance release for a genuinely-never-serving
operator is a follow-up. It cannot re-open RG-1 — any release keyed on
admission height would let the attacker (an admitted seat at unstake time)
through again, so the never-seated hold stays unconditional until a real
exit or explicit governance action.

Two characterization tests that PINNED the vulnerable behaviour are
inverted into regression guards
(TestExitHaltHoldsAnAdmittedNeverSeatedSeat,
TestRG1_AdmittedSeatIsHaltedBeforeFirstSeating). 54 state-processing POA
tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lectable

The prior fix (6638da1) armed only the never-seated branch, leaving an
equivalent RE-ELECTION gap the adversarial round found: a seat seated
once, left, and whose halt window has ELAPSED (released) returns
not-halted — which is exactly its state during the generation->ratification
window of a RE-election. Same drain, different seat state, untouched.

Root cause: the seat lifecycle fields (LastSeatedHeight/ExitHeight) only
update at ratification, so they LAG the window in which an election is
decided but not yet on-chain. Gating on them alone can never cover that
window.

The complete trigger is ELECTABILITY, not the seat clock: in both the
first-election and re-election gaps the attacker must be an electable
witness to win the pending election. IsPoaExitHalted now holds the bond
whenever the account is an electable witness (isElectableWitness reads the
exact set the election proposer draws from — GetWitnessesAtBlockHeight +
EnabledOnly, deterministic and freshness-filtered). Only once the operator
can no longer be elected (disabled/stale witness) does the clock govern
release.

This also fixes F2 (the freeze-forever the prior fix introduced): a
never-served admitted seat now has a release path — disable the witness,
wait a window from admission. And the refusal message is corrected: for an
electable hold it tells the operator to disable its witness (the real
action), rather than falsely claiming a fixed release height.

Fail-closed throughout: nil witness store or a read error holds.
Determinism preserved: isElectableWitness is a pure function of on-chain
witness state at height, the same call elections already depend on.

New regression guards: HoldsAnElectableWitnessAfterWindowElapsed (F1),
NeverSeatedSeatReleasesAfterDisablingWitness (F2),
TerminatesOnlyWhenNoLongerElectable. Test env gains a fakeWitnesses whose
electability is derived from the seat registry with an explicit disable()
for wind-down. All state-processing POA tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nt-in-time

The electability fix (44db718) sampled isElectableWitness at the UNSTAKE
height U, but committee membership is decided at generation G < U. An
attacker enabled at G (-> elected) could disable its witness at D in (G,U]
and unstake at U, where it reads non-electable and (if the seat clock had
elapsed) released -- then be seated at ratification from the frozen-at-G
membership with a drained, unslashable bond.

The fix does not need the off-chain generation membership. The
generation->ratification window is a handful of blocks; the halt window is
~3 days. So the bond is now held while the account is electable now OR has
had ANY witness announcement (enabled or just-disabled) within the last
`window` blocks -- because within a window of witness activity an election
it was in may still be in flight. Release requires the account to have been
witness-SILENT for a full window, by which point any in-flight election has
provably ratified (and would have re-seated it -> held on the seated
branch). No timing seam remains, and this stays entirely in the halt
predicate -- no surgery on the audited slash/ledger code.

hadRecentWitnessActivity reads GetWitnessAtHeight (the account's latest
announcement height); fail-closed on a transient read, a genuine
never-announced returns not-held. The refusal message and
PoaExitHaltReleaseHeight mirror the same condition.

Test env gains dated witness announcements (disable now takes a height);
new guard TestRG1c_ExitedElapsedButRecentlyDisabledIsHeld plus the F1/F2
guards updated to the max(seat-clock, disable+window) release. All
state-processing POA tests green.

This is the third and final iteration on the halt predicate; the earlier
two each left a timing seam this closes at the root (witness activity, the
only on-chain signal that tracks electability across the generation gap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants