Skip to content

Liquid Democracy

Vote delegation on the OhShii Launcher. Delegation is live on both the OHSHII ecosystem DAO (ONS) and every per-campaign DAO (SONS). This document covers the user model, the canister architecture, the public API, the security and rate-limit model, and the operational contract that ties the launcher and the locker together. It details the ONS implementation; the SONS implementation shares the same delegation engine, with the differences summarized under Scope.


Liquid Democracy lets a verified ONS voter (the follower) delegate their vote to another verified principal (the delegate / followee). When the delegate casts a vote on an ONS proposal, every follower’s vote is automatically applied with the follower’s own quadratic voting power, computed fresh from lock_storage at the moment the delegate voted.

The model is shallow by design: a follower’s delegation chain is exactly one hop. A principal cannot be both a delegate and a follower at the same time on ONS — the accepts_followers toggle enforces this bipartite-graph invariant. There are no transitive cascades, no cycle detection, no recursion budgets to tune.

Delegation is opt-in on both sides:

  • The delegate must explicitly enable accepts_followers (off by default).
  • The follower must explicitly call follow(delegate).

Either party can dissolve the relationship at any time:

  • The follower calls unfollow().
  • The delegate calls dismiss_follower(follower).
  • The delegate disables accepts_followers, which removes every follower atomically.

Vote delegation is live on both governance layers:

  • ONS — the OHSHII ecosystem-wide DAO that governs the launcher, the locker, and shared protocol parameters. This is the implementation the rest of this document details.
  • SONS — per-campaign mini-DAOs running on their own sons_governance canister instances. SONS runs the same delegation engine (the follow / unfollow / set_accepts_followers / dismiss_follower surface, the 500-follower cap, the 100-per-tick cascade on the 5-minute timer, and the one-hop / bipartite / snapshot / direct-vote-wins invariants), with the differences listed below.
AspectONS (ohshii_governance)SONS (sons_governance)
Cascade triggerOnly proposals whose required_token_canister_id == OHSHII_LEDGER_CANISTER_ID cascade — and since create_proposal coerces that field to OHSHII, that is every ONS proposal.Every round-1 direct vote cascades — each instance governs a single campaign token, so there is no per-proposal token gate.
Cascade dispatchcascade_delegated_votes(...).await inline in vote().ic_cdk::futures::spawn(...) so a cascade trap cannot roll back the committed direct vote.
Voting-power sourceFetched from lock_storage via a batched inter-canister call (async cascade).Computed canister-locally from the instance’s own locks (fully synchronous cascade, zero ICC).
Verification gateFires when any of the four providers is enabled (all default off); checked against ONS’s own verified-user sets (sync). Delegation is a Standard gate, so every method satisfies it.Per-DAO requires_verification flag, default on; checked by a bounded-wait is_verified ICC to ONS behind a circuit breaker, with a permanent “Forever Verified” local cache.
De-escalation (unfollow, dismiss_follower)Verified when a gate is on — they are not exempt.Skip verification by design (sync de-escalation paths). When the gate is off, the acquisition paths fall back to a local active-lock anti-Sybil check.
DAO-assigned delegationNone.BatchSetFollow — a critical governance proposal can assign a delegate to a list of followers without each follower’s signed consent (the proposal is the authorization), still enforcing accepts_followers (re-checked at execution), no self-follow, the bipartite invariant, the 500-follower cap, and a 300-per-batch limit.

ONS is single-token by construction: create_proposal coerces required_token_canister_id to the OHSHII ledger for every proposal (the field is kept on the wire for API/back-compat), so an ONS proposal is always an OHSHII proposal — a direct caller cannot create a non-OHSHII (campaign-token) proposal on ONS, and the launcher UI already sends OHSHII. The cascade therefore fans out to all ONS proposals; the == OHSHII_LEDGER_CANISTER_ID cascade gate and the vote() “campaign-token” branch are defensive and can no longer be reached on ONS.

Imported DAOs run the same sons_governance WASM, so they expose the identical delegation surface, including BatchSetFollow.

