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).

flowchart TB
User[User]
Select[Select DAO: OHSHII or SONS token]
User --> Select
Select --> ONSPath[OHSHII selected]
Select --> SONSPath[SONS token selected]
ONSPath --> PM[ohshii_governance]
SONSPath --> IG[sons_governance for that campaign]
PM --> CreateONS[create_proposal / vote / execute]
IG --> CreateSONS[create_proposal / vote / execute]
CreateONS --> TargetsONS[Targets: backend, storage, pool_manager, locker, OHSHII ledger/index/archive, OHSHII DEX pools]
CreateSONS --> TargetsSONS[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 strongbox custody transfers (icrc1_transfer / emergency_transfer — they move custody funds out of strongbox subaccounts, the same blast radius as a treasury withdrawal), 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 — the old Standard tier was defensible only while funding them required a prior critical withdrawal.

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 tierStandard 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 or be vetoed; 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.
  • Scope: upgrade the ohshii_governance WASM itself; target_canister_id MUST be the governance canister.
  • Target: CanisterUpgrade(CanisterUpgradeData).
  • Threshold: Critical. Guardian-vetoable.
  • What it does: self-upgrade through the post_upgrade hook.
  • 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 now 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.
  • 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/admin fallback: 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 canisters1–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:

    • kindcode (a WASM install_code / install_chunked_code) or asset (a frontend asset-canister commit_proposed_batch);
    • categoryCore (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 admin method 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), 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), and — post §8 (Findings A/B) — 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) — OhShii Locker strongbox-custody transfers stay reachable through CustomCall instead. 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 — Finding A: a different managed canister can now 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 admin 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.
  • What it does: call_raw to the target method — but only after a compile-time positive allowlist admits the call. As of the §8 migration the free-form passthrough is RETIRED: an AdminMethodCall whose (target_canister_id, method_name) pair is not present in the on-chain ADMIN_ALLOWLIST constant (currently 69 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 still run first as fast-fails, and the per-method critical tier is resolved separately — but the authoritative authorization gate is now 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.
  • 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 a 12-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 six 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) — 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, and the OHSHII / ICP / cycles ledgers are structurally reserved (is_reserved_custom_target), keeping the money-rail and self-management surfaces on their dedicated typed proposal paths.
  • 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 2xxExecuted (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 listing via admin_list_token, 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 are performed only when an ONS proposal is approved and executed (or not at all if no proposal path exists). In production these run only through an approved ONS proposal executed by ohshii_governance; any direct invocation is a bootstrap/test path and must not be relied on. 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 59 (target, method) pairs across four fixed ICC targets; the tables group them by target.

Note — the positive allowlist is the authoritative gate (§8 migration). Under ExecuteAdminMethod the canister maintains a compile-time positive allowlist (ADMIN_ALLOWLIST in src/ohshii_governance/src/types.rs, currently 69 (target, method) pairs): create_proposal rejects any pair not on it. The free-form / blocklist + redirect passthrough is RETIRED — arbitrary one-off external calls now go through CustomCall (module-hash-pinned, guardian-vetoable), and adding a new admin method requires a canister upgrade. Three creation-time behaviours survive as pre-gate fast-fails that run before the allowlist: (1) admin_restore_snapshot_with_workflow, admin_delete_canister_snapshot, set_voting_parameters, set_default_excluded_countries, and the six 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) 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 12-method allowlist (the two controller ops + the two snapshot ops + set_voting_parameters + set_default_excluded_countries + the six voter-verification methods). The tables below illustrate the main methods per target; the authoritative accept-set is ADMIN_ALLOWLIST.

Findings A/B — non-self canister management, now proposable at the CRITICAL tier. Two paths that were previously unreachable are now proposable under ExecuteAdminMethod at the CRITICAL tier (7-day window, guardian-vetoable): (A) proposal_stop_canister against a non-self managed canister — the old guard keyed on target_canister_id (which the frontend always sets to the governance canister, so the block was always true); it now keys on the decoded first arg, so stopping the governance canister and the OHSHII ledger stay blocked while a different managed canister can be stopped by an approved proposal; (B) admin_add_controller_to_canister / admin_remove_controller_from_canister against a non-self canister — managing the governance canister’s OWN controllers still routes to CriticalGovernanceOperation, but changing another managed canister’s controllers is now an allowlisted CRITICAL proposal. This lifted the ONS proposal-execution self-restriction (ensure_self_call_targets_self) for these non-self management calls.

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 path (not on-behalf finalization vesting, and never SONS-governance locks). This is the method the “Feature Toggles” proposal uses; the proposal UI shows a current→proposed diff.
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 (Finding B).
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 (Finding B).
admin_set_guest_feeSet the OHSHII Guest fee (per-LGE first-purchase fee for unverified 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.

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 since the 2026-06 supported-token freeze (the locker backend additionally rejects token ids not already listed, so only metadata refreshes of the frozen set remain possible via direct admin call). Kept here for the historical record.
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 (2026-07): 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.
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.

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 ICPSwap fees for a campaign and send to OhShii backend treasury.
proposal_fee_collect_and_buybackCollect fees and perform an OHSHII buyback with slippage protection.
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.
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.
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 operator 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 admin-callable for operators.

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.

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 admin method) 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 withdrawal source may be given as a raw 32-byte subaccount (reachable directly by all withdraw methods). 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.

