Skip to content

Governance: ONS vs SONS

This document explains the two governance paths: ONS (OHSHII Governance, OhShii ecosystem) and SONS (LGE Governance, one DAO per LGE campaign), and lists all proposal types and what they do. For how these mechanisms add up to capture resistance — the threat model, the honest limits, and the comparison with the SNS — see Governance & Capture Resistance.

User choiceGovernance canisterToken for votingScope of proposals
OHSHII (or “ONS”)ohshii_governance (single fixed canister)OHSHII token (quadratic voting on locked tokens)Launcher canisters, OhShii Locker, the OHSHII token suite (ledger/index/archive), and the OHSHII DEX liquidity positions
Select a SONS tokenThat token’s sons_governance (one per campaign)That LGE campaign tokenOnly that LGE: treasury, WASM, asset canister, pool

The frontend uses a single Governance page (OnsVotingPage.jsx). The user selects the DAO via a dropdown: OHSHII (ohshii_governance) or one of the SONS tokens. The selected value sets governanceCanisterId: for OHSHII it is OHSHII_GOVERNANCE_CANISTER_ID; for SONS it is the campaign’s governance_canister_id from storage. All subsequent proposal list, create, vote, and execute calls go to that canister.

Failed campaigns and refunds are not governance actions: when a campaign fails (expires without selling out), contributors request refunds via the backend method request_refund within 7 days (contributors on campaigns with deferred referral custody (referral_deferred=Some(true)) reclaim ~95% — the net ~90% plus the held referral 5%, tier-independent; legacy pre-escrow campaigns refund ~90%; the creator stays ~70% in both cases). A Guest-tier contributor is also refunded the 30,000 OHSHII Guest fee on a failed campaign (idempotent, snapshotted at contribution time). See WORKFLOWS.md (Campaign failure and refunds).

User

Select DAO: OHSHII or SONS token

OHSHII selected

SONS token selected

ohshii_governance

sons_governance for that campaign

create_proposal / vote / execute

create_proposal / vote / execute

Targets: backend, storage, pool_manager, locker, OHSHII ledger/index/archive, OHSHII DEX pools

Targets: that LGE treasury, WASM, asset canister, pool


Every proposal has a type (NORMAL or CRITICAL) and two possible closing modes. The model is identical for ONS (ohshii_governance) and SONS (sons_governance). The values below are the defaults; they remain DAO-tunable via proposal (see Voting parameters and config via proposals).

NORMAL proposalCRITICAL proposal
Voting windowFixed 3 days (a guardian may shorten a NORMAL proposal it creates down to 1 day)Fixed 7 days
Standard close (at deadline)Quorum 250,000 VP + 15 voters, then simple majority > 50% (adopt > reject)Quorum 350,000 VP + 20 voters, then ≥ 65% approval
Immediate approval (before deadline)Disabled — a NORMAL proposal always runs its full windowQuorum 450,000 VP + 30 voters, ≥ 75% approval and a positive guardian vote
Immediate rejectionDisabledSymmetric: 450,000 VP + 30 voters, approval ≤ 25%

Which proposals are CRITICAL is decided by the code — see is_critical_category / proposal_uses_critical_thresholds / admin_method_call_is_critical (ONS) and is_critical_proposal / is_critical_admin_method (SONS). A proposal runs the CRITICAL tier when any of these holds:

  • its category is critical (the upgrade categories, CriticalGovernanceOperation, SetGuardian, the two arbitrary-dispatch categories CustomCall and HttpsOutcall, and SetDaoCanisterRegistry, all of which are always critical) — see the category list below;
  • its target is an add/replace-guardian proposal;
  • it is an ExecuteAdminMethod whose raw call is in the per-method critical set — on ONS (admin_method_call_is_critical): the treasury / balance withdrawals, liquidity removal, ICPSwap position transfers, the treasury-funded liquidity operations (proposal_treasury_create_custom_pool, proposal_treasury_add_liquidity, proposal_retry_treasury_liquidity, proposal_remove_liquidity_to_treasury, proposal_retry_treasury_forward), OhShii Locker lock mutations (admin_update_lock_status / admin_delete_lock / admin_transfer_lock_recipient), the OhShii Locker custody move admin_subaccount_transfer (it moves tokens out of a lock’s custody subaccount, the same blast radius as a treasury withdrawal; the strongbox itself is a reserved CustomCall target, see below), the locker token delisting (remove_supported_token — a token-wide lock cancellation), and campaign deletion; plus two conditional cases — proposal_update_token_metadata when it targets the OHSHII token ledger, and admin_update_ico_info when it edits the OhShii root campaign dao-0000000001. On SONS (is_critical_admin_method): the equivalent AdminMethods — TreasuryWithdraw, RemoveLiquidity, TransferLiquidityPosition, CreatePoolAndAddLiquidity, AddLiquidityToPool, ModifyLock, DeleteLock, TransferLockBeneficiary (plus the pre-existing TransferLockCreator, BatchSetFollow, UpgradeIndexCanister). Pool creation / liquidity addition run the critical tier because they move treasury funds in a single proposal.

These per-method criticals stay under the ExecuteAdminMethod category (they target other canisters, so they cannot use the self-only CriticalGovernanceOperation) but run the full CRITICAL tier — 7-day window, higher quorum/approval, and guardian-vetoable.

Guardian-removal proposals keep their extra hardening: a hard 7-day window, never an immediate decision, and the guardian cannot vote on its own removal. Like every Critical proposal, removal is vetoable-but-overridable — the guardian may veto its own removal, but cannot block the community override that completes it.

Reading the per-category “Threshold” field (hardcoded vs votable)

Section titled “Reading the per-category “Threshold” field (hardcoded vs votable)”

In the per-category lists below, the Threshold is just the tier — Standard or Critical. Two different things hide behind that word:

  • Which tier a category uses is HARDCODED — it changes only with a canister upgrade (is_critical_category / proposal_uses_critical_thresholds on ONS, is_critical_proposal on SONS). So “Standard” / “Critical” below is the fixed classification, not a number.
  • The numeric values of each tier are DAO-VOTABLE DEFAULTS — quorum VP, voter counts, approval %, voting-window lengths and the veto-override bar live in stable memory and are changed by proposal (see Voting parameters and config via proposals), within fixed safety floors.
  • Hardcoded safety floors no vote can lower: guardian-removal runs a hard 7-day window and can never pass immediately (it can be vetoed, but only into the community override round); the veto-override window can never drop below 1 day and its quorum / minimum-voters can never be zero.

The complete votable-vs-hardcoded inventory (defaults, floors, which proposal changes each) is in Voting parameters and config via proposals.


ONS (OHSHII Governance): all proposal categories and methods

Section titled “ONS (OHSHII Governance): all proposal categories and methods”

OHSHII Governance uses proposal categories for the type of action, and for ExecuteAdminMethod it uses AdminMethodCallData (target canister + method name + Candid-encoded args). The following lists every category and the main executable methods.