The remainder of this document describes the ONS implementation in detail — its Candid API, stable-memory layout, and the lock_storage cross-canister dependency. SONS shares the same user model and cascade architecture; only the points in the table above differ.

A verified principal who has explicitly opted in to receive delegations by enabling the accepts_followers toggle. A delegate cannot themselves follow anyone — the toggle and the follow operation are mutually exclusive on the same DAO.

A delegate may have up to 500 followers (MAX_FOLLOWERS_PER_FOLLOWEE). This cap exists so the destructive bulk-removal path (toggle OFF) can complete inline within IC’s per-message instruction limit.

A verified principal with non-zero OHSHII voting power who has called follow(delegate). A follower has at most one active follow relationship — to switch delegate they must unfollow() first.

When the delegate votes, the canister fetches each follower’s quadratic voting power from lock_storage once, in a single batched ICC, and freezes the result inside a PendingDelegationEntry. The timer applies that snapshot synchronously, so a follower’s later lock expiry between enqueue and apply does not cancel the delegation.

If a follower’s voting power is zero at the moment the delegate votes, the resulting Vote row carries 0 VP and contributes nothing to the tally — but the row is still written, so the follower’s vote count reflects participation.

Verification gate (Universal Participation Gate)

Section titled “Verification gate (Universal Participation Gate)”

The verification gate is a Universal Participation Gate: it covers delegation and every other participation method. On ONS DAOs the gate fires when any of the four providers is enabled; when on, the following ONS write methods all require the caller to be verified by at least one method (is_any_method_verified):

  • vote() — direct vote
  • create_proposal() — proposal creation
  • follow() — delegation acquisition (both the caller AND the followee must be verified)
  • set_accepts_followers(true) — delegate opt-in (the caller must be verified)

Verification is the implicit economic gate that makes the entire DAO participation surface Sybil-resistant — anonymous principals cannot participate, and fresh principals must pay the verification cost of whichever method they choose.

Delegation itself is a Standard gate — any accepted method satisfies it, Google included. Strength enters one step later, in the cascade: a delegated ballot landing on a critical proposal is applied only for followers who hold a strong method. See Strength filter for the full rule, the two skip reasons and where each is logged. The de-escalation methods (unfollow, dismiss_follower) are not exempt: whenever a verification gate is enabled they require the caller to be verified, exactly like the acquisition methods. They differ only when verification is OFF — because they merely REMOVE state they carry no additional anti-Sybil substitute, whereas the acquisition paths fall back to a lock-existence check in that mode.

The verification storage is per-method on ONS — one verified set per provider, plus a derivable union. Verifications PERSIST across method-disable: a user verified via World ID keeps voting access if World ID’s enabled flag is later flipped to false, and they keep it when World ID is closed to new proofs. Re-enabling restores access without a re-verification round-trip.

Why ONS does NOT use Forever Verified (SONS-only): on this canister is_any_method_verified is already a sync stable-map lookup against the per-method verified sets — there is no ICC to save. Forever Verified (the permanent local cache that bypasses the verify ICC on cache hit) is structurally a SONS-family feature because SONS canisters reach across the subnet to query ONS for verification on every gated call. ONS itself is the source of truth, so caching makes no sense here.

The asynchronous fan-out of delegated votes from a single direct vote to N follower votes. Implemented as an enqueue step inside vote() (captures voting power snapshots) plus a periodic timer step (process_pending_delegations) that drains up to 100 entries per tick and applies each as a synchronous Vote row.

  1. Visit the OnsVotingPage and switch to the Following tab.
  2. In the “Follow someone” panel, paste the delegate’s principal id.
  3. The UI runs two pre-flight checks:
    • am_i_accepting_followers() — if true, the button is disabled because you are already a delegate; you’d need to disable accept-followers first.
    • accepts_followers(target) — if false, the button is disabled with a clear notice that the target is not accepting followers.
  4. Click Follow. Your wallet prompts to sign (Internet Identity 2.0 is the recommended sign-in method; Plug Wallet is still available but not recommended, and NFID Wallet sign-in is not offered; see the “Wallets and Sign-in” user guide). The canister fetches your voting power from lock_storage and registers the relationship if your VP is > 0.
  5. From this moment on, every ONS vote the delegate casts triggers an automatic vote from your wallet within ~5 minutes.
  1. Visit the Following tab on the OnsVotingPage.
  2. In the “Become a delegate” section at the top, click Enable accept-followers.
  3. A confirmation modal explains the implications: other verified ONS voters may now delegate their voting power to you, and any existing follow you had on someone else will be auto-removed.
  4. Confirm. From this moment on, anyone can call follow(your_principal).
  1. From the Following tab, click the Disable accept-followers button.
  2. A destructive-confirmation modal appears showing the current follower count and a preview of follower principals. The action is irreversible — the delegate’s followers will need to follow them again from scratch if they ever re-enable the toggle.
  3. Confirm. The canister inline-removes every follower (up to 500), resets the follower count, and emits a single AcceptsFollowersDisabled summary event in the audit log.