Operator note — 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, “Operator note (ambiguous outcomes)”).

And since 2026-08 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. This exists because the marker-reading procedure is fail-open: it assumes a human who checks the log before re-proposing, and the platform’s stated end state has no such operator — the party that re-proposes is an electorate.

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

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 TWO independently-togglable proof-of-personhood methods. Either method’s enabled = true activates the vote gate; either method’s verification satisfies it. See docs/WORLD_ID_VERIFICATION.md and docs/DECIDEID_VERIFICATION.md for the full specs.

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 admin-only setter (never in a proposal body).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

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 admin/self-callable (is_admin_or_self_guard), so it can be flipped by a NORMAL-lane ExecuteAdminMethod proposal once the DAO is autonomous, and by an authorized admin during the pre-DAO transition phase. 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 two methods are independent: enabling one does not affect the other; disabling one does not clear existing verifications via that method (verifications persist across method-disable, by design — see the load-bearing comment on is_any_method_verified in memory.rs).

Universal Participation Gate — combined behavior

The same gate applies to BOTH vote() AND create_proposal() on ONS. The two functions duplicate the following block (with a different action verb in the error message); the canonical reference is vote():

let any_gate_required = world_id_cfg.enabled || decideid_cfg.enabled;
if any_gate_required && !memory::is_any_method_verified(caller) {
return Err("Voter verification required (World ID or DecideID) before voting on ONS proposals.");
}

create_proposal() returns "Proposer verification required (World ID or DecideID) before creating ONS proposals." instead — 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.

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) ICC to ONS behind the V-4 breaker, with a HARD INVARIANT on the vote() .await placement; the per-DAO flag is exposed via requires_verification : () -> (bool) query.

The is_any_method_verified helper reads the verified-user sets (VERIFIED_USERS for World ID, DECIDEID_VERIFIED_USERS for DecideID) directly, NOT the per-method enabled flags. This makes the gate satisfy two invariants at the same time:

  1. OR satisfaction — a verification via either method satisfies the 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 (Required → Optional → Not Configured) never clears existing verifications. Only the explicit admin/DAO escape hatches admin_clear_world_id_verified / admin_clear_decideid_verified remove individual flags.

What happens when BOTH methods are disabled: the OR over the two enabled flags is false, so any_gate_required = false 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.

WorldID .enabledDecideID .enabledONS vote() for unverified caller
falsefalseAllowed — gate is off, anyone authenticated can vote
truefalseRejected unless caller is in VERIFIED_USERS OR DECIDEID_VERIFIED_USERS
falsetrueRejected unless caller is in VERIFIED_USERS OR DECIDEID_VERIFIED_USERS
truetrueRejected unless caller is in VERIFIED_USERS OR DECIDEID_VERIFIED_USERS

Operator note: a DAO that wants to halt voting WITHOUT lifting the sybil filter must leave at least one method enabled = true and use set_world_id_daily_cap(0) / set_decideid_daily_cap(0) to block new verifications. Setting both methods 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, burn exemption 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 (2026-07). 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. Exception: session-paid proposals (upgrade/asset flows whose fee was pulled at prepare_paid_upload time, before a proposal id exists) keep main-account custody (fee_subaccount = None) but follow the same outcome policy. 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)Deposit → treasury (escrow residual, net of the ledger fee) — the cost of a successful governance action. ONS forwards to the OhShii backend treasury; SONS settles into its own main account (the SONS treasury).
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)Forfeit — no refund; a FeeForfeited audit event is emitted

