Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ A second copy of the truth is a copy that goes stale, so it is gone.
| Question | Read |
|---|---|
| What is Solon, what are the models and routes? | `README.md` |
| How do I work in the repo — commands, CI, Prisma, gotchas? | `AGENTS.md` |
| How do I work in the repo — commands, CI, Drizzle, gotchas? | `AGENTS.md` |
| What are the design rules? | `docs/development/ui-guidelines.md` |
| What are the tokens? | `@fleet/design-tokens` — one package, shared by all three products |
| What is the schema? | `prisma/schema.prisma` (9 models) |
| What is the schema? | `src/lib/db/schema.ts` (9 models) |
| Which env vars exist? | `.env.example` |

## The three that matter most
Expand Down
2 changes: 1 addition & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"Bash(npm run typecheck)",
"Bash(npm run lint)",
"Bash(npm run test)",
"Bash(npm run prisma:generate)",
"Bash(npm run db:generate)",
"mcp__github__actions_list",
"mcp__github__pull_request_read"
]
Expand Down
8 changes: 4 additions & 4 deletions .cursor/rules/code-quality.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Next.js 16 Bitcoin governance platform:
- `src/app/` - Pages and API routes
- `src/components/` - UI components
- `src/lib/` - Business logic, Bitcoin integration
- `prisma/` - Database schema
- `src/lib/db/` - Database schema (Drizzle), client, enums

## Core Principles

Expand All @@ -25,7 +25,7 @@ Next.js 16 Bitcoin governance platform:
|------|----------|
| Navigation | `lib/site-config.ts` |
| Design Tokens | Tailwind config |
| Database | `prisma/schema.prisma` |
| Database | `src/lib/db/schema.ts` |
| Bitcoin Config | Environment variables |

### 3. Security First
Expand Down Expand Up @@ -53,10 +53,10 @@ className="bg-slate-800 shadow-lg" // Wrong

## TypeScript Standards

Derive types from the Prisma schema — never redeclare a model by hand:
Derive types from the Drizzle schema — never redeclare a model by hand:

```typescript
import type { Vote, Proposal, TreasurySource } from '@prisma/client';
import type { Vote, Proposal, TreasurySource } from '@/lib/db/schema';
```

There is **no transaction model**. The treasury is watch-only: a
Expand Down
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# --- Required ---------------------------------------------------------------

# PostgreSQL. `npm run prisma:push` creates the schema against this.
# PostgreSQL. `npm run db:migrate` creates the schema against this.
DATABASE_URL="postgresql://solon:solon@localhost:5432/solon"

# NextAuth v5. Generate with: openssl rand -base64 32
Expand Down
18 changes: 6 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# Golden CI floor — see bitbaum/fleet templates/ci/README.md.
# solon: npm + Prisma. Floor = verify (lint + typecheck + design:check + test) + build.
# solon: npm + Drizzle. Floor = verify (lint + typecheck + design:check + test) + build.
# Lint is `eslint .` — Next 16 removed `next lint`, and ESLint 9 wants flat
# config, so the rules live in eslint.config.mjs (eslint-config-next already
# exports a flat array). `.eslintrc.json` is gone.
# `next build` is hermetic: the server components that touch Prisma catch DB
# errors and render demo fallbacks, so no live DB is needed. Prisma has no
# postinstall, so the client is generated before typecheck/build need its types.
# `next build` is hermetic: the server components that touch the database catch
# errors and render demo fallbacks, so no live DB is needed. Drizzle has no
# codegen at all — types flow from src/lib/db/schema.ts at typecheck time.
# The Playwright e2e smoke tests stay deferred (need a running app).
name: CI

Expand Down Expand Up @@ -37,14 +37,11 @@ jobs:
- name: Install
run: npm ci

- name: Prisma generate (types for typecheck)
run: npx prisma generate

# SSOT: lint + typecheck, defined in package.json `verify`.
- name: Verify
run: npm run verify

# Build gate — hermetic (Prisma calls fall back without a live DB, see header).
# Build gate — hermetic (DB calls fall back without a live database, see header).
- name: Build
run: npm run build

Expand Down Expand Up @@ -81,11 +78,8 @@ jobs:
- name: Install
run: npm ci

- name: Prisma generate
run: npx prisma generate

- name: Migration replay (baseline + seed on a fresh database)
run: npx prisma migrate deploy
run: npx drizzle-kit migrate

- name: Vote spine integration spec
run: INTEGRATION=1 npx vitest run src/lib/domain/__tests__/vote-spine.integration.test.ts
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,7 @@ yarn.lock
# is where it is most opinionated and least useful, and it would bury the real
# diff. Remove this line when you want docs formatted too.
*.md

# drizzle-kit owns these files and rewrites them on every generate; formatting
# them creates a fight between two writers over generated output.
drizzle/meta
27 changes: 16 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Read `README.md` for what the product is. This file is how to work in the repo.

- **Framework**: Next.js 16.3 (App Router, `output: 'standalone'` for the Hetzner deploy)
- **Language**: TypeScript 5.5 (strict)
- **Database**: PostgreSQL via Prisma 5.17
- **Database**: PostgreSQL via Drizzle ORM (`drizzle-orm/node-postgres` + `pg` Pool)
- **Auth**: NextAuth v5 (beta) — sign in with OrangeCat (OAuth)
- **Styling**: Tailwind CSS 3.4, tokens in `src/app/globals.css`
- **Bitcoin**: `@noble/*`, `bs58check` (message signing / signature verification)
Expand All @@ -20,30 +20,35 @@ Read `README.md` for what the product is. This file is how to work in the repo.

```bash
npm run dev # next dev (localhost:3000)
npm run build # next build (standalone) — run `prisma:generate` first (no postinstall)
npm run build # next build (standalone) — no codegen step, Drizzle types come from the schema
npm run verify # lint + typecheck + design:check + test — run before every commit
```

`npm run verify` is the single source of truth for "is this change clean?" CI
calls it verbatim. Green `verify` locally ⇒ green CI.

## Prisma / database
## Drizzle / database