A delegate can selectively kick one follower without disabling the toggle (useful when a previously-trusted peer is no longer appropriate):

  1. Open the Voting Power expandable section on the OnsVotingPage.
  2. Click Show N Followers to expand the list.
  3. Click the red ✕ next to the principal you want to remove.
  4. A small confirmation modal asks for confirmation; click to proceed.
  5. The relationship is removed immediately; the kicked follower can follow(you) again later if they wish (subject to your toggle still being on).

From the Following tab, click the Unfollow button next to the delegate row. There is no confirmation modal — the operation is fully reversible (you can follow() the same delegate again right afterwards), so the friction is intentionally low.

The feature lives in the ohshii_governance canister (launcher repo). Five stable maps + one secondary index hold the persistent state, all updated together by the canister’s helper API so they stay consistent:

Stable mapMemory IDPurpose
FOLLOWS71follower → followee (forward index)
FOLLOWERS_BY_FOLLOWEE72(followee, follower) reverse index for range scans
PENDING_DELEGATIONS73FIFO cascade queue keyed (enqueued_at_ns, proposal_id, follower)
ACCEPTS_FOLLOWERS_FLAGS76Opt-in toggle set; presence = ON
FOLLOWERS_COUNT77Atomic capacity counter followee → u32
PENDING_DELEGATIONS_INDEX78Secondary index (proposal_id, follower) for O(log N) dedup

In addition, four heap-only rate-limit maps and two single-tuple global counters live in the canister’s heap. These reset on every upgrade by design; the persistent delegation state lives only in the stable maps above.

Four state-mutating updates and nine read-only regular queries. No method takes a dao_id argument — the feature is single-DAO by construction. No method is a composite query — the frontend orchestrates cross-canister fanout itself.

// === Updates ===
"follow": (principal) -> (variant { Ok; Err: text });
"unfollow": () -> (variant { Ok; Err: text });
"set_accepts_followers": (bool) -> (variant { Ok; Err: text });
"dismiss_follower": (principal) -> (variant { Ok; Err: text });
// === Queries ===
"accepts_followers": (principal) -> (bool) query;
"am_i_accepting_followers": () -> (bool) query;
"get_my_followee": () -> (opt principal) query;
"get_followee_of": (principal) -> (opt principal) query;
"get_my_followers": (nat32) -> (FollowersPage) query;
"get_followers_of": (principal, nat32) -> (FollowersPage) query;
"get_my_followers_count": () -> (nat32) query;
"list_delegates": (nat32, nat32) -> (DelegatesPage) query;
"get_pending_delegation_queue_size": () -> (nat64) query;
type FollowersPage = record {
followers: vec principal; // up to `max` rows
total_count: nat32; // canonical follower count
truncated: bool; // true if total_count > followers.len()
};
type DelegateEntry = record {
delegate: principal; // has accepts_followers = true
followers_count: nat32; // canonical O(1) counter read
};
type DelegatesPage = record {
delegates: vec DelegateEntry;
total_count: nat32; // total delegates currently opted in
truncated: bool;
};

The max argument on the paged-followers queries is clamped server-side to the cap of 500. The frontend uses 20 by default and offers a “Load more” affordance up to 100 (the lock_storage batch ceiling).