An Approved executable proposal settles nothing yetApproved 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 byte-identically (Approved/Executed/ExecutionFailed → full refund from the main account).

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 admin methods

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

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 admin method (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.
  • Scope: self-upgrade this sons_governance WASM (target MUST be itself).
  • Target: CanisterUpgrade(CanisterUpgradeData).
  • Threshold: Critical. Guardian-vetoable.
  • What it does: self-upgrade through the post_upgrade hook.
  • Scope: the high-risk lane — voting / runtime / delegation config changes, plus the admin methods 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 admin method, 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 now clears the same critical bar as every other upgrade.)
  • What it does: validate_commit_proposed_batch (create + execute) then commit_proposed_batch.
  • 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/admin fallback: 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 admin method remains available for index-only maintenance. See WORKFLOWS.md §5.3.
  • Scope: upgrade an arbitrary ordered set of canisters1–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 OHSHII / 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 2xxExecuted, a non-2xx → still Executed with the status in the event, a transport-level failureExecutionFailed. 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 ships one IDL and one code path for both DAOs, so the two must not diverge.
  • 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. Do not merge them, do not make one call the other, and never point the predicate at the registry.
  • unsupported is a permanent, designed answer. SONS is a per-campaign fleet upgraded at different times, so most DAOs will answer IC0302 for a long while and the frontend renders “does not expose a canister registry yet”. That branch is not a migration artefact — do not “clean it up” after a deploy wave.

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
TransferLiquidityPositionTransfer 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.Standard
CollectFeesAndBuybackCollect fees, then buy back a token with the collected ICP (slippage-protected).Standard
CreatePoolAndAddLiquidityCreate 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
AddLiquidityToPoolAdd 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
RemoveLiquidityRemove 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.Standard
TreasuryWithdrawWithdraw 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).Standard
ResolveAmbiguousTransferRecord 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
AuthorizeRepeatTransferAuthorise 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
ForceClearAmbiguousTransferRetire 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
ModifyLockChange 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
DeleteLockRemove 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
TransferLockBeneficiaryChange 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
TransferLockCreatorBatch-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 after this change 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 this change, lock_subaccount = None) keep the historical main-account behavior byte-identically.
  • 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
UpgradeIndexCanisterUpgrade 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 admin direct call admin_remove_permanently_verified_user.Standard
BatchSetFollowDAO-driven batch follow assignment: one followee for N followers, bypassing per-caller follow-consent (the proposal IS the authorization). Skips the L1–L4 rate limits, FollowOpGuard, and L6/L10 verification ICCs. 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 (admin / OHSHII governance / guardian)the 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 should raise it with the platform rather than spend a proposal on it; 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 admin backdoor; the DAO rescues itself.

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

The rejected alternatives were an admin 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 — admin direct-call methods

Section titled “SONS Forever Verified — admin direct-call methods”
MethodWhat it does
admin_remove_permanently_verified_userDirect-call eviction for the incident-response fast path (gated by is_admin_guard). Removes a principal from PERMANENT_VERIFIED_USERS; returns was_present. Anti-anonymous. Emits SystemLifecycle{event_type="permanent_verified_user_evicted_via_admin"}. Gated by is_admin_guard.
admin_get_permanent_verified_user_countAdmin-gated cardinality metric — returns the cache size only (privacy invariant: 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, burn exemption 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_votes20 voters≥ 1
critical_approval_pct65 %1–99
critical_immediate_vp_threshold450,000 VP≥ 1
critical_immediate_min_votes30 voters≥ 1
critical_immediate_approval_pct75 %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, 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, 59 (target, method) pairs) + per-method critical set + CriticalGovernanceOperation 12-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 verified flag (WorldID) + current VP. Never modified by governance behavior. The backend reads effective_tier from get_lge_eligibility and enforces max_purchase_limit_e8s.

TierConditionLimit (tokens)Fee
Guest!verified1,000,00030,000 OHSHII once per LGE, 100% to treasury
Humanverified && VP < 2,0004,000,000free
Fishverified && 2,000 ≤ VP < 10,00012,000,000free
Sharkverified && 10,000 ≤ VP < 30,00018,000,000free
Whaleverified && 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 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 verified flag, lock ownership, and VP are all untouched; the demotion is a pure derived value returned alongside the capital tier.

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 fee tiers used in LGE creation and token purchases are currently hardcoded but can be made governance-configurable via ONS proposals in the future. This section documents the current mechanics and the structure for a potential UpdateSponsorFeeTiers proposal type.

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/sponsor pool (split between sponsor and treasury based on VP). The referral pool is divided based on the sponsor’s OHSHII quadratic voting power (VP):

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

The total fee is always 10%; the 5% platform fee always goes to treasury, and the 5% referral pool split changes based on VP. 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 (allocate_ico_funds): 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 (process_fees_for_reserved_purchase): The sponsor is the buyer’s individual referrer (the person whose sponsor code the buyer used). VP of that referrer is checked at the time of each purchase, independently.

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) will see the updated tier applied to all future transactions where they are the referrer.