The ProposalCategory enum defines 18 variants, but create_proposal (via validate_category_target_pairing in ohshii_governance/src/lib.rs) rejects 5 legacy categories as retired (see “Retired categories” at the end of this section). The 13 categories that can be created today (the 11 below plus TreasuryCyclesWithdraw, the cycles-ledger treasury recharge — Standard tier, not guardian-vetoable — and SetDaoCanisterRegistry) each get a subsection below — scope, target, threshold, and what it does. CustomCall and HttpsOutcall (both always Critical, guardian-vetoable) are the arbitrary-dispatch lanes: a single on-chain-encoded Candid call to a controlled canister, and a typed DAO-composed HTTPS outcall to an external server. The newest, SetDaoCanisterRegistry (also always Critical), is the only category whose execution changes nothing but what the DAO says about itself. For how to read “Threshold”, see Reading the per-category Threshold field.

  • Scope: community signal / discussion.
  • Target: Motion(text).
  • Threshold: Standard.
  • What it does: records the vote; no on-chain execution.
  • Scope: upgrade the WASM of a Rust canister ONS controls (backend, dao_storage, pool_manager, ledger/index, …). Cannot target ohshii_governance itself — self-upgrades go through UpgradeGovernanceCanister.
  • Target: CanisterUpgrade(CanisterUpgradeData).
  • Threshold: Critical. Guardian-vetoable. (Every canister upgrade is a code change to a trusted component, so it clears the same bar as a governance self-upgrade. The category is kept distinct from UpgradeGovernanceCanister only for the self-target validation, not for a threshold difference.)
  • What it does: validates the staged WASM hash + size (re-verified after install — TOCTOU), then install_code / install_chunked_code.
  • The target is stopped for the install: before installing, the executor stops a running target, waiting up to 60 seconds for its open calls to finish, installs, then starts it again. If the target does not stop in time, nothing is installed and a target that was running is started again; the proposal fails with that reason. A target that was already stopped, or was already stopping, is left stopped after the install. If the start after a successful install fails (three attempts), the failure is recorded as an event and a delayed start is retried; the delayed start stands down if another proposal has changed the target’s state in the meantime (for example a later StopCanister). Only one lifecycle operation runs on a given canister at a time: an upgrade, a start or stop, and a snapshot take, load or restore of the same canister refuse each other instead of interleaving. The same rules apply to every step of the batch upgrade proposals below, to the upgrades the launcher backend performs on campaign governance, ledger and index canisters, and to the upgrades a campaign governance canister (SONS) executes through its own proposals (canister and ledger-suite upgrades, index upgrades). The backend never upgrades a campaign governance canister while that campaign is being finalized, and finalization does not start while that upgrade is in progress.
  • Snapshot and restore always restart the target: the snapshot and restore workflows (on ONS and on each SONS) stop the target, take or load the snapshot, and then start it again in every case — also when the stop timed out (nothing is taken or loaded) or the take or load failed (the error is returned after the restart).
  • Scope: upgrade the ohshii_governance WASM itself; target_canister_id MUST be the governance canister.
  • Target: CanisterUpgrade(CanisterUpgradeData).
  • Threshold: Critical. Guardian-vetoable.
  • Refused when a critical setting cannot be read: if the new code cannot decode the stored guardian, voting parameters, excluded-countries list, one of the four verification configurations (World ID, DecideID, Telegram, Google), the verification salt that binds each Telegram account and Google address to one wallet, or the marker that keeps the one-time defaults one-time, the upgrade fails and the previous code keeps running with the stored values untouched; the error names the setting and says whether its stored value did not decode or its storage header is not recognised. An unreadable salt is never replaced by a new one. A fixed module is needed to upgrade. While a setting cannot be read, its setter refuses to write; an unreadable verification configuration reads as disabled and closed to new proofs, and an unreadable guardian as no guardian (no principal is ever substituted).
  • What it does: self-upgrade through the post_upgrade hook. When the proposal executes, the governance canister first pauses: until the upgrade is applied it refuses every operation that would contact another canister — votes, proposal execution, verification, refunds, the guardian’s resolution drain — with an error that starts with SELF_UPGRADE_QUIET, and it waits for the calls it had already started to finish. Queries and purely local operations keep working, and a refused operation has done nothing, so it can simply be retried after the upgrade. The install is then sent without waiting for its reply, and the upgraded canister marks the proposal Executed. With no call in progress the pause lasts a few seconds; it ends as soon as the last call already in progress returns. If those calls have not finished after 10 minutes, nothing is installed and the proposal is marked ExecutionFailed (SELF_UPGRADE_QUIET_TIMEOUT). If the install is refused, the previous code keeps running, stays paused, and marks the proposal ExecutionFailed (SELF_INSTALL_REFUSED) after 30 minutes. The calls the pause waits for include every outbound call the canister makes, among them the signing calls of identity verification on ONS and the treasury-withdraw transfer on SONS; a call whose reply has already arrived no longer holds the pause when its continuation fails, while a call that is really still open keeps holding it.
  • Scope: commit a frontend (asset canister) batch.
  • Target: AssetUpgrade(AssetUpgradeData).
  • Threshold: Critical. Guardian-vetoable. (A frontend upgrade is a code/content change to a trusted surface, so it clears the same critical bar as every other upgrade.)
  • What it does: validate_commit_proposed_batch (at creation and again at execution) then commit_proposed_batch. At creation the asset-lease check runs before the validation call, and a target that does not answer within 15 seconds gets the proposal refused with nothing created and no fee taken.
  • Automatic batch cleanup: an asset canister allows at most ONE proposed batch, and proposed batches never expire upstream — so a failed proposal would otherwise block every future frontend upgrade. Every non-executed terminal transition (rejected, expired, veto-upheld, execution-failed) therefore schedules an automatic delete_batch on the target; the governance timer retries it with exponential backoff and, after repeated failures, emits a visible asset_batch_cleanup_gave_up event calling for manual cleanup (guardian fallback, also reachable by the transition-phase deploying principals: guardian_delete_asset_batch). Batches staged on the asset canister but never attached to any proposal are discovered and removed by an orphan probe (triggered by the abandoned-upload-session sweep and by the next prepare_asset_upload). A batch referenced by a still-live proposal is never deleted. Pending work is public via the get_pending_asset_cleanups query. The same cleanup applies to every AssetCommit step of an UpgradeDappBatch whose step did not commit.
  • Scope: upgrade a token’s ledger suite — index, ledger, and archive canisters — in ONE proposal, executed sequentially in the canonical safe order Index → Ledger → Archive(s). The Ledger step is mandatory (it anchors the suite); the Index step is optional (an auxiliary canister a DAO may not maintain — including it is recommended when one exists); archives are proposer-supplied and explicit — there is no hidden expansion: an archive spawned after the vote ships in the next batch. Cannot target ohshii_governance itself.
  • Target: CanisterUpgradeBatch(CanisterUpgradeBatchData) — an ordered list of 1–5 CanisterUpgradeSteps, each carrying a target canister, a suite role (Index | Ledger | Archive), a WASM hash + size, an optional Candid-encoded upgrade arg, and its own upload session.
  • Threshold: Critical. Guardian-vetoable.
  • What it does: validates the role order and suite shape at creation — with read-only suite probes (archives() on the ledger, ledger_id() on the index) applied verify-when-verifiable: a probe that answers and contradicts the proposal rejects it; a probe the ledger flavor does not expose logs a warning and proceeds on the proposer’s explicit list. Stages one upload session per step, then executes sequentially with stop-on-first-error: an all-or-nothing controller pre-flight (one uncontrolled target means zero installs), per-step idempotent skip (live module hash already equals the step hash), hash revalidation (TOCTOU), install, post-install verify, and per-step progress persisted in the proposal’s batch_step_results. No automatic rollback — ledger downgrades are unsupported upstream; a partial failure ends in ExecutionFailed with the per-step record, and recovery is fix-forward: a fresh batch proposal whose already-committed steps auto-skip. See WORKFLOWS.md §5.3 for the full flow.
  • Scope: upgrade an arbitrary ordered set of canisters — 1–10 steps — in ONE proposal, executed verbatim in the order the proposer specifies (no canonical order; the chain validates step shape, not release semantics). Each step carries one target + one kind + one category + its own git provenance:

    • kind — code (a WASM install_code / install_chunked_code) or asset (a frontend asset-canister commit_proposed_batch);
    • category — Core (a known OhShii canister: the launcher fleet — backend, dao_storage, pool_manager, the launcher frontend — the locker fleet, the quests canisters, and ohshii_governance itself) or Custom (any other principal, rendered with a warning in the proposal view);
    • provenance — per step (git_repo_url + git_commit_hash + build_instructions, all required non-empty), because a dapp batch mixes artifacts from several repos.

    The governance canister itself may be upgraded only as the FINAL step (and nothing may follow it). The ledger-suite batch (UpgradeCanisterBatch) remains the specialized suite path; UpgradeDappBatch is the general dapp-release lane.

  • Target: DappUpgradeBatch(DappUpgradeBatchData) — an ordered list of DappUpgradeSteps (target + category + typed payload (Wasm or AssetCommit) + per-step provenance).

  • Threshold: Critical. Guardian-vetoable.

  • What it does: validates the shape at creation — step count (1–10), pairwise-distinct targets, self-step-last, per-payload shape, Core membership (target must be a known OhShii canister), per-step provenance, and for each asset step an validate_commit_proposed_batch probe on the target. Stages one upload session per code step (30-minute window; asset steps consume no session — their bytes already live on the target asset canister). Execution runs an all-or-nothing controller pre-flight over EVERY target — asset targets included (one uncontrolled target means zero installs and zero commits), then sequential, stop-on-first-error: code steps re-validate the staged hash (TOCTOU), install, and verify the module hash; asset steps re-validate then commit_proposed_batch; per-step progress persists in the proposal’s batch_step_results. Fix-forward recovery (no rollback): a fresh batch — committed code steps auto-skip by live module hash; asset steps never auto-skip (a committed batch is consumed). See WORKFLOWS.md §5.4 for the full flow.

  • Scope: call a named method from the ADMIN_ALLOWLIST on a canister ONS controls. The general-purpose lane, and the home of the actions the retired categories used to cover.
  • Target: AdminMethodCall(AdminMethodCallData) = target_canister_id + method_name + Candid-encoded method_args + description.
  • Threshold: the per-method TIER is resolved separately from the allowlist gate. Standard by default, with a per-method CRITICAL set (admin_method_call_is_critical): treasury / balance withdrawals (proposal_treasury_withdraw, proposal_pool_manager_withdraw, proposal_pm_withdraw, proposal_pm_withdraw_account_id), the multi-recipient treasury batch (proposal_treasury_transfer_batch: up to 20 payments from the treasury in one proposal, or one OHSHII linear lock in the OhShii Locker owned by its recipient. The canister the proposal targets pays: the launcher backend, the pool manager or ohshii_governance. Several payments in one proposal are available from the launcher backend only. A lock can be funded from any of the three, from its main account or from a named subaccount, and the tokens land in the lock’s own custody subaccount. A launcher-backend subaccount is refused at creation, because those subaccounts hold per-campaign Guest-fee custody. The source account must hold the lock amount plus two ledger fees when the proposal is created; the legs run in order, a failed leg does not stop the others, each leg’s result is kept on the proposal, and the proposal reads Executed with a PARTIAL: note when only some legs landed or a leg’s outcome is unknown), liquidity removal (proposal_remove_liquidity, proposal_remove_custom_pool_liquidity), ICPSwap position transfers (proposal_transfer_pool_position, proposal_transfer_custom_pool_position), the treasury-funded liquidity operations (proposal_treasury_create_custom_pool, proposal_treasury_add_liquidity, proposal_retry_treasury_liquidity, proposal_remove_liquidity_to_treasury, proposal_retry_treasury_forward), OhShii Locker lock mutations (admin_update_lock_status, admin_delete_lock, admin_transfer_lock_recipient), the locker token delisting (remove_supported_token), campaign deletion (admin_delete_campaign), stopping a non-self managed canister (proposal_stop_canister) and non-self controller changes (admin_add_controller_to_canister / admin_remove_controller_from_canister), plus the three transfer-journal levers (admin_resolve_ambiguous_transfer, admin_authorize_repeat_transfer, admin_force_clear_ambiguous_transfer — they retire a record that is currently REFUSING a fund exit, so they inherit the tier of the withdrawal they unblock), run the CRITICAL tier (7-day window, higher quorum/approval, guardian-vetoable) without leaving this category. Two methods are conditionally critical: proposal_update_token_metadata when the target ledger is the OHSHII token, and admin_update_ico_info when it edits the root campaign dao-0000000001. The four defensive fund-movers icrc1_transfer / emergency_transfer / icrc2_approve / icrc2_transfer_from are deliberately not on the allowlist (no frontend path). A move out of an OhShii Locker lock’s custody subaccount goes through the typed, critical admin_subaccount_transfer on the locker backend; the strongbox itself is a reserved CustomCall target. Separately, a set of creation-time pre-gate fast-fails runs before the positive allowlist: a handful of self-targeted dangerous methods are redirected to CriticalGovernanceOperation; stopping the governance canister or the OHSHII ledger is hard-blocked (keyed on the decoded target, so a different managed canister can be stopped by proposal); the management canister aaaaa-aa is blocked as an AdminMethodCall target (a raw management-canister call belongs on no ad-hoc allowlist lane; it is also structurally reserved from CustomCall); and remove_supported_token for the OHSHII ledger, admin_list_token, and the deprecated proposal_create_custom_pool are rejected outright.
  • Outcome of a value-moving call: for a method on the value-moving list, the recorded status follows what actually moved. A call that did everything reads Executed. A partial result the target can describe (it answers Ok starting with PARTIAL_OUTCOME:) reads Executed with that description as its note. An outcome that cannot be determined — the reply was lost, or the target answers Err starting with OUTCOME_UNKNOWN: — reads Executed with an AMBIGUOUS: note and is not executed again. ExecutionFailed means nothing moved. The proposal deposit is refunded in full on both Executed and ExecutionFailed. The pool manager withdrawals (proposal_pool_manager_withdraw, proposal_pm_withdraw_account_id), the ONS withdrawal proposal_pm_withdraw, the funding leg and the pool-manager call of the backend treasury liquidity operations (proposal_treasury_create_custom_pool, proposal_treasury_add_liquidity, proposal_retry_treasury_liquidity), the liquidity decrease of proposal_remove_liquidity and proposal_remove_custom_pool_liquidity, and the OhShii Locker custody move admin_subaccount_transfer start their error with OUTCOME_UNKNOWN: when the outcome of their own transfer is unknown, and ONS reads that marker on every error shape.
  • What it does: call_raw to the target method — but only after a compile-time positive allowlist admits the call. There is no free-form passthrough: an AdminMethodCall whose (target_canister_id, method_name) pair is not present in the on-chain ADMIN_ALLOWLIST constant (currently 86 pairs across four fixed ICC targets — backend, pool_manager, ohshii_governance-self, OhShii Locker backend) is rejected at proposal creation. Adding a method needs a canister upgrade; arbitrary one-off external calls go through CustomCall instead. The pre-gate blocks/redirects above run first as fast-fails, and the per-method critical tier is resolved separately — but the authoritative authorization gate is the positive allowlist (see ExecuteAdminMethod: allowed method names and targets below).
  • Scope: elect, replace, or remove the platform guardian.
  • Target: SetGuardian(SetGuardianData { new_guardian }) — a non-anonymous Some(p) = add/replace; None / anonymous = remove.
  • Threshold: Critical for add/replace (guardian-vetoable). Removal is hardened: a hardcoded 7-day window, never immediate, and the guardian cannot vote on its own removal; like every Critical proposal it is vetoable-but-overridable (see Guardian Overridable Veto).
  • What it does: validates the principal (rejects anonymous, the governance canister, the management canister, and OhShii infra canisters), sets the guardian, then best-effort syncs the guardian caches on the SONS + non-ONS canisters.
  • A removal survives every later upgrade. The default guardian and the default World ID / DecideID configurations that post_upgrade can fill in are applied at most once per canister, behind a persisted latch; a guardian removed or a configuration cleared by an approved proposal is not put back by a later upgrade. The latch is itself one of the critical settings above: an upgrade whose code cannot read it is refused, so a damaged latch can never cause the defaults to be applied a second time.
  • Scope: the high-risk lane — controller add/remove and snapshot restore/delete targeting the governance canister, voting-parameter changes, the platform suggested-default excluded-countries list, and the centralized Shield kill switch. Cannot stop the governance canister.
  • Target: AdminMethodCall(..) restricted to an 18-method allowlist — the four self-management ops (admin_add_controller_to_canister, admin_remove_controller_from_canister, admin_restore_snapshot_with_workflow, admin_delete_canister_snapshot), set_voting_parameters, set_default_excluded_countries (the platform-wide suggested-default list of countries excluded from LGE participation, pre-populated into new LGE creation flows, together with its baseline subset — the entries included in every launch, which creators cannot remove and replacement proposals cannot drop; updating the default never touches any campaign’s already-resolved list), and the ten voter-verification setters/revocations (set_world_id_config, admin_clear_world_id_verified, set_decideid_config, admin_clear_decideid_verified, set_decideid_mode, set_decideid_oidc_config, set_telegram_config, admin_clear_telegram_verified, set_google_config, admin_clear_google_verified), and the two provider-intake switches (set_world_id_accepts_new_proofs, set_decideid_accepts_new_proofs), which sit on this list AND on the ExecuteAdminMethod allowlist because their tier depends on the argument: reopening a provider to new proofs runs critical, closing it does not — or SetShieldPreauthMode(..).
  • Threshold: Critical. Guardian-vetoable.
  • What it does: executes the allowlisted method, or fans the requested Shield pre-auth mode out to the 5 core canisters (backend, dao_storage, ohshii_governance, locker backend, lock_storage).
  • Scope: dispatch ONE arbitrary Candid call to a named method on ANY canister ONS controls, where the argument is supplied as human-readable Candid text and the governance canister — not the frontend — encodes it. The general escape hatch for a method that has no dedicated typed proposal path, without ever letting the UI become the interpreter.
  • Target: a custom-call payload = target canister id + method name + candid_text_args (a Candid textual value).
  • Threshold: Always Critical. Guardian-vetoable. Fixed 7-day window, critical quorum/approval bar, override round — a raw call to an arbitrary method is at least as dangerous as an upgrade, so it never runs the standard tier.
  • On-chain encoding (the security model). At creation the governance canister fetches the target’s published candid:service metadata (via the management canister canister_metadata), parses it, and encodes candid_text_args against the REAL signature of method_name into the executable blob. The frontend is NEVER the interpreter — this structurally forecloses the “vote on X, execute Y” class of attack, because the bytes that will run are derived on-chain from the exact human-readable text the voters saw. The proposal pins the target module_hash, the sha256 of the encoded payload, and a normalized render of the call.
  • Re-verification at execution. Before dispatch the executor re-verifies the pinned module_hash — fail-closed if the target’s code changed at all since the vote (a changed target is a different program than the one the DAO approved) — and that governance is still a controller of the target, then dispatches with a bounded wait (never unbounded).
  • Reserved targets (a CustomCall can never target them). The management canister aaaaa-aa, the governance canister itself, the OHSHII / ICP / cycles ledgers, the OhShii Locker lock_storage (the voting-power source of truth) and the locker backend (its only writer), and the OhShii Locker strongbox (the custody canister that holds every lock’s tokens) are structurally reserved (is_reserved_custom_target), checked at creation and again at execution. This keeps the money-rail, voting-power, custody and self-management surfaces on their dedicated typed proposal paths: lock changes and custody moves go through the locker backend’s individually classified ExecuteAdminMethod entries (custody moves through the critical admin_subaccount_transfer). A free-form call to the strongbox could otherwise move any lock’s tokens outside the locker’s checks, or grant a standing transfer right (authorize_proxy) with no further vote. The governance canister remains a controller of the strongbox, so a change the DAO needs there goes through a critical canister-upgrade proposal.
  • Preview before proposing. custom_call_preview(target, method_name, candid_text_args) is an update that returns the exact render + sha256 + module_hash the canister WOULD encode, without creating a proposal, so a proposer sees precisely what the DAO will vote on. It is rate-limited (a cycle-reserve floor + a global cap — ONS 120/hour) as a Sybil cycle-drain defence.
  • What it does / outcome: see the CustomCall workflow — the executor follows an “Executed unless provably no-op” policy (a reply, including a business Err/err arm, means the call ran, so the proposal is Executed; an ambiguous outcome is Executed + flagged for manual verification; only a clean, provably-no-effect system reject fails it).
  • Scope: a typed, DAO-composed HTTPS request to an external server — a canister HTTPS outcall, voted before it is sent. (It is a canister HTTPS outcall; it is not a bridge and not an oracle.)
  • Target: an HTTPS-outcall payload = url, method (Get / Post / Head), request headers, an optional body, a mandatory max_response_bytes (1..=2 MiB), a max_cycles cap, is_replicated, and a fixed, wasm-exported transform chosen by id (StripHeaders / StripHeadersAndBody). A proposal can never supply its own transform — only pick one of the built-in ones — so the response-normalization code is always canister code the proposer cannot inject into.
  • Threshold: Always Critical. Guardian-vetoable.
  • Cost gate. At execution the executor computes the real outcall cost from the request shape and max_response_bytes and aborts if cost > max_cycles — the DAO’s declared ceiling is enforced, never silently exceeded.
  • What it does / outcome: see the HttpsOutcall workflow. A 2xx → Executed (event error = None); a non-2xx (e.g. 500) → still Executed, with the status in the event (error = Some("HTTP status N")) — the request WAS delivered and a non-idempotent POST may already have had a side effect, so a retry would risk double execution; only a transport-level failure (the outcall never completed) → ExecutionFailed (status 0, error set).
  • Scope: replace the DAO canister registry — the public list of canisters this DAO declares it operates, rendered by the DAO Canisters panel on the governance page next to the intrinsic canisters (governance / ledger / index), which are read certified from the IC itself. The registry half is an attestation by the DAO, not a proof; the panel labels it as such, and every module hash it shows is still read live from the certified state tree, never taken from a registry record.
  • Target: DaoCanisterRegistryData { entries : vec ManagedCanister }, where each entry is { canister_id, name, category }. Execution REPLACES the whole list — there is no add/remove delta, so what voters read is exactly what lands. A delta would be unreviewable: its outcome would depend on state at execution time, which may change between the vote and the execution.
  • An EMPTY list is valid and is the removal path. It clears the registry permanently: the one-shot platform seed keys off a written latch, not off emptiness, so it never comes back on a later upgrade.
  • Validation (at creation AND again at execution, fail-closed): at most 30 entries; no duplicate canister_id; the anonymous principal, the management canister (aaaaa-aa) and every non-canister principal are rejected; name 1–64 chars and category 1–32 chars after trim. Characters that would need escaping in a frontend (< > & ' " \ and control characters) are rejected, not stripped — a governance decision must land byte-for-byte as voted. Errors carry the offending row index.
  • Threshold: Always Critical. Guardian-vetoable. It moves no funds and grants no power — but it is a public trust surface: a hostile entry in the list a DAO publishes as “canisters we operate” is a phishing vector. The practical consequence is deliberate and worth stating plainly: removing a hostile entry takes at least the full 7-day critical window, and a guardian can veto the removal into a 24h override round.
  • DISPLAY-ONLY — a hard constraint. No canister may read this list as an authorization input: not a cycle-balance membership test, not an upgrade-target predicate, not a fee/quota decision, not a controller check. The moment it gates something, a list that means “the DAO says these are ours” becomes a privilege table, and the proposal that edits it silently becomes a privilege-escalation vector. In particular it is not SONS’s get_managed_canisters (dynamic discovery), which does feed a cycle-balance predicate and must stay exactly as it is.
  • Read surface: get_dao_canister_registry : () -> (vec ManagedCanister) query, anonymous callers accepted — this is public transparency data rendered before any wallet connects.

Retired ONS categories (rejected at creation)

Section titled “Retired ONS categories (rejected at creation)”

UpdateLedger, ListTokenOnLocker, AddTokenController, ModifyLock, and DeleteLock still exist in the enum but create_proposal rejects them with “Proposal category … is retired and can no longer be used.” Their old actions are now performed via ExecuteAdminMethod: token-ledger metadata via the ledger’s admin_* methods, locker token-list maintenance on the locker backend (listing a NEW token is disabled: admin_list_token proposals are refused at creation, see the table below), and locker lock edits via admin_update_lock_status / admin_delete_lock on the locker backend (see the ExecuteAdminMethod tables below). Their legacy Campaign / Token / Lock targets are no-ops in the executor.

ONS ExecuteAdminMethod: allowed method names and targets

Section titled “ONS ExecuteAdminMethod: allowed method names and targets”

When the category is ExecuteAdminMethod, the proposal carries AdminMethodCallData: target_canister_id, method_name, method_args, description. In production these actions run only through an approved ONS proposal executed by ohshii_governance; a direct invocation is a transition-phase path that is retired at the DAO handover. The methods below are the ONS ADMIN_ALLOWLIST — the compile-time positive allowlist (src/ohshii_governance/src/types.rs) checked at proposal creation: an AdminMethodCall whose (target_canister_id, method_name) pair is not present is rejected. The allowlist currently holds 86 (target, method) pairs across four fixed ICC targets; the tables group them by target.

Note — the positive allowlist is the authoritative gate. Under ExecuteAdminMethod the canister maintains a compile-time positive allowlist (ADMIN_ALLOWLIST in src/ohshii_governance/src/types.rs, currently 86 (target, method) pairs): create_proposal rejects any pair not on it. There is no free-form / blocklist + redirect passthrough — arbitrary one-off external calls go through CustomCall (module-hash-pinned, guardian-vetoable), and adding a new allowlisted method requires a canister upgrade. Three creation-time behaviours run as pre-gate fast-fails before the allowlist: (1) admin_restore_snapshot_with_workflow, admin_delete_canister_snapshot, set_voting_parameters, set_default_excluded_countries, and the ten voter-verification setters/revocations (set_world_id_config, admin_clear_world_id_verified, set_decideid_config, admin_clear_decideid_verified, set_decideid_mode, set_decideid_oidc_config, set_telegram_config, admin_clear_telegram_verified, set_google_config, admin_clear_google_verified) are redirected to CriticalGovernanceOperation; (2) admin_stop_canister / proposal_stop_canister are hard-blocked when the decoded target is the governance canister or the OHSHII ledger; (3) admin_add_controller_to_canister / admin_remove_controller_from_canister are redirected to CriticalGovernanceOperation when the decoded first arg is the governance canister itself. The per-method critical set (admin_method_call_is_critical — withdrawals, liquidity removal, position transfers, lock mutations, campaign deletion) still resolves the tier IN-PLACE under ExecuteAdminMethod. Only CriticalGovernanceOperation keeps its own separate 18-method allowlist (the two controller ops + the two snapshot ops + set_voting_parameters + set_default_excluded_countries + the ten voter-verification setters/revocations listed in (1) + the two accepts_new_proofs intake switches, which are on BOTH lists because their tier depends on the argument — see the note below the ONS self table). The tables below illustrate the main methods per target; the authoritative accept-set is ADMIN_ALLOWLIST.

Non-self canister management runs at the CRITICAL tier. Two management actions are proposable under ExecuteAdminMethod at the CRITICAL tier (7-day window, guardian-vetoable): proposal_stop_canister against a non-self managed canister — the block keys on the decoded first argument, so stopping the governance canister and the OHSHII ledger stays blocked while a different managed canister can be stopped by an approved proposal; and admin_add_controller_to_canister / admin_remove_controller_from_canister against a non-self canister — managing the governance canister’s OWN controllers routes to CriticalGovernanceOperation, while changing another managed canister’s controllers is an allowlisted CRITICAL proposal.

Backend (ohshii_launcher_backend):

method_nameWhat it does
admin_set_feature_statusesSingle multi-toggle method. Enable/disable any subset of the 7 platform feature toggles in one call — LGE creation, LGE participation, token creation, index canister creation, the Bitcoin Gateway and Solana Gateway (cross-chain), and OhShii Locker public lock creation — each with an optional popup message. The request carries one optional record per feature; only the provided ones change. The locker_lock_creation toggle is forwarded to the locker backend; it gates only the public create_lock_v2 / create_lock_v3 paths (not the governance treasury lock path, and never SONS-governance locks). This is the method the “Feature Toggles” proposal uses; the proposal UI shows a current→proposed diff. A switch whose stored value the installed module cannot read is reported disabled, and writing it is refused until a module that reads the stored value is installed; an upgrade to a module that cannot read a switch or a fee configuration is refused, and the backend keeps its previous module.
admin_set_ico_creation_status(Legacy single-feature setter; retained for compatibility.) Enable/disable LGE creation platform-wide (with optional message).
admin_set_ico_participation_status(Legacy.) Enable/disable LGE participation.
admin_set_token_creation_status(Legacy.) Enable/disable token creation.
admin_set_index_creation_status(Legacy.) Enable/disable index canister creation.
admin_update_campaign_statusUpdate campaign state (Active, Finalizing, Completed, Failed, Frozen) and/or tokens_sold. Completed is terminal and must be reached by the finalization flow; Finalizing is set (via an approved ONS proposal executed by ohshii_governance) to start/retry finalization.
admin_update_ico_paramsUpdate global LGE parameters.
admin_update_ico_infoUpdate campaign info (description, website, telegram, etc.); guardians can also hide/unhide and freeze.
admin_update_excluded_countriesReplace a specific campaign’s excluded-countries list (regional participation eligibility, ISO 3166-1 alpha-2 codes). Takes effect immediately for participants declaring from that point forward; the confirmation is a frontend gate (not recorded per-participation on-chain), and the change is forward-only — earlier participations are never retroactively affected. Platform-baseline entries are always retained — a replacement can add to or modify only the non-baseline portion (the executor unions the current baseline into every applied list). The proposal payload renders the full list inline (codes + names) plus a content hash, so voters verify exactly what they vote on.
admin_delete_campaignDelete a campaign (cleanup; in production executed by ohshii_governance via an approved ONS proposal).
admin_create_pool_earlyCreate ICPSwap pool for a campaign before normal finalization.
admin_create_snapshot_with_workflowCreate a canister snapshot (orchestrated via backend). Target: any canister the backend can control.
admin_restore_snapshot_with_workflowRestore from snapshot. Not allowed under ExecuteAdminMethod; must use CriticalGovernanceOperation (any target).
admin_delete_canister_snapshotDelete a snapshot. Not allowed under ExecuteAdminMethod; must use CriticalGovernanceOperation.
admin_add_controller_to_canisterAdd a controller to a canister. If the decoded first arg is ohshii_governance, must use CriticalGovernanceOperation; against a non-self canister it is an allowlisted CRITICAL ExecuteAdminMethod.
admin_remove_controller_from_canisterRemove a controller. If the decoded first arg is ohshii_governance, must use CriticalGovernanceOperation; against a non-self canister it is an allowlisted CRITICAL ExecuteAdminMethod.
admin_set_guest_feeSet the OHSHII Guest fee (per-LGE first-purchase fee for Guest-tier wallets). The fee is transferred 100% to the treasury — nothing is burned.
proposal_treasury_create_custom_poolCritical. Create a custom ICPSwap pool funded from the backend treasury: the backend moves the passcode reserve (and, when add_liquidity = true, the token amounts + fee buffers) to the pool_manager with ledger-deduplicated (pinned created_at_time) funding legs, then the pool_manager creates the pool and mints a full-range position owned by the pool_manager. Amounts always define the initial price; a public front-run of the pool creation degrades deterministically to add-liquidity on the existing pool. Every step is journaled on-chain; a failed op is resumed by proposal_retry_treasury_liquidity.
proposal_treasury_add_liquidityCritical. Add treasury liquidity to an EXISTING ICPSwap pool: funds move backend → pool_manager, the pool is verified against the ICPSwap factory (anti-lookalike check), and a new full-range position is minted per proposal and tracked in the pool_manager’s treasury-position registry.
proposal_retry_treasury_liquidityCritical. Idempotently resume a failed treasury create/add operation by op id: re-sends only the funding legs not yet confirmed (ledger dedup via the pinned created_at_time), then re-drives the pool_manager saga from its persisted phase journal. A retry against an op that actually completed returns success without moving funds.
proposal_resume_frozen_governanceResume a campaign whose creation failed after payment, by installing the governance code into the canister that campaign’s own record already names, instead of buying a new one. Refuses anything else: a campaign that is not frozen, a canister recorded for a different campaign, or one that already holds code. If the canister holds less than a newly created governance canister receives, it tops it up only from that campaign’s own creation payment (the cycles the payment bought that the creation did not spend), never from the platform; any remainder is recorded and left to a manual top-up. It moves no campaign state — it completes the install the failed creation never reached, and records what it reused and what it topped up.

Backend levers never act on an OhShii infrastructure canister. The backend levers whose target canister is named in the call, or read from a stored campaign or token record, refuse before any call when that target is an OhShii infrastructure canister: the launcher backend and frontend, dao_storage, pool_manager, ohshii_governance, the OhShii Locker backend, frontend, proxy, strongbox and lock_storage, and the OHSHII ledger and index. The refusal text starts with INFRA_TARGET_REFUSED. The levers covered are admin_add_controller_to_canister, update_index_canister_controller, update_token_canisters_settings, upgrade_icrc1_token, proposal_update_token_metadata, the index levers (admin_upgrade_index_canister, proposal_edit_index_canister_frequency), the snapshot / restore workflows on their admin branch (an is_admin caller; a governance canister orchestrating its own snapshot or restore takes a different branch), and the writes that read their target from a stored record (guardian_update_token_logo, update_standalone_token, the imported-DAO controller handover remove_backend_controller_from_imported_governance, the post-finalization steps). Those canisters change only through their own controllers and the ONS proposal categories that upgrade them. Two ONS proposals are the only exceptions, and each is admitted only when ohshii_governance executes it:

  • proposal_update_token_metadata on the OHSHII token ledger (a CRITICAL proposal);
  • proposal_edit_index_canister_frequency on the OHSHII index canister, paired with the OHSHII ledger.

Any other caller of these levers on those two canisters is refused. When a campaign completes, the step that hands its ledger, index and archives to the campaign DAO skips, and records in the log, an archive or index id that names an infrastructure canister (the index is never touched, and the step completes); a ledger that names one stops that step.

OhShii Locker backend (ohshii_locker_backend):

method_nameWhat it does
admin_update_lock_statusUpdate lock status (e.g. Active, Frozen, Cancelled).
admin_delete_lockDelete a lock.
admin_transfer_lock_recipientChange the beneficiary of a lock.
admin_list_tokenNo longer proposal-creatable — hard-blocked at ONS create_proposal, because the Locker’s supported-token list is frozen (the Locker accepts a token that is not already listed only from the launcher backend, which does not call it, so only metadata refreshes of the listed tokens remain possible through a direct guarded call).
admin_update_tokenUpdate token config (e.g. min_lock_amount, logo).
remove_supported_tokenRemove a token from the supported list (locks become Cancelled; users can reclaim). CRITICAL tier: a token-wide cancellation clears the critical thresholds + guardian veto, symmetric with admin_delete_lock. OHSHII (7rrmx-tyaaa-aaaal-qsraq-cai) can never be removed — it is a permanent supported token; removing it would cancel every active OHSHII lock. The proposal is rejected for the OHSHII ledger at the frontend, at ONS proposal creation, and (authoritatively) in the locker backend. If the removal cannot cancel every lock, it reports how many were cancelled and is not recorded as a success; a new removal proposal for the same token resumes the cancellation of the remaining locks.
admin_retry_reclaim_fixupReplay a journaled reclaim fixup entry. Used when reclaim_lp_lock successfully transferred a position but the subsequent storage update failed; this method CAS-updates the on-chain lock record to reconcile the audit trail. Takes a single lock_id: text argument.
admin_clear_reclaim_fixupDismiss a journaled reclaim fixup entry that has already been reconciled offline. Takes a single lock_id: text argument. Does NOT perform any storage update.
admin_settle_unlock_externallyCRITICAL tier (on the ONS allowlist). Count one unlock tranche whose transfer outcome was unknown as delivered out of band. Takes lock_id: text, unlock_idx: nat32 and evidence_ref: text (the ledger block or transaction that proves the delivery; it must not be empty). Moves no tokens: it advances the lock’s unlock counter, records the tranche as settled by hand, and clears the pending unlock entry, so the tranche is never sent again. If the tranche was not in fact delivered, the recipient loses it.
admin_retry_unlock_with_new_identityCRITICAL tier (on the ONS allowlist). Send one unlock tranche whose transfer outcome was unknown again, under a fresh transfer identity, so the ledger’s deduplication cannot swallow it. Takes lock_id: text and unlock_idx: nat32. The call itself moves nothing; it reactivates the lock and the locker’s unlock timer sends the transfer. Use it only when the ledger shows the first transfer did not land: if it did, the recipient is paid twice from tokens that back other locks. A tranche already counted is not sent again. The locker refuses the retry with a named code, and changes nothing, whenever a new send is not provably safe: the tranche is already paid (UNLOCK_RETRY_ALREADY_PAID) or already settled, a send is still in flight (UNLOCK_RETRY_IN_FLIGHT), or its outcome is unknown and the first send could still land (UNLOCK_RETRY_WITHIN_DEDUP_WINDOW, UNLOCK_RETRY_OUTCOME_UNKNOWN). A tranche whose earlier send the ledger answered as a duplicate counts as already on the ledger: the retry is refused with UNLOCK_RETRY_ALREADY_PAID, and the locker’s unlock timer holds that tranche for admin_settle_unlock_externally instead of sending it again.
guardian_resolve_pending_unlockSTANDARD tier (on the ONS allowlist); the guardian can also call it directly. Decide one unlock tranche whose transfer outcome stayed unknown (its proxy record is still pending). Takes lock_id: text and unlock_idx: nat32; the proposal only names the tranche, the locker decides. It reads the balance of the lock’s own custody subaccount: if exactly this tranche plus one ledger fee is missing, the tranche is counted as delivered and never sent again; if the tranche is still there after the landing horizon (24 h 10 min after the send), it is sent again once under a new transfer identity; any other balance (another debit, a surplus of half a tranche or more, a changed ledger fee, a lock without its own subaccount) is refused and nothing changes (UNLOCK_RESOLVE_* codes). A gift of tokens to the subaccount can only be paid out to that lock’s recipient. The call moves nothing itself; a re-sent tranche is paid by the unlock timer.
admin_subaccount_transferCRITICAL tier (on the ONS allowlist). Move tokens out of one locker custody subaccount to a principal’s default account. Takes subaccount: blob (32 bytes), destination: principal, token_id: principal and amount: nat (raw units; the ledger fee is charged on top). Used to return the tokens of a lock whose creation outcome was unknown and that does not exist. It accepts ANY custody subaccount, including the one backing a live lock, so a proposal must name a subaccount that no lock uses; it refuses only a subaccount that a credit or reclaim is operating on at that moment. When the outcome of the transfer is unknown — the reply to ONS was lost, or the locker reports that its own transfer to the ledger has an unknown outcome — the proposal is recorded as an ambiguous outcome (Executed with an AMBIGUOUS: note), not as a failure; a failure is recorded only when the transfer provably did not happen (for example a ledger refusal). After an ambiguous outcome, check the ledger balance of the subaccount and of the destination before proposing it again: a second proposal is a second transfer.

The reclaim fixup queue can be inspected via the admin_list_reclaim_fixups : () -> (vec ReclaimFixupEntry) query method on the locker backend. It is a query method, so it is not called via a governance proposal — it can be read directly by any caller in the locker’s is_admin() set, which includes the ONS OHSHII Governance canister.

Pool manager:

method_nameWhat it does
proposal_fee_collect_to_treasuryCollect the ICPSwap trading fees of a position the pool manager owns and forward them to the OhShii backend treasury. The claim is refused before anything moves while a treasury liquidity operation or a liquidity removal on the same pool is unfinished. Each forward waits until the pool’s withdraw queue no longer holds the claimed fees, and sends the payout the pool reported, less its ledger fee, never an amount read from the pool manager’s balance, so value that other operations move into or out of that balance meanwhile is not reported as a payout still pending. If the queue is empty but the pool manager’s balance does not show the payout, the forward still sends it and the event is recorded for review; if the pool’s own payout failed after leaving its queue, the pool manager’s balance has fronted at most that payout. A failure after the claim is reported with the step that failed and where the funds are, and the proposal is recorded as executed with that note, because the claim itself already moved value. A failure before the claim is recorded as a failed execution. An outcome that cannot be determined is reported as unknown, so the proposal is recorded as executed with an ambiguity note rather than as failed, and it is not re-run.
proposal_fee_collect_and_buybackCollect the fees of a position the pool manager owns, then swap the ICP that actually arrived for the token the proposal names (slippage-protected). The collected tokens and the bought tokens are forwarded to the treasury. Same guarantees as proposal_fee_collect_to_treasury.
proposal_pool_manager_withdrawWithdraw ICP or an ICRC-1 token from the pool manager to a recipient. Supports an optional source subaccount (raw 32-byte) and an optional recipient subaccount.
proposal_pm_withdraw_account_idWithdraw ICP held under a legacy ICP AccountIdentifier (e.g. LGE deposit funds) by resolving the AccountIdentifier back to its raw subaccount, then transferring to a recipient. ICP-only; used when the source is a 64-hex AccountIdentifier rather than a raw 32-byte subaccount. The AccountIdentifier is checked when the proposal is created, through the pool manager’s resolve_subaccount_for_account_id query: an identifier the pool manager cannot resolve is refused before any fee, and if the pool manager cannot be reached the creation is refused with a message to try again.
proposal_remove_liquidity_to_treasuryCritical. Remove liquidity from a pool_manager-owned position (treasury-registry positions, or campaign positions only when the campaign is terminal) and forward both withdrawn assets to the backend treasury in the same operation. Withdraws move the exact recorded amounts (never a balance-driven sweep) and forward legs are fee-netted with pinned created_at_time. ICPSwap also pays removed amounts out on its own; when that payout reaches the pool manager first, the removal waits for it and forwards what arrived less the ledger fee, so the pool manager’s own funds never cover a fee. A failure after value moved is recorded as executed with a note saying where the funds are; an undeterminable outcome is recorded as unknown and not re-run.
proposal_retry_treasury_forwardCritical. Idempotently re-drain the pending withdraw/forward legs of a removal operation by op id (same pinned-leg dedup model).
admin_resolve_ambiguous_transferCritical. Record the outcome of a ledger reconciliation for one entry of the transfer journal (see the note below). did_land = false retires the record and a retry becomes possible again; did_land = true converts it to committed — never retires it, which would permit the double payment — and the block index is required. Evidence is mandatory.
admin_authorize_repeat_transferCritical. Authorise a second, genuinely identical payment for a committed journal record. A new authorisation, not a correction; the cited block index must MATCH the recorded one.
admin_force_clear_ambiguous_transferCritical. Retire a journal record regardless of state — the recovery path for a stuck state machine. Emits a distinct audit event from both levers above.
admin_reconcile_campaign_poolStandard. Backfill pool_manager’s campaign-keyed pool records (POOLS / POSITION_IDS / POOL_CANISTER_IDS / POOL_TOKENS / TOKEN_LEDGER_IDS / TOKEN_SYMBOLS) for a campaign whose ICPSwap pool + position exist on-chain but were never recorded (e.g. a legacy/externally-seeded pool like the OHSHII root pool). It discovers the pool from the ICPSwap factory (getPool on the campaign-token/ICP pair, 0.3% tier) and proves ownership via getUserPositionIdsByPrincipal before writing — it moves no funds and can only adopt a position pool_manager actually owns. After it runs, every campaign-keyed query (get_pool_state / get_position_id / get_pool_details / has_pool_position / campaign_has_liquidity_position) and every UI surface reading them become truthful. Also directly callable by the transition-phase deploying principals.

Token standard requirement (pool create / add liquidity / swap). Every pool-creation, add-liquidity, and treasury-swap proposal — on both ONS and SONS — accepts only tokens that genuinely implement the ICRC standard they are used as. Each non-ICP token ledger is checked against its advertised icrc1_supported_standards: ICRC-1 is required for every leg, and ICRC-2 is additionally required whenever that leg uses the approve/transferFrom deposit path (i.e. its standard is ICRC-2), so an ICRC-1-only token is still usable via the transfer+deposit path. The check runs fail-closed at execution before any funds move (backend / pool_manager / SONS) and is mirrored as a hard block at proposal creation in the Create Proposal form (the ledger inputs show a live metadata panel — symbol, decimals, fee, and ICRC-1/ICRC-2 support — and submission is blocked for a non-conforming token). The ICP ledger is exempt (ICPSwap always labels it ICP). A swap whose pool-metadata input standard normalizes to neither ICRC-1 nor ICRC-2 is rejected rather than silently defaulting to the ICRC-2 path.

Treasury swap outcomes (proposal_treasury_swap). On the ICRC-1 path the treasury transfers the swap amount plus the pool’s own input fee (read from the pool’s fee cache) into its deposit account in the pool, which is what the pool’s depositAndSwap draws; both pool fee arguments come from that cache. A refusal by the pool is reported as a refusal that names the pool’s error, never as an unknown outcome, and nothing is swapped. When the input had already been transferred to the pool, the backend returns it to the treasury main account in the same call and says where it went; if it cannot do so, the reply says that nothing was swapped and where the input is, and an identical swap is refused until the input is recovered with sweep_pool_unused_balance and the journal record is retired with admin_resolve_ambiguous_transfer (did_land = false).

Pool initialization uses the ledger’s own standard (ICRC-2 preferred). When a proposal creates a pool (as opposed to adding to an existing one), the standard the pool is initialized with for each non-ICP token is taken from the ledger’s advertised icrc1_supported_standards, not from the proposal: a ledger that advertises both ICRC-1 and ICRC-2 initializes the pool as ICRC-2, one that advertises only ICRC-1 initializes as ICRC-1, and one that advertises neither is rejected. The same resolved standard drives both the pool’s registered standard and the deposit path, so they cannot disagree. Adding liquidity to an already-existing pool does not change its standard.

Withdrawal proposals — memo and source format. The withdrawal proposals (Treasury Token Withdrawal, Pool Manager Withdrawal, OHSHII Governance Withdrawal, and the SONS TreasuryWithdraw AdminMethod) accept an optional memo. The memo is validated against the target ledger’s advertised maximum memo length (icrc1:max_memo_length) — in the Create Proposal form and again before the on-chain transfer; when the ledger does not advertise a maximum, the memo field is disabled. The Treasury Token Withdrawal debits only the backend’s main account: a source other than the main account (empty, or 32 zero bytes) is refused at execution and nothing moves; the same holds for every payment of the multi-recipient proposal_treasury_transfer_batch, and ONS refuses such a backend proposal at creation. The Pool Manager, OHSHII Governance and SONS withdrawals accept the source as a raw 32-byte subaccount. A source given as a legacy ICP AccountIdentifier is reachable only via proposal_pm_withdraw_account_id on the pool manager, which resolves it to the underlying subaccount; the Pool Manager Withdrawal proposal routes to it automatically when the source is an AccountIdentifier.

Note for proposers and voters — a failed withdrawal is not automatically a safe re-proposal. A ledger transfer is committed the moment the ledger processes the message, whatever the response turns out to be, so “the proposal ended in an error” and “the funds did not move” are not the same statement. Each withdrawal therefore records its own outcome in the canister’s log, and the marker says which case you are in:

MarkerWhat it meansRe-proposing
TREASURY_TRANSFER_COMMITTEDthe ledger accepted it; the entry carries the block indexalready paid — do not re-propose
TREASURY_TRANSFER_DEFINITE_FAILthe ledger refused it, or the call was cleanly rejected — no funds movedsafe
TREASURY_TRANSFER_AMBIGUOUSthe call timed out or the reply could not be decoded — the transfer may have landedstop: confirm against the ledger’s own transaction history first
TREASURY_TRANSFER_RAW_OKa checkpoint written the instant the ledger replied, before the outcome is knownnot a verdict — read the resolution marker that follows it

A re-proposal carries a new proposal id and is not deduplicated by the ledger, so an unverified retry on an ambiguous outcome pays twice. The canister log is a rotating buffer and is the pointer — the ledger’s own transaction history is the authority on whether the block exists. This mirrors the same discipline the CustomCall proposals already follow (see WORKFLOWS.md, “Note (ambiguous outcomes)”).

The canister ENFORCES that, rather than relying on the marker being read. Every treasury withdrawal (backend, pool manager, OHSHII governance, and each campaign DAO) claims a record in a durable transfer journal — keyed on the withdrawal’s semantics (ledger, source, recipient, recipient subaccount, amount) — before dispatching the transfer, and refuses while a prior identical attempt is unresolved or committed. The refusal names the journal key and which state it is in. Reading the log is not required for safety: the party that re-proposes is an electorate, so the guarantee is enforced by the canister itself.

The journal never expires and is never trimmed; a record leaves it only when the ledger provably refused the transfer (nothing landed, so a retry is safe and the block lifts automatically) or through one of three governance proposals, which mean deliberately different things:

ProposalMeaningEffect
Resolve Ambiguous Transfer (did_land = false)“I checked the ledger; it did not land”record retired, the withdrawal may be re-proposed
Resolve Ambiguous Transfer (did_land = true, block index)“I checked the ledger; it did land at block N”record converted to committed — the withdrawal stays blocked
Authorize Repeat Transfer (prior block index)“it landed at block N and a second identical payment is intended”record retired, a second payment permitted
Force Clear Ambiguous Transfer (justification)recovery path for a stuck recordrecord retired, logged as a distinct event

Treasury cycles top-ups follow a narrower rule. A top-up (proposal_treasury_cycles_topup) claims a record in the same journal, keyed on the target canister and the amount, but an identical top-up is refused only while the previous identical one has an uncertain outcome (still in flight, or its transfer reply was lost). After a top-up whose cycles were credited, a new identical top-up runs normally, with no repeat authorisation: a periodic top-up of the same canister by the same amount is routine. A top-up whose ICP reached the cycles minting canister without the cycles being credited, and a record resolved as landed after a reconciliation, still block until Authorize Repeat Transfer. Withdrawals and swaps keep the rule above.

The two Resolve outcomes are not interchangeable with Authorize Repeat: “it did not happen” and “it happened and I want it again” are opposite findings and only one of them authorises a second payment. All four are Critical — a clear on the standard lane would let a proposal unblock and then re-propose the payment at a lower bar than the withdrawal itself. Authorize Repeat additionally verifies its citation: the block index it names must match the one recorded. Each canister exposes the journal read-only as list_ambiguous_transfers.

OHSHII governance (guardian):

method_nameWhat it does
admin_set_guardian_principalSet or remove the guardian principal (also available as category SetGuardian).

OHSHII governance (voter verification):

ohshii_governance ships with four independently-togglable verification methods, and they are not interchangeable: each carries a strength, and some actions require the stronger one.

ProviderStrengthWhat it provesStatus
GoogleStandardone Google account, one walletlive
TelegramStrongcontrol of a phone-verified Telegram accountlive — opened 2026-09-05
DecideIDStrongDecideAI proof of personhoodlegacy — closed to new proofs since 2026-09-05; existing verifications keep counting
World IDStrongWorld ID proof of personhoodlegacy — closed to new proofs since 2026-09-05; existing verifications keep counting

Strong is a superset of Standard. A wallet holding any Strong method satisfies every Standard gate. A wallet holding only Google does not reach a Strong gate, and the refusal it gets says which method is missing rather than “not verified” — telling a verified user they are unverified would be wrong.

Each provider carries two independent flags. enabled answers “does this provider make the gate mandatory?”; accepts_new_proofs answers “will this provider still register a new proof?”. They are orthogonal, which is what makes enabled = false, accepts_new_proofs = true — verify for the benefits, not required to vote — a state the DAO can choose, and what makes retiring a provider possible without revoking anyone.

Verification, once granted, is never revoked by configuration. Neither flipping enabled nor closing a provider to new proofs removes anyone from a verified set. Only an explicit governance escape hatch does.

method_nameWhat it doesThreshold
set_world_id_configUpdate the World ID gate (rp_id, action, required_identifier, enabled, rp_key_name).Critical (→ CriticalGovernanceOperation)
set_world_id_request_presetToggle what the World ID widget requests at proof time (V4Capable vs V3Legacy, stored in WorldIdConfig.request_preset, default V4Capable). Controls only the widget request shape — never what the canister accepts.Standard
set_world_id_daily_capCap on register_world_id_verification calls per rolling 24h. 0 pauses.Standard
set_decideid_configUpdate the DecideID gate (issuer canister, origin, credential_type, derivation_origin, enabled, presentation_max_age_seconds). Cannot enable while issuer is anonymous (Not Configured).Critical (→ CriticalGovernanceOperation)
set_decideid_modeSwitch the DecideID active mode (Vc on-canister VC popup vs Oidc OAuth redirect).Critical (→ CriticalGovernanceOperation)
set_decideid_oidc_configPersist the public OIDC config (client_id, endpoint URLs, redirect URI, aud/iss). The client_secret has its own separate setter, deliberately outside the proposal lane because a proposal payload is public and permanent on-chain.Critical (→ CriticalGovernanceOperation)
set_decideid_daily_capCap on register_decideid_verification calls per rolling 24h. 0 pauses.Standard
admin_clear_world_id_verifiedRemove a principal’s World ID flag (escape hatch).Critical (→ CriticalGovernanceOperation)
admin_clear_world_id_nullifierFree a spent World ID nullifier (incident response only — weakens sybil resistance if misused).Standard
admin_clear_decideid_verifiedRemove a principal’s DecideID flag (escape hatch).Critical (→ CriticalGovernanceOperation)
admin_clear_decideid_presentationFree a recorded JWT-VP digest (incident response only — weakens replay protection if misused).Standard
admin_reset_decideid_rate_limitDrop the per-caller 5/hour register-attempt counter. opt principal clears one caller; null clears all.Standard
set_google_configUpdate the Google provider (trusted signer, accepted frontend origins, attribute key, bundle max age, enabled, accepts_new_proofs).Critical (→ CriticalGovernanceOperation)
set_telegram_configUpdate the Telegram provider (bot id, bot username, Mini App short name, session max age, onboarding value lifetime, enabled, accepts_new_proofs).Critical (→ CriticalGovernanceOperation)
admin_clear_google_verifiedRemove a principal’s Google flag (escape hatch). Keeps the account binding, so revoking a wallet does not return its Google account to circulation.Critical (→ CriticalGovernanceOperation)
admin_clear_telegram_verifiedRemove a principal’s Telegram flag (escape hatch). Keeps the account fingerprint, for the same reason.Critical (→ CriticalGovernanceOperation)
admin_clear_google_email_hashRelease a Google account’s binding so it can be bound again (lost-wallet recovery). Refuses while the wallet is still Google-verified — revoke first, then release.Standard
admin_clear_telegram_nullifierRelease a Telegram account’s fingerprint so it can be bound again (lost-wallet recovery). Refuses while the wallet is still Telegram-verified — revoke first, then release.Standard
set_world_id_accepts_new_proofsOpen or close World ID to new proofs, without touching whether it makes the gate mandatory. Closing it revokes nobody.Critical when reopening (accepts_new_proofs = true → CriticalGovernanceOperation); Standard when closing
set_decideid_accepts_new_proofsSame, for DecideID.Critical when reopening; Standard when closing

The two accepts_new_proofs setters are the one value-conditional pair on this list: their tier is decided by the argument, not by the method name. Reopening a provider to new proofs (accepts_new_proofs = true) is redirected to CriticalGovernanceOperation — higher thresholds, a fixed 7-day window, guardian-vetoable — because every open provider is another way for one person to enter the electorate again on a second wallet, and the per-provider uniqueness checks cannot see across providers. Closing a provider (false) narrows that surface, revokes nobody, and stays on the Standard route on purpose: a defensive lever must never be harder to pull than the one that undoes it. Every provider configuration setter and every flag-clearing escape hatch is Critical in both directions, and therefore guardian-vetoable.

The two account-binding releases stay Standard, and instead carry a precondition: they refuse while the wallet is still verified on that provider. A tier could not have closed that gap — an approved critical proposal would free the slot just the same — and the legitimate use is lost-wallet recovery, which by construction follows a revocation. The enforced order is therefore revoke (Critical), then release (Standard).

No provider on this list requires a DAO-held secret. Telegram is a signature check against a published key; Google is an Internet-Identity-signed bundle. Only the legacy DecideID OIDC path has a client secret, which is why that one field keeps its own setter and stays out of proposal bodies. The one secret the new providers use is a canister-generated salt that no setter writes and no query reads, so their configuration setters can be full, public proposal bodies.

Governed v3/v4 request preset. set_world_id_request_preset flips a stored WorldIdConfig.request_preset (default V4Capable) that controls only what the World ID widget asks the user to produce — v4-capable proofs (World ID 4.0) or the legacy v3 request shape. It never changes what the canister accepts: the v4 /verify endpoint accepts legacy v3 proofs natively (the canister sends the matching v3 wire shape), so v3 World ID holders keep verifying regardless of the preset. The widget-side allow_legacy_proofs IDKit flag — a client widget flag, not a verify-request-body field — stays on, which is what keeps v3 holders working. The preset is a governance-controllable config: the setter is gated by is_admin_or_self_guard, so it is flipped by a NORMAL-lane ExecuteAdminMethod proposal. During the transition phase it is additionally reachable by the deploying principals, which are removed at the DAO handover; after that a governance proposal is the only route. V4Capable is the steady state; V3Legacy is a deliberate emergency narrowing-rollback only — it would otherwise lock out new World App users who can produce only v4 proofs. The preset never gates the canister’s accept path, so toggling it can never turn away an already-valid verification.

The four methods are independent: enabling one does not affect the others; disabling one does not clear existing verifications via that method (verifications persist across method-disable, by design).

Universal Participation Gate — combined behavior

The same gate applies to BOTH vote() AND create_proposal() on ONS (with a different action verb in the error message). In outline:

// One place composes the four providers' flags. Nothing else inlines an OR.
strong_gate_required() = world_id.enabled || decideid.enabled || telegram.enabled
any_gate_required() = strong_gate_required() || google.enabled
required_strength(is_critical) =
if is_critical && strong_gate_required() { Some(Strong) }
else if any_gate_required() { Some(Standard) }
else { None } // nothing is mandatory
// The gate itself, at every site:
if verification_gate_rejects(required_strength(is_critical), strength_of(caller)) {
return Err(<a refusal naming what is missing>);
}

Whether the action is critical is decided by the same predicate the voting thresholds use, read once and used for both, so the tier a proposal is gated at and the window it gets can never disagree.

create_proposal() refuses with a proposer-shaped message instead of a voter-shaped one — same gate, different action verb so the frontend can disambiguate. Both messages match the regex verification required that the frontend uses to mount the in-form <VerificationCTA> banner, and a Standard-only caller refused on a critical action gets a distinct message naming the Strong method rather than the generic one.

For SONS DAOs the equivalent gate covers vote(), create_proposal(), follow(), and set_accepts_followers(true), governed by the per-DAO requires_verification flag (a CriticalGovernanceOperation proposal can flip it). Verification storage is shared with ONS — one verification unlocks every OhShii DAO. The SONS-side check uses a bounded-wait (10s) call to ONS behind a circuit breaker; the per-DAO flag is exposed via requires_verification : () -> (bool) query.

The per-DAO verification cache, and what its tiers mean

Section titled “The per-DAO verification cache, and what its tiers mean”

Each campaign DAO keeps a small permanent record of who it has seen verified, so that a wallet does not pay a cross-canister call on every action. That record now stores which tier was confirmed, not merely “verified”:

Cached tierMeaning
Strong (confirmed)ONS answered Strong for this wallet. Every gate on this DAO is open to it, with no further call.
Standard (confirmed)ONS answered Standard. Standard gates are open; a critical action falls through to ONS, because only ONS can see a Strong method added since.
LegacyA record written before tiers existed, which says only “verified”. Read as Strong-unconfirmed, i.e. treated as Standard until ONS confirms it once.

A legacy record is never read as Strong on its own, because a bare “verified” cannot tell a Google-only wallet from a strongly verified one; reading it as Standard means the worst case is one extra confirming call, never a promotion.

Reading a legacy record as Strong-unconfirmed costs a legacy holder one cross-canister call per DAO, once, on their first Strong-gated action — or one call to that DAO’s refresh_my_verification, which settles it deliberately. After that the record carries the real tier and the call is never paid again. A record never moves down: confirming a legacy row can only date it or raise it.

One consequence is worth stating plainly rather than leaving to be discovered: while a legacy record is still unconfirmed, a delegated ballot cast on that holder’s behalf on a critical proposal is skipped, because the delegation cascade is synchronous and cannot make the confirming call. That is the fail-safe direction, and refresh_my_verification is the one-call fix.

The record is also pre-populated, so a wallet’s first action here does not depend on ONS answering. The platform pushes the verified set into a campaign DAO from the moment its canister exists: once at creation, then a few pages per housekeeping pass across the whole launch window, and a last bounded top-up just before the DAO becomes autonomous. Only the verification class crosses — never which provider a person used, which is more identifying than the class and which no gate on a campaign DAO reads.

Two consequences worth knowing:

  • The seed stops at the seal. Once a campaign finalizes its governance, the platform can no longer write that record — a write into a sovereign DAO’s voting gate would be a lever its own members could not revoke. A DAO sealed before its seed finished keeps what it got; everyone else is still served by the per-wallet call on first action, exactly as before.
  • A partial seed is readable, not silent. get_verified_seed_status reports how many rows landed and whether the last pass ran out of room, so an incomplete record is a fact anyone can check rather than something discovered during an outage.
  • An imported DAO is the narrow case, and it is handled separately. It is created and becomes autonomous in the same operation, so there is no launch window for the paced passes to work in: its record is filled in one go, during creation, with a larger allowance than the last-top-up a launching campaign gets. That pass is bounded by a wall-clock budget rather than only by a page count, because the person creating the DAO is waiting on the call — and a creation that outran the wait would leave a DAO that exists while its creator is told it failed.

The DAO can always vote its own verification-off rescue

Section titled “The DAO can always vote its own verification-off rescue”

A campaign DAO can vote to turn its verification requirement off. That proposal is critical, so it is gated at Strong — which would be a trap if the ecosystem canister were unreachable at exactly the moment the DAO needs the rescue, since confirming an unconfirmed record needs the very call that is failing.

So the rescue proposal, and only the rescue proposal, admits a cached legacy or confirmed-Strong record when the confirming call fails. The requirement stays Strong; what the outage changes is whether an unconfirmed record may stand in while nobody can be asked. A record ONS has already answered Standard is still refused — an outage does not make that answer stale. Nothing is written to the cache on this path, so the real confirmation still happens the moment the ecosystem canister answers again, and the exemption shrinks to nothing as the electorate confirms.

The predicate that answers “is this caller verified?” reads the verified-user sets directly — one per provider — never the per-method enabled flags. This makes the gate satisfy two invariants at the same time:

  1. Union satisfaction — a verification via any method satisfies the standard gate, regardless of which method’s flag is currently true. A user who verified via World ID two months ago still passes a gate that today only has DecideID enabled.
  2. Persistence on disable — turning a method off never clears existing verifications, and neither does closing it to new proofs. Only the explicit governance escape hatches remove an individual flag.

Strength is derived, never stored. A wallet’s strength is computed from which per-provider sets it is in. That is why the principals verified before strength levels existed are Strong by construction: every one of them was verified through World ID or DecideID.

What happens when EVERY method is disabled: nothing is mandatory, and the gate does not fire. ONS voting becomes open to any authenticated wallet — no verification required, no per-method check. Anonymous principals are still rejected by the reject_anonymous guard on vote(). All other voting rules (proposal status, vote rate limit, voting power) still apply.

The gate matrix, for a caller holding nothing, Google only, or any Strong method:

ActionnoneGoogle only (Standard)any Strong method
ONS / SONS standard proposal — vote or createrefused while the gate is activeadmittedadmitted
ONS / SONS critical proposal — vote or createrefusedrefused, and the refusal names the Strong methodadmitted
Delegation (follow, set_accepts_followers) and hotkey registrationrefused while the gate is activeadmittedadmitted
A delegated ballot landing on a critical proposal—skipped for that follower; other Strong followers of the same delegate are still appliedapplied
Token / LGE / index-canister / imported-DAO creationrefusedadmittedadmitted
Sponsor attribution — does a referral countnoyesyes
LGE purchase tier above Guest, guest-fee waiver, exemption from the Guest capnonoyes
Veto-override ballotrefusedrefusedadmitted only with a fresh attestation for that round

Two rows deserve their reason stated rather than left to be inferred.

  • The LGE tier ladder needs Strong, and Google is deliberately not enough. LGE buyers become the electorate of the campaign DAO that must pass critical quorums after the launch completes; an electorate that could not vote on a critical proposal would stall every campaign DAO it created.
  • Creation flows need only Standard. Creating a token, an LGE or an index canister is a paid action where the firewall exists as cycle-drain defence, not as electorate integrity.

Guest participation requires no verification of any kind. An unverified wallet keeps buying into an LGE as a Guest — the 1,000,000-token cap, the guest fee, and the 50-Guest cap per campaign — exactly as before. Google only adds capabilities; it never becomes a precondition for something an unverified wallet can already do.

If only Google is enabled, a critical proposal needs only Google. No Strong provider is available to demand, and demanding one anyway would make the critical tier unreachable — the same reasoning that makes the all-disabled row open.

Note for voters: a DAO that wants to halt voting WITHOUT lifting the Sybil filter must leave at least one method enabled = true and close the providers to new proofs instead. Setting every method to enabled = false removes the Sybil gate entirely; it does not pause voting.

Restore/delete snapshot, add/remove controller targeting the governance canister, set_voting_parameters, and set_default_excluded_countries are only allowed under CriticalGovernanceOperation. Stopping the governance canister or the OHSHII ledger is never allowed.

Voting parameters and fee methods on ohshii_governance (ONS):

MethodCategoryThresholdWhat it changes
set_voting_parametersCriticalGovernanceOperation (target = ohshii_governance)Critical (see Two-axis voting model)Quorum (standard/critical), approval %, min votes, voting durations, max VP per user, Guest-fee and purchase-limit params. All optional fields in VotingParamsUpdate; only provided fields are updated.
set_proposal_creation_feeExecuteAdminMethod (target = ohshii_governance)StandardFee in OHSHII tokens to create a proposal. Backend enforces ±50% change limit per execution.
set_rejection_costExecuteAdminMethod (target = ohshii_governance)StandardTokens deducted from refund when a proposal is rejected/expired. Must be ≤ proposal_creation_fee; ±50% change limit.

Current values are exposed via get_voting_parameters() (includes fees) and get_proposal_creation_fee() / get_rejection_cost().

Proposal deposit — per-proposal escrow + outcome policy. The creation fee is pulled DIRECTLY into a dedicated escrow subaccount of the governance canister, derived injectively from the proposal id (SHA-256("ohshii-proposal-fee:v1:" ‖ proposal_id_be8)) and snapshotted as fee_paid (+ fee_subaccount, fee_policy_v2 = true). The escrow is a single-proposal watertight compartment: its balance IS that proposal’s remaining obligation, no settlement can ever draw on another proposal’s deposit or on the treasury float. Session-paid proposals (upgrade/asset flows whose fee was pulled at prepare_* time, before a proposal id exists) record the escrow subaccount of the upload session that paid, so the same compartment rule and outcome policy apply. One deposit per proposal, batches included: the first paid upload staged for a batch is its deposit and every other staged step attaches to it and pays nothing; a batch with nothing staged pays one fee at creation into the proposal’s escrow. A batch that carries two paid uploads is refused (BATCH_MULTIPLE_DEPOSITS) before any fee is taken, and a follower whose anchor deposit is missing, was staged by another uploader or has expired is refused (FOLLOWER_WITHOUT_ITS_DEPOSIT). The batch’s single deposit follows the outcome table below exactly like any other deposit: a full refund when the batch is adopted, a partial refund when it is rejected. One deposit admits at most nine followers over its whole life (a batch has at most ten steps): removing a follower, or letting it expire, does not free its place. A follower’s asset lease ends no later than its deposit. Order for command-line clients: on SONS a single Wasm upload session lives 180 seconds from its prepare, and a follower is refused once its deposit has expired, so prepare the deposit and every follower first and upload the chunks afterwards; otherwise the batch must be staged again. The frontend stages Wasm steps inside the proposal and anchors only on 30-minute asset leases, so it is not affected. The resolution timer settles every deposit by terminal outcome (amounts net of the ledger transfer fee, tracking whatever fee / rejection cost the DAO had voted at creation):

Terminal outcomeDeposit settlement
Executed · Approved (a signal-only Motion — its terminal success)Full refund to the proposer (fee − ledger fee), paid from that proposal’s own escrow subaccount. A proposal the DAO adopts costs its proposer nothing: the cost of governance is borne by the proposals the DAO refused.
ExecutionFailed (incl. a stuck-Executing proposal reaped after the TTL)Full refund (fee_paid − ledger_fee) from the escrow — a trapped execution is not the proposer’s fault
Rejected · Expired (an Open proposal that missed quorum)Partial refund (fee_paid − rejection_cost − ledger_fee) from the escrow; the withheld rejection_cost residue settles to the treasury (same destinations as above)
Abandoned paid upload (a PendingUpload proposal whose upload never completes and expires, or an expired paid upload session)Forfeit — no refund; the deposit is swept from its escrow to the treasury by the timer and a FeeForfeited audit event is emitted

An Approved executable proposal settles nothing yet — Approved is transient there (execution pending); settling early would make a later ExecutionFailed refund unpayable, so the deposit stays in escrow until the terminal Executed/ExecutionFailed. Vetoed / VetoOverrideExpired are non-terminal: the timer finalizes a vetoed proposal and the deposit follows the final outcome. Legacy proposals (created before the escrow upgrade, fee_policy_v2 absent) keep the pre-escrow policy unchanged (Approved/Executed/ExecutionFailed → full refund from the main account).

Custody invariant. Every refund is paid from that proposal’s own escrow subaccount; no settlement leg ever draws on a governance canister’s main account. settle_refund_item fails CLOSED (REFUND_WITHOUT_ESCROW) on a fee_policy_v2 proposal owed a refund that records no escrow — the refusal names the proposal, leaves the deposit where it is, and marks a platform defect rather than a user error — and no fee collection in either canister pulls into the main account. This matters most on SONS, where the main account IS the DAO treasury: without it, a proposer’s refund would be drawn from collective funds.

A deposit that never reached a proposal. If the canister traps between the ledger accepting the fee and the proposal or session that owns that escrow being stored, nothing points at the money. A marker written before the ledger call survives that trap, and there are three ways out of it:

routeONSSONS
the timer, once the marker has outlived every legitimate windowautomaticautomatic
the manual lever guardian_sweep_orphan_fee_escrow(key)the guardian (and, during the transition phase, the deploying principals)the campaign’s guardian (and, during the transition phase, the deploying principals)
an approved proposalExecuteAdminMethod on guardian_sweep_orphan_fee_escrowExecuteAdminMethod with AdminMethod = SweepOrphanFeeEscrow and orphan_fee_escrow_key

The proposal route exists so the recovery stays available whether or not a guardian is set and after the transition-phase principals are removed.

No route can choose a recipient: the destination is the payer recorded when the fee was taken, and no argument or proposal field names one. All three run the same code, are idempotent, and refuse if any stored row still claims that escrow — an approved recovery that arrives after the deposit is already home executes and moves nothing.

The refund reuses the timestamp pinned when the fee was taken, so a retry is a ledger Duplicate rather than a second payment. Past the ledger’s 24-hour transaction window that pinned timestamp would reject every attempt forever — which is every proposal-driven recovery, since a vote cannot close in less — so a refused attempt re-reads the escrow, and if it still holds the whole deposit (proof that nothing left it) the recovery re-pins to the present and retries once. A ledger it cannot read, or an escrow short by any amount, keeps the original pin and fails.

get_orphan_fee_escrow_status() is the public read: pending counts the markers that exist right now (one is written before the ledger call of every fee pull, so a non-zero value is normally an in-flight charge), overdue counts those past ttl_ns, and only that second number means a deposit is stranded.

Every settlement leg is idempotent (pinned created_at_time per leg — refund_created_at_time / treasury_created_at_time — plus a pinned ledger fee on the amount-driven legs — refund_fee_pinned / treasury_fee_pinned — so a retry is an exact ledger Duplicate, never a second payment, even across a ledger fee change; a stale pin fails closed and self-recovers on escrow-custody legs after verifying the deposit is intact) and evented (FeeCollected, RefundProcessed/RefundFailed, FeeForwardedToTreasury/FeeForwardFailed, RejectionCostForwarded/…Failed, FeeForfeited). Refunds are paid automatically by the timer; two recovery levers exist:

  • claim_proposal_refund(proposal_id) — a PUBLIC, proposer-only fallback: if the automatic settlement fails repeatedly (ledger outage, trapped tick) the proposer drains their own owed refund out-of-band, from the same escrow, with the same pinned timestamp (a re-claim of an already-committed refund dedups at the ledger) and the same one-shot refund_processed flag. Anonymous and non-proposer callers are rejected by cheap synchronous checks; a per-proposer 60 s cooldown and the global refund guard bound the honest path. The deposit lifecycle is queryable via get_proposal_fee_status(proposal_id).
  • The guardian can drain all owed settlements via force_process_refunds (see What the Guardian CAN do).

Full flow + diagram: WORKFLOWS.md §5.5.

Where the withheld rejection_cost goes. On a rejected or expired proposal the proposer is refunded fee_paid − rejection_cost − ledger_fee; the rejection_cost portion is withheld. The destination differs by DAO:

  • ONS (ohshii_governance) forwards the withheld rejection_cost to the OhShii backend treasury.
  • SONS (sons_governance) has a single governance canister and no separate treasury, so the withheld rejection_cost is retained in the governance canister — settled from the proposal’s escrow into the main account (the SONS treasury). It is not refunded and not forwarded anywhere else. (The fee_recipient config field exists but is not consumed by any SONS transfer path.)

SONS (LGE Governance): all proposal categories and AdminMethods

Section titled “SONS (LGE Governance): all proposal categories and AdminMethods”

Each LGE has one sons_governance canister. Proposals are either a category (e.g. UpgradeCanister, ExecuteAdminMethod) or a ConfigChange. For ExecuteAdminMethod, the target is an AdminMethod variant and parameters are in AdminMethodParams.

The 13 SONS categories each get a subsection below. For how to read “Threshold”, see Reading the per-category Threshold field. Unlike ONS, no SONS category is retired — all 13 are live. CustomCall and HttpsOutcall (both always Critical, guardian-vetoable) mirror the ONS arbitrary-dispatch lanes for a single campaign DAO. The newest, SetDaoCanisterRegistry (also always Critical), mirrors the ONS lane of the same name.

  • Scope: community signal / discussion for one LGE campaign.
  • Target: None.
  • Threshold: Standard.
  • What it does: records the vote; no execution.
  • Scope: a motion about changing campaign info (description, website, socials).
  • Target: None.
  • Threshold: Standard.
  • What it does: signal-only — no execution. The actual on-chain write is the UpdateCampaignInfo action (under ExecuteAdminMethod).
  • Scope: run one of the 30 AdminMethod actions (grouped by area below).
  • Target: AdminMethod(AdminMethodParams) — the variant selects the action; the flat AdminMethodParams carries its fields.
  • Threshold: Standard, except the methods marked ★ (and self-targeting controller ops / snapshot restore-delete) which escalate to Critical.
  • What it does: dispatches to the executor for the chosen AdminMethod (30-arm match; all implemented).
  • Scope: upgrade a child canister this DAO controls (ledger, index, …). Must not target the governance canister — self-upgrades go through UpgradeGovernanceCanister.
  • Target: CanisterUpgrade(CanisterUpgradeData).
  • Threshold: Critical. Guardian-vetoable. (Every canister upgrade is a code change to a trusted component; the category is kept distinct from UpgradeGovernanceCanister only for the self-target validation, not for a threshold difference.)
  • What it does: validates the WASM hash (re-checked post-install) then install_code.
  • Scope: the non-critical config lane — fee fields (proposal_creation_fee and/or rejection_cost, ±50% per proposal) and/or the campaign’s excluded-countries list (new_excluded_countries, regional participation eligibility); at least one of the three required.
  • Target: ConfigUpdate(ConfigUpdateData) carrying fee fields and/or new_excluded_countries.
  • Threshold: Standard.
  • What it does: applies the fee deltas; for new_excluded_countries, pushes the replacement list to the launcher backend (which accepts only this campaign’s own governance canister), where the platform-baseline entries are unioned in (a replacement can never drop them) and the canonical record is version-bumped, then refreshes the local mirror from the canonical read-back. The change is forward-only — earlier participations are never retroactively affected. The proposal payload renders the full list inline (codes + names) plus a content hash. fee_recipient is not proposal-configurable (would break refund flows). Voting / runtime / delegation params are NOT allowed here — they go through CriticalGovernanceOperation.
  • Applies only its own fields: when the proposal executes, the configuration is read again after the ledger-fee query and only the fields the proposal names are changed, so a configuration change that lands while the proposal waits on the ledger (for example the admin and guardian changes made at finalization) is kept, not overwritten.
  • Scope: self-upgrade this sons_governance WASM (target MUST be itself).
  • Target: CanisterUpgrade(CanisterUpgradeData).
  • Threshold: Critical. Guardian-vetoable.
  • Refused when a critical setting cannot be read: this upgrade, like any other upgrade of a campaign governance canister, fails if the new code cannot read the stored configuration, campaign data or pool information — a stored value that does not decode, or a storage header that is not recognised. The previous code keeps running with the stored bytes untouched, and the error names each unreadable value. No default is ever written in their place; a fixed module is needed to upgrade. While one of them cannot be read, every write to it is refused.
  • What it does: self-upgrade through the post_upgrade hook. When the proposal executes, the governance canister first pauses: until the upgrade is applied it refuses every operation that would contact another canister — votes, proposal execution, verification, refunds, the guardian’s resolution drain — with an error that starts with SELF_UPGRADE_QUIET, and it waits for the calls it had already started to finish. Queries and purely local operations keep working, and a refused operation has done nothing, so it can simply be retried after the upgrade. The install is then sent without waiting for its reply, and the upgraded canister marks the proposal Executed. With no call in progress the pause lasts a few seconds; it ends as soon as the last call already in progress returns. If those calls have not finished after 10 minutes, nothing is installed and the proposal is marked ExecutionFailed (SELF_UPGRADE_QUIET_TIMEOUT). If the install is refused, the previous code keeps running, stays paused, and marks the proposal ExecutionFailed (SELF_INSTALL_REFUSED) after 30 minutes. The calls the pause waits for include every outbound call the canister makes, among them the signing calls of identity verification on ONS and the treasury-withdraw transfer on SONS; a call whose reply has already arrived no longer holds the pause when its continuation fails, while a call that is really still open keeps holding it.
  • Scope: the high-risk lane — voting / runtime / delegation config changes, plus the AdminMethods that must be critical (AddController / RemoveController targeting the governance canister, RestoreSnapshot, DeleteSnapshot). Carries the Democratic Emergency Exemption (below).
  • Target: AdminMethod(..) or ConfigUpdate(..) carrying the voting / runtime / delegation fields (incl. requires_verification).
  • Threshold: Critical. Guardian-vetoable.
  • What it does: runs the critical AdminMethod, or applies the critical config update.
  • Scope: commit a frontend (asset canister) batch for the campaign frontend.
  • Target: AssetUpgrade(AssetUpgradeData).
  • Threshold: Critical. Guardian-vetoable. (A frontend upgrade is a code/content change to a trusted surface, so it clears the same critical bar as every other upgrade.)
  • What it does: validate_commit_proposed_batch (create + execute) then commit_proposed_batch. At creation the asset-lease check runs before the validation call, and a target that does not answer within 15 seconds gets the proposal refused with nothing created and no fee taken.
  • Automatic batch cleanup: an asset canister allows at most ONE proposed batch, and proposed batches never expire upstream — so a failed proposal would otherwise block every future campaign-frontend upgrade. Exactly as on ONS (see the note under UpgradeAssetCanister (ONS)), every non-executed terminal transition (rejected, expired, veto-upheld, execution-failed) schedules an automatic delete_batch on the target; the governance timer retries it with exponential backoff and, after repeated failures, emits a visible asset_batch_cleanup_gave_up event calling for manual cleanup (guardian fallback, also reachable by the transition-phase deploying principals: guardian_delete_asset_batch). Batches staged on the asset canister but never attached to any proposal are discovered and removed by an orphan probe (triggered by the abandoned-upload-session sweep and by the next prepare_asset_upload). A batch referenced by a still-live proposal is never deleted, and creating a proposal against a batch already scheduled for cleanup is refused (re-stage a fresh batch). Pending work is public via the get_pending_asset_cleanups query. The same cleanup applies to every AssetCommit step of an UpgradeDappBatch whose step did not commit. Because SONS controls its campaign frontends (but holds no asset permission by default), the drain grants itself Commit on the target the first time it needs it (controller self-grant).
  • Scope: upgrade this campaign token’s ledger suite (index + ledger + archives) in ONE proposal, in the canonical safe order Index → Ledger → Archive(s). Every step is validated against the DAO’s managed canisters: the Ledger step (mandatory) must be the campaign ledger, the Index step (optional) must be the configured index canister, and archive steps must appear in the ledger’s own archives() list — campaign ledgers are launcher-created stock ICRC ledgers, so a failing archive probe is treated as a real anomaly and rejects the proposal (stricter than the ONS verify-when-verifiable stance). Must not target the governance canister.
  • Target: CanisterUpgradeBatch(CanisterUpgradeBatchData) — an ordered list of 1–5 CanisterUpgradeSteps, same shape as ONS (target + role + WASM hash/size + optional upgrade arg + upload session).
  • Threshold: Critical. Guardian-vetoable.
  • What it does: same execution model as the ONS block — sequential, stop-on-first-error, all-or-nothing controller pre-flight, per-step idempotent skip, TOCTOU hash revalidation, post-install verify, progress persisted in batch_step_results, and fix-forward recovery via a fresh batch (no automatic rollback). The single-index UpgradeIndexCanister action remains available for index-only maintenance. See WORKFLOWS.md §5.3.
  • Scope: upgrade an arbitrary ordered set of canisters — 1–10 steps — in ONE proposal, executed verbatim in proposer order (shape validated, not release semantics). Each step carries one target + one kind (code WASM install / asset frontend-asset commit) + one category + its own git provenance (git_repo_url + git_commit_hash + build_instructions, all required non-empty). The SONS delta is the Core definition: Core is the campaign’s sons_governance canister itself ONLY — every other target is Custom (rendered with a warning). The governance canister itself may be upgraded only as the FINAL step. The ledger-suite batch (UpgradeCanisterBatch) remains the specialized suite path.
  • Target: DappUpgradeBatch(DappUpgradeBatchData) — an ordered list of DappUpgradeSteps (target + category + typed payload + per-step provenance). The SONS code payload omits the ONS-only chunk-store fields (the chunk store is always the target itself).
  • Threshold: Critical. Guardian-vetoable.
  • What it does: identical to the ONS block — validates the shape at creation (step count 1–10, distinct targets, self-step-last, per-payload shape, Core = self-only, per-step provenance, per-asset-step validate_commit_proposed_batch); stages one upload session per code step (30-minute window; asset steps consume none); executes with an all-or-nothing controller pre-flight over EVERY target — asset targets included, then sequential stop-on-first-error: code steps re-validate + install + verify the module hash, asset steps re-validate + commit_proposed_batch, per-step progress in batch_step_results. Fix-forward recovery (no rollback): committed code steps auto-skip by live module hash; asset steps never auto-skip. See WORKFLOWS.md §5.4.
  • Scope: the ONS CustomCall lane, scoped to one LGE DAO — dispatch ONE arbitrary Candid call to a named method on a canister this sons_governance controls, with the argument supplied as human-readable Candid text that the governance canister encodes on-chain, never the frontend.
  • Target: target canister id + method name + candid_text_args.
  • Threshold: Always Critical. Guardian-vetoable.
  • On-chain encoding + re-verification. Identical model to CustomCall (ONS): at creation the canister fetches the target’s candid:service metadata, parses it, and encodes the text against the real method_name signature; it pins the target module_hash, the payload sha256, and a normalized render. At execution it re-verifies the pinned module_hash (fail-closed if the target’s code changed since the vote) and that governance is still a controller, then dispatches with a bounded wait.
  • Reserved targets. aaaaa-aa, the governance canister itself, and the ICP / cycles ledgers are structurally reserved — and a SONS DAO additionally reserves its own campaign ledger and index canister (is_reserved_custom_target), so a CustomCall can never bypass the typed token/treasury paths for its own money rails.
  • Preview + rate limit. custom_call_preview(...) returns the render + sha256 + module_hash without creating a proposal; rate-limited by a cycle-reserve floor + a global cap (SONS 60/hour).
  • Outcome: the same “Executed unless provably no-op” policy — see the CustomCall workflow.
  • Scope: the ONS HttpsOutcall lane for one campaign DAO — a typed, DAO-composed canister HTTPS outcall (not a bridge, not an oracle) to an external server, voted before it is sent.
  • Target: url, method (Get / Post / Head), headers, optional body, a mandatory max_response_bytes (1..=2 MiB), a max_cycles cap, is_replicated, and a fixed wasm-exported transform chosen by id (StripHeaders / StripHeadersAndBody) — a proposal can never supply its own transform.
  • Threshold: Always Critical. Guardian-vetoable.
  • Cost gate + outcome: the executor computes the real cost and aborts if cost > max_cycles; a 2xx → Executed, a non-2xx → still Executed with the status in the event, a transport-level failure → ExecutionFailed. See the HttpsOutcall workflow.
  • Scope: the ONS SetDaoCanisterRegistry lane for one campaign DAO — the same record, the same method name, the same whole-list replacement semantics and the same validation. The frontend panel uses one IDL and one code path for both DAOs, and the two lanes are identical.
  • No platform seed. Unlike ONS, a SONS registry starts empty: the platform knows nothing about a campaign DAO’s canisters beyond the intrinsic trio the panel already reads certified.
  • Threshold: Always Critical. Guardian-vetoable.
  • Two “managed canister” surfaces coexist on purpose. get_managed_canisters (+ ManagedCanisterInfo) is dynamic discovery and feeds a cycle-balance membership check; get_dao_canister_registry (+ ManagedCanister) is the DAO-curated, display-only attestation. They are independent: the registry never feeds the cycle-balance check.
  • unsupported is a designed answer. Each campaign DAO is upgraded by its own proposals, so a DAO running an older module answers IC0302 and the frontend renders “does not expose a canister registry yet”.

SONS AdminMethod (under ExecuteAdminMethod) — by area

Section titled “SONS AdminMethod (under ExecuteAdminMethod) — by area”

All 30 AdminMethod actions run through the ExecuteAdminMethod category (target AdminMethod(AdminMethodParams)), except where the Tier column says Critical — those are forced through CriticalGovernanceOperation. ★ = critical via is_critical_admin_method.

LP, pool & treasury

AdminMethodWhat it doesTier
TransferLiquidityPosition ★Transfer an ICPSwap liquidity position to a new owner (transferPosition). Targets the legacy campaign position by default, or a specific registry position via the optional pool_canister_id + pool_position_id pair (ownership re-verified at execution against the registry ∪ the pool’s live position list). On the legacy path a successful transfer clears the stored position_id.Critical
UpdatePositionIdUpdate the stored position_id (e.g. after migration).Standard
CollectFeesCollect ICPSwap trading fees into the treasury (100%). Supports the same optional registry-position targeting. When the pool has run the claim (or its reply was lost) but the collection does not complete, the proposal ends Executed with a note saying so: the pool pays claimed fees to the treasury on its own. A claim the pool refused ends it as failed.Standard
CollectFeesAndBuybackCollect fees, then swap the ICP that actually arrived from the fee claim for the token the proposal names (slippage-protected). The swap starts only after the pool has paid the collected ICP, and spends exactly the amount the pool reports it paid: ICP that other operations move into or out of the DAO treasury meanwhile is never spent, and is never reported as a payout still pending. If the treasury balance does not show the reported payout, the reported amount is still spent and the shortfall is recorded for review. A failure after the fees were collected ends the proposal as Executed with a note (the fees did move into the treasury), and the note says the buyback did not complete and where the funds are. A failure before anything was collected ends it as failed.Standard
CreatePoolAndAddLiquidity ★Create a custom ICPSwap pool and add full-range liquidity, funded from the SONS treasury (main account). Fee tier is restricted to 3000 (0.3%); deposits use exact approvals and the mint consumes the actually-credited amounts; the new position is recorded in the DAO’s pool-position registry (get_pool_positions), and the primary PoolInfo slot is only set when it was empty (the LGE pool reference is never repointed).Critical
AddLiquidityToPool ★Add treasury liquidity to an EXISTING ICPSwap pool: the pool is verified against the ICPSwap factory (anti-lookalike check), amounts are given per token ledger and mapped to the pool’s token order, and a new full-range position is minted per proposal and recorded in the pool-position registry. The spendable-balance check nets out pending per-lock custody funding on the campaign token so lock-earmarked funds are never consumed.Critical
SweepPoolUnusedBalanceRecover stranded deposits (a failed pool operation’s unused balance) from an ICPSwap pool back to the SONS treasury (main account). Idempotent; funds can only land on the DAO’s own main account.Standard
RemoveLiquidity ★Remove liquidity from the position (tokens return to treasury; None = remove all). Targets the legacy campaign position by default, or a specific registry position via pool_canister_id + pool_position_id.Critical
TreasurySwapSwap treasury tokens via ICPSwap with slippage protection. When the pool records the input token as ICRC-1, the transfer into the pool carries the pool’s own input fee. If the pool refuses the swap, the input is brought back and its location is read before the proposal answers: the answer says nothing was swapped and where the input is. While any of it is still in the pool, a new TreasurySwap on that pool is refused, with nothing moved, until the input is recovered (SweepPoolUnusedBalance, or the guardian’s pool claim).Standard
TreasuryWithdraw ★Withdraw ICP / ICRC-1 from the SONS treasury to a recipient — from the main account or an optional raw 32-byte source subaccount (and an optional recipient subaccount). Custody isolation: a source_subaccount that names any per-lock custody subaccount (append-only registry) is rejected at proposal creation AND at execution — lock-backing funds can never be withdrawn as treasury. An optional memo is validated against the target ledger’s advertised maximum memo length (icrc1:max_memo_length) before the transfer; disabled when the ledger does not advertise one.Critical
CyclesTopupTop up a canister with cycles using treasury ICP (via the CMC). Each top-up is recorded in the transfer journal, keyed on the target canister and the amount. An identical top-up is refused only while the previous identical one has an uncertain outcome (its transfer reply was lost, or its ICP reached the CMC without the cycles being credited); after a top-up whose cycles were credited, or one the CMC refunded, an identical one runs normally. When the ICP of a refused top-up has since been converted, Authorize Repeat Transfer citing its block lifts the refusal.Standard
ResolveAmbiguousTransfer ★Record the outcome of a ledger reconciliation for one entry of the transfer journal. TreasuryWithdraw claims a journal record keyed on the withdrawal’s semantics (ledger, source, recipient, recipient subaccount, amount) before dispatching, and refuses while a prior identical attempt is unresolved or committed — so a withdrawal whose outcome was lost (a SysUnknown reply, an undecodable response) cannot be silently re-issued and paid twice. did_land = false retires the record and the retry becomes possible again; did_land = true converts it to committed (it is never retired, which would permit the double payment) and the block index is required. Evidence is mandatory.Critical
AuthorizeRepeatTransfer ★Authorise a second, genuinely identical payment for a committed journal record. This is a new authorisation, not a correction, and its audit event says so; the cited block index must MATCH the one recorded, so the clearing act names what was reconciled rather than merely asserting it.Critical
ForceClearAmbiguousTransfer ★Retire a journal record regardless of state — the recovery path for a stuck state machine (e.g. a committed record whose block index was lost). Emits a distinct audit event from both levers above, so which route was used is answerable from the event log alone.Critical

Vesting locks

AdminMethodWhat it doesTier
ModifyLock ★Change a vesting lock’s status (valid transitions only). For isolated locks: a forced Completed queues a balance-driven sweep of the lock’s custody subaccount back to the treasury; Cancelled leaves the residual IN the subaccount, claimable by the lock’s creator via claim_cancelled_lock (locker-product cancel-then-reclaim parity).Critical
DeleteLock ★Remove a vesting lock from governance storage. For isolated locks, the un-vested remainder in the lock’s custody subaccount is swept back to the treasury (main account) by a queued custody op; for legacy locks the tokens simply remain commingled in the main account (unchanged). To let the user reclaim instead, Cancel (ModifyLock) — a Delete after a Cancel forfeits the pending claim to the treasury.Critical
TransferLockBeneficiary ★Change who receives future unlocks (beneficiary). State-only — the lock’s custody subaccount is derived from the lock_id and never moves. Stamps the lock’s recipient_change_count / last_recipient_change_at metadata and emits the same stable events (LockStateChanged / ErrorOccurred) as the creator-facing change_lock_recipient endpoint. As the DAO override path (e.g. lost-key recovery) it is NOT bound by the creator’s 24h cooldown or the one-way recipient_permanent latch; a governance change re-arms the creator’s cooldown. See WORKFLOWS.md § “Changing a SONS lock’s recipient” for the creator self-service flow (change_lock_recipient / set_lock_recipient_permanent, Shield-protected).Critical
TransferLockCreator ★Batch-reassign the lock CREATOR (voting-power holder); reads creator_transfers. Already-cast votes are NOT retroactively updated; new creators do NOT inherit follow/delegation state; verification is action-time. Batch capped at MAX_BATCH_CREATOR_TRANSFERS; 2-pass validate-then-apply. State-only — the lock’s custody subaccount is derived from the lock_id and never moves.Critical

Every non-LP lock created since per-lock isolation was introduced custodies its tokens in a dedicated 32-byte subaccount of the SONS canister, derived as SHA-256("ohshii-sons-lock:v1:" ‖ len(campaign_id) ‖ campaign_id ‖ len(lock_id) ‖ lock_id) and stored on the record (lock_subaccount). The main account is treasury-only:

  • Vesting locks (LGE finalize): batch_create_vesting_locks reserves fee × (number_of_unlocks + 1) per contributor (N unlock legs + 1 funding leg) and queues one idempotent funding op per lock (main → subaccount, gross − fee, pinned created_at_time, balance-gated, Duplicate→Ok). A timer drains the queue (kick at batch time + every 300 s); unlock payouts are gated on custody_funded == Some(true) and never fall back to the main account. Progress is observable via the public get_custody_ops_status query.
  • Manual locks (create_additional_lock): the ICRC-2 pull deposits DIRECTLY into the lock’s subaccount (funded from birth; fee math unchanged). If the record write fails after the deposit, a balance-driven refund op returns the funds to the caller automatically.
  • Unlocks: each tranche is paid from_subaccount = lock_subaccount. The fee reserve is sized at creation-time fee, so if the ledger fee later RISES the reserve can be overdrawn on ANY tranche: a definite InsufficientFunds on a funded isolated lock triggers a final settlement — everything still deliverable (balance − fee) is sent in one transfer and the lock completes (custody_final_settlement event); the beneficiary absorbs the accumulated fee delta instead of the lock freezing with the residual stranded.
  • Treasury integrity: an append-only registry (subaccount → lock_id) is consulted by TreasuryWithdraw at creation and execution; no treasury operation can ever draw from lock custody, and no lock operation can draw from the main account or another lock’s subaccount.
  • Legacy locks (created before per-lock isolation, lock_subaccount = None) keep the historical main-account behavior unchanged.
  • LP locks custody an ICPSwap position (NFT-like), not a fungible balance — out of scope for subaccount isolation.

Token & canister management

AdminMethodWhat it doesTier
UpdateTokenMetadataUpdate token metadata (name, symbol, fee, logo) on the ledger.Standard
AddControllerAdd a controller to a controlled canister.Standard (Critical if target = self)
RemoveControllerRemove a controller (never the governance canister itself).Standard (Critical if target = self)
CreateSnapshotSnapshot a controlled canister.Standard
RestoreSnapshotRestore from snapshot (destructive — overwrites state).Critical (always)
DeleteSnapshotDelete a snapshot (irreversible).Critical (always)
StopCanisterStop a controlled canister. Cannot stop self.Standard
StartCanisterStart a previously stopped canister.Standard
UpgradeIndexCanister ★Upgrade the index canister (WASM from an upload session). Cannot target the governance canister. Single-index maintenance only — whole-suite upgrades use the UpgradeCanisterBatch category. Critical because it installs code on a managed canister.Critical

Governance, campaign, verification & delegation

AdminMethodWhat it doesTier
SetGuardianSet / replace / remove the guardian principal.Standard (add/replace reaches Critical via the guardian-target classification; removal keeps the 7-day hardening)
UpdateCampaignInfoWrite campaign description / website / socials (sanitized) into this governance canister’s own record, readable via get_campaign_info. The executable counterpart of the UpdateCampaign category. Scope: it does not change the campaign text shown on the OhShii campaign/DAO pages — see the note below.Standard
ToggleFeatureFlagToggle a governance feature flag (e.g. allow_new_locks, allow_fee_collection).Standard
RemovePermanentlyVerifiedUserForever Verified escape hatch. Evict one principal from the per-DAO permanent-verified cache. Param target_user_principal (non-anonymous). Idempotent. Companion to the direct call admin_remove_permanently_verified_user.Standard
BatchSetFollow ★DAO-driven batch follow assignment: one followee for N followers, bypassing per-caller follow-consent (the proposal IS the authorization). Skips the per-caller follow rate limits and the per-follower verification calls. Still enforces: followee accepts_followers=true (re-checked at execution), bipartite/no-cycle invariant, no self-follow, MAX_FOLLOWERS_PER_FOLLOWEE, override semantics, MAX_BATCH_SET_FOLLOW.Critical

Where the campaign description actually lives (two records, on purpose)

Section titled “Where the campaign description actually lives (two records, on purpose)”

UpdateCampaignInfo is frequently misread, so it is worth being explicit. There are two independent copies of a campaign’s descriptive text, and they are not synchronised:

RecordWritten byRead by
campaign_data on the campaign’s own governance canisterthe DAO, via an UpdateCampaignInfo proposalthe get_campaign_info query (used for on-chain campaign-state checks)
the Campaign record in dao_storagethe launcher backend admin_update_ico_info — an approved ONS proposal, the guardian, or, during the transition phase, the deploying principalsthe OhShii campaign and DAO pages

The OhShii pages render the dao_storage copy. A passed UpdateCampaignInfo proposal therefore updates the DAO’s own on-chain record but does not change the text displayed on OhShii. That is intentional: the displayed campaign metadata is platform-curated, which is what allows OhShii to moderate its own surface (including takedowns) without needing a DAO vote. It is also why the guardian’s admin_update_ico_info writes only to dao_storage and never propagates into a campaign governance canister — a campaign DAO’s own state is never rewritten by the platform. The two directions are independent by design.

A DAO that wants its description changed on the OhShii pages needs the dao_storage copy updated (through admin_update_ico_info, executed by an approved ONS proposal) rather than an UpdateCampaignInfo proposal; the proposal form states this at creation time.

create_proposal() skips the async verify_caller_for_governance verification gate when ALL the following hold:

  • proposal category is CriticalGovernanceOperation,
  • proposal target is ConfigUpdate(data),
  • data.is_emergency_verification_off() == true, i.e. new_requires_verification == Some(false) AND all other ConfigUpdate fields are None.

The exemption exists because without it the DAO can soft-lock: with the verification gate ON, a proposer needs BOTH stake AND verification to submit any proposal — including the one that would flip the flag OFF. The Democratic Emergency Exemption lets ANY staked principal submit the narrow rescue payload; cached verified users (PERMANENT_VERIFIED_USERS) then vote on it without ICCs to ONS. No privileged backdoor; the DAO rescues itself.

Each invocation emits SystemLifecycle { event_type = "verification_gate_emergency_exemption_invoked", campaign_id = "caller=<p>" }.

The rejected alternatives were a privileged backdoor and a timer-based auto-disable on ONS unreachability — both rejected because they break the DAO model or create DDoS incentives respectively.

SONS Forever Verified — direct-call methods (non-proposal)

Section titled “SONS Forever Verified — direct-call methods (non-proposal)”
MethodWhat it does
admin_remove_permanently_verified_userDirect-call eviction by the authorized caller (is_admin_guard); the proposal route is the RemovePermanentlyVerifiedUser AdminMethod above. Removes a principal from PERMANENT_VERIFIED_USERS; returns was_present. Rejects the anonymous principal. Emits SystemLifecycle{event_type="permanent_verified_user_evicted_via_admin"}.
admin_get_permanent_verified_user_countCardinality metric for the authorized caller (is_admin_guard) — returns the cache size only (no public query exposes the principals themselves).

SONS LP Lock methods (non-proposal, direct calls)

Section titled “SONS LP Lock methods (non-proposal, direct calls)”
MethodWhat it does
create_lp_lockCreate an LP lock by pairing governance token with ICP on ICPSwap. Mints a full-range position and locks it. VP calculated from governance token amount.
reclaim_lp_lockReclaim an expired LP lock (transfer position back to user). Rate-limited.
get_lp_lock_feesQuery pending trading fees for a locked LP position.

See LP_LOCK_LIQUIDITY.md for the full deposit-to-lock flow and token ratio calculation.


Voting parameters and config via proposals

Section titled “Voting parameters and config via proposals”

Both ONS (ohshii_governance) and SONS (sons_governance) allow the DAO to change voting and fee configuration via proposals, so the community can tune quorum, approval thresholds, durations, and fees without code upgrades.

  • Voting parameters (quorum, approval %, durations, max VP per user, Guest-fee and purchase-limit params) are stored in stable memory and exposed via get_voting_parameters(). They are not hardcoded.
  • To change them, create a proposal with category CriticalGovernanceOperation, target canister = ohshii_governance, method set_voting_parameters, and Candid-encoded VotingParamsUpdate (all fields optional; only provided fields are updated). Threshold: Critical (see Two-axis voting model).
  • Proposal creation fee and rejection cost can be changed via ExecuteAdminMethod (target = ohshii_governance), methods set_proposal_creation_fee and set_rejection_cost, with Standard threshold. The canister enforces a ±50% change limit per execution to prevent governance attacks.
  • ConfigChange proposals carry ConfigUpdateData for non-critical fields only: new_proposal_creation_fee and new_rejection_cost (Standard threshold).
  • new_fee_recipient is deliberately blocked in proposal flows because changing fee destination can break proposal refund behavior.
  • Voting/runtime parameters in ConfigUpdateData (new_voting_duration_ns, quorum/approval thresholds, min votes, min VP to propose, min lock days, min/max/default voting durations, max VP per user, min lock amount, etc.) must use CriticalGovernanceOperation and therefore the critical tier (see Two-axis voting model).
  • In CreateProposalForm (SONS path), AddController and RemoveController are configured under ExecuteAdminMethod but are auto-submitted as CriticalGovernanceOperation when target_canister_id equals the selected governance canister (self-target).
  • Fee and rejection cost changes remain limited to ±50% of the current value per proposal to prevent a single proposal from making fees unreachable or removing spam protection. Larger changes require multiple sequential proposals.
  • The Create Proposal form (SONS path) offers standard Config Change methods for fee settings and a critical method for voting parameter changes.

Parameter reference — votable defaults vs hardcoded

Section titled “Parameter reference — votable defaults vs hardcoded”

This is the authoritative list of what the DAO can change by vote and what is hardcoded (changeable only by a canister upgrade). Defaults are the values a freshly-deployed canister starts with.

Votable (DAO-tunable) — voting thresholds. ONS changes all of these in one set_voting_parameters call (CriticalGovernanceOperation); on ONS they are snapshotted per-proposal (grandfathering). SONS splits them: the standard-tier ones via ConfigChange, the critical-tier ones (marked †) via CriticalGovernanceOperation.

ParameterDefaultFloor / bound
standard_quorum_vp250,000 VP≥ 1
min_quorum_votes15 voters≥ 1
critical_quorum_vp350,000 VP≥ 1
critical_min_quorum_votes †20 voters≥ 1
critical_approval_pct65 %1–99
critical_immediate_vp_threshold †450,000 VP≥ 1
critical_immediate_min_votes30 voters≥ 1
critical_immediate_approval_pct †75 %1–99
immediate_approval_pct / immediate_min_votes / immediate_approval_vp_threshold90 % / 20 / 500,000vestigial — NORMAL immediate close is disabled; kept for stable-storage compat
veto_override_approval_pct80 %1–99
veto_override_quorum_vp700,000 VP≥ 1 (never 0)
veto_override_min_votes50 voters≥ 1 (never 0)
veto_override_duration_ns1 day≥ 1 day (hard floor)

Votable — durations, proposing & economics. (ONS: set_voting_parameters, Critical. SONS: the critical-tier durations marked † via CriticalGovernanceOperation, the rest via ConfigChange.)

ParameterDefaultFloor / bound
normal_voting_duration_ns † (SONS)3 daysguardian_min ≤ normal ≤ critical
critical_voting_duration_ns † (SONS)7 days≥ normal
guardian_min_voting_duration_ns † (SONS)1 day≤ normal
min / max / default_voting_duration_ns3 d / 7 d / 7 dmin ≤ max
min_vp_to_propose10,000 VP≥ 1
min_lock_duration_days45 days≥ 1
max_voting_power_per_user (the VP cap)36,000> 0
days_in_month30> 0
min_lock_amount (SONS)configurable≥ 1
proposal_creation_fee30,000 OHSHII / 30k token±50 % per proposal; ≥ rejection_cost + 3×ledger_fee. ONS via set_proposal_creation_fee (ExecuteAdminMethod, Standard); SONS via ConfigChange
rejection_cost25,000 OHSHII / 25k token±50 % per proposal; ≤ creation_fee − 3×ledger_fee. Same routes as the fee
requires_verification (SONS only)trueboolean. Set at creation: LGE DAOs are always true; an imported DAO may be created false by the importer. Changed afterwards via CriticalGovernanceOperation only
LGE tier limits + VP thresholds (ONS)see Voter Benefits Protocolmonotonic; via set_voting_parameters

Hardcoded (only changeable by a canister upgrade).

ThingValue / ruleWhy not votable
Tier classification of each category / methodis_critical_category / proposal_uses_critical_thresholds / admin_method_call_is_critical (ONS); is_critical_proposal + is_critical_admin_method (SONS)which proposals are Critical is a policy invariant, not a number
Guardian-removal hardeninghard 7-day window, never immediate, guardian can’t vote on its own removalstops a guardian from fast-tracking or rushing its own removal; removal is vetoable-but-overridable like any Critical proposal
Veto-override floorswindow ≥ 1 day; quorum & min-voters never 0guarantees a veto can only delay, never permanently block
fee_recipientnot proposal-configurablechanging the fee destination can break proposal-refund flows
The ±50%-per-proposal fee clamp itselffixed guardprevents one proposal from making fees unreachable or removing spam protection
Quadratic VP formulasqrt( Σ token_amount × duration_months² ) over a principal’s eligible locks, capped at max_voting_power_per_userformula is fixed; only the cap value is votable
Delegation / batch capsMAX_FOLLOWERS_PER_FOLLOWEE 500, MAX_BATCH_SET_FOLLOW 300, MAX_BATCH_CREATOR_TRANSFERS 300, MAX_DELEGATIONS_PER_TICK 100instruction-budget safety
Guardian eligibility ban listinfra canisters / management canister / governance-self rejectedprivilege-escalation safety
ExecuteAdminMethod positive allowlist (ADMIN_ALLOWLIST, 86 (target, method) pairs) + per-method critical set + CriticalGovernanceOperation 18-method allowlistfixed method setswhich methods exist and which are dangerous is a policy invariant (adding one needs a canister upgrade)
Shield kill-switch targetsthe 5 core canisters (SONS excluded)a fixed, non-caller-supplied set

Voting Parameters Grandfathering (Snapshot at Creation)

Section titled “Voting Parameters Grandfathering (Snapshot at Creation)”

When a proposal is created, the decision-relevant voting parameters are snapshotted into a voting_params_snapshot field on the proposal record. All subsequent quorum and threshold decisions — immediate approval, immediate rejection, and expiry evaluation — use the snapshotted values rather than the current configuration.

This ensures proposals are always judged by the rules active when they were created (grandfathering rule). If a governance proposal later changes voting thresholds, already-open proposals continue to be evaluated against the parameters they were created under.

Snapshotted parameters:

  • standard_quorum_vp — VP quorum for the NORMAL standard close
  • min_quorum_votes — minimum voter count for the NORMAL standard close
  • immediate_approval_vp_threshold / immediate_min_votes / immediate_approval_pct — legacy NORMAL-path immediate fields (immediate approval is disabled for NORMAL proposals; kept for backward compatibility)
  • critical_quorum_vp — VP quorum for the CRITICAL standard close
  • critical_approval_pct — approval % for the CRITICAL standard close
  • critical_min_quorum_votes — minimum voters for the CRITICAL standard close (opt — two-axis model)
  • critical_immediate_min_votes — minimum voters for CRITICAL immediate approval
  • critical_immediate_approval_pct — approval % for CRITICAL immediate approval (opt — two-axis model)
  • critical_immediate_vp_threshold — VP threshold for CRITICAL immediate approval (opt — two-axis model)
  • veto_override_* — veto-override round params (opt)

Backward compatibility: Proposals created before a given field existed have None for that field (or voting_params_snapshot = None entirely) and fall back to the current configuration / default constants. Per-type voting durations are NOT snapshotted — the expiry_timestamp is computed once at creation and stored on the proposal.

This applies to both ONS (ohshii_governance) and SONS (sons_governance) canisters.


Voter Benefits Protocol (LGE Participation Tiers)

Section titled “Voter Benefits Protocol (LGE Participation Tiers)”

LGE participation is gated by the Voter Benefits Protocol, a 5-tier system built on two independent, orthogonal axes composed only at eligibility-check time. Authoritative call: ohshii_governance.get_lge_eligibility(principal).

Pure function of the caller’s strong verification (Telegram, World ID or DecideID) + current VP. Never modified by governance behavior. The backend reads effective_tier from get_lge_eligibility and enforces max_purchase_limit_e8s.

The tier ladder reads STRONG, not “verified”. A wallet whose only method is Google (Standard) sits at Guest exactly as an unverified wallet does — see the gate matrix above for why. Read strong in the table below as “holds Telegram, DecideID or World ID”.

TierConditionLimit (tokens)Fee
Guest!strong1,000,00030,000 OHSHII once per LGE, 100% to treasury
Humanstrong && VP < 2,0004,000,000free
Fishstrong && 2,000 ≤ VP < 10,00012,000,000free
Sharkstrong && 10,000 ≤ VP < 30,00018,000,000free
Whalestrong && VP ≥ 30,00024,000,000free

A campaign’s creator is bounded by a ceiling of their own instead of by the tiers above: 80,000,000 tokens of their own campaign. It is a single total, not an extra budget — the creator’s initial allocation and any later purchases count against the same number, so a creator who takes the full allocation at creation can buy nothing more, and one who takes 10,000,000 may buy the remaining 70,000,000.

This holds because the initial allocation is recorded as an ordinary contribution row, so the same per-user sum that meters purchases already includes it. The ceiling is applied in dao_storage, in the same synchronous gate that meters every other buyer, reading the creator’s identity from the stored campaign record. It applies to no one else.

Pure function of the caller’s vote history on the last 3 ONS proposals that are countable for that caller. Never persisted; recomputed on every call. The canister scans newest-to-oldest with headroom (3 + 12 proposals, so up to 15 recent IDs) and keeps walking backward until it has collected 3 countable proposals or the scan window is exhausted.

This is intentionally per-wallet. A young proposal can count for Alice but not Bob: if Alice already voted on it, it enters Alice’s denominator and numerator; if Bob did not vote and the proposal has not provided a fair 24-hour window, it is forgiven and skipped for Bob.

scanned = memory::get_latest_n_proposals(3 + 12)
for p in scanned newest-to-oldest:
user_voted = round-1 vote exists OR override-round vote exists
if p.status == PendingUpload:
skip
else if user_voted:
include p in denominator and numerator
else if p.status == Open and age < 24h:
skip
else if p.status == Open and age >= 24h:
include p as missed
else if p.status in {Approved, Executing, Rejected, Executed, ExecutionFailed}:
include p as missed
else if p.status in {Vetoed, VetoOverrideExpired} and vetoed_at - created_at < 24h:
skip
else:
include p as missed
stop once 3 proposals have been included
votes = included proposals where user_voted
required = min(2, recent.len())
is_active_voter = votes >= required

Any direct vote counts — no VP threshold on the vote itself. A veto-override vote also counts, so users who participate in the second round are not scored as missing the proposal.

Fast democratic resolution is not punished: Approved, Rejected, Executed, ExecutionFailed (and the transient Executing) count even if the DAO reached a legitimate decision in less than 24 hours. Fast Guardian vetoes are different: a Vetoed proposal that was killed before 24 hours is skipped for non-voters, but still counts for voters who actually participated.

If fewer than 3 countable proposals are found in the scan window, the denominator is smaller. Example: if only 2 proposals are countable, required = min(2, 2) = 2; if 1 is countable, required = 1; if 0 are countable, required = 0 so no one is demoted just because governance has no fair sample yet.

effective_tier = if tier ∈ {Fish, Shark, Whale} && !is_active_voter
then Human
else tier

A strongly-verified Fish/Shark/Whale that misses the 2-of-3 rule participates as Human (4M limit) until they vote on the next ONS proposal — at which point the full tier is restored on the very next purchase. The underlying verification, lock ownership, and VP are all untouched; the demotion is a pure derived value returned alongside the capital tier.

LgeEligibility additionally carries the caller’s verification_strength, so an interface can tell a Google-verified wallet that it needs a strong method rather than telling it that it is unverified.

If the backend’s ICC to ohshii_governance.get_lge_eligibility fails for any reason (network, decode, governance canister stopped), the backend falls back to Human limit (4M) + Guest fee required. This is the strictest realistic tier and prevents an outage from being exploited to exceed limits.

All tier limits and VP thresholds live in VotingParamsConfig and are editable via set_voting_parameters (ONS CriticalGovernanceOperation proposal). Monotonicity is enforced: purchase_limit_guest ≤ purchase_limit_human ≤ purchase_limit_fish ≤ purchase_limit_shark ≤ purchase_limit_whale and min_vp_fish ≤ min_vp_shark ≤ min_vp_whale. The Guest fee amount is BurningFeeConfig.burn_amount_e8s (repurposed field name, preserved for stable-storage compatibility).

ohshii_governance:

  • get_lge_eligibility(principal) -> variant { Ok: LgeEligibility; Err: text } — update (does ICC to lock_storage for VP). Backend-authoritative path.
  • get_lge_eligibility_for_vp(principal, nat64) -> LgeEligibility — query. Frontend fast path after fetching VP via get_voting_power.
  • get_active_voter_status(principal) -> ActiveVoterStatus — query (axis 2 only).
  • tier_for_vp(principal, nat64) -> ParticipationTier — query (axis 1 only).

backend:

  • purchase_tokens_icrc2 calls get_lge_eligibility, uses effective_tier + max_purchase_limit_e8s + guest_fee_e8s.
  • transfer_ohshii_guest_fee — single atomic ICRC-2 transfer_from to OHSHII_TREASURY_ADDRESS (memo GUEST_FEE), triggered only when effective_tier == Guest and the user has not yet contributed in this LGE.

The sponsor reward tiers used in LGE creation and token purchases are hardcoded in the launcher backend; changing them requires a canister upgrade, which is itself an ONS proposal. Full mechanics: Sponsor System.

Every fee-bearing transaction has a fixed 10% total fee (100/1000 of the token cost): 5% OhShii platform fee (always to treasury) + 5% referral band. The sponsor’s share of the referral band depends on the sponsor’s OHSHII quadratic voting power (VP) and on the sponsor being an active voter:

  • < 2,000 VP (or no sponsor): 0% to sponsor, 10% to treasury (5% platform + 5% unearned referral)
  • 2,000 VP: 2% to sponsor, 8% to treasury (5% platform + 3% unearned referral)
  • 3,000 VP: 3% to sponsor, 7% to treasury (5% platform + 2% unearned referral)
  • 5,000 VP: 5% to sponsor, 5% to treasury (platform fee only)

On every current campaign the referral band is held per campaign and settled at the LGE outcome: on success the sponsor claims its share from the campaign’s own SONS DAO; on failure the whole band is refunded to buyers. The campaign always receives 90% of the token cost regardless of sponsor tier.

How VP is checked (per-transaction, not per-campaign)

Section titled “How VP is checked (per-transaction, not per-campaign)”

VP is not checked once at campaign creation and cached. It is resolved independently for each fee-bearing transaction at the time the transaction occurs:

  1. At LGE creation: the sponsor is the LGE creator’s referrer (the person whose sponsor code the creator used). VP of that referrer is checked once when the creation fee is paid.
  2. At each token purchase: the sponsor is the buyer’s individual referrer. VP of that referrer is checked at the time of each purchase, independently, and the resulting tier is snapshotted into the contribution.

Because VP is resolved per-transaction, different buyers in the same campaign can have different sponsors with different VP tiers. A sponsor whose VP changes (e.g. by locking or unlocking OHSHII tokens) sees the updated tier applied to all future transactions where they are the referrer; already-snapshotted contributions keep their tier.


  • Canister: Single ohshii_governance canister. In dfx.json; deployed with the launcher.
  • Voting power: OHSHII token quadratic voting power (based on locked tokens). Supports three lock types: OneTime (single unlock date), Linear (vesting schedule), and LPLock (ICPSwap liquidity position lock — VP based on governance token amount and lock duration, same formula as OneTime). Lock points are not used for voting. Lock points are a separate linear scoring system for tokens listed on OhShii Locker, used by third-party dapps for utility locks and gating (no quadratic cap). Frontend uses ohshii_governance.get_voting_parameters() and get_voting_power() (or lock_storage for quadratic VP).
  • Cap application rule: the VP cap is applied on the sum of all eligible locks for the same principal (aggregated per user, not per single lock only). Example with cap 36,000: 36,000 + 4,000 => 36,000 votable/displayed VP.
  • Voting parameters: Stored in stable memory; updatable via CriticalGovernanceOperation (set_voting_parameters on self). Fee and rejection cost updatable via ExecuteAdminMethod (Standard threshold). See Voting parameters and config via proposals.
  • Guardian: Optional guardian principal; for a NORMAL proposal it may choose a voting period between 1 day and the normal 3 days. Critical proposals keep their fixed 7 days, and a guardian-removal proposal runs at least 7 days. Frontend shows “Guardian” badge and “Resign as Guardian” when applicable.
  • Vote delegation (Liquid Democracy): ONS supports per-principal vote delegation via the follow / unfollow / set_accepts_followers / dismiss_follower endpoints. A verified voter with non-zero VP can delegate to a single explicitly opted-in delegate; when the delegate votes on an ONS proposal, every follower’s vote is automatically applied within one timer tick using each follower’s own snapshot voting power. Bipartite graph by construction (a principal cannot be both a delegate and a follower simultaneously). Full lifecycle, security model, and API reference: LIQUID_DEMOCRACY.md.
  • Timer: ohshii_governance runs a timer (e.g. 5 min) for proposal expiry and auto-execution. The same timer also drives the delegation cascade (process_pending_delegations) which applies up to 100 queued delegated votes per tick synchronously. Status and public restart are shown on System Status and OnsVotingPage.
  • Where it appears: System Status page (OHSHII Governance card), OnsVotingPage when “OHSHII” is selected (Following tab + Voting Power section show delegation state), CreateProposalForm ONS path.
  • Canister: One sons_governance per LGE campaign. Not in dfx.json; created at LGE creation by the backend (create_empty_governance_canister + install WASM in src/backend/src/lib.rs).
  • Voting power: The LGE campaign token (and locks/vesting on that governance). Supports three lock types: OneTime, Linear, and LPLock (ICPSwap LP position lock — VP based on governance token amount and lock duration). Frontend uses the selected token’s governance canister for get_voting_parameters() and get_voting_power().
  • Cap application rule: same aggregation logic applies in SONS governance: cap is enforced on the principal aggregated VP across eligible locks.
  • Config and voting parameters: Split by risk level: proposal_creation_fee / rejection_cost via ConfigChange (Standard), voting/runtime parameters via CriticalGovernanceOperation (Critical). fee_recipient is not proposal-configurable. See Voting parameters and config via proposals.
  • Timer: Each sons_governance has its own timer (e.g. 1 min for unlocks, 5 min for proposals). OnsVotingPage shows the selected governance canister’s timer status and module hash.
  • Where it appears: OnsVotingPage when a SONS token is selected; CreateProposalForm SONS path.

Both ohshii_governance and sons_governance support create/restore/delete snapshot proposals. When the target is the governance canister itself, snapshot/restore is delegated to the ohshii_launcher_backend as orchestrator, because a canister cannot stop itself (the call would never return).

  • OHSHII Governance (ONS) self-snapshot/restore: The backend is already a controller of ohshii_governance. The ohshii_governance notifies the backend to run stop → snapshot/restore → start; no controller change.
  • LGE Governance (SONS) self-snapshot/restore: The governance canister is not created with the backend as controller. So the governance canister adds the OhShii backend as a temporary controller, notifies the backend to run the workflow with cleanup_backend_controller_after_start = true, and the backend removes itself from the governance controllers after start. This keeps the backend from retaining control and is required so the backend can call stop/start on the target. The Create Proposal form shows the target canister when creating a snapshot proposal; when the target is the current (SONS) governance canister, this temporary-controller flow is used.

Backend methods: backend_orchestrate_snapshot_workflow, backend_orchestrate_restore_workflow. Full workflow diagrams: WORKFLOWS.md.

The Guardian is a governance role held by a single principal, for emergency recovery, stored in GUARDIAN_PRINCIPAL. It is set via the SetGuardian proposal category (Critical threshold). The Guardian exists for scenarios where the proposal pipeline itself is degraded and a Critical proposal cannot be executed — typically because the index that backs get_proposals_paginated is corrupted or because a freshly-deployed canister needs an immediate emergency action before the DAO can be assembled.

Guardian principal — validity constraints

Section titled “Guardian principal — validity constraints”

The guardian is a governance role held by one principal. Every guardian-set entry point — the SetGuardian proposal (validated at creation AND re-checked at execution), the transition-phase admin_set_guardian_principal (ONS — reachable by the deploying principals until the DAO handover retires them), and the SONS update_config full-config override — runs validate_guardian_choice, which enforces:

  • Never the anonymous principal. None and Some(anonymous) both mean “remove the guardian” and normalise to “no guardian” — the guardian is never stored as anonymous. Were it stored as anonymous, any unauthenticated caller would satisfy the guardian == caller check.
  • Never an OhShii infrastructure canister (launcher backend/frontend, dao_storage, pool_manager, ohshii_governance, proxy, strongbox, locker backend/frontend, lock_storage, OHSHII ledger), never the management canister, and never the governance canister itself.
  • None (no guardian) is valid and means nobody holds the role — it never means “everyone”. All caller checks treat None as “no guardian”: is_guardian() returns false, guardian_veto rejects with NotAuthorized, and the immediate-approval guardian-yes gate is simply skipped (no guardian → no veto window to protect).

Bad choices are rejected at proposal creation (fail fast, before a vote is spent) and re-checked at execution (defence in depth). The constraint is identical on ONS and SONS.

Where a campaign DAO’s guardian comes from

Section titled “Where a campaign DAO’s guardian comes from”
  • At install. A campaign’s governance canister is installed with the campaign’s post-LGE guardian as its initial_guardian init argument: the guardian chosen at creation, else the creator. The init argument runs the same validate_guardian_choice.
  • While the LGE runs, the emergency role on that canister is held by the ecosystem DAO’s guardian; the campaign’s own guardian takes effect when the campaign completes.
  • At completion, if the field is empty. A campaign governance canister installed before the init argument existed can reach completion with no guardian. The backend therefore offers the same principal on finalize_governance : (opt principal), and the canister writes it only into an empty guardian field, through validate_guardian_choice, in the same message that sets finalized = true. A guardian that is already set is never replaced; a refused offer leaves the field empty, does not stop finalization, and is recorded in the canister’s event log with the offered principal and the reason.
  • After completion, only an approved SetGuardian proposal changes it.

The Guardian cannot impact decentralization. None of the actions below can change a vote outcome or rewrite governance state. The guardian’s only governance-affecting action is the overridable veto — and a veto is overridable by the community (see Guardian Overridable Veto), so the community always keeps the final word. Every other capability is maintenance, disaster recovery, or emergency DoS defence: it rebuilds a derivable secondary index from its source-of-truth, sets or refreshes the Shield firewall, hides a description on the frontend, hands the role back, or drives an idempotent recovery of owed funds to a fixed treasury destination (the refund drain and the stuck-pool-funds claim) — never naming a recipient, spending the treasury at the guardian’s discretion, or touching proposals, votes, anti-replay sets, or DAO-voted parameters (see What the Guardian CANNOT do).

ONS vs SONS guardian. The ONS guardian (ohshii_governance) holds the full toolkit below. A SONS (per-campaign sons_governance) guardian holds a deliberately narrower set — only the rows marked ONS + SONS: the overridable veto, its own canister-local Shield pre-auth mode and cache refresh, the refund drain (force_process_refunds), the asset-batch cleanup (guardian_delete_asset_batch), the chunk-store cleanup (guardian_clear_chunk_store), and the stuck-pool-funds recovery (guardian_claim_pool_funds, which on SONS sweeps the campaign governance canister’s own pool position back to its treasury), plus the orphan fee-escrow sweep described under the proposal deposit. The cross-canister kill switch, description moderation, index rebuilds and resign are ONS-only; a SONS guardian cannot rebuild indices, moderate, run the kill switch, or resign (its role is set/replaced/removed through the campaign DAO’s own SetGuardian proposal). Every one of these — including the funds recovery — either rebuilds a derivable index, drives an idempotent drain/recovery to a fixed treasury destination, or opens a community-override veto; none can name a recipient, change a tally, or spend the treasury at the guardian’s discretion.

A campaign DAO has exactly ONE guardian at any moment, and which one it is changes once — at launch completion. While the launch is still running, the campaign’s guardian is the OhShii guardian: the campaign is not yet autonomous, and the emergency brake has to exist before its DAO does. When the launch completes, the campaign’s own guardian — the one its DAO sets, replaces or removes through its SetGuardian proposal — becomes the only one the campaign accepts, and the OhShii guardian’s powers over that campaign end there. The change is one-way: there is no path back. So on a completed campaign, no guardian action is available to OhShii at all, and every one listed above belongs to the campaign’s DAO.

Anyone can read which guardian a campaign currently accepts: get_effective_guardian() on the campaign’s governance canister answers with that one principal. A campaign that has not set a guardian for its current phase has none, and no guardian action is available on it.

One capability a campaign’s guardian holds on the launcher backend rather than on its own canister. The two relaunch recovery entry points — clearing a stuck relaunch marker, and retrying a relaunch step — accept the campaign’s guardian directly, so a campaign wedged mid-relaunch can be unwedged by the party whose campaign it is, without waiting for a vote. The backend resolves which principal that is by asking the campaign’s own governance canister the same get_effective_guardian() question above; it never accepts both of a campaign’s configured guardian principals, and it never substitutes the OhShii guardian. The capability is rate-limited per campaign so one campaign’s recovery can never throttle another’s, and a refusal names the limit and when to retry; the same two entry points are also reachable by an approved OHSHII proposal, which remains available after the transition phase. Neither moves any value, and neither can pay an address a caller chooses. Full flow: WORKFLOWS.md §“If a relaunch gets stuck”.

EndpointDAOKindPurposeLimits
guardian_veto(proposal_id)ONS + SONSGovernance brake (overridable)Cast a blocking-but-overridable veto on a vetoable Open proposal: moves it to Vetoed and opens a veto-override round. Never a permanent kill — see Guardian Overridable Veto.none — least-gated path by design
shield_set_preauth_mode(mode)ONS + SONSOperational (firewall)Set this canister’s Shield pre-auth mode (Disabled / Enforce / TrustedOnly). DoS-defence control; does not alter any tally.emergency control: authorization check only, no rate limit
guardian_refresh_preauth_cache(context)ONS + SONSOperational (maintenance)Re-derive a Shield pre-auth cache context’s membership without a canister upgrade.—
coordinate_shield_kill_switch(mode, justification)ONSOperational (firewall, emergency)Fan one pre-auth mode across the 5 core canisters at once (launcher backend, dao_storage, ohshii_governance, locker backend, lock_storage — SONS excluded).sync gate only; 10-s bounded-wait ICC per target
update_proposal_description_moderation(gov_id, proposal_id, moderated)ONSModeration (cosmetic, FE-only)Mask / unmask a proposal description on the frontend only — a view control. Never changes proposal state, eligibility, or the vote. Applies to any target governance canister (incl. SONS proposals); the flag lives in ONS memory and no SONS state is ever mutated.guardian-exclusive (is_guardian_guard — no other role required)
guardian_set_surface_moderation(subject, record) / guardian_clear_surface_moderation(subject)ONSModeration (cosmetic, FE-only)Choose which fields OhShii’s own interfaces display for a token (by ledger canister id) or an LGE / imported-DAO campaign (by campaign id): logo / name / symbol, plus description and website+social links for a campaign, and whether the subject is omitted from public listings. Works whoever controls the ledger and whatever state the campaign is in (a completed LGE and an autonomous DAO included). The subject is never touched — no ledger metadata is rewritten, no campaign record is mutated, no state of the subject is read or written; the on-chain data stays readable by anyone directly from the canister. Display-only: it never gates a purchase, claim, refund, transfer, update or withdrawal, and a delisted asset stays visible and fully manageable in its holder’s own wallet. A record with every flag cleared removes the row.guardian-exclusive (is_guardian_guard — no other role required)
guardian_rebuild_indexes(which)ONSDisaster recoveryClear and timer-rebuild a derivable secondary index from its source-of-truth. Targets are the explicit GovRebuildableIndex enum: All, FollowersByFollowee, FollowersCount, VotersByProposal, RpKeysCache, JwksCache, AnyMethodVerifiedUsers.10-min cooldown + 50B cycles floor; 5-fail circuit-breaker; 4-h watchdog
guardian_abort_rebuild()ONSDisaster recoveryCancel an in-flight rebuild cascade. Resets stable state and heap re-entrancy flag; partial state on the rebuilt index is preserved as-is.—
guardian_resign()ONSRole lifecycleHand back the role by setting GUARDIAN_PRINCIPAL to the management canister (aaaaa-aa), a principal no caller can present — the role becomes unexercisable; only a Critical SetGuardian vote can re-elect one.—
force_process_refunds()ONS + SONSDisaster recovery (maintenance)Force a full proposal-resolution drain (expire → execute approved → reap stuck-Executing → process refunds) out-of-band, without waiting for the resolution timer. It only drains owed refunds/execution and is idempotent — it cannot pay a refund twice and cannot change a vote, a proposal outcome, or a DAO-voted parameter (so it does not affect decentralization).sync guardian gate only; idempotent (per-proposal refund_processed flag + the refund processing guard + deterministic refund timestamp)
guardian_delete_asset_batch(target, batch_id)ONS + SONSOperational (asset cleanup)Manual fallback for the automatic delete_batch drain when it has repeatedly failed (asset_batch_cleanup_gave_up). Fires the same guarded path as the automatic drain — including the refusal to delete a batch referenced by a still-live proposal, and the controller-permission self-heal. Frees a blocked frontend-upgrade slot; touches no proposal, tally, or fund.guardian or the transition-phase deploying principals; reject_anonymous guard + body re-check; live-proposal refusal
guardian_clear_chunk_store(target)ONS + SONSOperational (upgrade hygiene)Empty the management-canister chunk store of a canister the governance canister controls, so a chunked upgrade can stage into it. Refused while an upgrade executed by that governance canister is staging into the same store, and refused (by the management canister) for a canister it does not control. A chunked upgrade already empties a controlled target’s store by itself before staging; this is the manual route.Deletes staged upgrade chunks only — never code, state or cycles.
guardian_claim_pool_funds(...)ONS + SONSDisaster recovery (funds)Trigger the on-chain recovery of tokens stranded in an ICPSwap pool after a failed treasury swap or add-liquidity proposal — sweeps the pool’s unused balance and any transferred-but-not-deposited deposit-subaccount (the two official ICPSwap reclaim scenarios), both tokens, back to the executing canister’s own treasury. The destination is fixed to that treasury account — the guardian cannot name a recipient — so it moves owed funds home without touching a vote, an outcome, or a DAO-voted parameter (it does not affect decentralization). Idempotent (re-running on an already-swept pool is a no-op) and per-side non-fatal. On ONS the guardian picks which canister holds the funds (Backend for a failed swap, PoolManager for failed liquidity), which then sweeps its own pool position; on SONS the funds are always on the campaign governance canister itself.Shield-wrapped (trusted callers — guardian, governance-self, transition-phase deploying principals — pass the firewall); per-pool single-flight guard; fails closed while a treasury op is mid-flight on the pool (so it can never steal an in-flight deposit)
admin_clear_oidc_pending_states() / admin_clear_oidc_jwks_cache()ONSOperational (verification maintenance)Invalidate in-flight DecideID OIDC handshakes, and force a refetch of DecideAI’s signing keys when they rotate faster than the 1h cache TTL.Cannot remove anyone’s verification. The worst case is that a user part-way through a verification must start it again. Callable by the guardian, or by the DAO through an approved proposal.

The two Shield pre-auth endpoints (shield_set_preauth_mode, guardian_refresh_preauth_cache), the kill switch, and guardian_claim_pool_funds are also callable by governance-self and, during the transition phase, by the deploying principals (is_admin_guardian_or_governance_for_preauth) — they are not guardian-exclusive.

update_proposal_description_moderation is the one guardian-exclusive operational endpoint: it is gated by is_guardian_guard, so the guardian exercises it on the guardian role alone, both during the transition phase and after it.

Scope is deliberate: this endpoint accepts any target governance canister, including a SONS, and that does not weaken DAO autonomy. The moderation flag is stored in ohshii_governance’s own memory, keyed by (governance_canister_id, proposal_id), and is read back by the OhShii frontend. It performs no inter-canister call and mutates no SONS state: the campaign DAO’s proposals, descriptions, eligibility and tallies are untouched, and a masked proposal continues to exist, execute and settle exactly as before. What the guardian controls here is what OhShii’s own frontend renders — the platform’s moderation of its own surface — not the DAO’s governance. A DAO that disagrees keeps its proposal, its vote and its outcome; only OhShii’s presentation of the description changes.

Equivalent storage-side rebuilds (disaster recovery, reached from the same Guardian role via a 10-s bounded-wait is_guardian ICC to ohshii_governance):

  • dao_storage.admin_rebuild_indexes(which) (launcher) — accepts an ohshii_governance self-call OR the Guardian OR, during the transition phase, the deploying principals.
  • lock_storage.guardian_rebuild_indices(which) (OhShii Locker) — accepts ohshii_governance OR locker_backend (or, during the transition phase, the deploying principals) synchronously, or any other principal via the Guardian ICC. Anti-race RebuildGuard; 30/hour authorized-rebuild quota + 90-s per-run cooldown; a probe-rate cap bounds ICC burn from non-guardian spam.

Launcher-backend endpoints reachable by the same role. A small set of launcher-backend operations accept ONS governance (an ExecuteAdminMethod proposal — decisions of record belong to the DAO) and the Guardian for a faster first response (the guardian check is a bounded is_guardian ICC to ohshii_governance): the LGE recovery retries (guardian_retry_pool_creation, guardian_retry_finalization, guardian_retry_campaign_icp_return — see WORKFLOWS.md), and guardian_update_standalone_token_logo, which updates a standalone token’s icrc1:logo while the OhShii backend is among that ledger’s controllers. That controllership is never part of a token’s standing controllers: the backend holds it only transiently, during creation and during an update the creator signs for, and removes itself at the end; it remains only when that removal did not complete or a controller added the backend, and it is creator-removable at any time (see Creating a Standalone Token); the call preserves name/symbol/fee and the controllers list exactly, and fails closed with platform holds no control over this ledger when the backend is not a controller — so the reachable surface is always the one the token’s own controllers list grants. None of these endpoints can change a vote, a tally, or a DAO-voted parameter.

Stuck-pool-funds recovery — the execution arm. guardian_claim_pool_funds on ONS is a thin proxy: it resolves the fixed target canister (backend for a failed treasury swap, pool_manager for failed treasury liquidity — never an arbitrary principal) and calls that canister’s sweep_pool_unused_balance(pool). On SONS the guardian method does the recovery in-canister (the campaign governance canister owns the position). Each sweep_pool_unused_balance runs the two official ICPSwap reclaim steps for both tokens — deposit any transferred-but-not-deposited subaccount balance, then withdraw the pool’s unused balance — with live per-token fees (never hardcoded), all-Nat math, per-side non-fatal, funds landing only on that canister’s own treasury (MAIN) account. It acquires the canister’s per-pool single-flight guard and refuses (fails closed) while a treasury op is mid-flight on the pool, so a recovery can never race and consume an in-flight deposit. The receivers are gated on ohshii_governance (plus the transition-phase deploying principals) and are not directly guardian-callable — the guardian reaches them only through the ONS proxy, while an approved ONS proposal calls them directly.

guardian_veto is a blocking but overridable veto — never a permanent kill. It exists so that a captured or mistaken guardian can only delay a critical proposal by one override window, never block it. Both ohshii_governance (ONS) and every per-campaign sons_governance (SONS) instance expose guardian_veto with identical semantics.

Vetoable proposals. The guardian may veto a proposal that is still Open and whose target is a critical operation. The vetoable set is exactly the critical set (is_vetoable_proposal on ONS, is_vetoable_proposal on SONS): the critical categories — SetGuardian (add/replace and removal), UpgradeCanister, UpgradeGovernanceCanister, UpgradeAssetCanister, CriticalGovernanceOperation, UpgradeCanisterBatch, UpgradeDappBatch, CustomCall, HttpsOutcall, SetDaoCanisterRegistry — plus the per-method critical ExecuteAdminMethod calls (ONS admin_method_call_is_critical: treasury/pool withdrawals, liquidity removal, position transfers, lock mutations, campaign deletion, and the OHSHII-ledger / root-campaign conditionals) and the critical SONS AdminMethods (is_critical_admin_method: TreasuryWithdraw, RemoveLiquidity, TransferLiquidityPosition, ModifyLock, DeleteLock, TransferLockBeneficiary, TransferLockCreator, BatchSetFollow, UpgradeIndexCanister, CreatePoolAndAddLiquidity, AddLiquidityToPool, AddController, RemoveController, StopCanister, and the three transfer-journal levers ResolveAmbiguousTransfer / AuthorizeRepeatTransfer / ForceClearAmbiguousTransfer). Vetoability is decided from the proposal target payload (method_name / method_args), never the category label. A guardian-removal proposal is also vetoable, with a deliberate safeguard: the guardian may not vote in either round on its own removal, the veto only opens the community override round (it never kills the removal), and the guardian cannot block that override — so the community always completes the removal at the override supermajority. Making removal vetoable raises the bar to remove the guardian from a simple critical majority to the fresh-personhood override supermajority, so a stockpile of bought verified identities cannot remove the watchdog without live human proofs. The one residual is bounded and availability-only: a contested removal (one the guardian vetoes) runs through the fresh-personhood override, so a sustained attestation outage can delay it — a liveness bound, never a capture, since the guardian’s only power is the overridable veto.

Veto-override round. A veto moves the proposal Open → Vetoed (a non-terminal status) and opens a second voting round:

  • Fresh personhood re-attestation required (caller-driven). Casting an override-round vote requires a fresh personhood re-attestation bound to that specific override — the standing verification that admitted the voter in round 1 is not enough. The human attests directly on the ecosystem canister (ONS), with the calling principal as the subject (attest_fresh_override_personhood). For a SONS override the human attests their own personhood on ONS as well (attest_fresh_override_personhood_for_sons): ONS trusts no SONS assertion — the campaign-DAO id is inert key material, the verified caller is the subject — and the campaign DAO only reads whether the human attested (a query), checking per-DAO eligibility locally in SONS. One verified human spends one per-override nullifier (keyed to that exact (DAO, proposal) round), so personhood cannot be reused to multiply override weight. The gate reads an immutable per-proposal snapshot (override_requires_fresh_attest), so it cannot be flipped mid-round; an override that did not opt in at veto time is exempt. On a SONS override, a sustained outage of the attestation dependency auto-extends the window (+24h) so an outage cannot silently uphold a veto.
  • Direct-only — single votes, no liquid-democracy cascade. An override-round vote is recorded for the caller alone; it does not enqueue delegated follower votes even when the caller is a delegate with followers. Only ROUND 1 votes trigger the cascade. The override quorum (≥50 unique voters by default) is therefore a count of real, direct voters — a popular delegate cannot clear the override bar alone. Identical on ONS and SONS (vote() calls cascade_delegated_votes strictly inside the Open arm, never the Vetoed arm).
  • Separate vote tally and separate vote map, so a round-1 voter may vote again.
  • No immediate decision — the timer finalizes it at override_expiry_timestamp (default window: 24 hours from the veto).
  • An UPHELD veto blocks its proposer from creating new proposals for 30 days — on that DAO alone. The block is written on one transition only: the moment the override round closes against the proposer. It is not written when the DAO overrides the guardian (a proposal the electorate adopts costs its author nothing), and not when a proposal is rejected by an ordinary vote (there the cost is economic — the withheld rejection cost). The refusal names the date the block lifts, and the form reads it before a proposer approves any fee. The registers are per-DAO: a block on ONS does not touch any campaign DAO, and a block on one campaign does not touch another. It can be lifted early: on ONS by the guardian or by an approved proposal (guardian_clear_veto_proposer_block, also reachable by the transition-phase deploying principals), on a SONS by an approved proposal (AdminMethod = ClearVetoProposerBlock) — a penalty the body that imposed it cannot withdraw would be permanent the first time it fired wrongly.
  • The override succeeds (→ Approved, executes) only if it clears the override bar; otherwise the veto stands (→ Rejected, refund minus rejection_cost, paid exactly once).

Why the override needs a per-account anchor. The override exists to annul a take-over mounted with acquired or farmed verified identities — a stockpile of already-verified wallets aimed at a Critical proposal — and only a proof whose anchor survives a change of wallet defeats that. Forcing a live proof collapses a farm of N wallets onto the real accounts behind them, because one account produces one attestation per round. A per-wallet verification cannot do that: re-verifying with it would simply re-confirm each bought wallet one more time, leaving the bloc intact. That is why the override has never accepted DecideID and does not accept Google — Google is Standard and never reaches an override ballot at all.

The anchor is a Telegram account. The (DAO, proposal) round is folded into the fingerprint’s own preimage, so one Telegram account yields one ballot per round however many verified wallets its holder controls, and a person’s attestations in two rounds cannot be matched to each other. The check needs no outbound call, so the availability residual described below does not apply to it.

The legacy anchor. A World ID attestation is also accepted for as long as that legacy provider accepts new proofs. Its nullifier is deterministic in a per-(DAO, proposal) action and the World account, so it also de-duplicates one human to one ballot; it needs one outbound call per attestation. See Telegram Verification.

This raises the cost of a bought-identity override capture and keeps it community-annullable; it does not eliminate a coalition of enough distinct real humans, and Telegram accounts are cheaper to accumulate than World IDs. See Governance & Capture Resistance.

Override bar — DAO-tunable, snapshotted per-proposal at creation: ≥ 80% adopt, ≥ 700,000 VP quorum, ≥ 50 unique voters, within a 24-hour window. All four are DAO-tunable via proposal and are surfaced live on the Governance Parameters panel (ONS and SONS). The canister floors these values — a parameter change can never set the window below 1 day or the quorum / minimum-voters to zero, so the override always stays reachable (no vote can turn the veto into a permanent kill). If the override does not pass within the window, the veto stands and the proposal is Rejected.

Display status — VetoOverrideExpired. Once the override window closes but before the timer finalizes, query getters surface a derived VetoOverrideExpired status. It is never persisted — the stored status stays Vetoed.

Guardian-removal hardening. A proposal to remove the guardian gets a hard 7-day minimum voting window, can never pass immediately, and the guardian may not vote on its own removal. Removal is vetoable-but-overridable like any Critical proposal: the guardian may veto it, but cannot vote in the override round and cannot block the community completing the removal at the override supermajority.

Immediate-approval gate. A vetoable proposal cannot pass immediately (before its deadline) unless the guardian has also cast a positive vote — preserving a real window for the guardian to veto instead of being raced by a fast quorum.

guardian_veto is an emergency defensive action and is deliberately the least-gated path in the canister, so an attacker cannot lock the guardian out of the brake. Its gate is the guardian authorization check, enforced under full consensus.

Payment-custody levers on the launcher backend (guardian ∪ ONS)

Section titled “Payment-custody levers on the launcher backend (guardian ∪ ONS)”

Every paid launcher flow (token creation, LGE creation, imported-DAO creation, index canister, cycles top-up, discounted cycles) debits the payer into a per-payment subaccount and records a durable journal row before any work runs. When the work fails after the debit, the flow refunds the payer from that subaccount; when the refund itself cannot complete (a ledger reject, an ambiguous transfer, a stopped callee), the row stays in the journal and housekeeping retries it. The payer can always trigger the retry with reclaim_payment(memo), and read every row with get_my_payment_custody().

The backend also exposes a small set of recovery levers gated on ohshii_governance ∪ guardian ∪ the transition-phase deploying principals — the same “drive an idempotent recovery to a fixed destination” class as the table above, and subject to the same hard wall:

EndpointPurposeLimits
guardian_resolve_payment_custody(memo, action)Re-drive one journal row that housekeeping could not settle: RetryRefund re-sends the refund to the payer, RetrySweep re-sends the success sweep to the fixed treasury / campaign destinations frozen at debit time, RetryCyclesLeg re-sends the pinned ICP→cycles conversion (the ledger deduplicates it, so it can only land once), MarkResolved(reason) records a reconciliation against the ledger for an ambiguous row.Cannot name a recipient, change an amount, or move funds anywhere but the payer or the destinations the flow itself would have used. A row whose subaccount is still owed to a campaign, a sponsor or the reserve funder refuses the refund action. Every action is idempotent and journaled. RetrySweep, like every other sweep, admits only a completed payment (or a cycles discount already delivered) and otherwise answers PAYMENT_SWEEP_STATE_REFUSED and moves nothing; RetryRefund on an empty payment subaccount records the row as refunded only when the row itself explains why it is empty (the payment was below one ledger fee, its own cycles leg spent it, or this row already sent a refund whose reply was lost, recorded in the refund_attempt_e8s field before the transfer), and otherwise leaves it RefundFailed with PAYMENT_REFUND_SUBACCOUNT_EMPTY_UNEXPLAINED.
guardian_resolve_discount_legacy(principal, action)Settle a discounted-cycles purchase made before the custody journal existed, whose ICP is still parked in the old caller-keyed subaccount: RefundToCaller or ForwardToFunder.Two fixed destinations only; a stranger cannot trigger it and a non-guardian call is refused before any transfer.
guardian_list_payment_custody(state?, limit) (query)List journal rows by state for reconciliation.Read-only.

None of these can alter a vote, a proposal, a DAO-voted parameter, or a campaign’s outcome; they exist so that a payer’s stuck refund or a stuck treasury sweep can be completed without a canister upgrade.

Three limits hold across the whole toolkit above, not just the rebuild surface, and they are the ones to check first:

  • It cannot name a recipient. No Guardian lever accepts a destination. Every value-moving recovery settles to a fixed destination the caller cannot influence.
  • It cannot change a tally. No Guardian action writes votes, voting power or verification records. Its only power over an outcome is the overridable veto, and the community can override it.
  • It cannot spend at discretion. Every value-moving lever is an idempotent recovery, not a payment.

A SONS guardian additionally cannot rebuild indices: index rebuild is an ONS-only capability and is not part of the per-campaign Guardian toolkit.

What the Guardian CANNOT REBUILD (HARD WALL)

Section titled “What the Guardian CANNOT REBUILD (HARD WALL)”

The GovRebuildableIndex enum is the structural allowlist. Any index NOT on that enum is permanently outside Guardian reach. Explicitly hard-walled (cross-referenced in code as HARD_WALLED_LABELS):

  • USED_NULLIFIERS, USED_DECIDEID_PRESENTATIONS, USED_TELEGRAM_NULLIFIERS and USED_GOOGLE_EMAIL_HASHES — the four anti-replay sets, one per provider. Wiping any of them lets one account verify more than one wallet.
  • PROPOSALS and EXECUTED_PROPOSALS — primary governance state. Not derivable.
  • VOTING_PARAMS_CONFIG — DAO-voted parameters. Reset would lose every value the community voted in.
  • GUARDIAN_PRINCIPAL — the auth root of the Guardian role itself.
  • VERIFIED_USERS, DECIDEID_VERIFIED_USERS, TELEGRAM_VERIFIED_USERS and GOOGLE_VERIFIED_USERS — the four primary verification records, one per provider. The derivable union ANY_METHOD_VERIFIED_USERS CAN be rebuilt from them — the rebuild now walks all four in sequence — but they themselves cannot.
  • FOLLOWS — primary delegation state.
  • PENDING_DELEGATIONS / PENDING_DELEGATIONS_INDEX — in-flight delegation queue applied by the cascade timer.
  • Write-freeze: while a cascade is active, every mutator of the targeted index returns Err("maintenance_in_progress: index <X> rebuild active, ETA ~Ns"). The frontend surfaces this as a maintenance banner (no Unhandled Promise Rejection).
  • Failure circuit-breaker: a stable consecutive_failures counter on the rebuild state, bumped before each chunk and reset on success, auto-aborts the cascade after 5 trap-faulted ticks.
  • Wall-clock watchdog: cascade auto-aborts after 4 hours regardless of progress.

Full engineering reference: DISASTER_RECOVERY.md.

A voting hotkey lets a native (stake-holding) principal authorise a second principal it controls — the hotkey — to cast votes that resolve to, and count exactly as, the native’s: same Voting Power, same ballot key, same tally effect, same delegation-cascade effect. It is the NNS-neuron-hotkey analog and exists so a holder can vote from any dApp / interface / signer without exposing the native key, and so per-campaign (SONS) DAOs become frontend-independent.

Hotkeys are voting-only — a hotkey gets no other power as the native (no follow/propose/config/attest, no lock/treasury/fund methods). The registry is per governance canister: a hotkey registered on ONS is not a hotkey on any SONS, and each SONS instance keeps its own independent registry (mirroring the delegation model). Up to MAX_HOTKEYS_PER_NATIVE = 3 hotkeys per native, per DAO.

Method inventory (identical surface on ONS and SONS)

Section titled “Method inventory (identical surface on ONS and SONS)”

Both ohshii_governance (ONS) and every sons_governance (SONS) expose the same 8-method surface. register_hotkey / accept_hotkey are Shield-wrapped, so pow_solution is the last argument (opt blob); the sync de-escalation methods are not Shield-wrapped.

MethodSignatureCallerWhat it does
register_hotkey(principal, opt blob) -> (variant { Ok; Err : text })the nativeStep 1 of the consent handshake. Runs the structural / mutual-exclusion / native-verification / native-VP gates, then writes a pending request (does not activate resolution).
accept_hotkey(principal, opt blob) -> (variant { Ok; Err : text })the hotkeyStep 2. msg_caller == hotkey proves control; re-checks structure + the per-native cap, then atomically finalizes the registry so the hotkey resolves to the native.
cancel_hotkey_request(principal) -> (variant { Ok; Err : text })native (or the named principal declining)Point-delete a pending request. Sync, not Shield-wrapped.
remove_hotkey(principal) -> (variant { Ok; Err : text })native removing, or hotkey resigningSync de-escalation; authorization keys on the reverse authority map.
get_my_hotkeys() -> (vec principal) queryanyThe caller’s bound hotkeys (forward-index prefix scan).
get_hotkey_owner(principal) -> (opt principal) queryanyReverse map: hotkey → native (its owner), if any.
is_hotkey(principal) -> (bool) queryanyWhether the principal is a registered hotkey.
get_pending_hotkey_request(principal) -> (opt principal) queryanyPending map: hotkey → the native awaiting its acceptance (drives the accept UI).

The wire error type is text on both canisters; internally SONS returns a domain error that the frontend unwraps to the underlying message.

Inside vote(), immediately after the Shield early-return, the raw msg_caller is resolved through the reverse map: effective_voter = NATIVE_BY_HOTKEY.get(caller).unwrap_or(caller). This is a single synchronous stable-map read (no inter-canister call). The raw wire caller is kept for the Shield/PoW binding, provenance, and the guardian check; every other site in vote() (dedup, placeholder, VP lookup, Vote.voter, verification subject, cascade root) keys on the resolved native. Consequences:

  • Voting Power is the native’s quadratic VP (from the native’s locks), capped at max_voting_power_per_user. A hotkey’s own locks are dormant while registered — resolution is total and one-directional, so a hotkey can never vote its own stake or double-count. To use that stake, the principal must be removed as a hotkey and vote directly (which then requires its own verification). Counting a hotkey’s own locks toward the native’s VP under the native’s verification is not currently available; the DAO could vote to add such a feature in the future.
  • Verification subject = the native: the native’s verification satisfies the gate, so the hotkey never verifies to cast the native’s ballot. Registration additionally requires the native to be verified while a gate is active, at Standard — a hotkey inherits the native’s rights and the per-proposal tier is re-derived from the resolved native at vote time, so registration does not need to pre-empt it. On an override round the fresh attestation keys on the native: a hotkey may cast the override ballot only if the native personally attested, because the proof is bound to the attesting caller and cannot be presented by anyone else.

Group cap-invariant. Because resolution runs before dedup / placeholder / VP on every path, the group {native} ∪ {all its hotkeys} collapses onto one ballot per proposal per round, keyed on the native and capped at the native’s VP — in both the round-1 map and the veto-override map. Owner + hotkey, or several hotkeys, on the same proposal: the first ballot wins and the rest are deduped (Already voted). This is an invariant of “resolution-first + native-keyed dedup + native VP + dormant hotkey VP”, not a separate gate.

Registration is a mandatory two-signature handshake (native proposes → hotkey accepts), so a principal can never be bound as a hotkey without both parties consenting. This closes the no-consent hijack (binding an honest voter as your hotkey to redirect/suppress their ballots) and the symmetric VP-theft (binding a whale as your hotkey to borrow its VP). register_hotkey writes only a pending row; accept_hotkey (signed by the hotkey) finalizes the authority map. Pending rows carry a requested_at_ns timestamp and are stale after PENDING_HOTKEY_TTL_NS = 24h (rejected by accept_hotkey, GC’d by the post-upgrade reconciliation sweep). Removal / resignation / cancellation are all sync, cheap, and never harder than registration.

A registered hotkey may not participate in Liquid Democracy, and a delegation participant may not become a hotkey. This mutual exclusion is enforced atomically at the mutators — register_follow (follower side; covers follow() and the SONS execute_batch_set_follow proposal executor) and set_accepts_followers_flag (delegate side) — with a friendly pre-flight in verify_follow_invariants and, on SONS, in the BatchSetFollow Pass-1 validation (rejecting a registered hotkey as follower or followee before any apply). A rejected follow/accept returns FollowError::PrincipalIsRegisteredHotkey (reason code principal_is_registered_hotkey). The native is unrestricted — it may follow / delegate as usual, and a hotkey-cast vote still triggers the native’s delegation cascade (cascade root = resolved native). See LIQUID_DEMOCRACY.md.

  • Guardian removal-vote ban — both-check. The rule that the guardian cannot vote on its own removal is checked against both the resolved native and the raw wire caller (is_guardian(native) || is_guardian(hotkey_caller) on ONS; the equivalent per-arm check on SONS), so a guardian can neither be secretly bound as a hotkey nor route its own removal ballot through a consensual one.
  • Override round. An override-round vote requires a fresh personhood re-attestation that is cryptographically bound to the attesting caller — a hotkey can never present the native’s proof. With resolution, vote()’s override gate checks the attestation marker for the native: a hotkey may cast the override ballot only if the native personally attested for that round (attest endpoints are unchanged and stay keyed on their own msg_caller). On SONS the override-attest gate is unconditional even when requires_verification == false.

Every ballot carries an appended Vote.cast_via_hotkey : opt principal, set to the hotkey when the vote was cast through one (None for a direct native vote and for all pre-feature ballots, which decode cleanly as None). A HotkeyVoteCast event is emitted alongside the usual VoteCast (which keeps voter = native), and HotkeyRegistered / HotkeyRemoved events record registry changes. The registry is held in three stable maps, with the same shape on both canisters:

MapPurpose
NATIVE_BY_HOTKEYReverse map (hotkey → native): the uniqueness + resolution authority.
HOTKEYS_BY_NATIVEForward index (native, hotkey): listing, removal, per-native cap.
PENDING_HOTKEYTwo-step-consent pending requests ({ native, requested_at_ns }), keyed on the hotkey; TTL-bounded.

They are primary state, not derivable indexes, so the guardian rebuild cannot touch them.

  • DISASTER_RECOVERY.md — Guardian rebuild endpoints, derivable indices, write-freeze contract.
  • ARCHITECTURE.md — Canister map and governance scope table.
  • WORKFLOWS.md — Create LGE, token, vote, create/execute proposal, backend vs frontend upgrade, self-snapshot flows.
  • OHSSHII_LOCKER_INTEGRATION.md — OhShii Locker token list and lock management via ONS proposals, SONS vesting vs Locker.
  • LP_LOCK_LIQUIDITY.md — LP Lock mechanics: ICPSwap V3 liquidity provision, sqrtPriceX96, token ratio calculation, deposit-to-lock flow.
  • LOGGING_SYSTEM.md — Stable memory event logging for both ONS and SONS governance canisters, event types, and monitoring.
  • GOOGLE_VERIFICATION.md — Google verification (Standard): the Internet-Identity attribute-bundle mechanism, the Internet-Identity-session-only limitation, one Google account per wallet, the address-normalisation rules, privacy model, governance controls and escape hatches.
  • TELEGRAM_VERIFICATION.md — Telegram verification (Strong): the flow, the privacy model, and the veto-override attestation it now carries.
  • WORLD_ID_VERIFICATION.md — World ID voter verification (Strong, legacy): flow, privacy model, configuration states including the accepts_new_proofs retirement flag, threshold-ECDSA RP signatures, rate limits and governance controls.
  • DECIDEID_VERIFICATION.md — DecideID voter verification (Strong, legacy): configuration states including the accepts_new_proofs retirement flag, the VC and OIDC paths, privacy model, rate limits and pause levers.
  • LIQUID_DEMOCRACY.md — Vote delegation on ONS: follow / unfollow / accept-followers toggle / dismiss-follower flows, the snapshot-based cascade model, four-layer rate limits, the cross-canister batch VP fetch contract, event log reference, and FAQ.
  • FRONTEND.md — Frontend architecture: React + JSX + JSDoc @ts-check type-checking, the canister-types.js + utils/canister.js bridges, Candid wire conventions, auth providers, and the TypeScript version policy.
  • Deploy/VOTER_VERIFICATION_GUIDE.md — Verifying WASM hashes for DAO proposals.