list_delegates(offset, max) is the public delegate directory that powers the “Follow a delegate” picker: one page (max clamped to 100) of principals currently accepting followers, each with its canonical follower count. Rows come back in stable-map key order — voting power is deliberately not included (that would require a cross-canister call from inside a query). The frontend batches the same VP query it already uses for the Voting Power panel (lock_storage.calculate_quadratic_voting_power_batch on ONS, the canister-local get_voting_power_batch on SONS DAOs) over the page and sorts by VP client-side, so the picker lists delegates from highest to lowest voting power with zero update-call cost. The equivalent list_delegates query exists on every sons_governance instance.

The cascade — how delegated votes propagate

Section titled “The cascade — how delegated votes propagate”
  1. Enqueue stage (synchronous, runs inside vote() after the direct vote commits):

    • Verify the proposal’s required token is the OHSHII ledger — the cascade fans out only OHSHII-token proposals (see Scope).
    • List the followers of the voter via a stable range scan (capped at 500 by the canister’s per-followee cap).
    • De-duplicate against any followers who already direct-voted on this proposal or already have an entry queued (via the secondary index).
    • Fetch every eligible follower’s voting power from lock_storage in a single batched ICC, parallelised across chunks of 100 if necessary. Bounded wait with a 15-second timeout; on timeout the affected chunk is logged and dropped.
    • Push one PendingDelegationEntry per follower, carrying the frozen voting-power snapshot.
  2. Apply stage (synchronous, runs inside the existing run_periodic_tasks() timer):

    • Drain up to 100 entries from the queue per tick (~5 minutes by default).
    • For each entry, re-verify the proposal is still open, the follower is still verified, the follow is still active, and the follower has not direct-voted in the interim. If any check fails, log a DelegatedVoteFailed event and drop the entry.
    • Apply the snapshot: write the Vote row, update the proposal tally, append to the follower’s voter history, log a DelegatedVoteCast event.

The apply stage performs no inter-canister calls. This is the property that makes the cascade cycle-safe — a popular delegate’s vote that fans out to hundreds of followers cannot stall the timer or drain the canister via repeated long-running ICCs.

Strength filter — a delegated ballot is held to the proposal’s own bar

Section titled “Strength filter — a delegated ballot is held to the proposal’s own bar”

A delegated ballot is subject to the same verification requirement as a direct one on that proposal, and the requirement depends on the proposal:

  • On a standard proposal, any verified follower’s ballot is applied — verification of any accepted method is enough, exactly as it is for a direct vote.
  • On a critical proposal, a follower’s ballot is applied only if that follower holds a strong verification method (Telegram, World ID or DecideID). A follower verified only with Google is skipped for that proposal; the delegate’s other, strongly-verified followers are applied normally, and the delegate’s own direct vote is unaffected.

The rule this enforces: what a DAO votes on is who may decide, not which endpoint the decision arrives through. Without the filter a Google-verified wallet refused at vote() on a critical proposal would still have had a ballot recorded for it through a delegate — the same voter, the same proposal, a different door.

A skipped follower is skipped with a reason, and the reason is in the event log. Two distinct reasons, because they need different remedies:

ReasonWhat it meansWhat the follower does
follower_unverifiedThe follower holds no verification at allVerify with any method
follower_insufficient_strengthThe follower is verified, but only at Standard, and this proposal is criticalAdd a strong method

Skipping is not unfollowing: the delegation itself is untouched, later proposals are unaffected, and the follower can always still vote directly on the proposal they were skipped on.

Where each is recorded. The filter runs at both stages, through one shared check, so a strength that changes between enqueue and apply cannot slip through and the two stages cannot drift apart. At the apply stage each skipped entry produces its own DelegatedVoteFailed event carrying the follower, the delegate and the reason — one entry per queued delegation. At the enqueue stage the cascade instead emits one summary event per cascade, delegation_cascade_strength_skipped, carrying the proposal id and the number of followers skipped. That is deliberate: a delegate with 500 followers on a critical proposal would otherwise write 500 rows into a bounded event log and evict the history a reader of the log actually needs.

