Governance
Made Open's governance layer lets groups of users self-organize into DAOs — lightweight collectives with members, proposals, weighted votes, a time-credit treasury, moderation, and dispute resolution. It is the coordination substrate used by the marketplace for arbitration and by communities for collective decisions.
The implementation lives in three service directories and one migration:
| Component | Location |
|---|---|
| DAOs / Proposals / Votes | apps/hub/src/services/governance/ |
| Time-credit ledger | apps/hub/src/services/time-bank/TimeBankService.ts |
| Dispute lifecycle | apps/hub/src/services/dispute/DisputeService.ts |
| Moderation log | apps/hub/src/services/moderation/ModerationService.ts |
| Schema | supabase/migrations/00006_phase6_governance.sql |
| REST API | apps/hub/src/api/routes/governance.ts, disputes.ts, timebank.ts |
Data Model
All tables below are defined in supabase/migrations/00006_phase6_governance.sql. Every table has RLS enabled.
daos
CREATE TABLE daos (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES auth.users(id),
name text NOT NULL,
description text,
slug text NOT NULL UNIQUE,
dao_type text NOT NULL DEFAULT 'community'
CHECK (dao_type IN ('community','cooperative','collective','trust')),
quorum_pct numeric(5,2) NOT NULL DEFAULT 50.0,
pass_threshold numeric(5,2) NOT NULL DEFAULT 50.0,
voting_period_h integer NOT NULL DEFAULT 168,
is_public boolean DEFAULT true,
treasury_credits numeric(12,2) DEFAULT 0,
federated_ap_id text,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
quorum_pct, pass_threshold and voting_period_h are the three knobs that define how voting behaves for every proposal in the DAO. treasury_credits is a numeric balance on the DAO row itself — there is no separate treasury account table and no smart-contract integration. federated_ap_id holds the ActivityPub @id once a DAO is federated (the column exists; no broadcast path is currently wired from DAOService, see Federation).
dao_members
CREATE TABLE dao_members (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
dao_id uuid NOT NULL REFERENCES daos(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id),
role text NOT NULL DEFAULT 'member'
CHECK (role IN ('admin','member','observer')),
joined_at timestamptz DEFAULT now(),
UNIQUE (dao_id, user_id)
);
Members are referenced by auth.users(id) (uuid), not by W3C DID. The DAO creator is automatically added as an admin on creation (DAOService.createDAO).
proposals
CREATE TABLE proposals (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
dao_id uuid NOT NULL REFERENCES daos(id) ON DELETE CASCADE,
author_id uuid NOT NULL REFERENCES auth.users(id),
title text NOT NULL,
description text,
proposal_type text NOT NULL DEFAULT 'general'
CHECK (proposal_type IN (
'general','treasury','membership','rule_change',
'dispute_resolution','resource_allocation')),
status text NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','active','passed','rejected','expired','executed')),
action_payload jsonb,
votes_yes integer NOT NULL DEFAULT 0,
votes_no integer NOT NULL DEFAULT 0,
votes_abstain integer NOT NULL DEFAULT 0,
quorum_reached boolean DEFAULT false,
voting_opens_at timestamptz,
voting_closes_at timestamptz,
executed_at timestamptz,
federated_ap_id text,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
action_payload is free-form JSON that describes what a passed proposal should do; it is stored but not automatically executed — executeProposal records the execution timestamp and emits an event, nothing more.
votes
CREATE TABLE votes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
proposal_id uuid NOT NULL REFERENCES proposals(id) ON DELETE CASCADE,
voter_id uuid NOT NULL REFERENCES auth.users(id),
choice text NOT NULL CHECK (choice IN ('yes','no','abstain')),
weight numeric(10,4) NOT NULL DEFAULT 1.0,
voted_at timestamptz DEFAULT now(),
UNIQUE (proposal_id, voter_id)
);
One vote per user per proposal. weight is supplied by the caller of VotingService.castVote (default 1.0). There is no automatic weight derivation from reputation, stake, or time-credit balance — this is an opt-in extension point: a future caller (e.g. a reputation-weighted voting route) can pass any numeric weight and the tally will respect it. All MVP REST callers use the default, so today's governance is effectively one-member-one-vote.
time_credits / time_credit_transactions
CREATE TABLE time_credits (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES auth.users(id),
balance numeric(12,2) NOT NULL DEFAULT 0,
lifetime_earned numeric(12,2) NOT NULL DEFAULT 0,
lifetime_spent numeric(12,2) NOT NULL DEFAULT 0,
updated_at timestamptz DEFAULT now(),
UNIQUE (owner_id)
);
CREATE TABLE time_credit_transactions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES auth.users(id),
amount numeric(12,2) NOT NULL,
balance_after numeric(12,2) NOT NULL,
tx_type text NOT NULL
CHECK (tx_type IN (
'exchange_credit','exchange_debit',
'governance_reward','dao_treasury_deposit',
'dao_treasury_withdrawal','admin_adjustment')),
reference_id text,
note text,
created_at timestamptz DEFAULT now()
);
time_credits holds one row per user (unique on owner_id). time_credit_transactions is append-only (no RLS UPDATE/DELETE policy). The tx_type enum reserves dao_treasury_deposit/dao_treasury_withdrawal values for a future treasury flow, but today no code path moves credits between a user's time_credits.balance and a DAO's treasury_credits — see Future Direction.
disputes
CREATE TABLE disputes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES auth.users(id), -- claimant
respondent_id uuid NOT NULL REFERENCES auth.users(id),
subject_type text NOT NULL CHECK (subject_type IN ('exchange','listing','dao_action','other')),
subject_id text,
description text NOT NULL,
evidence_json jsonb DEFAULT '[]',
status text NOT NULL DEFAULT 'open'
CHECK (status IN ('open','under_review','resolved','dismissed','escalated')),
resolution text,
resolved_by uuid REFERENCES auth.users(id),
moderator_id uuid REFERENCES auth.users(id),
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
moderation_actions
CREATE TABLE moderation_actions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
moderator_id uuid NOT NULL REFERENCES auth.users(id),
target_type text NOT NULL, -- 'listing','declaration','proposal','user'
target_id text NOT NULL,
action text NOT NULL
CHECK (action IN ('warn','hide','remove','ban','restore')),
reason text,
notes text,
created_at timestamptz DEFAULT now()
);
Voting Mechanism
The votes table stores weighted yes/no/abstain only. There is a single voting mechanism, applied uniformly across all DAOs and proposal types:
- Weight — each vote carries a
numeric(10,4)weight supplied toVotingService.castVote(voterId, proposalId, choice, weight = 1). The proposal row's runningvotes_yes/votes_no/votes_abstaincounters are incremented by that weight. - Participation — at tally time, participation is computed as
totalVotes / memberCount * 100wheretotalVotes = votes_yes + votes_no + votes_abstainandmemberCountis the row count indao_membersfor that DAO. - Quorum — quorum is reached when
participationPct >= dao.quorum_pct. - Pass — when quorum is reached, the proposal passes if
votes_yes / (votes_yes + votes_no) * 100 >= dao.pass_threshold. Abstain votes count toward quorum but not toward the pass calculation. - Failure modes — if quorum is not met, the proposal is marked
expired. If quorum is met but the yes percentage is below threshold, it's markedrejected.
There is no quadratic voting, no liquid democracy / delegation, no consensus-requires-no-objection mode, and no distinct supermajority type. pass_threshold can of course be set to 66 or 75 to emulate a supermajority, but the mechanism itself is unchanged.
Proposal Lifecycle
┌──────────┐
│ draft │ createProposal
└────┬─────┘
│ activateProposal
▼
┌──────────┐
│ active │ castVote (votes_yes / votes_no / votes_abstain ++)
└────┬─────┘
│ finalizeProposal
┌──────────┼──────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌─────────┐
│ passed │ │rejected│ │ expired │ (quorum not met → expired)
└───┬────┘ └────────┘ └─────────┘
│ executeProposal
▼
┌────────┐
│executed│
└────────┘
createProposalvalidates the author is a DAO member and inserts atstatus='draft'. The author can freely edit drafts (RLS allows UPDATE whereauth.uid() = author_id AND status = 'draft').activateProposalflips toactive, stampsvoting_opens_at = now()andvoting_closes_at = now() + dao.voting_period_h hours.castVoteonly accepts votes whilestatus='active'andnowis within[voting_opens_at, voting_closes_at]. Duplicate votes are rejected (unique onproposal_id + voter_id).finalizeProposalcomputes quorum and pass; sets status topassed,rejected, orexpired. Finalization runs automatically:ProposalService.start()spins up a 60-second background poll (finalizeExpired) that finalizes anyactiveproposal whosevoting_closes_athas passed. The REST route and tests can also callfinalizeProposaldirectly for deterministic timing.executeProposaltransitionspassed → executed, stampsexecuted_at, and emitsgovernance.ProposalExecutedcarryingaction_payload. Nothing inProposalServiceinterprets the payload; any downstream effect must come from an event subscriber.
Services
DAOService — services/governance/DAOService.ts
createDAO(ownerId, { name, description?, slug, daoType, quorumPct?, passThreshold?, votingPeriodH?, isPublic? }): Promise<DAO>
getDAO(id): Promise<DAO | null>
getDAOBySlug(slug): Promise<DAO | null>
listDAOs(memberUserId?): Promise<DAO[]>
updateDAO(ownerId, id, updates): Promise<DAO>
addMember(daoId, userId, role): Promise<DAOMember>
removeMember(daoId, userId): Promise<void>
updateMemberRole(daoId, userId, role): Promise<DAOMember>
listMembers(daoId): Promise<DAOMember[]>
getMembership(daoId, userId): Promise<DAOMember | null>
Emits: governance.DAOCreated.
ProposalService — services/governance/ProposalService.ts
createProposal(authorId, daoId, { title, description?, proposalType, actionPayload?, votingOpensAt?, votingClosesAt? }): Promise<Proposal>
getProposal(id): Promise<Proposal | null>
listProposals(daoId, status?): Promise<Proposal[]>
activateProposal(authorId, id): Promise<Proposal>
finalizeProposal(id): Promise<Proposal>
executeProposal(id): Promise<Proposal>
Emits: governance.ProposalCreated, governance.ProposalActivated, governance.ProposalFinalized (carries VoteTally), governance.ProposalExecuted.
VotingService — services/governance/VotingService.ts
castVote(voterId, proposalId, choice: 'yes'|'no'|'abstain', weight = 1): Promise<Vote>
getVote(voterId, proposalId): Promise<Vote | null>
listVotes(proposalId): Promise<Vote[]>
getTally(proposalId): Promise<VoteTally>
Emits: governance.VoteCast.
TimeBankService — services/time-bank/TimeBankService.ts
getBalance(ownerId): Promise<TimeCredit> // auto-creates zero-balance row
credit(ownerId, amount, txType, referenceId?, note?): Promise<TimeCreditTransaction>
debit(ownerId, amount, txType, referenceId?, note?): Promise<TimeCreditTransaction> // throws on insufficient balance
transfer(fromOwnerId, toOwnerId, amount, referenceId?, note?): Promise<{from, to}>
listTransactions(ownerId, limit?): Promise<TimeCreditTransaction[]> // DESC by created_at
rewardGovernanceParticipation(ownerId, proposalId): Promise<TimeCreditTransaction> // credits 0.1 'governance_reward'
Emits: timebank.Credited, timebank.Debited, timebank.Transferred.
TimeBankService.start() subscribes to governance.VoteCast (consumer time-bank-governance-reward) and automatically calls rewardGovernanceParticipation(voterId, proposalId) for every vote. Failures in the handler are logged, not rethrown — a reward failure must never block vote publishing.
DisputeService — services/dispute/DisputeService.ts
openDispute(claimantId, { respondentId, subjectType, subjectId?, description, evidence? }): Promise<Dispute>
assignModerator(disputeId, moderatorId): Promise<Dispute> // open → under_review
addEvidence(userId, disputeId, evidence): Promise<Dispute> // open | under_review only
resolveDispute(moderatorId, disputeId, resolution, outcome: 'resolved'|'dismissed'): Promise<Dispute>
escalateDispute(moderatorId, disputeId): Promise<Dispute> // under_review → escalated
getDispute(id): Promise<Dispute | null>
listDisputes(ownerId, status?): Promise<Dispute[]>
listModeratorQueue(moderatorId): Promise<Dispute[]>
Status machine (enforced by assertValidTransition):
open ──► under_review ──► resolved | dismissed | escalated
On resolveDispute, a W3C Verifiable Credential is issued to the claimant via VCService.issueCredential:
outcome='resolved'→DisputeResolvedCredentialoutcome='dismissed'→DisputeLostCredential
The claimant DID is synthesized as did:key:${ownerId}.
Emits: dispute.Opened, dispute.ModeratorAssigned, dispute.Resolved, dispute.Dismissed, dispute.Escalated.
ModerationService — services/moderation/ModerationService.ts
recordAction(moderatorId, { targetType, targetId, action: 'warn'|'hide'|'remove'|'ban'|'restore', reason?, notes? }): Promise<ModerationRecord>
listActions(targetType, targetId): Promise<ModerationRecord[]>
getModerationHistory(moderatorId): Promise<ModerationRecord[]>
isBanned(userId): Promise<boolean> // true if most recent user-targeted action is 'ban'
Emits: moderation.ActionTaken.
ModerationService is not exposed over REST; it is consumed internally (e.g., by other services deciding whether a user is banned before allowing an action).
Treasury
A DAO's treasury is a single numeric(12,2) column on the daos row (treasury_credits). There is no smart-contract integration, no multi-asset support, and no treasury account in time_credits. The tx_type enum on time_credit_transactions reserves dao_treasury_deposit and dao_treasury_withdrawal values for a future treasury flow, but no service method currently moves credits into or out of daos.treasury_credits. See Future Direction — treasury transfers are an intentional deferral, not a bug.
Federation
Both daos and proposals carry a nullable federated_ap_id column intended for ActivityPub @ids. The columns are present and the row mappers in DAOService and ProposalService expose them as federatedApId, but neither service publishes DAOs or proposals to ActivityPub today. Federation of the marketplace (listings, exchanges) is wired separately via FederatedMarketplaceService; a comparable FederatedGovernanceService is on the roadmap — see Future Direction. Treat DAO federation as schema-ready, runtime-planned.
REST API
All endpoints require Supabase auth unless noted. Request/response shapes are Zod-validated in apps/hub/src/api/schemas/governance.schemas.ts.
DAOs — apps/hub/src/api/routes/governance.ts
| Method | Path | Notes |
|---|---|---|
POST | /api/governance/daos | Body: createDAOBodySchema. Creator is auto-added as admin. |
GET | /api/governance/daos?memberId= | Public. Filter by membership optional. |
GET | /api/governance/daos/:id | Public. |
PATCH | /api/governance/daos/:id | Owner only. Body: updateDAOBodySchema. |
POST | /api/governance/daos/:id/members | Caller must be admin. Body: addMemberBodySchema. |
DELETE | /api/governance/daos/:id/members/:userId | Caller must be admin. |
GET | /api/governance/daos/:id/members | — |
Proposals & Votes
| Method | Path | Notes |
|---|---|---|
POST | /api/governance/proposals | Body: createProposalBodySchema. Author must be a member. |
GET | /api/governance/proposals?daoId=&status= | daoId required. |
GET | /api/governance/proposals/:id | — |
POST | /api/governance/proposals/:id/activate | draft → active. |
POST | /api/governance/proposals/:id/finalize | `active → passed |
POST | /api/governance/proposals/:id/execute | passed → executed. |
POST | /api/governance/proposals/:id/votes | Body: castVoteBodySchema {choice, weight?}. |
GET | /api/governance/proposals/:id/votes | — |
GET | /api/governance/proposals/:id/tally | Returns VoteTally. |
Disputes — apps/hub/src/api/routes/disputes.ts
| Method | Path | Notes |
|---|---|---|
POST | /api/disputes | Body: openDisputeBodySchema. |
GET | /api/disputes?status= | Lists disputes where caller is claimant or respondent. |
GET | /api/disputes/:id | — |
GET | /api/disputes/moderator/queue | Disputes assigned to caller + unassigned open. |
POST | /api/disputes/:id/evidence | Body: addEvidenceBodySchema. |
POST | /api/disputes/:id/moderator | Body: {moderatorId?} (defaults to caller). |
POST | /api/disputes/:id/resolve | Body: `{resolution, outcome: 'resolved' |
POST | /api/disputes/:id/escalate | — |
Time Bank — apps/hub/src/api/routes/timebank.ts
| Method | Path | Notes |
|---|---|---|
GET | /api/timebank/balance | Auto-creates zero-balance row. |
POST | /api/timebank/credit | Body: creditDebitBodySchema. |
POST | /api/timebank/debit | Body: creditDebitBodySchema. Throws on insufficient balance. |
POST | /api/timebank/transfer | Body: transferBodySchema. |
GET | /api/timebank/transactions?limit= | DESC by created_at. |
ModerationService has no REST surface.
End-to-End Example
Create a DAO, add a second member, propose and pass a rule change.
# 1. Create the DAO (caller becomes admin automatically)
POST /api/governance/daos
{
"name": "Neighborhood Tool Library",
"slug": "nbhd-tools",
"daoType": "cooperative",
"quorumPct": 40,
"passThreshold": 60,
"votingPeriodH": 72
}
→ 201 { "id": "dao-uuid", ... }
# 2. Admin invites a second member
POST /api/governance/daos/dao-uuid/members
{ "userId": "user-bob", "role": "member" }
→ 201
# 3. Bob drafts a proposal
POST /api/governance/proposals
{
"daoId": "dao-uuid",
"title": "Require check-out logs for power tools",
"proposalType": "rule_change",
"description": "...",
"actionPayload": { "rule": "power_tool_checkout_required" }
}
→ 201 { "id": "prop-uuid", "status": "draft", ... }
# 4. Bob activates the proposal (voting opens; closes in 72h)
POST /api/governance/proposals/prop-uuid/activate
→ 200 { "status": "active", "votingOpensAt": "...", "votingClosesAt": "..." }
# 5. Both members vote yes
POST /api/governance/proposals/prop-uuid/votes { "choice": "yes" }
POST /api/governance/proposals/prop-uuid/votes { "choice": "yes" }
# 6. Finalize after the window closes
POST /api/governance/proposals/prop-uuid/finalize
→ 200 { "status": "passed", "quorumReached": true, "votesYes": 2, ... }
# 7. Record execution
POST /api/governance/proposals/prop-uuid/execute
→ 200 { "status": "executed", "executedAt": "..." }
At each step the relevant governance.* event is published to NATS; downstream subscribers (reputation updates, notifications, etc.) react independently.
Future Direction
The following are explicitly not implemented but remain design directions for later phases. They are separated here so the rest of this document stays factual.
- Alternative voting mechanisms — quadratic voting (power = √credits), liquid democracy / delegation, consensus-requires-no-objection, and distinct supermajority types. The
votestable andVotingServicewould need new columns (delegated_from,credits_spent) and branching logic. The existingweightparameter is an opt-in extension point any future route can use without a schema change. - DAO-level ActivityPub federation — publishing
daosandproposalsvia the existing ActivityPub service so members on different hubs can participate. Schema hooks (federated_ap_id) exist; aFederatedGovernanceServicedoes not. - Treasury credit movement — wiring the reserved
dao_treasury_deposit/dao_treasury_withdrawaltx_typevalues intime_credit_transactionsto actual methods onTimeBankServicethat atomically debit a member'stime_credits.balanceand creditdaos.treasury_credits(or vice versa). Treasury balances are tracked today; movement between them is not. - Automated proposal execution — interpreting
proposals.action_payloadand carrying out the action (membership change, treasury disbursement, rule update) without a human callingexecute. - Smart-contract treasury — replacing
daos.treasury_creditswith an on-chain or multi-signature vault. - Reputation-weighted votes — deriving
weightfrom a user's reputation score or time-credit stake instead of accepting it from the caller. - Rehabilitation credentials — richer VC types issued on dispute outcomes (
CommunityViolationCredential,RedemptionCredential,FairModeratorCredential) feeding a reputation domain.