feat(governance): RV16 — el override del veto se decide al cerrar la ventana, con 4/5 de quien participa

Cierra L-11 (override real del veto). David aprobó el 4/5 el 2026-10-01: «exigir una mayoría
calificada de 4/5 en el consejo para anular un veto de emergencia es el cerrojo adecuado antes
de retirar la llave Sudo».

Qué cambia
- La ventana de override tiene dos lados: revertir (override_council_veto) y sostener
  (uphold_council_veto, nuevo). Quien votó Nay puede sostener el veto; antes no tenía voz.
- Nada se decide al cruzar un umbral. finalize_override (nuevo) decide UNA vez, al cerrar la
  ventana: se revierte si override >= ceil(4/5 * max(quórum, participación)).
- El quórum se fija al vetar: max(Aye + Nay del voto original, MinOverrideQuorum). Abstain no
  cuenta. Así el bond que el veto devuelve no basta para revertirlo y un upgrade no lo mueve.
- MinOverrideQuorum en el runtime = 100M OGC (el 10% de PILAR_4 §6.6); integrity_test exige
  que supere MIN_PROPOSAL_STAKE.
- El periodo de voto ordinario dura al menos 14 días (MIN = DEFAULT) y el veto solo cabe dentro
  de él. Un voto de peso 0 en la ventana se rechaza.
- spec_version 16. Pesos de uphold_council_veto y finalize_override medidos (frame-omni-bencher
  0.15.0, 50 steps x 20 repeats); los demás re-medidos en el mismo banco.

La revisión adversarial (nivel 1 de la directiva) tumbó la primera versión
- Bloqueante: la regla de quórum no era monótona y votar «sostener» podía REVERTIR el veto.
  Ahora sostener nunca ayuda a revertir (test con la frontera exacta 2250/2251).
- El mínimo de 48 h regalaba la vía de emergencia a cualquiera; ahora 14 días.
- Las abstenciones inflaban el quórum; el quórum podía cambiar con un upgrade; la spec no subía.
- 18 contrafactuales (mutaciones V1..V18), todos en rojo sin su pieza.

Divergencia que queda abierta (D1 del roadmap V2): PILAR_4 §6.6 ratificó «override por 2/3» y
el código implementa 4/5, que es lo que David confirmó. La constitución del DAO lo fija.

Tests: gobernanza 78 (94 con runtime-benchmarks), runtime 44.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CharonandClaude Opus 5.5 committed 2026-10-07 10:30:18 -05:00
1 parent a694cbd9f0
commit 3771a15165
8 files changed
+1081 -173

No files matched your search

