Skip to content

feat(governance): let proposer cancel a collective proposal early#1428

Draft
gilescope wants to merge 2 commits into
mainfrom
giles-cancel
Draft

feat(governance): let proposer cancel a collective proposal early#1428
gilescope wants to merge 2 commits into
mainfrom
giles-cancel

Conversation

@gilescope
Copy link
Copy Markdown
Contributor

Overview

Adds a cancel_proposal extrinsic to a new pallet, pallet-collective-proposer-cancel, wired as two instances — CouncilProposerCancel and TechnicalCommitteeProposerCancel. Each instance lets the original proposer withdraw their pallet_collective proposal without waiting out the 5-day voting window or organising enough NO votes for early disapproval.

Why

pallet_collective::disapprove_proposal and kill are gated on EnsureRoot, and Root on this chain only comes via a successful federated motion (a 5-day vote). That meant an obviously-bad proposal could not be cleared cheaply by its own author — the only options were marshalling NO votes or waiting out the window. This was raised in chat: "We have no clean way to cancel a proposal early… was it a design choice or should we implement a workaround?". This PR adds the workaround: a tightly-scoped extrinsic that only the original proposer can call.

How

  • New crate pallets/collective-proposer-cancel (instance-generic over a pallet_collective::Config<I>). The cancel_proposal(hash) extrinsic looks the proposer up in pallet_collective::CostOf, verifies the caller matches, releases any held deposit, and calls pallet_collective::Pallet::do_disapprove_proposal.
  • For the lookup to work, the runtime must record the proposer in CostOf. Both collective instances previously had Consideration = (), which causes is_none() == true and CostOf is never written. They now use runtime_common::governance::RecordProposer — a tiny no-deposit MaybeConsideration impl that returns is_none() == false so the (AccountId, _) row is stored. No deposit is taken; encoded size is unchanged from the unit type.
  • The fail mode is documented: with Consideration = () (or any MaybeConsideration whose is_none() == true), cancel_proposal always returns ProposerNotRecorded.
  • DisapproveOrigin/KillOrigin remain EnsureRoot — the new path doesn't widen Root's blast radius, it just adds an orthogonal proposer-only path that cannot affect anyone else's proposal.

🗹 TODO before merging

  • Reviewer to comment /bot rebuild-metadata on this PR — adding pallets and changing CostOf's value type means runtime metadata must be regenerated.
  • Ready

📌 Submission Checklist

  • All commits are signed off (git commit -s) for the DCO
  • Changes are backward-compatible (or flagged if breaking)
  • Pull request description explains why the change is needed
  • Self-reviewed the diff
  • I have included a change file, or skipped for this reason:
  • If the changes introduce a new feature, I have bumped the node minor version
  • Update documentation (if relevant)
  • Updated AGENTS.md if build commands, architecture, or workflows changed
  • No new todos introduced

🧪 Testing Evidence

Unit tests in pallets/collective-proposer-cancel/src/tests.rs cover:

  • proposer can cancel their own proposal (storage cleared, event emitted)
  • non-proposer call → NotProposer, original storage intact
  • bogus hash → ProposalMissing
  • proposal closed via normal vote → subsequent cancel returns ProposalMissing
  • double-cancel → second call returns ProposalMissing
cargo test -p pallet-collective-proposer-cancel
test result: ok. 7 passed; 0 failed

cargo clippy -p midnight-node-runtime -p pallet-collective-proposer-cancel -p runtime-common --all-targets — clean.

  • Additional tests are provided (if possible)

🔱 Fork Strategy

  • Node Runtime Update
  • Node Client Update
  • Other:
  • N/A

Links

Adds pallet-collective-proposer-cancel and wires it as two instances
(CouncilProposerCancel, TechnicalCommitteeProposerCancel). Each instance
exposes a cancel_proposal extrinsic that lets the original proposer
withdraw their pallet_collective proposal without waiting out the 5-day
voting window or marshalling enough NO votes for early disapproval.

pallet_collective::disapprove_proposal and kill are gated on EnsureRoot,
which on this chain is only reachable through a successful federated
motion. That left no quick path to retract a clearly-bad proposal.

Proposer identity is recovered from pallet_collective::CostOf. Both
collectives now use a no-deposit RecordProposer MaybeConsideration in
runtime-common so that map is populated; under the previous unit ()
Consideration it was left empty.
@gilescope gilescope requested a review from a team as a code owner April 27, 2026 11:55
@gilescope gilescope marked this pull request as draft April 27, 2026 12:15
@jacek-kurkowski-shielded
Copy link
Copy Markdown
Contributor

Hi @gilescope !

It seems that it fails with Transaction call is not expected.

Screenshot 2026-04-28 at 14 24 37 Screenshot 2026-04-28 at 14 23 16

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants