Made Open

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:

ComponentLocation
DAOs / Proposals / Votesapps/hub/src/services/governance/
Time-credit ledgerapps/hub/src/services/time-bank/TimeBankService.ts
Dispute lifecycleapps/hub/src/services/dispute/DisputeService.ts
Moderation logapps/hub/src/services/moderation/ModerationService.ts
Schemasupabase/migrations/00006_phase6_governance.sql
REST APIapps/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:

  1. Weight — each vote carries a numeric(10,4) weight supplied to VotingService.castVote(voterId, proposalId, choice, weight = 1). The proposal row's running votes_yes / votes_no / votes_abstain counters are incremented by that weight.
  2. Participation — at tally time, participation is computed as totalVotes / memberCount * 100 where totalVotes = votes_yes + votes_no + votes_abstain and memberCount is the row count in dao_members for that DAO.
  3. Quorum — quorum is reached when participationPct >= dao.quorum_pct.
  4. 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.
  5. 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 marked rejected.

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│
      └────────┘
  • createProposal validates the author is a DAO member and inserts at status='draft'. The author can freely edit drafts (RLS allows UPDATE where auth.uid() = author_id AND status = 'draft').
  • activateProposal flips to active, stamps voting_opens_at = now() and voting_closes_at = now() + dao.voting_period_h hours.
  • castVote only accepts votes while status='active' and now is within [voting_opens_at, voting_closes_at]. Duplicate votes are rejected (unique on proposal_id + voter_id).
  • finalizeProposal computes quorum and pass; sets status to passed, rejected, or expired. Finalization runs automatically: ProposalService.start() spins up a 60-second background poll (finalizeExpired) that finalizes any active proposal whose voting_closes_at has passed. The REST route and tests can also call finalizeProposal directly for deterministic timing.
  • executeProposal transitions passed → executed, stamps executed_at, and emits governance.ProposalExecuted carrying action_payload. Nothing in ProposalService interprets the payload; any downstream effect must come from an event subscriber.

Services

DAOServiceservices/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.

ProposalServiceservices/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.

VotingServiceservices/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.

TimeBankServiceservices/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.

DisputeServiceservices/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'DisputeResolvedCredential
  • outcome='dismissed'DisputeLostCredential

The claimant DID is synthesized as did:key:${ownerId}.

Emits: dispute.Opened, dispute.ModeratorAssigned, dispute.Resolved, dispute.Dismissed, dispute.Escalated.

ModerationServiceservices/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

MethodPathNotes
POST/api/governance/daosBody: createDAOBodySchema. Creator is auto-added as admin.
GET/api/governance/daos?memberId=Public. Filter by membership optional.
GET/api/governance/daos/:idPublic.
PATCH/api/governance/daos/:idOwner only. Body: updateDAOBodySchema.
POST/api/governance/daos/:id/membersCaller must be admin. Body: addMemberBodySchema.
DELETE/api/governance/daos/:id/members/:userIdCaller must be admin.
GET/api/governance/daos/:id/members

Proposals & Votes

MethodPathNotes
POST/api/governance/proposalsBody: createProposalBodySchema. Author must be a member.
GET/api/governance/proposals?daoId=&status=daoId required.
GET/api/governance/proposals/:id
POST/api/governance/proposals/:id/activatedraft → active.
POST/api/governance/proposals/:id/finalize`active → passed
POST/api/governance/proposals/:id/executepassed → executed.
POST/api/governance/proposals/:id/votesBody: castVoteBodySchema {choice, weight?}.
GET/api/governance/proposals/:id/votes
GET/api/governance/proposals/:id/tallyReturns VoteTally.

Disputes — apps/hub/src/api/routes/disputes.ts

MethodPathNotes
POST/api/disputesBody: openDisputeBodySchema.
GET/api/disputes?status=Lists disputes where caller is claimant or respondent.
GET/api/disputes/:id
GET/api/disputes/moderator/queueDisputes assigned to caller + unassigned open.
POST/api/disputes/:id/evidenceBody: addEvidenceBodySchema.
POST/api/disputes/:id/moderatorBody: {moderatorId?} (defaults to caller).
POST/api/disputes/:id/resolveBody: `{resolution, outcome: 'resolved'
POST/api/disputes/:id/escalate

Time Bank — apps/hub/src/api/routes/timebank.ts

MethodPathNotes
GET/api/timebank/balanceAuto-creates zero-balance row.
POST/api/timebank/creditBody: creditDebitBodySchema.
POST/api/timebank/debitBody: creditDebitBodySchema. Throws on insufficient balance.
POST/api/timebank/transferBody: 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 votes table and VotingService would need new columns (delegated_from, credits_spent) and branching logic. The existing weight parameter is an opt-in extension point any future route can use without a schema change.
  • DAO-level ActivityPub federation — publishing daos and proposals via the existing ActivityPub service so members on different hubs can participate. Schema hooks (federated_ap_id) exist; a FederatedGovernanceService does not.
  • Treasury credit movement — wiring the reserved dao_treasury_deposit / dao_treasury_withdrawal tx_type values in time_credit_transactions to actual methods on TimeBankService that atomically debit a member's time_credits.balance and credit daos.treasury_credits (or vice versa). Treasury balances are tracked today; movement between them is not.
  • Automated proposal execution — interpreting proposals.action_payload and carrying out the action (membership change, treasury disbursement, rule update) without a human calling execute.
  • Smart-contract treasury — replacing daos.treasury_credits with an on-chain or multi-signature vault.
  • Reputation-weighted votes — deriving weight from 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.