+286 -87
View File
@@ -23,7 +23,10 @@
//! - 5.2: `pallet_collective` integration (Technical Council)
//! - 5.3: OIP stake minimum 1,000 OGC canonical
//! - 5.4: Stake-weighted voting (1 OGC = 1 vote, 14-day period)
//! - 5.5: Technical Council veto + override (2/3 voters)
//! - 5.5: Technical Council veto (2/3 del council) + override comunitario. PILAR_4 §6.6
//! ratificó «overridable por 2/3 voters»; el código implementa 4/5 de la participación
//! en la ventana, con quórum (RV16), y David confirmó el 4/5 el 2026-10-01. Lo que
//! el código hace y lo que dice la doctrina difieren hasta que la constitución lo fije.
//! - 5.6: AI governance agent interfaces (5 agents advise-only)
//! - 5.7: Emergency referendum (48h, 5/7 Technical Council)
//! - 5.8: Treasury disbursement extrinsics
@@ -208,6 +211,17 @@ pub mod pallet {
/// de quien votara en ella (demostrado por la revisión adversaria de RV15).
pub const MAX_VOTING_PERIOD_BLOCKS: u64 = 2 * DEFAULT_VOTING_PERIOD_BLOCKS;
/// RV16: periodo de voto mínimo de una propuesta ORDINARIA: 14 días, el que
/// ratifica PILAR_4 §6.6 («14-day period»).
///
/// Antes podía ser 0: el proponente enviaba, votaba en el mismo bloque y
/// finalizaba en el siguiente, aprobando con su único voto. La primera versión de
/// RV16 lo subió a 48 h y la revisión adversaria mostró por qué no basta: era la
/// duración de la vía de emergencia sin sus 5/7 endosos del council, y como el
/// veto solo cabe dentro del periodo, el proponente elegía cuánto tiempo tenía el
/// council para reaccionar. Las 48 h quedan solo para `submit_emergency_proposal`.
pub const MIN_VOTING_PERIOD_BLOCKS: u64 = DEFAULT_VOTING_PERIOD_BLOCKS;
/// Emergency voting period canonical canonical 48 hours per CHN-4 5.7.
/// 48 hours × 600 blocks/hour (@ 6s slot Aura) = 28,800 blocks.
pub const EMERGENCY_VOTING_PERIOD_BLOCKS: u64 = 48 * 600;
@@ -309,6 +323,14 @@ pub mod pallet {
#[pallet::constant]
type MaxActiveVotes: Get<u32>;
/// RV16: participación mínima (en OGC) para que un override pueda revertir
/// un veto. El quórum efectivo, fijado en el veto, es el MAYOR entre esto y la
/// participación del voto original (Aye + Nay): revertir al council exige al
/// menos tanta comunidad como la que votó. Es un parámetro de la constitución,
/// y `integrity_test` exige que supere el bond de una propuesta.
#[pallet::constant]
type MinOverrideQuorum: Get<u128>;
/// FIX-6: PalletId del que se deriva la cuenta-tesorería (vault). Cuenta
/// determinística sin private key — nadie la controla por wallet, solo la
/// governance vía treasury_disbursement (Root). Honor Inmutable #16: la
@@ -330,6 +352,19 @@ pub mod pallet {
#[pallet::storage_version(STORAGE_VERSION)]
pub struct Pallet<T>(_);
#[pallet::hooks]
impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
/// RV16: el quórum de override tiene que superar el bond de una propuesta. Si
/// no, el proponente revertiría solo el veto con el bond que el propio veto le
/// devuelve (hallazgo C3), y con 0 bastaría una unidad base.
fn integrity_test() {
assert!(
T::MinOverrideQuorum::get() > MIN_PROPOSAL_STAKE,
"MinOverrideQuorum debe superar MIN_PROPOSAL_STAKE (hallazgo C3)"
);
}
}
/// RV15: por qué este pallet congela OGC.
#[pallet::composite_enum]
pub enum FreezeReason {
@@ -339,6 +374,25 @@ pub mod pallet {
GovernanceVote,
}
/// RV16: votos que SOSTIENEN el veto del council durante su ventana de
/// override (la otra mitad del recuento; antes no existía y quien votó Nay no
/// podía defender el veto).
#[pallet::storage]
pub type UpholdVotes<T: Config> = StorageDoubleMap<
_,
Blake2_128Concat,
ProposalId,
Blake2_128Concat,
T::AccountId,
u128,
OptionQuery,
>;
/// RV16: OGC total que sostiene el veto, por propuesta.
#[pallet::storage]
pub type UpholdStakeTotal<T: Config> =
StorageMap<_, Blake2_128Concat, ProposalId, u128, ValueQuery>;
/// RV15: los votos vivos que respalda cada cuenta (ver [`VoteBacking`]). El
/// freeze `GovernanceVote` de la cuenta es el máximo de sus `amount`.
#[pallet::storage]
@@ -357,6 +411,16 @@ pub mod pallet {
pub type OverrideDeadline<T: Config> =
StorageMap<_, Blake2_128Concat, ProposalId, u64, OptionQuery>;
/// RV16: quórum del override de cada propuesta vetada, FIJADO en el veto:
/// max(Aye + Nay del voto original, `MinOverrideQuorum`). Se guarda y no se
/// recalcula al decidir: un runtime upgrade que cambie el parámetro no mueve la
/// meta de una ventana ya abierta (hallazgo de la revisión de RV16). La
/// abstención no cuenta: si contara, el mismo OGC abstenido en muchas propuestas
/// a la vez inflaría el quórum de todas sin aparecer en ninguna ventana.
#[pallet::storage]
pub type OverrideQuorum<T: Config> =
StorageMap<_, Blake2_128Concat, ProposalId, u128, OptionQuery>;
/// CHN-4 canonical: `Proposals<StorageMap<ProposalId, Proposal>>` canonical.
#[pallet::storage]
#[pallet::getter(fn proposal_of)]
@@ -595,15 +659,16 @@ pub mod pallet {
},
/// Council veto canonical overridden por community supermajority (Phase 5.5 v0.0.35).
///
/// Emitted canonical cuando override votes reach 4/5 total stake canonical canonical.
/// Proposal status canonical flipped from Rejected back to Voting OR Approved
/// (depending canonical en tally pre-veto).
/// RV16: lo emite `finalize_override` cuando, cerrada la ventana, revertir
/// reunió 4/5 del mayor entre el quórum y la participación. La propuesta pasa
/// a `Approved`. (Antes se emitía al cruzar el umbral en mitad de la ventana.)
CouncilVetoOverridden {
/// ProposalId canonical overridden.
/// Propuesta cuyo veto se revierte.
id: ProposalId,
/// Total override stake canonical at moment of override.
/// OGC que votó revertir.
override_stake: u128,
/// Total community stake canonical baseline (proposal.stake at submit).
/// RV16: participación total en la ventana (revertir + sostener). Antes era
/// la base del umbral viejo (stake + yes + nay + abstain de la propuesta).
total_community_stake: u128,
},
/// Emergency proposal submitted canonical (Phase 5.7 v0.0.35).
@@ -654,6 +719,30 @@ pub mod pallet {
/// OGC que sigue congelado por votos todavía vivos.
still_frozen: u128,
},
/// RV16: un voto en la ventana de override de un veto (para el indexer del
/// portal: antes un voto de override no emitía nada hasta el desenlace).
OverrideVoteCast {
/// Propuesta vetada.
id: ProposalId,
/// Quien vota.
voter: T::AccountId,
/// `true` = sostiene el veto; `false` = vota revertirlo.
uphold: bool,
/// OGC que respalda el voto (congelado hasta el cierre de la ventana).
weight: u128,
},
/// RV16: se cerró la ventana y el veto se SOSTIENE (el override no alcanzó
/// 4/5 de la participación o no hubo quórum).
CouncilVetoUpheld {
/// Propuesta vetada.
id: ProposalId,
/// OGC que votó revertir el veto.
override_stake: u128,
/// OGC que votó sostenerlo.
uphold_stake: u128,
/// Quórum que se exigía.
quorum: u128,
},
}
/// Pallet errors canonical.
@@ -729,6 +818,15 @@ pub mod pallet {
VotingPeriodTooLong,
/// RV15: la cuenta ya respalda `MaxActiveVotes` votos vivos.
TooManyActiveVotes,
/// RV16: el periodo de voto pedido es menor que `MIN_VOTING_PERIOD_BLOCKS`.
VotingPeriodTooShort,
/// RV16: la ventana de override sigue abierta; el resultado se decide al
/// cerrarse, no al cruzar un umbral a mitad de camino.
OverrideWindowOpen,
/// RV16: esta propuesta no tiene un override pendiente de decidir.
OverrideNotPending,
/// RV16: un voto de la ventana de override tiene que pesar más de 0.
ZeroWeightWindowVote,
}
/// Minimum stake canonical proposal submission per CHN-4 5.3.
@@ -763,6 +861,10 @@ pub mod pallet {
voting_period_blocks <= MAX_VOTING_PERIOD_BLOCKS,
Error::<T>::VotingPeriodTooLong
);
ensure!(
voting_period_blocks >= MIN_VOTING_PERIOD_BLOCKS,
Error::<T>::VotingPeriodTooShort
);
let proposer_acct = AccountId::new(account_bytes::<T>(&who));
let id = crate::derive_proposal_id(&proposer_acct, &title_hash, &body_hash);
@@ -1044,6 +1146,14 @@ pub mod pallet {
Error::<T>::ProposalAlreadyFinalized
);
// RV16: el veto solo cabe DURANTE el periodo de voto. Antes se podía vetar
// entre end_block y el finalize, y el resultado dependía de quién llamara
// primero (carrera entre el council y cualquiera que finalizara).
ensure!(
current_block_u64::<T>() <= proposal.end_block,
Error::<T>::VotingPeriodClosed
);
// Step 4: council size canonical > 0
let council_size = CouncilSize::<T>::get();
ensure!(council_size > 0, Error::<T>::InsufficientCouncilSize);
@@ -1070,6 +1180,10 @@ pub mod pallet {
let required = council_veto_threshold(council_size);
if live_count >= required {
// RV16: el quórum del override se fija AQUÍ, con la participación del
// voto original (Aye + Nay; la abstención no cuenta).
let turnout = proposal.yes_weight.saturating_add(proposal.nay_weight);
OverrideQuorum::<T>::insert(proposal_id, turnout.max(T::MinOverrideQuorum::get()));
// Veto threshold reached canonical → flip status canonical
proposal.status = ProposalStatus::Rejected;
Proposals::<T>::insert(proposal_id, proposal);
@@ -1142,21 +1256,21 @@ pub mod pallet {
Ok(())
}
/// CHN-4 5.5 v0.0.35 canonical: `override_council_veto` Phase 5.5 dispatchable.
/// CHN-4 5.5 + RV16: votar REVERTIR un veto del council durante su ventana.
///
/// Community supermajority canonical override de council veto. 4/5 total stake
/// canonical required canonical per CHN-4 5.5. Proposal status canonical flipped
/// desde Rejected canonical back to Approved canonical (community canonical >
/// council canonical veto on supermajority).
/// Doctrina: el council veta con 2/3 y la comunidad revierte con 4/5
/// (`docs/ORIGIN_DAO_DONDE_VIVE_V1.md`). RV16 hace real ese 4/5 (hallazgo C3 de
/// SUPER_AUDIT_V1, demostrado otra vez por la revisión de RV15):
/// - el voto NO voltea nada al cruzar un umbral; el resultado se decide al
/// cerrarse la ventana, en [`Pallet::finalize_override`];
/// - el 4/5 es de la PARTICIPACIÓN en la ventana (revertir + sostener), no de
/// los recuentos de la propia propuesta: antes contaba el bond del proponente,
/// que el veto le devuelve y que luego podía votar;
/// - quien quiere el veto puede defenderlo con [`Pallet::uphold_council_veto`];
/// - hace falta quórum.
///
/// 7-step canonical flow:
/// 1. ensure_signed canonical
/// 2. Get proposal (must canonical be Rejected previamente vía veto)
/// 3. Validate proposal.stake > 0 (baseline canonical)
/// 4. !AdvisorFlags canonical (humans-only canonical canonical)
/// 5. !OverrideVotes contains canonical (double-vote prevention)
/// 6. Insert vote + increment total stake
/// 7. Check threshold canonical 4/5: if >= → flip to Approved + emit CouncilVetoOverridden
/// El peso sale del saldo y queda congelado hasta el cierre de la ventana (RV15).
/// Una cuenta vota una vez en la ventana, en un solo lado.
#[pallet::call_index(8)]
#[pallet::weight(T::WeightInfo::override_council_veto())]
pub fn override_council_veto(
@@ -1165,73 +1279,7 @@ pub mod pallet {
stake_weight: u128,
) -> DispatchResult {
let who = ensure_signed(origin)?;
let mut proposal = Proposals::<T>::get(proposal_id)
.ok_or(Error::<T>::ProposalNotFound)?;
ensure!(
matches!(proposal.status, ProposalStatus::Rejected),
Error::<T>::ProposalNotRejected
);
// Audit 2026-07-04: el override SOLO revierte un VETO del council, no
// un rechazo por voto popular. Sin esta marca, una propuesta que la
// comunidad votó EN CONTRA (Rejected vía finalize_proposal) podía
// resucitarse a Approved, invirtiendo el voto. Solo las vetadas aplican.
ensure!(
VetoRejected::<T>::contains_key(proposal_id),
Error::<T>::ProposalNotVetoRejected
);
ensure!(
!AdvisorFlags::<T>::contains_key(&who),
Error::<T>::AdvisorMayNotVote
);
ensure!(
!OverrideVotes::<T>::contains_key(proposal_id, &who),
Error::<T>::AlreadyOverrideVoted
);
// RV15: el override tiene ventana, y su peso sale del saldo y queda
// congelado hasta que la ventana cierre.
let deadline =
OverrideDeadline::<T>::get(proposal_id).ok_or(Error::<T>::OverrideWindowClosed)?;
ensure!(
current_block_u64::<T>() <= deadline,
Error::<T>::OverrideWindowClosed
);
Self::back_vote_with_funds(
&who,
proposal_id,
BackingKind::Override,
stake_weight,
deadline,
)?;
OverrideVotes::<T>::insert(proposal_id, &who, stake_weight);
let new_total = OverrideStakeTotal::<T>::get(proposal_id)
.saturating_add(stake_weight);
OverrideStakeTotal::<T>::insert(proposal_id, new_total);
let baseline = proposal.stake.saturating_add(proposal.yes_weight)
.saturating_add(proposal.nay_weight)
.saturating_add(proposal.abstain_weight);
let required = override_supermajority_threshold(baseline);
if new_total >= required {
proposal.status = ProposalStatus::Approved;
Proposals::<T>::insert(proposal_id, proposal);
Self::deposit_event(Event::CouncilVetoOverridden {
id: proposal_id,
override_stake: new_total,
total_community_stake: baseline,
});
}
Ok(())
Self::cast_override_window_vote(who, proposal_id, stake_weight, false)
}
/// CHN-4 5.7 v0.0.35 canonical: `submit_emergency_proposal` Phase 5.7 dispatchable.
@@ -1473,6 +1521,89 @@ pub mod pallet {
});
Ok(())
}
/// RV16: votar SOSTENER un veto del council durante su ventana de override.
/// Es la otra mitad del recuento: sin ella, quien votó Nay no podía defender
/// el veto y cualquier minoría con OGC suficiente lo revertía sola.
#[pallet::call_index(14)]
#[pallet::weight(T::WeightInfo::uphold_council_veto())]
pub fn uphold_council_veto(
origin: OriginFor<T>,
proposal_id: ProposalId,
stake_weight: u128,
) -> DispatchResult {
let who = ensure_signed(origin)?;
Self::cast_override_window_vote(who, proposal_id, stake_weight, true)
}
/// RV16: decide un override cuando su ventana ya cerró. Lo puede llamar
/// cualquiera.
///
/// El veto se revierte (la propuesta pasa a `Approved`) solo si:
/// 1. la participación (revertir + sostener) alcanza el quórum, que es el
/// MAYOR entre `MinOverrideQuorum` y la participación del voto original
/// (Aye + Nay + Abstain); y
/// 2. revertir reúne al menos 4/5 de esa participación.
///
/// En cualquier otro caso el veto se sostiene. Se decide una sola vez.
#[pallet::call_index(15)]
#[pallet::weight(T::WeightInfo::finalize_override())]
pub fn finalize_override(origin: OriginFor<T>, proposal_id: ProposalId) -> DispatchResult {
ensure_signed(origin)?;
let mut proposal =
Proposals::<T>::get(proposal_id).ok_or(Error::<T>::ProposalNotFound)?;
ensure!(
matches!(proposal.status, ProposalStatus::Rejected),
Error::<T>::ProposalNotRejected
);
ensure!(
VetoRejected::<T>::contains_key(proposal_id),
Error::<T>::ProposalNotVetoRejected
);
let deadline =
OverrideDeadline::<T>::get(proposal_id).ok_or(Error::<T>::OverrideNotPending)?;
ensure!(
current_block_u64::<T>() > deadline,
Error::<T>::OverrideWindowOpen
);
let override_stake = OverrideStakeTotal::<T>::get(proposal_id);
let uphold_stake = UpholdStakeTotal::<T>::get(proposal_id);
let participation = override_stake.saturating_add(uphold_stake);
let quorum =
OverrideQuorum::<T>::get(proposal_id).ok_or(Error::<T>::OverrideNotPending)?;
// Se decide una sola vez: sin ventana, no hay override pendiente.
OverrideDeadline::<T>::remove(proposal_id);
OverrideQuorum::<T>::remove(proposal_id);
// Regla MONÓTONA: revertir necesita 4/5 del MAYOR entre el quórum y la
// participación. La primera versión exigía `participation >= quorum` y
// aparte el 4/5 de la participación, y la revisión adversaria demostró que
// así SOSTENER podía revertir el veto: un voto de sostener completaba el
// quórum que el lado revertir no alcanzaba solo, y no votar era la manera
// de defender el veto. Con max(quorum, participación), cada OGC que sostiene
// solo puede subir el listón de revertir, nunca bajarlo.
let overridden = override_stake > 0
&& override_stake >= override_supermajority_threshold(quorum.max(participation));
if overridden {
proposal.status = ProposalStatus::Approved;
Proposals::<T>::insert(proposal_id, proposal);
Self::deposit_event(Event::CouncilVetoOverridden {
id: proposal_id,
override_stake,
total_community_stake: participation,
});
} else {
Self::deposit_event(Event::CouncilVetoUpheld {
id: proposal_id,
override_stake,
uphold_stake,
quorum,
});
}
Ok(())
}
}
/// El mayor OGC respaldado por una lista de votos (0 si está vacía).
@@ -1485,6 +1616,74 @@ pub mod pallet {
<frame_system::Pallet<T>>::block_number().saturated_into::<u64>()
}
/// RV16 — un voto en la ventana de override de un veto, de cualquiera de los dos
/// lados. Comparte TODAS las comprobaciones: que la propuesta esté vetada (no
/// rechazada por voto popular), que la ventana esté abierta, que la cuenta no sea
/// advisor, que no haya votado ya en la ventana (en ningún lado), y que el peso
/// salga de su saldo y quede congelado hasta el cierre.
impl<T: Config> Pallet<T> {
fn cast_override_window_vote(
who: T::AccountId,
proposal_id: ProposalId,
stake_weight: u128,
uphold: bool,
) -> DispatchResult {
// Un voto de peso 0 en la ventana no decide nada y dejaría una entrada
// permanente (y a la cuenta sin poder votar después en esa ventana).
ensure!(stake_weight > 0, Error::<T>::ZeroWeightWindowVote);
let proposal = Proposals::<T>::get(proposal_id).ok_or(Error::<T>::ProposalNotFound)?;
ensure!(
matches!(proposal.status, ProposalStatus::Rejected),
Error::<T>::ProposalNotRejected
);
// Audit 2026-07-04: el override SOLO revierte un VETO del council, no
// un rechazo por voto popular.
ensure!(
VetoRejected::<T>::contains_key(proposal_id),
Error::<T>::ProposalNotVetoRejected
);
ensure!(
!AdvisorFlags::<T>::contains_key(&who),
Error::<T>::AdvisorMayNotVote
);
ensure!(
!OverrideVotes::<T>::contains_key(proposal_id, &who)
&& !UpholdVotes::<T>::contains_key(proposal_id, &who),
Error::<T>::AlreadyOverrideVoted
);
let deadline =
OverrideDeadline::<T>::get(proposal_id).ok_or(Error::<T>::OverrideWindowClosed)?;
ensure!(
current_block_u64::<T>() <= deadline,
Error::<T>::OverrideWindowClosed
);
Self::back_vote_with_funds(
&who,
proposal_id,
BackingKind::Override,
stake_weight,
deadline,
)?;
if uphold {
UpholdVotes::<T>::insert(proposal_id, &who, stake_weight);
UpholdStakeTotal::<T>::mutate(proposal_id, |t| *t = t.saturating_add(stake_weight));
} else {
OverrideVotes::<T>::insert(proposal_id, &who, stake_weight);
OverrideStakeTotal::<T>::mutate(proposal_id, |t| {
*t = t.saturating_add(stake_weight)
});
}
Self::deposit_event(Event::OverrideVoteCast {
id: proposal_id,
voter: who,
uphold,
weight: stake_weight,
});
Ok(())
}
}
/// RV15 — el OGC que respalda un voto.
impl<T: Config> Pallet<T> {
/// Exige que `weight` no supere el OGC LIBRE de `who` y lo deja congelado