Two details about that summary:

  • It is emitted only when at least one follower was skipped, so a healthy cascade stays silent.
  • It is emitted even when every follower was skipped and no ballot is enqueued at all — that is precisely the case worth seeing, and it is the one an early return would hide.

The count is strength-only, and the filter order is what makes that true. At enqueue the cascade excludes, in this order: the guardian (on a proposal to remove the guardian), then anyone who has already voted directly or is already queued, and only then applies the strength filter. So skipped=<n> counts only followers who would otherwise have been enqueued — a follower who already voted directly is not counted, because they were never going to receive a delegated ballot. The order does not change which followers are admitted; it only makes the count mean exactly “skipped for strength”.

When no verification method is enabled, the filter is inert: the proposal’s requirement resolves to “nothing mandatory”, nothing is dropped for strength, and no summary event is emitted.

Direct vote: visible on the proposal tally immediately.

Delegated vote: visible within one timer tick (~5 minutes by default). For proposals that close quickly, both the proposal voting period and the cascade tick rate are tuned to ensure delegated votes have time to settle.

If a follower casts their own direct vote on a proposal between the moment the cascade enqueues their entry and the moment the timer applies it, the direct vote always wins. The timer detects this via a proposal-vote lookup and drops the cascade entry with reason direct_vote_already_cast.

Verification gate (Universal Participation Gate)

Section titled “Verification gate (Universal Participation Gate)”

Every state-mutating delegation method — follow, set_accepts_followers, and the de-escalation methods unfollow and dismiss_follower (plus vote and create_proposal) — requires the caller to be verified by any accepted method (is_any_method_verified) whenever a verification gate is enabled. The check is enforced both at the inspect_message boundary (cycle saver, rejects unverified callers pre-Candid-decode) and inside each method body (the authoritative check). The de-escalation methods differ only in that, when verification is OFF, they apply no lock-existence anti-Sybil substitute — they only remove state, never add it.

For SONS DAOs the same Universal Participation Gate is governed by the per-DAO requires_verification flag (SONS-side semantics: a bounded-wait ICC to ONS behind a circuit breaker). Each SONS DAO can flip its own gate via a CriticalGovernanceOperation proposal — verification is opt-in per DAO, not enforced ecosystem-wide.

The accepts_followers toggle enforces a strict role separation:

  • A principal with the toggle on cannot call follow(_).
  • A principal currently following someone cannot have their toggle on.

Enabling the toggle while currently following someone auto-removes the follow first. The auto-removal is logged as FollowRemoved with reason auto_on_toggle_accept.

Hotkeys and delegation are mutually exclusive

Section titled “Hotkeys and delegation are mutually exclusive”

A principal is either a registered voting hotkey or a delegation participant — never both. A registered hotkey may not follow(_) and may not enable set_accepts_followers(true), and a principal already in the delegation graph (a follower, a delegate, or a principal that still has followers) cannot be registered as a hotkey. This keeps a hotkey’s votes resolving cleanly to its native and prevents a hotkey principal from ever acquiring a second ballot through the cascade.

The exclusion is enforced atomically at the mutators, not only at a call-site pre-flight:

  • Follower side — inside register_follow, so it covers every writer of FOLLOWS: follow() on both ONS and SONS, and the SONS execute_batch_set_follow proposal executor. The SONS BatchSetFollow Pass-1 validation additionally rejects a registered hotkey as follower or followee before any entry is applied (fail-closed, no partial state).
  • Delegate side — inside set_accepts_followers_flag, closing the window between a delegate’s idempotency check and the flag write.

A rejected follow / accept returns FollowError::PrincipalIsRegisteredHotkey (reason code principal_is_registered_hotkey). verify_follow_invariants carries the same check as a friendly pre-flight.

The cascade is unaffected, and the native (owner) is unrestricted: a native may follow, delegate, and accept followers exactly as before, and a hotkey-cast vote still triggers the native’s delegation cascade (the cascade root is the resolved native). For the full hotkey model — the two-step consent handshake, the group vote cap, guardian/override interaction, and the method inventory — see GOVERNANCE.md (Voting Hotkey).