- Schema SSOT: `prisma/schema.prisma` (9 models). Types flow from it via `@prisma/client`.
- **`prisma generate` is NOT automatic** — no postinstall hook. Run `npm run prisma:generate`
before `typecheck` or `build` so the client types exist. CI does this explicitly.
- **`db push` against a real database is manual.** `npm run prisma:push` is run by hand.
Do not add a push step to the workflow.
- Migration history begins at `prisma/migrations/0_init` (versioned baseline).
- Schema SSOT: `src/lib/db/schema.ts` (9 models). Types flow from it via `$inferSelect`;
enum vocabulary lives in `src/lib/db/enums.ts` (dependency-free, safe for client code).
- **There is no codegen.** Typecheck and build read the schema module directly.
- Migrations live in `drizzle/` (`npm run db:generate` after a schema change;
`npm run db:migrate` applies them). **Running migrations against a real
database is manual / deploy-time only** — do not add a push step to CI's verify job.
- Migration history begins at `drizzle/0000_init` (baseline matching the tables the
retired Prisma migrations created — byte-identical names, proven by pg_dump diff)
plus `drizzle/0001_seed_org1` (org #1 reference data). The production database
predates this history and is baselined in the deploy ledger
(`public._deploy_schema_history`), so these two files never run there.

## CI

`.github/workflows/ci.yml` runs two jobs on every push/PR to `main`:

| Job | What it does |
|---|---|
| `verify` | `npm ci` → `prisma generate` → `npm run verify` → `npm run build` |
| `integration` | `prisma migrate deploy` on a **fresh** Postgres, then the vote-spine integration spec |
| `verify` | `npm ci` → `npm run verify` → `npm run build` |
| `integration` | `drizzle-kit migrate` on a **fresh** Postgres, then the vote-spine integration spec |

The integration job is why migrations must replay cleanly from the baseline: it
builds the database from scratch every run.
Expand Down
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ Proposal (DRAFT) ──open──> VotingSession (OPEN) ──signed votes──

## Data model

`prisma/schema.prisma` is the SSOT — **9 models**, with types, validation and API
contracts derived from it.
`src/lib/db/schema.ts` is the SSOT — **9 models** (Drizzle), with types, validation
and API contracts derived from it.

```
Organization ── has many ──> Member (HUMAN | AGENT, own Bitcoin key)
Expand Down Expand Up @@ -129,7 +129,7 @@ Bitcoin message signing and verification is `src/lib/bitcoin/message.ts`.
|---|---|
| Framework | Next.js 16.3 (App Router, `output: 'standalone'`) |
| Language | TypeScript 5.5 (strict) |
| Database | PostgreSQL + Prisma 5.17 |
| Database | PostgreSQL + Drizzle ORM |
| Auth | NextAuth v5 (beta) |
| Styling | Tailwind CSS 3.4 — tokens in `src/app/globals.css` |
| Bitcoin | `@noble/*` + `bs58check` (signing / verification) |
Expand All @@ -147,8 +147,7 @@ cd solon
npm install

cp .env.example .env # set DATABASE_URL
npm run prisma:generate # no postinstall hook — run this before typecheck/build
npm run prisma:push
npm run db:migrate # applies drizzle/ migrations (baseline + seed)

npm run dev # http://localhost:3000
```
Expand Down
25 changes: 25 additions & 0 deletions drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Drizzle Kit config. Schema lives in src/lib/db/schema.ts; generated SQL in
* ./drizzle (the deploy pipeline's apply-schema.sh probes exactly that path).
*
* dbCredentials are only needed by db:migrate / db:push / studio — `db:generate`
* is offline. Env files are loaded with Node's own loader (no dotenv dep).
*/
import { defineConfig } from "drizzle-kit";

for (const f of [".env.local", ".env"]) {
try {
process.loadEnvFile(f);
} catch {
// file absent (CI) — DATABASE_URL comes from the environment
}
}

export default defineConfig({
schema: "./src/lib/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://unset:unset@localhost:5432/unset",
},
});
142 changes: 142 additions & 0 deletions drizzle/0000_init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
CREATE TYPE "public"."AuditEventType" AS ENUM('ORG_CREATED', 'MEMBER_ADDED', 'MEMBER_STATUS_CHANGED', 'PROPOSAL_CREATED', 'SESSION_OPENED', 'VOTE_CAST', 'SESSION_CLOSED', 'POLICY_ACTIVATED');--> statement-breakpoint
CREATE TYPE "public"."DecisionCategory" AS ENUM('ALLOCATION_POLICY', 'TREASURY_SPEND', 'OPERATIONS', 'AID_DISBURSEMENT', 'MEMBERSHIP', 'SAFETY', 'GOVERNANCE_RULES');--> statement-breakpoint
CREATE TYPE "public"."Electorate" AS ENUM('ALL_MEMBERS', 'HUMANS_ONLY');--> statement-breakpoint
CREATE TYPE "public"."KeyCustody" AS ENUM('SELF', 'SERVICE');--> statement-breakpoint
CREATE TYPE "public"."MemberStatus" AS ENUM('ACTIVE', 'SUSPENDED', 'RETIRED');--> statement-breakpoint
CREATE TYPE "public"."MemberType" AS ENUM('HUMAN', 'AGENT');--> statement-breakpoint
CREATE TYPE "public"."PolicyStatus" AS ENUM('ACTIVE', 'SUPERSEDED');--> statement-breakpoint
CREATE TYPE "public"."ProposalStatus" AS ENUM('DRAFT', 'OPEN', 'CLOSED');--> statement-breakpoint
CREATE TYPE "public"."SessionOutcome" AS ENUM('APPROVED', 'REJECTED', 'EXPIRED');--> statement-breakpoint
CREATE TYPE "public"."SessionStatus" AS ENUM('ACTIVE', 'CLOSED');--> statement-breakpoint
CREATE TYPE "public"."VoteThreshold" AS ENUM('SIMPLE_MAJORITY', 'SUPERMAJORITY');--> statement-breakpoint
CREATE TYPE "public"."VotingMethod" AS ENUM('SINGLE_CHOICE', 'CONSENT', 'APPROVAL', 'DOT', 'SCORE', 'RANKED');--> statement-breakpoint
CREATE TABLE "agent_api_keys" (
"id" text PRIMARY KEY NOT NULL,
"member_id" text NOT NULL,
"key_hash" text NOT NULL,
"created_at" timestamp (3) DEFAULT now() NOT NULL,
"revoked_at" timestamp (3)
);
--> statement-breakpoint
CREATE TABLE "audit_events" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"event_type" "AuditEventType" NOT NULL,
"actor_member_id" text,
"subject_type" text NOT NULL,
"subject_id" text NOT NULL,
"payload" jsonb NOT NULL,
"created_at" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "members" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"display_name" text NOT NULL,
"member_type" "MemberType" NOT NULL,
"key_custody" "KeyCustody" NOT NULL,
"bitcoin_address" varchar(90) NOT NULL,
"public_key_hex" text,
"voting_weight" numeric(10, 2) DEFAULT 1 NOT NULL,
"status" "MemberStatus" DEFAULT 'ACTIVE' NOT NULL,
"oc_actor_id" text,
"system" text,
"joined_at" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organizations" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"name" text NOT NULL,
"description" text,
"governance_profile" text DEFAULT 'TOWN' NOT NULL,
"created_at" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "policies" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"key" text NOT NULL,
"version" integer NOT NULL,
"content" jsonb NOT NULL,
"status" "PolicyStatus" NOT NULL,
"approved_by_session_id" text,
"activated_at" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "proposals" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"category" "DecisionCategory" NOT NULL,
"title" text NOT NULL,
"body" text NOT NULL,
"policy_key" text,
"proposed_content" jsonb,
"target" text,
"content_hash" text,
"method" "VotingMethod",
"options" jsonb,
"proposer_member_id" text NOT NULL,
"proposer_signature" text NOT NULL,
"status" "ProposalStatus" DEFAULT 'DRAFT' NOT NULL,
"created_at" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "treasury_sources" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"label" text NOT NULL,
"address" varchar(90) NOT NULL,
"created_at" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "votes" (
"id" text PRIMARY KEY NOT NULL,
"session_id" text NOT NULL,
"member_id" text NOT NULL,
"ballot" jsonb NOT NULL,
"weight" numeric(10, 2) NOT NULL,
"signed_message" text NOT NULL,
"signature" text NOT NULL,
"created_at" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "voting_sessions" (
"id" text PRIMARY KEY NOT NULL,
"proposal_id" text NOT NULL,
"status" "SessionStatus" DEFAULT 'ACTIVE' NOT NULL,
"opens_at" timestamp (3) DEFAULT now() NOT NULL,
"closes_at" timestamp (3) NOT NULL,
"electorate" "Electorate" NOT NULL,
"method" "VotingMethod" DEFAULT 'SINGLE_CHOICE' NOT NULL,
"options" jsonb,
"dot_budget" integer,
"threshold" "VoteThreshold" NOT NULL,
"quorum_percent" integer NOT NULL,
"eligible_count" integer NOT NULL,
"eligible_weight" numeric(12, 2) NOT NULL,
"outcome" "SessionOutcome",
"winning_option_key" text,
"closed_at" timestamp (3)
);
--> statement-breakpoint
ALTER TABLE "agent_api_keys" ADD CONSTRAINT "agent_api_keys_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "members" ADD CONSTRAINT "members_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "policies" ADD CONSTRAINT "policies_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "policies" ADD CONSTRAINT "policies_approved_by_session_id_fkey" FOREIGN KEY ("approved_by_session_id") REFERENCES "public"."voting_sessions"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "proposals" ADD CONSTRAINT "proposals_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "proposals" ADD CONSTRAINT "proposals_proposer_member_id_fkey" FOREIGN KEY ("proposer_member_id") REFERENCES "public"."members"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "treasury_sources" ADD CONSTRAINT "treasury_sources_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "votes" ADD CONSTRAINT "votes_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "public"."voting_sessions"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "votes" ADD CONSTRAINT "votes_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "voting_sessions" ADD CONSTRAINT "voting_sessions_proposal_id_fkey" FOREIGN KEY ("proposal_id") REFERENCES "public"."proposals"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
CREATE UNIQUE INDEX "agent_api_keys_key_hash_key" ON "agent_api_keys" USING btree ("key_hash");--> statement-breakpoint
CREATE INDEX "audit_events_organization_id_created_at_idx" ON "audit_events" USING btree ("organization_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "members_oc_actor_id_key" ON "members" USING btree ("oc_actor_id");--> statement-breakpoint
CREATE UNIQUE INDEX "members_organization_id_bitcoin_address_key" ON "members" USING btree ("organization_id","bitcoin_address");--> statement-breakpoint
CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations" USING btree ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "policies_organization_id_key_version_key" ON "policies" USING btree ("organization_id","key","version");--> statement-breakpoint
CREATE UNIQUE INDEX "treasury_sources_organization_id_address_key" ON "treasury_sources" USING btree ("organization_id","address");--> statement-breakpoint
CREATE UNIQUE INDEX "votes_session_id_member_id_key" ON "votes" USING btree ("session_id","member_id");--> statement-breakpoint
CREATE UNIQUE INDEX "voting_sessions_proposal_id_key" ON "voting_sessions" USING btree ("proposal_id");
File renamed without changes.
Loading