SPP lifecycle & state machine
An SPP proposal moves from creation through each stage to execution. Its state determines whether it may advance, based on the stage's timing and body results.
The states
At any moment a proposal is in one of these states, computed live from timestamps and tallies (only Executed and Canceled are stored flags; even Expired is derived, from maxAdvance):
- Active — exists, but not yet advanceable (too early, or the current stage's thresholds aren't met).
- Advanceable — the current stage's thresholds are met, within its timing window; the proposal can advance (or, on the last stage, execute).
- Executed — terminal, the actions ran.
- Canceled — terminal.
- Expired — the stage's
maxAdvancepassed without becoming advanceable. Terminal and unrecoverable: nothing un-expires a proposal.
The state(proposalId) function decides this, in order (each check short-circuits):
- executed → Executed; canceled → Canceled (sticky, checked first).
- past
maxAdvance→ Expired. - before
minAdvance→ Active (too early, regardless of how many bodies already reported). - veto stage and
voteDurationnot yet elapsed → Active (the veto-window floor). - thresholds met → Advanceable; otherwise → Active.
"Thresholds met" applies the hard-veto rule: enough vetoes → not met; otherwise, enough approvals → met.
From state, the rest follows: canProposalAdvance is "state is Advanceable"; canExecute is "Advanceable and on the last stage"; and hasSucceeded (the IProposal signal other plugins poll) is "on the last stage and Advanceable or already Executed". It stays true after execution, which matters for nesting SPP as a body.
How a stage's results are tallied: push and pull
A stage's tally, how many bodies have approved or vetoed, is computed two ways per body:
- Push (a body reports). A body (or anyone acting for it) calls
reportProposalResult, which records its verdict in storage. Once recorded, that verdict is cached permanently and trusted, SPP never re-checks it. This is how automatic bodies feed back: the sub-proposal SPP created on them has a single action that callsreportProposalResultwhen the body's own process executes it. - Pull (SPP asks). If a body hasn't reported but is automatic and had a sub-proposal created, SPP does a read-only
hasSucceededcall on that sub-proposal on the fly; if it succeeded, the body's registeredresultType(approval or veto) is credited. SPP doesn't ask which way the body voted, it decided at config time what "this body succeeded" means.
Why both? Push requires someone to actually execute the body's sub-proposal; pull lets SPP treat a stage as decided the moment the body's internal vote passed, without waiting for that extra execution. Whichever happens first wins (a cached push skips the pull). The pull call is deliberately hardened, a body that reverts or returns garbage simply contributes nothing rather than breaking the whole tally.
This is why an automatic body's
hasSucceededmust be monotonic (true forever once true): SPP may poll it long after the body's own voting window closed, while still waiting on other bodies. A body whosehasSucceededflips back to false would silently strand the SPP proposal. See composing bodies.
Reporting a result
reportProposalResult(uint256 proposalId, uint16 stageId, ResultType resultType, bool tryAdvance)- Anyone may call it, but it's safe: SPP only ever reads the recorded verdict for addresses actually registered as bodies in that stage. A report from a non-body is simply never counted (it just wastes the caller's gas, worth a UI guard, not a contract one).
- A report overwrites. There's no "first report is final", a body can flip its own verdict (approval ↔ veto) any time before the proposal actually advances past the stage.
tryAdvance: truealso advances when the proposal is advanceable and the caller hasADVANCE_PERMISSION_ID, orEXECUTE_PROPOSAL_PERMISSION_IDon the last stage. Missing permission or unsuitable state skips the attempt, so the body's report and other actions in its transaction can still succeed when SPP is not ready. If advancement actually runs, an execution failure can still revert the transaction.
Advancing and executing
advanceProposal moves an advanceable proposal to the next stage (creating that stage's sub-proposals) or, on the last stage, executes it. Advancing past the last stage is executing; there's no separate "finished" step. Advancing needs ADVANCE_PERMISSION_ID; executing needs EXECUTE_PROPOSAL_PERMISSION_ID. Both are granted to anyone by default, so once the state machine says advanceable, anyone can move the proposal forward; the body results, not the caller, determine whether it may advance. Execution runs the proposal's actions against the snapshotted target, honoring the failsafe action map set at creation (individual actions may be allowed to fail without reverting the batch).
When a stage begins, SPP creates a sub-proposal on each of its automatic bodies through a guarded low-level call (with a gas-griefing check). If a body's createProposal reverts, or the body rejects SPP, that body contributes nothing to the tally (a SubProposalNotCreated event) instead of blocking the other bodies or the whole pipeline. A misconfigured body therefore doesn't cause sub-proposal creation for the rest of the stage to fail.
Cancelling and editing
Two optional interventions, each gated both by a per-stage flag and a permission:
cancel(needsCANCEL_PERMISSION_ID, and the current stage'scancelable) ends the proposal permanently.edit(needsEDIT_PERMISSION_ID, and the current stage'seditable) replaces the proposal's actions before it advances. If the current stage has bodies, editing is only allowed once the stage is advanceable (you can't rewrite what bodies are actively voting on); a bodyless timelock stage can be edited while merely active.
Neither is possible on a terminal proposal (executed, canceled, expired). Install grants neither permission by default: a DAO must grant CANCEL_PERMISSION_ID/EDIT_PERMISSION_ID explicitly for these to work at all, on top of setting the per-stage flags.
Keep in mind
- Vetoes trump approvals. Meeting the veto threshold blocks a stage no matter how many approvals it has. A lone
vetoThreshold = 1body is an absolute veto. - Expired is a dead end. Miss
maxAdvanceand the proposal is permanently stuck; the only way forward is a fresh proposal. Advance/execute passed proposals in time. voteDurationcan overrideminAdvance. On a veto stage the effective floor ismax(minAdvance, voteDuration)(see stages).- Reports are overwritable, not final, until the proposal advances past the stage.
- Cancel/edit need explicit permission grants and the per-stage flag, both, or they silently can't be used.
See also
- Stages & bodies — the thresholds and timings this machine runs on.
- Composing bodies — the
hasSucceededcontract the pull-tally depends on. - SPP overview.