Every batched VP fetch from the launcher to the locker uses Call::bounded_wait(...).change_timeout(15). If the locker becomes unresponsive, the timeout fires and the call returns an error rather than leaving a callback indefinitely in flight. This protects the launcher’s ability to be stopped and upgraded.

LayerDefence
L1 cycle floorEvery method aborts before doing expensive work if the canister cycle balance is below MIN_CYCLES_FOR_DELEGATION_CASCADE = 500B. Last line of defence; activates after attack starts.
L2 global capSingle-tuple counter (no per-key allocation) with a canister-wide per-minute ceiling on follow and dismiss_follower. A swarm of fresh identities cannot collectively exceed the cap.
L3 per-caller capHeap maps keyed by principal. follow = 5/hr; unfollow = 1/hr + 1-hr cooldown; set_accepts_followers = 3/hr + 5-min cooldown; dismiss_follower = 30/hr. The per-caller maps are bounded by lazy cleanup and a hard cap on the map size.
L4 economic gateImplicit: every method requires verified status, which means the caller paid a verification cost. Bounds Sybil amplification to roughly the daily verification quota.

follow and set_accepts_followers take a trailing opt blob Shield proof of work. When the follow cap or the shared no-gate budget of set_accepts_followers(true) is spent, the refusal is the Shield proof-of-work demand; the page solves the proof automatically and retries, and a caller with a valid proof is checked against its own lane (10/hr per caller, bounded by the canister-wide Shield ceiling) instead of the shared budget. Old clients that do not send the argument are unaffected.

follow() is the only delegation update with an await (the VP fetch). It acquires a per-caller in-memory lock via FollowOpGuard, which releases on Drop — including in cleanup context after a trap. This prevents a single caller from having two follow() calls in flight at once, which could otherwise race on the capacity reservation.

The MAX_FOLLOWERS_PER_FOLLOWEE = 500 cap is enforced atomically inside register_follow (in the canister’s memory layer): a single with(borrow_mut) block reads the counter, checks the cap, increments, and inserts both index entries. Concurrent follow() calls on the same delegate race only at this point, and the race is resolved deterministically by IC’s serial message scheduler — no caller can slip past the cap, no counter drift can develop.

The Following tab on the OnsVotingPage is implemented in src/frontend/src/components/DelegationPanel.jsx. It behaves as follows:

  1. No update calls on render. All four delegation mutators are invoked only from user actions (the toggle, the Follow button, the Unfollow button, the dismiss “X”). Loading the page never calls them.
  2. Two-query fanout for VP display. The Voting Power panel computes Total / Own / Delegated voting power by calling ohshii_governance.get_my_followers(...) (regular query) and then lock_storage.calculate_quadratic_voting_power_batch(...) (regular query) directly from the browser. Two round-trips, both fast, neither composite.
  3. Destructive confirmation on toggle OFF. The “Disable accept- followers” path always opens a modal that lists the current follower count and a preview of follower principals before the call goes through. This protects delegates from accidentally removing their entire follower base.

The cascade depends on a method exposed by lock_storage (part of the OhShii Locker):

calculate_quadratic_voting_power_batch :
(vec principal, principal) -> (vec QuadraticVotingPowerResult) query;
  • Up to 100 principals per call (the launcher chunks larger inputs).
  • Returns one VP result per input principal, same index order.
  • Plain #[query] — not composite.

Deployment dependency. The cascade requires lock_storage to expose calculate_quadratic_voting_power_batch; a lock_storage version without it cannot serve the cascade’s voting-power fetch. Anyone can confirm the method is present by reading lock_storage’s public Candid interface.

