Governance API Reference
Version: 3.0 Date: 2026-08-25 Canister: governance Candid Interface: governance.didREST wrapper: oracle-bridge /api/governance/proposals/* (see PLATFORM-004.3 REST Surface)
bl-1404 reconciliation (2026-08-25): this page is the single canonical governance API reference. It was merged from the former
governance-api.md(comprehensive guide) andgovernance.md(Candid quick-ref) and every method name, argument, and return type below has been arbitrated against the livegovernance.did. Where the two prior pages disagreed, the.didwon.
Overview
The governance canister provides a decentralized proposal system with time-windowed voting and ICRC-7 vote-pass NFT mechanics for the Hello World DAO ecosystem. It enforces 1-member-1-vote (1M1V) — each member casts exactly one vote per proposal, backed by a per-vote Soul-Bound vote-pass NFT.
Key Features
- Proposal Management:
create_proposalopens a proposal with a customizable voting window (24–168 hours);update_proposal+get_proposal_versionsgive proposers an append-only draft-revision history (PLATFORM-004.1). - Vote-Pass NFT System: casting a vote mints a per-proposal ICRC-7 vote-pass NFT to the voter; all passes for a proposal are burned at finalization.
- 1-Member-1-Vote Enforcement: each vote increments the tally by exactly 1, independent of any token holdings.
- Four-Choice Voting:
Yes,No,Abstain, andNeedsRefinement(counts toward quorum but not approval — Story FOS-4.1.1). - Proposal Categories with Thresholds:
Constitutional,Personnel,Operational,Treasury,SoftwareDevelopment,Immutable,Tier1Constitutional— each with configurable default quorum/approval percentages. - Three-Gate Immutable / Tier1Constitutional Proposals: Article XIV / OA §11.2 changes require a 90% supermajority + 180-day window + CLT Board ratification.
- CLT Board Management:
set_clt_board,ratify_proposal,get_clt_board,get_ratification_status,attest_clt_ratification. - Execution Orchestration (ADR-034 / arch-004-3a.1): approved Treasury-payout / Personnel-status proposals dispatch an orchestrated effect and enter
Executing; the off-chain governance-service reports the terminal outcome viareport_execution_result, with an on-chain immutable audit trail (get_execution_intents/get_execution_results). - §14.5 Amended-Proposal Appeal Path (BL-450, partial build):
submit_amended_proposal/mark_proposal_invalid/get_invalidationfile and disposition appeals against already-tallied proposals. - FOS Document Publish Flow (PLATFORM-004.3):
POST /api/governance/proposals/from-documentandPUT /api/governance/proposals/:id/from-documenton oracle-bridge wrapcreate_proposal/update_proposalbehind a FounderyOS-facing REST surface.
Proposal Lifecycle
stateDiagram-v2
[*] --> Draft: create_proposal()
Draft --> Active: voting window opens (voting_start_at)
Active --> Active: cast_vote() — mints a vote-pass NFT per voter
Active --> Approved: finalize() — majority yes + quorum
Active --> Rejected: finalize() — majority no / no quorum
Approved --> Executed: execute_proposal() — self-contained category
Approved --> Executing: execute_proposal() — orchestrated (Treasury Payout / Personnel StatusChange)
Executing --> Executed: report_execution_result(Ok)
Executing --> Failed: report_execution_result(Err)
Approved --> Invalid: mark_proposal_invalid() — §14.5 appeal path
Executed --> [*]
Rejected --> [*]
Failed --> [*]
note right of Active
Each voter mints one Soul-Bound
vote-pass NFT on cast_vote;
all are burned at finalize
end noteAuthentication
Governance is a CQRS command target (ADR-014). Member-initiated writes do not arrive from the member's own principal — they are forwarded by the oracle-bridge service principal, which authenticates the member's session and passes the resolved member identity as an explicit principal argument (the voter_principal / caller parameter on create_proposal, cast_vote, update_proposal, etc.). The canister verifies:
- oracle-bridge caller gate (AUTH-003.6) — mutating proposal/vote methods require the configured oracle-bridge principal as
ic_cdk::caller(). - membership gate — the forwarded member principal must be an active member (checked against the membership canister).
principals.is_authorizedgate (Layer 2, defense-in-depth) — privileged surfaces (finalize,execute_proposal,report_execution_result,mark_proposal_invalid,clear_test_proposals) additionally check a permission string on the principals canister.- controller-only — configuration/threshold/CLT-board setters.
Voting eligibility (the 30-day post-activation cooldown) is owned by the membership canister's
voting_eligibility_status(BL-414/BL-428). Do not query governance for eligibility; the governance-side shims (set_member_sbt_issuance,check_voting_activation) are deprecated no-ops kept for one upgrade cycle.
Data Types
ProposalCategory
type ProposalCategory = variant {
Constitutional; // Bylaws, membership rules
Personnel; // Hiring, roles, compensation (OA Article XIII)
Operational; // Day-to-day management
Treasury; // Spending, allocations
SoftwareDevelopment; // Features, infrastructure
Immutable; // Article XIV protected provisions (90% + 180 days + CLT ratification)
Tier1Constitutional; // Foundational amendments (90% / 180d / CLT) (OA Section 11.2)
};ProposalStatus
type ProposalStatus = variant {
Draft; // In review period (24 hours before voting)
Active; // Voting period is active
Approved; // Passed (majority yes)
Rejected; // Failed (majority no or no quorum)
Executing; // Orchestrated effect (Treasury Payout / Personnel StatusChange) in flight (ADR-034)
Executed; // Successfully executed
Failed; // Execution failed
Invalid; // Retroactively invalidated via the §14.5 amended-proposal path (BL-450)
};VoteChoice
type VoteChoice = variant {
Yes;
No;
Abstain;
NeedsRefinement; // Counts toward quorum but NOT approval (Story FOS-4.1.1)
};Proposal
type Proposal = record {
id : nat64;
category : ProposalCategory;
title : text;
description : text;
proposer : principal;
org_id : opt nat64; // None = HWDAO-wide; Some(id) = Org-scoped (org-002-1)
created_at : nat64; // Nanoseconds
voting_start_at : nat64; // Nanoseconds
voting_end_at : nat64; // Nanoseconds
status : ProposalStatus;
yes_votes : nat64;
no_votes : nat64;
abstain_votes : nat64;
needs_refinement_votes : nat64; // Count of NeedsRefinement votes (Story FOS-4.1.1)
quorum_threshold : nat64; // Minimum total votes required (Story 4-3)
approval_threshold : nat8; // Percentage of yes votes required (0-100)
execution_data : opt blob; // Category-specific execution data (max 100KB)
deliberation_end_at : opt nat64; // End of deliberation for Tier1Constitutional (BL-086)
clt_ratified : opt bool; // CLT Board ratification flag (BL-086)
version_number : opt nat32; // Current content version number (PLATFORM-004.1)
versions : opt vec ProposalVersion; // Append-only version history (PLATFORM-004.1)
is_test_data : opt bool; // Staging E2E regression marker (tcu-004-1.1)
};CreateProposalArgs
type CreateProposalArgs = record {
category : ProposalCategory;
title : text;
description : text;
voting_period_hours : nat64; // 24-168 hours
execution_data : opt blob; // Category-specific execution data (max 100KB)
org_id : opt nat64; // Optional Official Org scope (org-002-2)
is_test_data : opt bool; // Staging-only test marker; omit in production
};Vote
type Vote = record {
proposal_id : nat64;
voter : principal;
choice : VoteChoice;
voted_at : nat64; // Nanoseconds
vote_pass_nft_id : opt nat64; // ICRC-7 NFT token ID
refinement_feedback : opt text; // Feedback for NeedsRefinement votes (max 500 chars)
};VoteBreakdown
Aggregated counts + threshold state, readable at any time before finalization (returned by get_vote_breakdown).
type VoteBreakdown = record {
yes_votes : nat64;
no_votes : nat64;
abstain_votes : nat64;
needs_refinement_votes : nat64;
total_votes : nat64;
quorum_threshold : nat64;
approval_threshold : nat8; // 0..=100
approval_percentage : nat8; // 0..=100, floor((yes*100)/total); 0 when total=0
quorum_met : bool;
approval_met : bool;
};RatificationStatus
Returned by get_ratification_status (a record, not a variant).
type RatificationStatus = record {
ratified : vec RatificationRecord; // { ratifier : principal; ratified_at : nat64 }
pending : vec principal;
fully_ratified : bool;
};ProposalVersion
type ProposalVersion = record {
version_number : nat32; // 1-indexed — version this entry captures
content : text; // description as it existed at this version
updated_at : nat64; // nanoseconds — when this version was superseded
updated_by : principal; // member principal that performed the update
};Vote-Pass NFT (ICRC-7, Soul-Bound)
Vote-pass NFTs follow ICRC-7. They are non-transferable (icrc7_transfer always returns NonTransferable), single-use (one per member per proposal, minted on cast_vote), and burned at finalization (recorded in the burn-history audit trail).
type TransferError = variant {
NonTransferable; // Vote-pass NFTs cannot be transferred
NonExistingTokenId;
Unauthorized;
GenericError : record { error_code : nat64; message : text };
};Proposal Management Methods
create_proposal
Create a new proposal (requires active membership). This is the live proposal-creation path — the legacy open_proposal entrypoint was removed (bl-1330).
create_proposal : (CreateProposalArgs, principal) -> (variant { Ok : nat64; Err : text });- Args:
(CreateProposalArgs, voter_principal)— the second argument is the forwarded member identity used for the membership check and proposer attribution (AUTH-003.6 forwarding model). - Returns:
Ok(proposal_id)on success. - Validation errors (returned as
Errtext):Title cannot be empty·Title too long (max 200 characters)·Description cannot be empty·description exceeds maximum length of 10000 chars.
TypeScript (via generated IDL):
const result = await governanceActor.create_proposal(
{
category: { Treasury: null },
title: 'Allocate funds for community event',
description: 'Proposal to allocate 1000 DOM for Q1 community meetup…',
voting_period_hours: 72n,
execution_data: [],
org_id: [],
is_test_data: [],
},
voterPrincipal,
);
if ('Ok' in result) {
console.log('Created proposal ID:', result.Ok);
}In practice member clients do not call the canister directly — they POST to the oracle-bridge REST surface, which forwards the resolved
voter_principal. See PLATFORM-004.3.
get_proposal (query)
get_proposal : (nat64) -> (opt Proposal) query;list_proposals (query)
list_proposals : (opt ProposalFilter, opt Pagination) -> (vec Proposal) query;
type ProposalFilter = record {
status : opt ProposalStatus;
category : opt ProposalCategory;
proposer : opt principal;
org_scope : opt OrgScope; // All | HelloWorld | Org : nat64 (org-002-3)
};
type Pagination = record { offset : nat64; limit : nat64 };update_proposal
Replace the description of a proposal still in Draft status (PLATFORM-004.1). See Draft Proposal Version History for the full contract.
update_proposal : (nat64, text, opt principal) -> (variant { Ok : nat32; Err : text });Returns the post-increment version number (nat32) — the first successful update returns 2.
get_proposal_versions (query)
get_proposal_versions : (nat64) -> (vec ProposalVersion) query;Oldest-first history of previous descriptions. The current description lives on the Proposal itself and is not included.
Voting Methods
cast_vote
Cast a vote on an active proposal (requires active membership). 1M1V: each vote increments the tally by exactly 1 and mints one Soul-Bound vote-pass NFT for the voter.
cast_vote : (nat64, VoteChoice, opt text, principal) -> (variant { Ok; Err : text });- Args:
(proposal_id, choice, opt feedback, voter_principal).opt feedback— optional refinement feedback (max 500 chars), used withNeedsRefinement.voter_principal— forwarded member identity (AUTH-003.6), used for the membership check and 1M1V vote attribution.
- Returns:
Okon success;Errif not a member, already voted, or voting not active.
Requirements:
- Caller (forwarded principal) must be an active member.
- Proposal must be in
Activestatus. - Cannot vote twice on the same proposal.
Side effect: mints a vote-pass NFT for the voter (burned at finalization).
Legacy: the
.didstill exposesvote : (nat64, VoteChoice) -> ()— a simplified, void-returning alias retained for compatibility. New integrations MUST usecast_vote, which forwards the member identity and returns aResult.
has_voted (query)
has_voted : (nat64, principal) -> (bool) query;get_vote (query)
get_vote : (nat64, principal) -> (opt Vote) query;get_vote_breakdown (query)
Aggregated counts + threshold state at any time (pre-finalization).
get_vote_breakdown : (nat64) -> (opt VoteBreakdown) query;get_refinement_feedback (query)
All NeedsRefinement feedback entries for a proposal. The voter principal is redacted (secret-ballot direction, bl-1173-2) — feedback stays community-visible but is not linked back to a member's vote choice.
get_refinement_feedback : (nat64) -> (vec RefinementFeedback) query;
type RefinementFeedback = record { feedback : text; voted_at : nat64 };Proposal Finalization & Execution
finalize
Finalize a proposal after its voting period ends. Tallies votes, sets Approved/Rejected, and burns all vote-pass NFTs for the proposal.
finalize : (nat64) -> (nat64, nat64, nat64, nat64);- Returns:
(yes_votes, no_votes, abstain_votes, needs_refinement_votes)— a 4-tuple (theneeds_refinement_votescount was added in Story FOS-4.1.1). - Authorization (bl-1333): Layer-1 anonymous-reject + Layer-2
principals.is_authorized(caller, "governance:proposal:finalize"). Holders:dao-officer(manual) andgovernance-service(auto-finalize orchestrator). Denial traps; the tuple shape is unchanged.
TypeScript:
const [yes, no, abstain, needsRefinement] = await governanceActor.finalize(proposalId);
const total = yes + no + abstain + needsRefinement;execute_proposal
Execute an approved proposal. Self-contained categories transition Approved → Executed (or Failed); orchestrated categories (Treasury Payout / Personnel StatusChange) transition Approved → Executing and emit an ExecutionIntentEvent — the terminal outcome is then set only by report_execution_result.
execute_proposal : (nat64) -> (variant { Ok; Err : text });- Errors:
Proposal not approved(Rejected/Draft/Active) ·Proposal already executed(Executed/Failed) ·Proposal execution already in progress(Executing). - Authorization (arch-004-3a.1): Layer-1 anonymous-reject + Layer-2
principals.is_authorized(caller, "governance:proposal:execute"). This is the manual execute/retry path; automatic dispatch reaches execution through an internal Rust call fromfinalize, not this method. Who may re-drive execution manually is an open owner decision (bl-1331).
report_execution_result
The off-chain governance-service reports an orchestrated effect's outcome, transitioning Executing → Executed (Ok) / Failed (Err) and appending an immutable ExecutionResultEvent. Matching replay is an idempotent no-op; conflicting replay is rejected.
report_execution_result : (nat64, variant { Ok : ExecutionEvidence; Err : text }) -> (variant { Ok; Err : text });
type ExecutionEvidence = record { summary : text; external_ref : opt text };Authorization: Layer-1 anonymous-reject + Layer-2 principals.is_authorized(caller, "governance:execution:report") — mandatory and fail-closed.
get_execution_intents / get_execution_results (query)
On-chain, immutable audit trail of orchestrated execution.
get_execution_intents : (nat64) -> (vec ExecutionIntentEvent) query;
get_execution_results : (nat64) -> (vec ExecutionResultEvent) query;ICRC-7 Vote-Pass NFT Methods
Vote-pass NFTs are Soul-Bound Tokens minted on cast_vote and burned at finalization. These methods are implemented and live.
icrc7_name : () -> (text) query; // e.g. "Hello World DAO Vote Pass"
icrc7_symbol : () -> (text) query; // e.g. "HWDVP"
icrc7_total_supply : () -> (nat64) query; // active (not-yet-burned) vote passes
icrc7_balance_of : (principal) -> (nat64) query;
icrc7_owner_of : (nat64) -> (opt principal) query;
icrc7_token_metadata : (nat64) -> (opt vec record { text; Value }) query;
icrc7_tokens : (opt nat64, opt nat64) -> (vec nat64) query; // (prev, take) pagination
icrc7_tokens_of : (principal) -> (vec nat64) query;
icrc7_transfer : (principal, principal, nat64, opt blob, opt nat64)
-> (variant { Ok : nat64; Err : TransferError }); // ALWAYS returns NonTransferablemint_vote_passes (legacy)
mint_vote_passes : (nat64) -> ();Legacy pre-mint entrypoint. In the current model a vote-pass is minted per voter on cast_vote, so pre-minting to all members is no longer part of the standard flow.
Audit Trail
get_burn_history / get_all_burn_history (query)
get_burn_history : (nat64) -> (vec BurnRecord) query;
get_all_burn_history : (opt nat64, opt nat64) -> (vec BurnRecord) query; // (offset, limit)
type BurnRecord = record {
token_id : nat64;
proposal_id : nat64;
voter : principal;
burned_at : nat64;
};get_voter_stats (query)
get_voter_stats : (principal) -> (VoterStats) query;
type VoterStats = record { proposals_created : nat64; votes_cast : nat64 };Category Statistics
get_proposal_count_by_category : () -> (vec record { ProposalCategory; nat64 }) query;
get_active_proposals_by_category : (ProposalCategory) -> (vec Proposal) query;DOM Price Feed (BL-076.1)
Governance-set DOM/USD price, set by a passed price proposal.
get_current_dom_price : () -> (opt DomPriceRecord) query; // None if never set
get_dom_price_history : (opt nat64) -> (vec DomPriceRecord) query; // newest first, default 10, max 50
type DomPriceRecord = record { usd_price : float64; set_at : nat64; proposal_id : nat64 };CLT Board & Ratification (Immutable / Tier1Constitutional)
The Immutable and Tier1Constitutional categories enforce three gates before an outcome takes effect: a 90% supermajority, a 180-day voting window, and CLT Board ratification.
set_clt_board
Replace the CLT Board member list (controller-only; full replacement, not additive).
set_clt_board : (vec principal) -> (variant { Ok; Err : text });ratify_proposal
CLT Board ratification of a passed proposal (CLT Board members only).
ratify_proposal : (nat64) -> (variant { Ok : text; Err : text });get_clt_board (query)
get_clt_board : () -> (vec principal) query;get_ratification_status (query)
Returns the RatificationStatus record (ratified list, pending list, fully_ratified flag).
get_ratification_status : (nat64) -> (RatificationStatus) query;attest_clt_ratification
Attest CLT Board ratification for a Tier1Constitutional proposal (controller-only).
attest_clt_ratification : (nat64) -> (variant { Ok; Err : text });§14.5 Amended-Proposal Appeal Path (BL-450 — partial build)
File and disposition appeals against already-tallied proposals. Partial build: only the two coercion grounds (Duress, AccountTakeover) are live invalidation-eligible; the integrity grounds are defined but Tier-B counsel-gated, and MaterialMisrepresentation is intake-only (routes to §14.8, never invalidates).
type FindingType = variant {
Duress; AccountTakeover; // LIVE invalidation-eligible
EvidenceMisrepresentation; ProcessIrregularity; // DEFINED, counsel-gated (not live)
MaterialMisrepresentation; // Intake-only
};
submit_amended_proposal : (nat64, FindingType, text, principal) -> (variant { Ok : nat64; Err : text });
list_amended_proposals : (nat64) -> (vec AmendedProposal) query;
get_amended_proposal : (nat64) -> (opt AmendedProposal) query;
get_invalidation : (nat64) -> (opt InvalidationRecord) query; // Some ⇔ target is Invalid
mark_proposal_invalid : (nat64, nat64, FindingType) -> (variant { Ok; Err : text });Live gating note:
submit_amended_proposalandmark_proposal_invalidare fail-closed and no role currently holds the required permissions, so they refuse every caller today. This is the designed pre-rollout state, not an outage — the permissions are granted by the story that ships the member-facing appeal flow. Filing does not invalidate the target; disposition is a separatemark_proposal_invalidcall.
Operational Decision Logging (BL-086, OA §4A.6)
log_operational_decision : (LogOperationalDecisionArgs) -> (variant { Ok : nat64; Err : text });
get_operational_logs : (opt nat64, opt nat64) -> (vec OperationalLog) query;
get_operational_log : (nat64) -> (opt OperationalLog) query;
type LogOperationalDecisionArgs = record {
decision : text;
resources_committed : opt text;
rsa_section : opt text;
};Threshold Configuration (Controller Only)
Adjust default quorum/approval percentages per category. Only affects future proposals; existing proposals retain their original thresholds.
set_default_quorum_percentage : (ProposalCategory, nat8) -> (variant { Ok; Err : text });
set_default_approval_percentage : (ProposalCategory, nat8) -> (variant { Ok; Err : text });Health & State
health : () -> (text) query; // returns "ok"
export_state : () -> (blob) query; // public — proposals/votes are public
import_state : (blob) -> (variant { Ok; Err : text }); // controller-only restoreError Messages
| Error | Cause | Resolution |
|---|---|---|
Not a member / Not an active member | Forwarded caller is not an active member | Complete signup / reactivate |
Already voted | Duplicate vote attempt | Cannot vote twice |
Voting not active | Proposal not in Active status | Wait for the voting window |
Proposal not found | Invalid proposal ID | Check the proposal exists |
Proposal not approved | Executing a non-approved proposal | Proposal must pass first |
Title cannot be empty / Title too long (max 200 characters) | Invalid title on create | Fix title |
Description cannot be empty / description exceeds maximum length of 10000 chars | Invalid description on create/update | Fix description |
only the original proposer can update this proposal | Forwarded caller ≠ proposal.proposer | Only the proposer may update |
proposal not in Draft status | Update after the draft window (time-aware) | Draft edits close when voting starts |
PLATFORM-004.1 — Draft Proposal Version History
Draft proposals carry an append-only edit history so proposers can refine content during the review window without losing previous revisions. The current content lives in description; previous revisions accumulate in versions as ProposalVersion entries.
Invariant: after N successful update_proposal calls, proposal.version_number == N + 1 and proposal.versions.len() == N. Entry versions[i] contains the content that was current at version_number = i + 1, which description has since replaced.
update_proposal — full contract
Signature: update_proposal : (nat64, text, opt principal) -> (variant { Ok : nat32; Err : text });
Parameters:
proposal_id: nat64— target proposal.new_content: text— replacement fordescription. Empty strings and content over 10,000 characters reject at the canister boundary.opt caller: opt principal— under the oracle-bridge forwarding model (AUTH-003.6), this is the forwardedvoter_principal, NOTic_cdk::caller(). MUST equalproposal.proposer.
Returns: Ok(new_version_number) — the post-increment version, so the first successful update returns 2.
Err message | Cause |
|---|---|
proposal not found | Unknown proposal_id |
only the original proposer can update this proposal | Forwarded caller does not match proposal.proposer |
proposal not in Draft status | Effective status is not Draft (time-aware — a Draft that silently rolled into Active cannot be updated) |
Description cannot be empty | new_content is the empty string |
description exceeds maximum length of 10000 chars | Content too long (BL-190 cap) |
proposal has reached maximum revision count of 100 — cannot update further | versions.len() >= MAX_VERSIONS_PER_PROPOSAL (BL-190) |
Effective-Status Gate (important)
update_proposal checks effective status rather than the stored ProposalStatus. A proposal whose stored status is still Draft but whose voting_start_at has elapsed is treated as Active — the canister rejects the update with proposal not in Draft status rather than silently editing a proposal that voters might already be considering.
PLATFORM-004.3 — FOS Document → Proposal REST Surface
Two REST endpoints on oracle-bridge wrap the canister's create_proposal and update_proposal methods with the FounderyOS publish-flow wire contract. The FOS editor never calls the canister or talks to oracle-bridge directly — the FOS backend converts editor documents to the 10,000-char-capped Markdown the canister accepts, then forwards to oracle-bridge with the user's session cookie.
Gateway path (via PLATFORM-006 API gateway): /oracle/api/governance/proposals/from-document (prefix stripped at the edge). Direct access: https://staging-oracle.helloworlddao.com/api/governance/proposals/from-document.
Authentication
- Browser → FounderyOS: cross-domain session established by identity-service PKCE SSO —
POST /cross-domain/authorize+POST /token/exchange(ADR-024 / ADR-025 / ADR-026). No extra token required. ⛔ The former PLATFORM-003 endpoint/api/v1/auth/cross-domain-loginwas deleted 2026-06-25 (oracle-bridge478b50a); see the retired cross-domain-auth reference. (bl-1286) - FOS backend → oracle-bridge: the FOS request's session cookie is forwarded verbatim. oracle-bridge's
requireSessionAuthmiddleware resolves the voter principal viaresolveVoterPrincipal(session). - oracle-bridge → governance canister: forwards the resolved principal as the
voter_principalargument (AUTH-003.6 forwarding model). The canister rejects ifcaller != proposal.proposeron updates.
POST /api/governance/proposals/from-document — Create
Create a new proposal from a converted Markdown body. Rate limit: canisterRateLimit (ic-query budget).
Request body (zod-validated on oracle-bridge):
{
category: "Operational" | "Treasury" | "Constitutional"
| "Personnel" | "SoftwareDevelopment" | "Immutable" | "Tier1Constitutional";
title: string; // 1..=200 chars
content: string; // 1..=10,000 chars (the converted Markdown)
content_format?: "text"; // reserved for future rich-text
votingPeriodHours: number; // 24..=180*24 (180-day window allowed for Immutable)
sector_tags?: Array< // REQUIRED when category == "SoftwareDevelopment"
"housing" | "food_water" | "education" | "economy"
| "healthcare" | "environment" | "energy"
>;
}Response (201):
{
"success": true,
"proposalId": 42,
"versionNumber": 1,
"governanceUrl": "https://staging-governance.helloworlddao.com/proposals/42"
}Errors (oracle-bridge surface):
| Status | error | Cause |
|---|---|---|
400 | <raw canister Err> | Canister-side validation failure (unknown categories, invalid sector tags, voting period out of range, etc.) |
400 | Document has no content after conversion | Converter produced an empty body |
401 | Unauthorized | Session cookie absent / expired |
403 | NOT_ELIGIBLE | Any governance eligibility rejection — the precise cause is carried in the additive reason field (see Eligibility rejections) |
403 | NOT_MEMBER | oracle-bridge pre-gate rejection before the canister is called |
500 | Internal Server Error | Uncaught error; creation is idempotent at the proposal layer |
502 | IC-transport errors | Classified via classifyIcError |
PUT /api/governance/proposals/:id/from-document — Update
Replace an existing Draft proposal's content with a freshly converted Markdown body. Rate limit: canisterRateLimit.
{
title?: string; // 1..=200 — accepted for forward-compat, NOT forwarded to canister today
content: string; // 1..=10,000 — becomes the new description
content_format?: "text";
}The title is deliberately not forwarded to update_proposal today — the canister only versions description. Accepting the field keeps the wire contract stable for when title-update lands in a future sub-story.
Response (200): same { success, proposalId, versionNumber, governanceUrl } shape, with the incremented versionNumber.
Errors (mapped from canister Err strings via mapGovernanceUpdateErr):
| Status | error | Canister Err substring | Meaning |
|---|---|---|---|
400 | VALIDATION_ERROR | description cannot be empty, exceeds maximum length | Content empty or > 10,000 chars |
400 | <raw> | other | Unknown canister domain error (surfaces raw) |
403 | NOT_AUTHOR | only the original proposer | Caller does not match proposal.proposer |
403 | NOT_ELIGIBLE | every VotingEligibility sentence — see Eligibility rejections | Caller failed the canister's eligibility gate; reason names which arm |
404 | NOT_FOUND | proposal not found | Unknown proposal id |
409 | NOT_IN_DRAFT | proposal not in Draft status | Proposal is Active / Approved / Rejected / Executed / Failed (time-aware) |
409 | REVISION_CAP | maximum revision count | 100-revision cap reached |
500 | Internal Server Error | — | Uncaught error |
502 | IC-transport errors | — | classifyIcError classification |
Eligibility rejections (NOT_ELIGIBLE)
Since bl-1171, every governance eligibility rejection on the oracle-bridge REST surface returns 403 with a machine-readable reason alongside member-safe copy. The governance canister returns Result<_, String>, so oracle-bridge classifies the sentence in one shared module (src/utils/vote-eligibility-error.ts) rather than in each route.
Applies to all six eligibility-bearing routes: POST /api/governance/proposals, POST /api/governance/proposals/:id/vote, PATCH /api/governance/proposals/:id, POST /api/governance/proposals/from-document, PUT /api/governance/proposals/:id/from-document, and the service-token path POST /api/governance/vote-cast/:id.
Response body:
{
"success": false,
"error": "NOT_ELIGIBLE",
"reason": "VOTING_COOLDOWN",
"message": "Your membership is still in its post-activation waiting period. You can vote in 5 days.",
"eligibleAt": "2026-07-28T20:42:56.827Z",
"eligibleInSeconds": 432000
}error remains NOT_ELIGIBLE — unchanged, because governance-suite and dao-suite both branch on it. Everything else is additive.
| Field | Presence | Notes |
|---|---|---|
error | always | Always the literal NOT_ELIGIBLE. Branch on this plus reason, never on message. |
reason | always (classified rejections) | Stable machine code — see the table below. Absent on the residual membership catch-all. |
message | always | Member-safe sentence. Contains no principal, no raw timestamp, no internal column name. Safe to render verbatim. |
eligibleAt | VOTING_COOLDOWN only | ISO-8601 UTC instant the member becomes eligible. Omitted when it exceeds 366 days or could not be decoded. |
eligibleInSeconds | VOTING_COOLDOWN only | Whole seconds remaining, rounded up, clamped to 0. Present exactly when eligibleAt is. |
reason values, one per VotingEligibility arm the canister can return:
reason | Canister Err |
|---|---|
VOTING_COOLDOWN | Voting cooldown: eligible after {ns} (ns since epoch) |
NOT_ACTIVE_MEMBER | Not an active member |
MEMBERSHIP_EXPIRED | Your membership has expired |
MEMBERSHIP_REVOKED | Your membership has been revoked |
MEMBERSHIP_INACTIVE | Your membership is inactive — reactivate it to vote |
MEMBERSHIP_RECORD_INCOMPLETE | Membership state error: activated_at missing — contact support |
NO_MEMBERSHIP | No membership found for principal |
ORG_MEMBERSHIP_REQUIRED | Not a member of org {id} |
The raw eligible_at nanosecond integer is logged server-side and appears in no response body on any route.
Note on stability. These
reasoncodes are derived by matching canister prose, which is inherently fragile — a Rust-side reword silently breaks the mapping. Each arm is pinned by a literal fixture in oracle-bridge's test suite so a reword fails CI by name. The durable fix is a typed Candid error variant, tracked asbl-1171-1.
Related Documentation
- ICRC-7 Specification: https://github.com/dfinity/ICRC/blob/main/ICRCs/ICRC-7/ICRC-7.md
- 1M1V Governance Design: 1-member-1-vote-1m1v-governance-model.md
- Cross-Domain Auth (retired): cross-domain-auth.md
- Governance README: governance README.md
- Inter-Canister Architecture: inter-canister-communication.md
Changelog
Version 3.0 (2026-08-25) — bl-1404
- Merged the former
governance-api.md(comprehensive) andgovernance.md(Candid quick-ref) into this single canonical page, arbitrated method-by-method against the livegovernance.did. - Corrected stale vocabulary: removed the retired
open_proposal : (text, text, nat64, nat64) -> (nat64)entrypoint (removed on-chain by bl-1330) in favour ofcreate_proposal : (CreateProposalArgs, principal) -> (variant { Ok : nat64; Err : text }); documentedcast_vote(with theopt feedback+ forwardedvoter_principalargs andResultreturn) as the primary voting method, with the void-returningvotenoted as a legacy alias. - Corrected signatures/types against the
.did:finalizereturns a 4-tuple (addsneeds_refinement_votes);VoteChoiceincludesNeedsRefinement; the fullProposalCategory(7 variants),ProposalStatus(8 variants),Proposal,CreateProposalArgs,Vote,VoteBreakdown, andRatificationStatus(a record, previously mis-documented as a variant) now match live. - Removed the v1.0 "current implementation is stubbed / not enforced" framing and the speculative "future implementation" pseudo-code that no longer reflected the shipped canister; the ICRC-7 vote-pass methods are documented as live (they were previously listed as "to be implemented").
- Preserved the accurate advanced sections: PLATFORM-004.1 draft version history, PLATFORM-004.3 FOS document → proposal REST surface, and the bl-1171
NOT_ELIGIBLEerror contract. Added the execution-orchestration (ADR-034) and §14.5 amended-proposal surfaces from the live.did.
Version 2.2 (2026-07-29)
- bl-1171: documented the
NOT_ELIGIBLEerror contract; oracle-bridge classifies everyVotingEligibilityarm into a stablereasoncode plus member-safemessage, across all six eligibility-bearing routes.
Version 2.1 (2026-04-20)
- PLATFORM-004.1: draft-only version history —
ProposalVersion,update_proposal,get_proposal_versions. - PLATFORM-004.3: FOS document → proposal REST surface on oracle-bridge.
Version 2.0 (2026-04-06)
- Added the Immutable proposal category and CLT Board ratification methods.
Version 1.0 (2025-11-15)
- Initial API documentation.