A future UpdateSponsorFeeTiers proposal type (or an ExecuteAdminMethod variant) could allow the DAO to adjust these thresholds via ONS proposals. The proposal would carry:

  • threshold_tier_2_percent: VP threshold for 2% sponsor share (currently 2,000)
  • threshold_tier_3_percent: VP threshold for 3% sponsor share (currently 3,000)
  • threshold_tier_5_percent: VP threshold for 5% sponsor share (currently 5,000)
  • Scope: Changes affect all future transactions — both LGE creations and token purchases. Existing campaigns are affected for future purchases (since VP is checked per-transaction, not cached at creation).
  • Earnings not retroactive: Sponsor earnings from past transactions (already transferred) are not recalculated or adjusted.
  • Fixed total fee by design: The overall 10% total fee (5% platform always to treasury + 5% referral pool split between sponsor 0-5% and treasury 5-0%) is fixed by design and should not be governance-configurable, as it is a core economic guarantee. The campaign always receives 90%.
  • Transfer thresholds: Minimum transfer amounts (30,000 e8s campaign, 20,000 e8s sponsor) are safety mechanisms and should remain hardcoded.

This extension point exists if the DAO wishes to fine-tune sponsor incentives in the future without code upgrades.


  • 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 can create proposals with 1-day voting duration. 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 single emergency-operator principal stored in GUARDIAN_PRINCIPAL (memory id 13). 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 an emergency human operator. Every guardian-set entry point — the SetGuardian proposal (validated at creation AND re-checked at execution), the admin-only admin_set_guardian_principal (ONS, pre-DAO), 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.

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), 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). That is six capabilities, and the list above names all six. 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.

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, intentionally un-gated)
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 — admin not 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 — admin not 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 = canister_self() — the role becomes unexercisable by any human; 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 admin; reject_anonymous guard + body re-check; live-proposal refusal
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, but guardian/admin/governance-self T-bypass the pipeline; 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 admin and governance-self (is_admin_guardian_or_governance_for_preauth) — they are not guardian-exclusive.

update_proposal_description_moderation is the one guardian-exclusive operational endpoint — and as of 2026-07-19 its gate finally matches that description. It previously carried an is_admin_guard attribute, which is evaluated before the method body and admits only the bootstrap-admin allowlist; the body then required the guardian check, so the real gate was the intersection of the two roles. It happened to work only because those roles overlap today, and would have become uncallable by anyone once the bootstrap admins are retired on the pure-DAO trajectory. The attribute is now is_guardian_guard, so the guardian can exercise it without also being an admin, today and after the admins are gone.

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 admin OR ohshii_governance self-call OR Guardian.
  • lock_storage.guardian_rebuild_indices(which) (OhShii Locker) — accepts admin OR ohshii_governance OR locker_backend 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 — 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 granted at creation among the token’s standard controllers and is creator-removable at any time (see STANDALONE_TOKENS.md); 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 admin/ohshii_governance-gated (not directly guardian-callable) — the guardian reaches them only through the ONS proxy, and admins/ONS proposals can also call 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 (security property D1). 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, SetDaoCanisterRegistryplus 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 admin methods (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 World ID 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).
  • 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 World ID, not DecideID, for the override. The fresh re-attestation is World-ID-only by design. The override exists to annul a take-over mounted with acquired or farmed verified identities — a stockpile of already-verified principals aimed at a Critical proposal — and only a proof with a per-human anchor defeats that. World ID’s nullifier is stable across principals (deterministic in (app_id, action, World account)), so a fresh per-(DAO, proposal) action lets the round de-duplicate one human to one ballot however many verified principals that human controls; DecideID is principal-bound — re-verifying with it would just re-confirm each bought principal, never collapsing the bloc to its real humans — so it powers normal eligibility (World ID OR DecideID) but cannot power the override. 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. 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 (D1 cannot be voted away). If the override does not pass within the window, the veto stands and the proposal is Rejected.

Phase 2 — 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 carries no rate limit, no cycle-reserve floor and no Shield: it is an emergency defensive action and must remain the least-gated path in the canister — gating it would let an attacker lock the guardian out of the brake. The only gate is the guardian authorization check, enforced under full consensus.

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 and USED_DECIDEID_PRESENTATIONS — anti-replay sets. Wiping enables vote duplication.
  • 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 and DECIDEID_VERIFIED_USERS — primary verification records (the derivable union ANY_METHOD_VERIFIED_USERS CAN be rebuilt from them, 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 sync stable-map read (no ICC, no new await — the SONS single-await invariant is preserved). 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 World ID / DecideID status 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.

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 mutatorsregister_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. Three new stable maps hold the registry — identical shape on both canisters:

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

All three are additive (virgin, upgrade-safe) and are not rebuildable/derivable indexes, so they are excluded from the guardian rebuild set and the write-freeze contract. The Vote field, the three events, and the 8 methods are additive-Candid changes (didc subtype-safe).

  • DISASTER_RECOVERY.md — Guardian rebuild endpoints, derivable indices, write-freeze contract, operator runbook.
  • 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.
  • WORLD_ID_VERIFICATION.md — World ID voter verification: flow, privacy model, three-state config (not_configured / optional / required), threshold-ECDSA RP signatures, rate limits, governance controls and operator checklist.
  • 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.