Every delegation-related state change is recorded in the existing ohshii_governance event log, queryable through get_recent_events, get_events, and the System Status → Stable Logs tab in the UI.

  • FollowRegistered { follower, followee } — a new follow.
  • FollowRemoved { follower, followee, reason } — a follow ended; reason is one of unfollow, auto_on_toggle_accept, dismissed_by_followee.
  • AcceptsFollowersEnabled { who, auto_unfollowed_target } — toggle turned on; auto_unfollowed_target is Some(_) if the user was following someone at the moment they enabled the toggle.
  • AcceptsFollowersDisabled { who, followers_removed } — toggle turned off; the count of followers cleared inline.
  • DelegatedVoteCast { proposal_id, follower, followee, voting_power, decision } — the timer applied a delegated vote.
  • FollowFailed { caller, target, reason } — any failed follow / unfollow / dismiss attempt, with a closed-set reason code.

  • AcceptsFollowersToggleFailed { who, requested, reason } — toggle rejected (rate limit, unverified, cycle floor).

  • DelegatedVoteFailed { proposal_id, follower, followee, reason } — a queue entry was dropped before applying; reason explains why. These are cascade reasons written to the event log — they are not values any update returns to a caller. The closed set:

    ReasonMeaning
    follower_unverifiedThe follower holds no verification at all, and a gate is active.
    follower_insufficient_strengthThe follower is verified at Standard only, and the proposal is critical.
    proposal_closedThe proposal was no longer open when the timer reached the entry.
    snapshot_too_staleThe frozen voting-power snapshot aged past its bound before it could be applied.
    follow_revokedThe follow was removed, or re-pointed at a different delegate, between enqueue and apply.
    direct_vote_already_castThe follower voted directly in the interim; the direct vote always wins.
    guardian_self_removalThe follower is the guardian and the proposal is to remove the guardian.
    update_proposal_failedThe tally write itself failed; the entry is dropped rather than half-applied.
  • SystemLifecycle { event_type: "delegation_cascade_strength_skipped" } — one summary row per cascade, carrying proposal=<id> skipped=<n>: how many followers the strength filter excluded at enqueue. Emitted only when n > 0, and emitted even when every follower was skipped. See Strength filter.

  • SystemLifecycle { event_type: "delegation_cascade_skipped_low_cycles" } — the canister was below the cycle floor when a cascade tried to fire.

  • SystemLifecycle { event_type: "followers_count_drift_detected" } — the periodic drift detector found a mismatch between the canonical follower counter and the index-based count. It does not fire under normal operation; a row of this type signals an inconsistency.

User-facing errors returned as Err(text) from the four updates. Each value corresponds to a stable closed-set reason code in the audit log.

ReasonWhen it appears
not_verifiedCaller holds no verification from any provider.
self_followCaller principal equals the followee principal.
already_followingCaller already has a registered follow; must unfollow() first.
caller_accepts_followers_on_this_daoCaller has the delegate toggle on; cannot follow others.
principal_is_registered_hotkeyCaller (or followee) is a registered voting hotkey; hotkeys cannot use delegation. Remove the hotkey registration first. See Hotkeys and delegation are mutually exclusive.
target_does_not_accept_followers_on_this_daoFollowee has not enabled the toggle.
target_not_verifiedFollowee is not a verified principal.
followee_at_capacityFollowee already has the maximum 500 followers.
caller_has_no_voting_powerCaller has no eligible OHSHII locks (need ≥ 45-day duration to count).
vp_lookup_failedThe bounded-wait call to lock_storage timed out or returned an undecodable response. Retry.
not_followingunfollow() called by a principal with no active follow.
caller_not_a_delegatedismiss_follower() called by a principal with the toggle off.
not_follower_of_callerdismiss_follower() named a principal that does not currently follow the caller.
rate_limit_follow / rate_limit_unfollow / rate_limit_toggle / rate_limit_dismissPer-caller window or cooldown exceeded; OR the canister-wide global cap was reached (try again in a minute).
cycle_floorCanister is conserving cycles; try again in a few minutes.
reentrant_follow_in_flightSame caller has another follow() request still in flight.

Does delegating change my own ability to vote directly?

Section titled “Does delegating change my own ability to vote directly?”

No. You can always cast a direct vote on any open ONS proposal. If you do, the cascade detects it and drops the corresponding queue entry without applying the delegated vote — your direct vote always wins.

What happens if my voting power changes after I delegate?

Section titled “What happens if my voting power changes after I delegate?”

