Feat/poa admission - #237
Open
lordbutterfly-hive wants to merge 45 commits into
Open
Conversation
…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.
…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>
This was referenced Aug 10, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat(poa): validator-admission (POA) — inert, gated behind consensus 0.7.0
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.0consensus-version batch and is completely inert on everynetwork — 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 → 7so 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)
0.7.0(
Version0_7_0Active,feature_gates.go). Until then every POA rule is dead codeand the chain is byte-identical to base.
version below the floor (
election-proposer.go,PinnedVersionFloorfilter).So the fleet must be running a binary that announces
0.7.0before the flooris raised, or the committee empties and the vault freezes. That is the entire reason
for the
currentConsensus 3 → 7bump here — it is the safe, inert first step.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-stakebatch,
0.6=feat/vault-protection. POA is at0.7.0specifically to avoidcollision. 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.7exist (developnot merged), soa future 3→7 floor rise activates only POA. Before raising the floor on whatever
branch testnet actually runs, this must be empty:
What's included (build map sections A+B only)
V0_7_0+ 5 feature gates flipping as one batch.poa_seatsregistry — append-only, height-addressed; records seating/exitbookkeeping (the "when did this account leave the set" fact that existed nowhere
before). Bootstrap seeds it from the election in force at activation.
vsc.admit_vote— admission by ceil(2/3) of seats, cloned from the auditedwitness-vote governance engine.
PoaMaxNewMembersPerElection = 1.payout-release (~6h hold on a departing seat).
What's explicitly NOT in this PR (by design)
system-config.goTestnetConfig765/3 → <epoch>/7) — theactivation trigger, landed separately after the fleet announces 0.7.0.
identity + vetting + governance removal, not slashing. The exit-halt is a
forward-compat hook.
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
$ltself-collision; amissing
omitemptystarved the committee; two value-queried height fields hadomitemptyso the halt never armed; the seat gate had no starvation guard). These aresquashed into the commits below.
Deliberately-open design items (not code bugs, not blocking this inert merge):
RunningVersionbump + Route B guarded risemakeDIDsuses floor(2/3) vs ceil(2/3) elsewherecompareIndexOptionsnil-compat could defeat registry uniquenessTests
~90 POA tests, verified green at HEAD:
POA_MINEskip)go buildon all touched packages exits 0,go vetcleanPre-existing unrelated failure
TestH6GatewayPoPGate(H-6 gate disabled) is identicalat base. No new failures introduced.
The consensus-version bump carries in-commit tripwire updates the codebase's own error
messages demand:
version_test.gosource-pin → 0.7.0, and the "binary must announcethe highest batch" tripwire relocated to
poa_gates_test.go(
TestRunningVersionImplementsPoa).Deploy order (do not raise the floor early)
V0_4/5/6grep — must be empty.each announces 0.7.0. Inert — nothing activates.
MinMembers(=3) upgraded witnesses will be in the committee at the targetepoch (bootstrap refuses below 3 and does not retry).
bites one epoch later (built-in slack).
Commits (13, off
c21610f9)Files changed (35, vs
c21610f9)consensusversion (gate + version line)
state-processing (admission engine + seats)
election-proposer (floor filter + POA election)
poaseats DB (registry)
params / config / governance / wiring