Delegation snapshots your VP at the moment the delegate votes, not at the moment you set up the delegation. Locks that mature, expire, or are added between two of the delegate’s votes will be reflected correctly the next time the cascade runs.

If your VP drops to zero between snapshots, your delegated Vote row is still written (so your participation count is recorded) but contributes 0 to the tally — exactly as if you had cast a direct zero-VP vote.

No. Each principal can have at most one active delegate at any time. To switch delegates you must unfollow() first, then follow(new_one).

Can a delegate be a follower of someone else?

Section titled “Can a delegate be a follower of someone else?”

No. The bipartite-graph invariant prevents this on ONS. If you want to switch from being a delegate to being a follower (or vice versa), toggle accepts_followers to off first (which removes your existing followers) before calling follow(...).

No. Either party can dissolve the relationship at any time:

  • The follower calls unfollow().
  • The delegate calls dismiss_follower(follower) or disables their accepts_followers toggle.

What happens if my delegate gets compromised?

Section titled “What happens if my delegate gets compromised?”

You can call unfollow() immediately to stop receiving any further delegated votes. Any votes the delegate has already cast cannot be retracted, but future ones will not propagate to your wallet.

How long does it take for a delegated vote to show up?

Section titled “How long does it take for a delegated vote to show up?”

Up to one timer tick (~5 minutes by default) after the delegate casts their vote. The OnsVotingPage shows your delegated votes in your vote history alongside direct votes once the timer has applied them.

No. A delegated vote uses the follower’s own quadratic voting power, applied with the same formula as a direct vote. The delegate’s voting power is unaffected — they vote with their own VP, and each follower votes with theirs.

Does my LGE tier (Fish / Shark / Whale) include delegated VP?

Section titled “Does my LGE tier (Fish / Shark / Whale) include delegated VP?”

No. The Voter Benefits Protocol tier ladder gates LGE purchase limits on each principal’s personal stake in OHSHII locks only. Liquid Democracy delegates votes, not tier privileges — so a Whale who accepts a hundred followers does NOT share their Whale-tier purchase limit with those followers, and a Guest who follows a Whale does NOT inherit any of the Whale’s tier benefits.

The get_lge_eligibility(principal) endpoint that powers the tier badge in the UI and the purchase-limit check in the launcher backend queries lock_storage.calculate_quadratic_voting_power(principal, OHSHII_LEDGER) for that single principal — never the cascade-aggregated total displayed in the Voting Power panel. The “Total VP / Own VP / Delegated to You” headline is purely informational and never feeds into a tier lookup.

Yes. Open the Voting Power expandable section on the OnsVotingPage — when your accepts_followers toggle is on, the “Show N Followers” button reveals the list, including each follower’s current voting power. From this list you can also dismiss any single follower.

Does the canister charge me cycles for delegating?

Section titled “Does the canister charge me cycles for delegating?”

No. Cycle costs are paid by the ohshii_governance canister, funded through the protocol treasury. The four-layer rate-limit model is the defence against cycle-drain attacks; it caps the total work the canister can be asked to do per minute.

How does this interact with a provider’s intake caps?

Section titled “How does this interact with a provider’s intake caps?”

Delegation respects the same verification gate as direct voting. If your verification has been cleared (for instance, after a governance-authorized verification reset), follow() will reject you with not_verified until you re-verify. Existing follows are not auto-revoked when verification is cleared; the cascade’s apply step will simply drop your queue entries with follower_unverified until you re-verify.

My delegate voted, but nothing was recorded for me. Why?

Section titled “My delegate voted, but nothing was recorded for me. Why?”

If it happened only on one proposal and that proposal was critical, the likeliest reason is verification strength: a delegated ballot on a critical proposal is applied only for followers holding a strong method, and a wallet verified only with Google is skipped with follower_insufficient_strength. Your delegation is untouched, later standard proposals are unaffected, and you can still vote on that proposal directly. Adding a strong method clears it. See Strength filter.

Other reasons the apply step drops an entry — you voted directly in the interim, the follow was removed, the proposal closed, the snapshot aged out — are listed with their log reasons under Failure events.