diff --git a/.gitignore b/.gitignore index d53b555..c503f0d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ storybook-static # local state .env +# wrangler local artifacts +.wrangler + # landing assets (keep large videos out of git history) public/landing/*.mp4 public/landing/*.webm diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c841876 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +.wrangler +dist +node_modules +.yarn +docs/vortex-1.0-paper.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 25fa621..aac8f70 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,6 @@ { - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "css.lint.vendorPrefix": "ignore", + "css.lint.propertyIgnoredDueToDisplay": "ignore", + "css.lint.unknownAtRules": "ignore" } diff --git a/README.md b/README.md index 86b38a4..136fbc8 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,19 @@ -# Vortex Webapp Layout +# Vortex Experimental Mockups -Experimental Vortex interface built with React + Rsbuild, Tailwind-like utility styles, and shared UI components. +This repo ships: + +1. A React UI mockup of Vortex (Humanode governance hub) +2. An off-chain “simulation backend” served from `/api/*` (Cloudflare Pages Functions) + +Humanode mainnet is used only as a read-only eligibility gate; all simulated governance state lives off-chain. ## Stack - React with React Router - Rsbuild -- Yarn (see `.node-version` for Node version) -- UI primitives in `src/components/primitives` -- Tailwind-style utilities via PostCSS +- Tailwind v4 (via PostCSS) + token-driven CSS (`src/styles/base.css`) +- Yarn (Node version: `.node-version`) +- Pages Functions API in `functions/` + Postgres via Drizzle ## Getting Started @@ -23,11 +28,41 @@ Dev server: http://localhost:3000 Landing: http://localhost:3000/ App: http://localhost:3000/app +## Simulation API (local) + +The UI reads from `/api/*` (Cloudflare Pages Functions). For local dev, run the API locally so the UI can reach it: + +- One command: `yarn dev:full` (API on `:8788` + UI on `:3000` + `/api/*` proxy) +- Two terminals: + - Terminal 1: `yarn dev:api` + - Terminal 2: `yarn dev` + +If only `yarn dev` runs, `/api/*` is not available and auth/gating/read pages will show an “API is not available” error. + +### Backend docs + +- Start here: `docs/README.md` +- Docs are grouped in: `docs/simulation/`, `docs/ops/`, `docs/paper/` +- Module map (paper → docs → code): `docs/simulation/vortex-simulation-modules.md` +- API contract: `docs/simulation/vortex-simulation-api-contract.md` +- Proposal wizard architecture: `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` +- Local dev: `docs/simulation/vortex-simulation-local-dev.md` +- Scope and rules: `docs/simulation/vortex-simulation-scope-v1.md`, `docs/simulation/vortex-simulation-state-machines.md` +- Vortex 1.0 reference (working copy): `docs/paper/vortex-1.0-paper.md` + ## Scripts - `yarn dev` – start the dev server +- `yarn dev:api` – run the Pages Functions API locally (Node runner) +- `yarn dev:full` – run UI + API together (recommended) +- `yarn dev:api:wrangler` – run the API via `wrangler pages dev` against `./dist` - `yarn build` – build the app -- `yarn exec tsc --noEmit` – type-check +- `yarn test` – run API/unit tests +- `yarn prettier:check` / `yarn prettier:fix` + +## Type checking + +- Repo typecheck (UI + Pages Functions + DB seed builders): `yarn exec tsc --noEmit` ## Project Structure @@ -36,7 +71,11 @@ App: http://localhost:3000/app - `src/data` – glossary (vortexopedia), page hints/tutorial content - `src/pages` – feature pages (proposals, human-nodes, formations, chambers, factions, courts, feed, profile, invision, etc.) - `src/styles` – base/global styles -- `prolog/vortexopedia.pl` – Prolog version of the glossary data (for future integration) +- `functions/` – Pages Functions API (`/api/*`) + shared server helpers +- `db/` – Drizzle schema + migrations + seed builders +- `scripts/` – DB seed/clear + local API runner +- `prolog/vortexopedia.pl` – Prolog glossary mirror +- `public/landing/` – landing page assets (see `public/landing/README.md`) ## Shared Patterns @@ -46,5 +85,6 @@ App: http://localhost:3000/app ## Notes -- Builds output to `dist/`. +- `dist/` is generated build output. - Keep glossary entries in sync between `src/data/vortexopedia.ts` and `prolog/vortexopedia.pl` if you edit definitions. +- DB-backed dev requires `DATABASE_URL` + `yarn db:migrate && yarn db:seed` (see `docs/simulation/vortex-simulation-local-dev.md`). diff --git a/db/migrations/0001_bitter_oracle.sql b/db/migrations/0001_bitter_oracle.sql new file mode 100644 index 0000000..55fcf80 --- /dev/null +++ b/db/migrations/0001_bitter_oracle.sql @@ -0,0 +1,8 @@ +CREATE TABLE "auth_nonces" ( + "nonce" text PRIMARY KEY NOT NULL, + "address" text NOT NULL, + "request_ip" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "used_at" timestamp with time zone +); diff --git a/db/migrations/0002_dear_betty_ross.sql b/db/migrations/0002_dear_betty_ross.sql new file mode 100644 index 0000000..12d9b33 --- /dev/null +++ b/db/migrations/0002_dear_betty_ross.sql @@ -0,0 +1,10 @@ +CREATE TABLE "events" ( + "seq" bigserial PRIMARY KEY NOT NULL, + "type" text NOT NULL, + "stage" text, + "actor_address" text, + "entity_type" text NOT NULL, + "entity_id" text NOT NULL, + "payload" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/db/migrations/0003_cultured_supreme_intelligence.sql b/db/migrations/0003_cultured_supreme_intelligence.sql new file mode 100644 index 0000000..aa74596 --- /dev/null +++ b/db/migrations/0003_cultured_supreme_intelligence.sql @@ -0,0 +1,16 @@ +CREATE TABLE "idempotency_keys" ( + "key" text PRIMARY KEY NOT NULL, + "address" text NOT NULL, + "request" jsonb NOT NULL, + "response" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "pool_votes" ( + "proposal_id" text NOT NULL, + "voter_address" text NOT NULL, + "direction" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "pool_votes_proposal_id_voter_address_pk" PRIMARY KEY("proposal_id","voter_address") +); diff --git a/db/migrations/0004_smiling_iron_man.sql b/db/migrations/0004_smiling_iron_man.sql new file mode 100644 index 0000000..374cecf --- /dev/null +++ b/db/migrations/0004_smiling_iron_man.sql @@ -0,0 +1,9 @@ +CREATE TABLE "chamber_votes" ( + "proposal_id" text NOT NULL, + "voter_address" text NOT NULL, + "choice" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "chamber_votes_proposal_id_voter_address_pk" PRIMARY KEY("proposal_id","voter_address") +); + diff --git a/db/migrations/0005_warm_alex_wilder.sql b/db/migrations/0005_warm_alex_wilder.sql new file mode 100644 index 0000000..623e557 --- /dev/null +++ b/db/migrations/0005_warm_alex_wilder.sql @@ -0,0 +1,2 @@ +ALTER TABLE "chamber_votes" ADD COLUMN "score" integer; + diff --git a/db/migrations/0006_heavy_hidden_dream.sql b/db/migrations/0006_heavy_hidden_dream.sql new file mode 100644 index 0000000..60a8829 --- /dev/null +++ b/db/migrations/0006_heavy_hidden_dream.sql @@ -0,0 +1,13 @@ +CREATE TABLE "cm_awards" ( + "id" bigserial PRIMARY KEY NOT NULL, + "proposal_id" text NOT NULL, + "proposer_id" text NOT NULL, + "chamber_id" text NOT NULL, + "avg_score" integer, + "lcm_points" integer NOT NULL, + "chamber_multiplier_times10" integer NOT NULL, + "mcm_points" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "cm_awards_proposal_id_unique" UNIQUE("proposal_id") +); + diff --git a/db/migrations/0007_ancient_formation.sql b/db/migrations/0007_ancient_formation.sql new file mode 100644 index 0000000..6f128e2 --- /dev/null +++ b/db/migrations/0007_ancient_formation.sql @@ -0,0 +1,39 @@ +CREATE TABLE "formation_projects" ( + "proposal_id" text PRIMARY KEY NOT NULL, + "team_slots_total" integer NOT NULL, + "base_team_filled" integer DEFAULT 0 NOT NULL, + "milestones_total" integer NOT NULL, + "base_milestones_completed" integer DEFAULT 0 NOT NULL, + "budget_total_hmnd" integer, + "base_budget_allocated_hmnd" integer, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE "formation_team" ( + "proposal_id" text NOT NULL, + "member_address" text NOT NULL, + "role" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "formation_team_pk" PRIMARY KEY("proposal_id","member_address") +); + +CREATE TABLE "formation_milestones" ( + "proposal_id" text NOT NULL, + "milestone_index" integer NOT NULL, + "status" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "formation_milestones_pk" PRIMARY KEY("proposal_id","milestone_index") +); + +CREATE TABLE "formation_milestone_events" ( + "id" bigserial PRIMARY KEY NOT NULL, + "proposal_id" text NOT NULL, + "milestone_index" integer NOT NULL, + "type" text NOT NULL, + "actor_address" text, + "payload" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/db/migrations/0008_courts_v1.sql b/db/migrations/0008_courts_v1.sql new file mode 100644 index 0000000..ffa0e1d --- /dev/null +++ b/db/migrations/0008_courts_v1.sql @@ -0,0 +1,24 @@ +CREATE TABLE "court_cases" ( + "id" text PRIMARY KEY NOT NULL, + "status" text NOT NULL, + "base_reports" integer DEFAULT 0 NOT NULL, + "opened" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE "court_reports" ( + "case_id" text NOT NULL, + "reporter_address" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "court_reports_pk" PRIMARY KEY("case_id","reporter_address") +); + +CREATE TABLE "court_verdicts" ( + "case_id" text NOT NULL, + "voter_address" text NOT NULL, + "verdict" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "court_verdicts_pk" PRIMARY KEY("case_id","voter_address") +); diff --git a/db/migrations/0009_era_rollups.sql b/db/migrations/0009_era_rollups.sql new file mode 100644 index 0000000..6e5d3c8 --- /dev/null +++ b/db/migrations/0009_era_rollups.sql @@ -0,0 +1,16 @@ +CREATE TABLE "era_snapshots" ( + "era" integer PRIMARY KEY NOT NULL, + "active_governors" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE "era_user_activity" ( + "era" integer NOT NULL, + "address" text NOT NULL, + "pool_votes" integer DEFAULT 0 NOT NULL, + "chamber_votes" integer DEFAULT 0 NOT NULL, + "court_actions" integer DEFAULT 0 NOT NULL, + "formation_actions" integer DEFAULT 0 NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "era_user_activity_pk" PRIMARY KEY("era","address") +); diff --git a/db/migrations/0010_era_rollup_status.sql b/db/migrations/0010_era_rollup_status.sql new file mode 100644 index 0000000..c2cb0dc --- /dev/null +++ b/db/migrations/0010_era_rollup_status.sql @@ -0,0 +1,26 @@ +CREATE TABLE "era_rollups" ( + "era" integer PRIMARY KEY NOT NULL, + "required_pool_votes" integer DEFAULT 0 NOT NULL, + "required_chamber_votes" integer DEFAULT 0 NOT NULL, + "required_court_actions" integer DEFAULT 0 NOT NULL, + "required_formation_actions" integer DEFAULT 0 NOT NULL, + "required_total" integer DEFAULT 0 NOT NULL, + "active_governors_next_era" integer DEFAULT 0 NOT NULL, + "rolled_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE "era_user_status" ( + "era" integer NOT NULL, + "address" text NOT NULL, + "status" text NOT NULL, + "required_total" integer DEFAULT 0 NOT NULL, + "completed_total" integer DEFAULT 0 NOT NULL, + "is_active_next_era" boolean DEFAULT false NOT NULL, + "pool_votes" integer DEFAULT 0 NOT NULL, + "chamber_votes" integer DEFAULT 0 NOT NULL, + "court_actions" integer DEFAULT 0 NOT NULL, + "formation_actions" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "era_user_status_pk" PRIMARY KEY("era","address") +); + diff --git a/db/migrations/0011_api_rate_limits.sql b/db/migrations/0011_api_rate_limits.sql new file mode 100644 index 0000000..d44adc9 --- /dev/null +++ b/db/migrations/0011_api_rate_limits.sql @@ -0,0 +1,7 @@ +CREATE TABLE "api_rate_limits" ( + "bucket" text PRIMARY KEY NOT NULL, + "count" integer DEFAULT 0 NOT NULL, + "reset_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + diff --git a/db/migrations/0012_user_action_locks.sql b/db/migrations/0012_user_action_locks.sql new file mode 100644 index 0000000..87dc74e --- /dev/null +++ b/db/migrations/0012_user_action_locks.sql @@ -0,0 +1,7 @@ +CREATE TABLE "user_action_locks" ( + "address" text PRIMARY KEY NOT NULL, + "locked_until" timestamp with time zone NOT NULL, + "reason" text, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + diff --git a/db/migrations/0013_admin_state.sql b/db/migrations/0013_admin_state.sql new file mode 100644 index 0000000..e086839 --- /dev/null +++ b/db/migrations/0013_admin_state.sql @@ -0,0 +1,6 @@ +CREATE TABLE "admin_state" ( + "id" integer PRIMARY KEY NOT NULL, + "writes_frozen" boolean DEFAULT false NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + diff --git a/db/migrations/0014_proposal_drafts.sql b/db/migrations/0014_proposal_drafts.sql new file mode 100644 index 0000000..6017be2 --- /dev/null +++ b/db/migrations/0014_proposal_drafts.sql @@ -0,0 +1,12 @@ +CREATE TABLE "proposal_drafts" ( + "id" text PRIMARY KEY NOT NULL, + "author_address" text NOT NULL, + "title" text NOT NULL, + "chamber_id" text, + "summary" text NOT NULL, + "payload" jsonb NOT NULL, + "submitted_at" timestamp with time zone, + "submitted_proposal_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/db/migrations/0015_proposals.sql b/db/migrations/0015_proposals.sql new file mode 100644 index 0000000..8c2f9de --- /dev/null +++ b/db/migrations/0015_proposals.sql @@ -0,0 +1,12 @@ +CREATE TABLE "proposals" ( + "id" text PRIMARY KEY NOT NULL, + "stage" text NOT NULL, + "author_address" text NOT NULL, + "title" text NOT NULL, + "chamber_id" text, + "summary" text NOT NULL, + "payload" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + diff --git a/db/migrations/0016_chamber_memberships.sql b/db/migrations/0016_chamber_memberships.sql new file mode 100644 index 0000000..3898f67 --- /dev/null +++ b/db/migrations/0016_chamber_memberships.sql @@ -0,0 +1,9 @@ +CREATE TABLE "chamber_memberships" ( + "chamber_id" text NOT NULL, + "address" text NOT NULL, + "granted_by_proposal_id" text, + "source" text DEFAULT 'accepted_proposal' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "chamber_memberships_chamber_id_address_pk" PRIMARY KEY("chamber_id","address") +); + diff --git a/db/migrations/0017_chambers.sql b/db/migrations/0017_chambers.sql new file mode 100644 index 0000000..80f414d --- /dev/null +++ b/db/migrations/0017_chambers.sql @@ -0,0 +1,12 @@ +CREATE TABLE "chambers" ( + "id" text PRIMARY KEY NOT NULL, + "title" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "multiplier_times10" integer DEFAULT 10 NOT NULL, + "created_by_proposal_id" text, + "dissolved_by_proposal_id" text, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "dissolved_at" timestamp with time zone +); diff --git a/db/migrations/0018_proposal_stage_denominators.sql b/db/migrations/0018_proposal_stage_denominators.sql new file mode 100644 index 0000000..b69d00a --- /dev/null +++ b/db/migrations/0018_proposal_stage_denominators.sql @@ -0,0 +1,8 @@ +CREATE TABLE "proposal_stage_denominators" ( + "proposal_id" text NOT NULL, + "stage" text NOT NULL, + "era" integer NOT NULL, + "active_governors" integer NOT NULL, + "captured_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "proposal_stage_denominators_proposal_id_stage_pk" PRIMARY KEY("proposal_id","stage") +); diff --git a/db/migrations/0019_delegations.sql b/db/migrations/0019_delegations.sql new file mode 100644 index 0000000..072c44c --- /dev/null +++ b/db/migrations/0019_delegations.sql @@ -0,0 +1,17 @@ +CREATE TABLE "delegations" ( + "chamber_id" text NOT NULL, + "delegator_address" text NOT NULL, + "delegatee_address" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "delegations_chamber_id_delegator_address_pk" PRIMARY KEY("chamber_id","delegator_address") +); +--> statement-breakpoint +CREATE TABLE "delegation_events" ( + "seq" bigserial PRIMARY KEY NOT NULL, + "chamber_id" text NOT NULL, + "delegator_address" text NOT NULL, + "delegatee_address" text, + "type" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/db/migrations/0020_veto.sql b/db/migrations/0020_veto.sql new file mode 100644 index 0000000..507b1ae --- /dev/null +++ b/db/migrations/0020_veto.sql @@ -0,0 +1,14 @@ +ALTER TABLE "proposals" ADD COLUMN "veto_count" integer DEFAULT 0 NOT NULL; +ALTER TABLE "proposals" ADD COLUMN "vote_passed_at" timestamp with time zone; +ALTER TABLE "proposals" ADD COLUMN "vote_finalizes_at" timestamp with time zone; +ALTER TABLE "proposals" ADD COLUMN "veto_council" jsonb; +ALTER TABLE "proposals" ADD COLUMN "veto_threshold" integer; + +CREATE TABLE "veto_votes" ( + "proposal_id" text NOT NULL, + "voter_address" text NOT NULL, + "choice" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "veto_votes_proposal_id_voter_address_pk" PRIMARY KEY("proposal_id","voter_address") +); diff --git a/db/migrations/0021_chamber_multiplier_submissions.sql b/db/migrations/0021_chamber_multiplier_submissions.sql new file mode 100644 index 0000000..aa89997 --- /dev/null +++ b/db/migrations/0021_chamber_multiplier_submissions.sql @@ -0,0 +1,11 @@ +-- v1: chamber multiplier voting submissions (outsiders-only aggregation) + +CREATE TABLE "chamber_multiplier_submissions" ( + "chamber_id" text NOT NULL, + "voter_address" text NOT NULL, + "multiplier_times10" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "chamber_multiplier_submissions_chamber_id_voter_address_pk" PRIMARY KEY("chamber_id","voter_address"), + CONSTRAINT "chamber_multiplier_submissions_multiplier_range" CHECK ("multiplier_times10" >= 1 AND "multiplier_times10" <= 100) +); diff --git a/db/migrations/meta/0001_snapshot.json b/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..990c022 --- /dev/null +++ b/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,217 @@ +{ + "id": "b35347c6-133d-469b-a78b-62653ba59142", + "prevId": "1416fd76-c86c-4384-90bd-43856c9d4db3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_nonces": { + "name": "auth_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clock_state": { + "name": "clock_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "current_era": { + "name": "current_era", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.eligibility_cache": { + "name": "eligibility_cache", + "schema": "", + "columns": { + "address": { + "name": "address", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_active_human_node": { + "name": "is_active_human_node", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'rpc'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.read_models": { + "name": "read_models", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "address": { + "name": "address", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/db/migrations/meta/0002_snapshot.json b/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..2a02ec1 --- /dev/null +++ b/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,279 @@ +{ + "id": "100019a4-96ff-414a-94ef-d99b7496527f", + "prevId": "b35347c6-133d-469b-a78b-62653ba59142", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_nonces": { + "name": "auth_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clock_state": { + "name": "clock_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "current_era": { + "name": "current_era", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.eligibility_cache": { + "name": "eligibility_cache", + "schema": "", + "columns": { + "address": { + "name": "address", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_active_human_node": { + "name": "is_active_human_node", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'rpc'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "seq": { + "name": "seq", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_address": { + "name": "actor_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.read_models": { + "name": "read_models", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "address": { + "name": "address", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/db/migrations/meta/0003_snapshot.json b/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..a23cc58 --- /dev/null +++ b/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,373 @@ +{ + "id": "c5b8f5c7-1d23-4738-8a05-7bfb2203266f", + "prevId": "100019a4-96ff-414a-94ef-d99b7496527f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_nonces": { + "name": "auth_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clock_state": { + "name": "clock_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "current_era": { + "name": "current_era", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.eligibility_cache": { + "name": "eligibility_cache", + "schema": "", + "columns": { + "address": { + "name": "address", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_active_human_node": { + "name": "is_active_human_node", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'rpc'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "seq": { + "name": "seq", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_address": { + "name": "actor_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_keys": { + "name": "idempotency_keys", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pool_votes": { + "name": "pool_votes", + "schema": "", + "columns": { + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voter_address": { + "name": "voter_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "pool_votes_proposal_id_voter_address_pk": { + "name": "pool_votes_proposal_id_voter_address_pk", + "columns": ["proposal_id", "voter_address"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.read_models": { + "name": "read_models", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "address": { + "name": "address", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/db/migrations/meta/0004_snapshot.json b/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..e80e107 --- /dev/null +++ b/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,1305 @@ +{ + "id": "8bc8a52d-7433-4a5e-a9d5-284a19c00c25", + "prevId": "c5b8f5c7-1d23-4738-8a05-7bfb2203266f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_state": { + "name": "admin_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "writes_frozen": { + "name": "writes_frozen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_rate_limits": { + "name": "api_rate_limits", + "schema": "", + "columns": { + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reset_at": { + "name": "reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_nonces": { + "name": "auth_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chamber_votes": { + "name": "chamber_votes", + "schema": "", + "columns": { + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voter_address": { + "name": "voter_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "choice": { + "name": "choice", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chamber_votes_proposal_id_voter_address_pk": { + "name": "chamber_votes_proposal_id_voter_address_pk", + "columns": ["proposal_id", "voter_address"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clock_state": { + "name": "clock_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "current_era": { + "name": "current_era", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cm_awards": { + "name": "cm_awards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposer_id": { + "name": "proposer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chamber_id": { + "name": "chamber_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avg_score": { + "name": "avg_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lcm_points": { + "name": "lcm_points", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chamber_multiplier_times10": { + "name": "chamber_multiplier_times10", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mcm_points": { + "name": "mcm_points", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.court_cases": { + "name": "court_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_reports": { + "name": "base_reports", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "opened": { + "name": "opened", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.court_reports": { + "name": "court_reports", + "schema": "", + "columns": { + "case_id": { + "name": "case_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reporter_address": { + "name": "reporter_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "court_reports_case_id_reporter_address_pk": { + "name": "court_reports_case_id_reporter_address_pk", + "columns": ["case_id", "reporter_address"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.court_verdicts": { + "name": "court_verdicts", + "schema": "", + "columns": { + "case_id": { + "name": "case_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voter_address": { + "name": "voter_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "court_verdicts_case_id_voter_address_pk": { + "name": "court_verdicts_case_id_voter_address_pk", + "columns": ["case_id", "voter_address"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.eligibility_cache": { + "name": "eligibility_cache", + "schema": "", + "columns": { + "address": { + "name": "address", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_active_human_node": { + "name": "is_active_human_node", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'rpc'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.era_rollups": { + "name": "era_rollups", + "schema": "", + "columns": { + "era": { + "name": "era", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "required_pool_votes": { + "name": "required_pool_votes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "required_chamber_votes": { + "name": "required_chamber_votes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "required_court_actions": { + "name": "required_court_actions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "required_formation_actions": { + "name": "required_formation_actions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "required_total": { + "name": "required_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "active_governors_next_era": { + "name": "active_governors_next_era", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rolled_at": { + "name": "rolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.era_snapshots": { + "name": "era_snapshots", + "schema": "", + "columns": { + "era": { + "name": "era", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "active_governors": { + "name": "active_governors", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.era_user_activity": { + "name": "era_user_activity", + "schema": "", + "columns": { + "era": { + "name": "era", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pool_votes": { + "name": "pool_votes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "chamber_votes": { + "name": "chamber_votes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "court_actions": { + "name": "court_actions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "formation_actions": { + "name": "formation_actions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "era_user_activity_era_address_pk": { + "name": "era_user_activity_era_address_pk", + "columns": ["era", "address"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.era_user_status": { + "name": "era_user_status", + "schema": "", + "columns": { + "era": { + "name": "era", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "required_total": { + "name": "required_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_total": { + "name": "completed_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active_next_era": { + "name": "is_active_next_era", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pool_votes": { + "name": "pool_votes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "chamber_votes": { + "name": "chamber_votes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "court_actions": { + "name": "court_actions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "formation_actions": { + "name": "formation_actions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "era_user_status_era_address_pk": { + "name": "era_user_status_era_address_pk", + "columns": ["era", "address"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "seq": { + "name": "seq", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_address": { + "name": "actor_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formation_milestone_events": { + "name": "formation_milestone_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "milestone_index": { + "name": "milestone_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_address": { + "name": "actor_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formation_milestones": { + "name": "formation_milestones", + "schema": "", + "columns": { + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "milestone_index": { + "name": "milestone_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "formation_milestones_proposal_id_milestone_index_pk": { + "name": "formation_milestones_proposal_id_milestone_index_pk", + "columns": ["proposal_id", "milestone_index"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formation_projects": { + "name": "formation_projects", + "schema": "", + "columns": { + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_slots_total": { + "name": "team_slots_total", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "base_team_filled": { + "name": "base_team_filled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "milestones_total": { + "name": "milestones_total", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "base_milestones_completed": { + "name": "base_milestones_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_total_hmnd": { + "name": "budget_total_hmnd", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "base_budget_allocated_hmnd": { + "name": "base_budget_allocated_hmnd", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formation_team": { + "name": "formation_team", + "schema": "", + "columns": { + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_address": { + "name": "member_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "formation_team_proposal_id_member_address_pk": { + "name": "formation_team_proposal_id_member_address_pk", + "columns": ["proposal_id", "member_address"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_keys": { + "name": "idempotency_keys", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pool_votes": { + "name": "pool_votes", + "schema": "", + "columns": { + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voter_address": { + "name": "voter_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "pool_votes_proposal_id_voter_address_pk": { + "name": "pool_votes_proposal_id_voter_address_pk", + "columns": ["proposal_id", "voter_address"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proposal_drafts": { + "name": "proposal_drafts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "author_address": { + "name": "author_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chamber_id": { + "name": "chamber_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "submitted_proposal_id": { + "name": "submitted_proposal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.read_models": { + "name": "read_models", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_action_locks": { + "name": "user_action_locks", + "schema": "", + "columns": { + "address": { + "name": "address", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "address": { + "name": "address", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 6d4526c..7cf85d6 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -8,6 +8,27 @@ "when": 1766509637223, "tag": "0000_nosy_mastermind", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1766533489857, + "tag": "0001_bitter_oracle", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1766670704887, + "tag": "0002_dear_betty_ross", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1766674457787, + "tag": "0003_cultured_supreme_intelligence", + "breakpoints": true } ] } diff --git a/db/schema.ts b/db/schema.ts index 5a95af2..e2fc286 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -1,4 +1,21 @@ -import { integer, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { + bigserial, + boolean, + integer, + jsonb, + primaryKey, + pgTable, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +export const adminState = pgTable("admin_state", { + id: integer("id").primaryKey(), + writesFrozen: boolean("writes_frozen").notNull().default(false), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); export const users = pgTable("users", { address: text("address").primaryKey(), @@ -8,6 +25,17 @@ export const users = pgTable("users", { .defaultNow(), }); +export const authNonces = pgTable("auth_nonces", { + nonce: text("nonce").primaryKey(), + address: text("address").notNull(), + requestIp: text("request_ip"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + usedAt: timestamp("used_at", { withTimezone: true }), +}); + export const eligibilityCache = pgTable("eligibility_cache", { address: text("address").primaryKey(), isActiveHumanNode: integer("is_active_human_node").notNull(), // 0/1 for portability @@ -27,8 +55,8 @@ export const clockState = pgTable("clock_state", { .defaultNow(), }); -// Temporary storage for mock-equivalent page payloads during Phase 4 migration. -// This lets us seed from `src/data/mock/*` while we build normalized tables + event log. +// Temporary storage for page read models during Phase 4 migration. +// Seed fixtures live in `db/seed/fixtures/*` while normalized tables + event log are built out. export const readModels = pgTable("read_models", { key: text("key").primaryKey(), payload: jsonb("payload").notNull(), @@ -36,3 +64,436 @@ export const readModels = pgTable("read_models", { .notNull() .defaultNow(), }); + +// Proposal drafts (Phase 12). +// Drafts are author-owned and are the source of truth for the proposal creation wizard. +export const proposalDrafts = pgTable("proposal_drafts", { + id: text("id").primaryKey(), + authorAddress: text("author_address").notNull(), + title: text("title").notNull(), + chamberId: text("chamber_id"), + summary: text("summary").notNull(), + payload: jsonb("payload").notNull(), + submittedAt: timestamp("submitted_at", { withTimezone: true }), + submittedProposalId: text("submitted_proposal_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +// Canonical proposals (Phase 14). +// This starts the migration away from `read_models` as source of truth. +export const proposals = pgTable("proposals", { + id: text("id").primaryKey(), + stage: text("stage").notNull(), // pool | vote | build (v1) + authorAddress: text("author_address").notNull(), + title: text("title").notNull(), + chamberId: text("chamber_id"), + summary: text("summary").notNull(), + payload: jsonb("payload").notNull(), // stage-agnostic proposal content (v1: derived from draft) + vetoCount: integer("veto_count").notNull().default(0), + votePassedAt: timestamp("vote_passed_at", { withTimezone: true }), + voteFinalizesAt: timestamp("vote_finalizes_at", { withTimezone: true }), + vetoCouncil: jsonb("veto_council"), + vetoThreshold: integer("veto_threshold"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const vetoVotes = pgTable( + "veto_votes", + { + proposalId: text("proposal_id").notNull(), + voterAddress: text("voter_address").notNull(), + choice: text("choice").notNull(), // veto | keep + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.proposalId, t.voterAddress] }), + }), +); + +export const chamberMultiplierSubmissions = pgTable( + "chamber_multiplier_submissions", + { + chamberId: text("chamber_id").notNull(), + voterAddress: text("voter_address").notNull(), + multiplierTimes10: integer("multiplier_times10").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.chamberId, t.voterAddress] }), + }), +); + +// Captures the active-governor denominator at proposal stage entry. +// This prevents quorum math from drifting when eras advance mid-stage. +export const proposalStageDenominators = pgTable( + "proposal_stage_denominators", + { + proposalId: text("proposal_id").notNull(), + stage: text("stage").notNull(), // pool | vote (v1) + era: integer("era").notNull(), + activeGovernors: integer("active_governors").notNull(), + capturedAt: timestamp("captured_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.proposalId, t.stage] }), + }), +); + +// Delegation graph (Phase 29). +// Delegation affects chamber vote weight but not proposal-pool attention. +export const delegations = pgTable( + "delegations", + { + chamberId: text("chamber_id").notNull(), + delegatorAddress: text("delegator_address").notNull(), + delegateeAddress: text("delegatee_address").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.chamberId, t.delegatorAddress] }), + }), +); + +export const delegationEvents = pgTable("delegation_events", { + seq: bigserial("seq", { mode: "number" }).primaryKey(), + chamberId: text("chamber_id").notNull(), + delegatorAddress: text("delegator_address").notNull(), + delegateeAddress: text("delegatee_address"), + type: text("type").notNull(), // set | clear + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +// Chamber voting eligibility (Phase 17). +// Membership is granted when a proposal is accepted in a chamber. +// General chamber membership is granted when any proposal is accepted anywhere. +export const chamberMemberships = pgTable( + "chamber_memberships", + { + chamberId: text("chamber_id").notNull(), + address: text("address").notNull(), + grantedByProposalId: text("granted_by_proposal_id"), + source: text("source").notNull().default("accepted_proposal"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.chamberId, t.address] }), + }), +); + +// Canonical chambers (Phase 18). +export const chambers = pgTable("chambers", { + id: text("id").primaryKey(), + title: text("title").notNull(), + status: text("status").notNull().default("active"), // active | dissolved (v1) + multiplierTimes10: integer("multiplier_times10").notNull().default(10), + createdByProposalId: text("created_by_proposal_id"), + dissolvedByProposalId: text("dissolved_by_proposal_id"), + metadata: jsonb("metadata").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + dissolvedAt: timestamp("dissolved_at", { withTimezone: true }), +}); + +// Append-only event log backbone (Phase 5). +export const events = pgTable("events", { + seq: bigserial("seq", { mode: "number" }).primaryKey(), + type: text("type").notNull(), + stage: text("stage"), + actorAddress: text("actor_address"), + entityType: text("entity_type").notNull(), + entityId: text("entity_id").notNull(), + payload: jsonb("payload").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const poolVotes = pgTable( + "pool_votes", + { + proposalId: text("proposal_id").notNull(), + voterAddress: text("voter_address").notNull(), + direction: integer("direction").notNull(), // 1 (upvote) or -1 (downvote) + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.proposalId, t.voterAddress] }), + }), +); + +export const chamberVotes = pgTable( + "chamber_votes", + { + proposalId: text("proposal_id").notNull(), + voterAddress: text("voter_address").notNull(), + choice: integer("choice").notNull(), // 1 (yes), -1 (no), 0 (abstain) + score: integer("score"), // optional 1..10 CM input (v1) + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.proposalId, t.voterAddress] }), + }), +); + +export const idempotencyKeys = pgTable("idempotency_keys", { + key: text("key").primaryKey(), + address: text("address").notNull(), + request: jsonb("request").notNull(), + response: jsonb("response").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const apiRateLimits = pgTable("api_rate_limits", { + bucket: text("bucket").primaryKey(), + count: integer("count").notNull().default(0), + resetAt: timestamp("reset_at", { withTimezone: true }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const userActionLocks = pgTable("user_action_locks", { + address: text("address").primaryKey(), + lockedUntil: timestamp("locked_until", { withTimezone: true }).notNull(), + reason: text("reason"), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const cmAwards = pgTable("cm_awards", { + id: bigserial("id", { mode: "number" }).primaryKey(), + proposalId: text("proposal_id").notNull(), + proposerId: text("proposer_id").notNull(), + chamberId: text("chamber_id").notNull(), + avgScore: integer("avg_score"), // 1..10 scale (rounded) + lcmPoints: integer("lcm_points").notNull(), + chamberMultiplierTimes10: integer("chamber_multiplier_times10").notNull(), + mcmPoints: integer("mcm_points").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const formationProjects = pgTable("formation_projects", { + proposalId: text("proposal_id").primaryKey(), + teamSlotsTotal: integer("team_slots_total").notNull(), + baseTeamFilled: integer("base_team_filled").notNull().default(0), + milestonesTotal: integer("milestones_total").notNull(), + baseMilestonesCompleted: integer("base_milestones_completed") + .notNull() + .default(0), + budgetTotalHmnd: integer("budget_total_hmnd"), + baseBudgetAllocatedHmnd: integer("base_budget_allocated_hmnd"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const formationTeam = pgTable( + "formation_team", + { + proposalId: text("proposal_id").notNull(), + memberAddress: text("member_address").notNull(), + role: text("role"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.proposalId, t.memberAddress] }), + }), +); + +export const formationMilestones = pgTable( + "formation_milestones", + { + proposalId: text("proposal_id").notNull(), + milestoneIndex: integer("milestone_index").notNull(), + status: text("status").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.proposalId, t.milestoneIndex] }), + }), +); + +export const formationMilestoneEvents = pgTable("formation_milestone_events", { + id: bigserial("id", { mode: "number" }).primaryKey(), + proposalId: text("proposal_id").notNull(), + milestoneIndex: integer("milestone_index").notNull(), + type: text("type").notNull(), + actorAddress: text("actor_address"), + payload: jsonb("payload").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const courtCases = pgTable("court_cases", { + id: text("id").primaryKey(), + status: text("status").notNull(), + baseReports: integer("base_reports").notNull().default(0), + opened: text("opened"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const courtReports = pgTable( + "court_reports", + { + caseId: text("case_id").notNull(), + reporterAddress: text("reporter_address").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.caseId, t.reporterAddress] }), + }), +); + +export const courtVerdicts = pgTable( + "court_verdicts", + { + caseId: text("case_id").notNull(), + voterAddress: text("voter_address").notNull(), + verdict: text("verdict").notNull(), // guilty|not_guilty + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.caseId, t.voterAddress] }), + }), +); + +export const eraSnapshots = pgTable("era_snapshots", { + era: integer("era").primaryKey(), + activeGovernors: integer("active_governors").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const eraUserActivity = pgTable( + "era_user_activity", + { + era: integer("era").notNull(), + address: text("address").notNull(), + poolVotes: integer("pool_votes").notNull().default(0), + chamberVotes: integer("chamber_votes").notNull().default(0), + courtActions: integer("court_actions").notNull().default(0), + formationActions: integer("formation_actions").notNull().default(0), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.era, t.address] }), + }), +); + +export const eraRollups = pgTable("era_rollups", { + era: integer("era").primaryKey(), + requiredPoolVotes: integer("required_pool_votes").notNull().default(0), + requiredChamberVotes: integer("required_chamber_votes").notNull().default(0), + requiredCourtActions: integer("required_court_actions").notNull().default(0), + requiredFormationActions: integer("required_formation_actions") + .notNull() + .default(0), + requiredTotal: integer("required_total").notNull().default(0), + activeGovernorsNextEra: integer("active_governors_next_era") + .notNull() + .default(0), + rolledAt: timestamp("rolled_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const eraUserStatus = pgTable( + "era_user_status", + { + era: integer("era").notNull(), + address: text("address").notNull(), + status: text("status").notNull(), + requiredTotal: integer("required_total").notNull().default(0), + completedTotal: integer("completed_total").notNull().default(0), + isActiveNextEra: boolean("is_active_next_era").notNull().default(false), + poolVotes: integer("pool_votes").notNull().default(0), + chamberVotes: integer("chamber_votes").notNull().default(0), + courtActions: integer("court_actions").notNull().default(0), + formationActions: integer("formation_actions").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.era, t.address] }), + }), +); diff --git a/db/seed/events.ts b/db/seed/events.ts new file mode 100644 index 0000000..027e0bb --- /dev/null +++ b/db/seed/events.ts @@ -0,0 +1,30 @@ +import type { FeedItemDto } from "@/types/api"; + +import { feedItemsApi } from "./fixtures/feedApi.ts"; + +export type EventSeedEntry = { + type: "feed.item.v1"; + stage: FeedItemDto["stage"]; + actorAddress: string | null; + entityType: "feed"; + entityId: string; + payload: FeedItemDto; + createdAt: Date; +}; + +export function buildEventSeed(): EventSeedEntry[] { + return [...feedItemsApi] + .sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ) + .map((item) => ({ + type: "feed.item.v1" as const, + stage: item.stage, + actorAddress: null, + entityType: "feed" as const, + entityId: item.id, + payload: item, + createdAt: new Date(item.timestamp), + })); +} diff --git a/src/data/mock/chamberDetail.ts b/db/seed/fixtures/chamberDetail.ts similarity index 98% rename from src/data/mock/chamberDetail.ts rename to db/seed/fixtures/chamberDetail.ts index dd3e7c7..4c4a820 100644 --- a/src/data/mock/chamberDetail.ts +++ b/db/seed/fixtures/chamberDetail.ts @@ -107,5 +107,3 @@ export const chamberChatLog: ChatMessage[] = [ "Sandbox + faucet flow: what’s the simplest onboarding UX we want?", }, ]; - -export default chamberProposals; diff --git a/src/data/mock/chambers.ts b/db/seed/fixtures/chambers.ts similarity index 100% rename from src/data/mock/chambers.ts rename to db/seed/fixtures/chambers.ts diff --git a/src/data/mock/courts.ts b/db/seed/fixtures/courts.ts similarity index 99% rename from src/data/mock/courts.ts rename to db/seed/fixtures/courts.ts index 99e0227..dc68b7f 100644 --- a/src/data/mock/courts.ts +++ b/db/seed/fixtures/courts.ts @@ -226,5 +226,3 @@ export const courtCases: CourtCase[] = [ }, }, ]; - -export default courtCases; diff --git a/src/data/mock/factions.ts b/db/seed/fixtures/factions.ts similarity index 100% rename from src/data/mock/factions.ts rename to db/seed/fixtures/factions.ts diff --git a/src/data/mock/feed.tsx b/db/seed/fixtures/feedApi.ts similarity index 81% rename from src/data/mock/feed.tsx rename to db/seed/fixtures/feedApi.ts index 52f4065..2739536 100644 --- a/src/data/mock/feed.tsx +++ b/db/seed/fixtures/feedApi.ts @@ -1,31 +1,6 @@ -import type { ReactNode } from "react"; +import type { FeedItemDto } from "@/types/api"; -import { HintLabel } from "@/components/Hint"; -import type { FeedStage } from "@/types/stages"; - -export type FeedItem = { - id: string; - title: string; - meta: string; - stage: FeedStage; - summaryPill: string; - summary: ReactNode; - stageData?: { - title: string; - description: string; - value: ReactNode; - tone?: "ok" | "warn"; - }[]; - stats?: { label: string; value: ReactNode }[]; - proposer?: string; - proposerId?: string; - ctaPrimary?: string; - ctaSecondary?: string; - href?: string; - timestamp: string; // ISO string -}; - -export const feedItems: FeedItem[] = [ +export const feedItemsApi: FeedItemDto[] = [ { id: "voluntary-commitment-staking", title: "Voluntary Governor Commitment Staking", @@ -139,17 +114,11 @@ export const feedItems: FeedItem[] = [ meta: "Faction · Social media awareness", stage: "faction", summaryPill: "Slots open", - summary: ( - - Votes: 16 · ACM: 1,120 · Creator and - ops slots open for upcoming campaigns. - - ), + summary: + "Votes: 16 · ACM: 1,120 · Creator and ops slots open for upcoming campaigns.", ctaPrimary: "Open faction", ctaSecondary: "Follow", href: "/app/factions/social-media-awareness", timestamp: "2025-03-20T20:00:00Z", }, ]; - -export default feedItems; diff --git a/src/data/mock/formation.ts b/db/seed/fixtures/formation.ts similarity index 98% rename from src/data/mock/formation.ts rename to db/seed/fixtures/formation.ts index e459716..f378471 100644 --- a/src/data/mock/formation.ts +++ b/db/seed/fixtures/formation.ts @@ -67,5 +67,3 @@ export const getFormationProjectById = ( ): FormationProject | undefined => (id ? formationProjects.find((project) => project.id === id) : undefined) ?? undefined; - -export default formationProjects; diff --git a/src/data/mock/humanNodeProfiles.ts b/db/seed/fixtures/humanNodeProfiles.ts similarity index 100% rename from src/data/mock/humanNodeProfiles.ts rename to db/seed/fixtures/humanNodeProfiles.ts diff --git a/src/data/mock/humanNodes.ts b/db/seed/fixtures/humanNodes.ts similarity index 100% rename from src/data/mock/humanNodes.ts rename to db/seed/fixtures/humanNodes.ts diff --git a/src/data/mock/invision.ts b/db/seed/fixtures/invision.ts similarity index 100% rename from src/data/mock/invision.ts rename to db/seed/fixtures/invision.ts diff --git a/src/data/mock/myGovernance.ts b/db/seed/fixtures/myGovernance.ts similarity index 100% rename from src/data/mock/myGovernance.ts rename to db/seed/fixtures/myGovernance.ts diff --git a/src/data/mock/proposalDraft.ts b/db/seed/fixtures/proposalDraft.ts similarity index 100% rename from src/data/mock/proposalDraft.ts rename to db/seed/fixtures/proposalDraft.ts diff --git a/src/data/mock/proposalPages.ts b/db/seed/fixtures/proposalPages.ts similarity index 100% rename from src/data/mock/proposalPages.ts rename to db/seed/fixtures/proposalPages.ts diff --git a/src/data/mock/proposals.ts b/db/seed/fixtures/proposals.ts similarity index 99% rename from src/data/mock/proposals.ts rename to db/seed/fixtures/proposals.ts index 9714cfe..e742113 100644 --- a/src/data/mock/proposals.ts +++ b/db/seed/fixtures/proposals.ts @@ -529,5 +529,3 @@ export const proposals: ProposalListItem[] = [ ctaSecondary: "Add to agenda", }, ]; - -export default proposals; diff --git a/db/seed/readModels.ts b/db/seed/readModels.ts index 9c7178f..129051b 100644 --- a/db/seed/readModels.ts +++ b/db/seed/readModels.ts @@ -1,20 +1,31 @@ -import { chambers } from "../../src/data/mock/chambers.ts"; +import { chambers } from "./fixtures/chambers.ts"; import { chamberChatLog, chamberGovernors, chamberProposals, chamberThreads, proposalStageOptions, -} from "../../src/data/mock/chamberDetail.ts"; -import { courtCases } from "../../src/data/mock/courts.ts"; -import { humanNodes } from "../../src/data/mock/humanNodes.ts"; -import { humanNodeProfilesById } from "../../src/data/mock/humanNodeProfiles.ts"; -import { proposals } from "../../src/data/mock/proposals.ts"; +} from "./fixtures/chamberDetail.ts"; +import { courtCases } from "./fixtures/courts.ts"; +import { factions } from "./fixtures/factions.ts"; +import { formationMetrics, formationProjects } from "./fixtures/formation.ts"; +import { humanNodes } from "./fixtures/humanNodes.ts"; +import { humanNodeProfilesById } from "./fixtures/humanNodeProfiles.ts"; +import { + invisionChamberProposals, + invisionEconomicIndicators, + invisionGovernanceState, + invisionRiskSignals, +} from "./fixtures/invision.ts"; +import { eraActivity, myChamberIds } from "./fixtures/myGovernance.ts"; +import { proposalDraftDetails } from "./fixtures/proposalDraft.ts"; +import { proposals } from "./fixtures/proposals.ts"; +import { feedItemsApi } from "./fixtures/feedApi.ts"; import { chamberProposalPageById, formationProposalPageById, poolProposalPageById, -} from "../../src/data/mock/proposalPages.ts"; +} from "./fixtures/proposalPages.ts"; export type ReadModelSeedEntry = { key: string; payload: unknown }; @@ -36,6 +47,53 @@ export function buildReadModelSeed(): ReadModelSeedEntry[] { entries.push({ key: "proposals:list", payload: { items: proposals } }); + entries.push({ key: "feed:list", payload: { items: feedItemsApi } }); + + entries.push({ key: "factions:list", payload: { items: factions } }); + for (const faction of factions) { + entries.push({ key: `factions:${faction.id}`, payload: faction }); + } + + entries.push({ + key: "formation:directory", + payload: { metrics: formationMetrics, projects: formationProjects }, + }); + + entries.push({ + key: "invision:dashboard", + payload: { + governanceState: invisionGovernanceState, + economicIndicators: invisionEconomicIndicators, + riskSignals: invisionRiskSignals, + chamberProposals: invisionChamberProposals, + }, + }); + + entries.push({ + key: "my-governance:summary", + payload: { eraActivity, myChamberIds }, + }); + + entries.push({ + key: "proposals:drafts:list", + payload: { + items: [ + { + id: "draft-vortex-ux-v1", + title: proposalDraftDetails.title, + chamber: proposalDraftDetails.chamber, + tier: proposalDraftDetails.tier, + summary: proposalDraftDetails.summary, + updated: "2026-01-09", + }, + ], + }, + }); + entries.push({ + key: "proposals:drafts:draft-vortex-ux-v1", + payload: proposalDraftDetails, + }); + entries.push({ key: "courts:list", payload: { diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..80ff9e3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,111 @@ +# Docs + +This folder documents the **Vortex simulation backend** that powers the UI in this repo. + +Docs are grouped by intent: + +- `docs/simulation/` — core simulation specs, architecture, and implementation plan +- `docs/ops/` — operational runbook for the backend +- `docs/paper/` — working reference copy of the Vortex 1.0 paper + +## Overview + +This repo ships two pieces together: + +1. A React UI mockup of Vortex (the “governance hub”) +2. An off-chain simulation backend served from `/api/*` + +The simulation is **not** an on-chain implementation. Humanode mainnet is used only as an **eligibility gate** (read-only). All governance state (proposals, votes, courts, formation, reputation/CM, feed/history) is off-chain in Postgres and advanced with deterministic rules. + +### On-chain vs off-chain boundary (v1) + +- On-chain (read-only): determine whether an address is an **active Human Node** +- Off-chain (authoritative): everything else + +### How the backend fits the UI + +The UI reads from `/api/*`. The contract is kept stable so the backend can evolve without forcing UI churn: + +- Contract source of truth: `docs/simulation/vortex-simulation-api-contract.md` +- TS DTO types used by the UI: `src/types/api.ts` + +In v1, reads are backed by a transitional `read_models` table (and optional overlays from normalized tables), so pages can render without requiring the full normalized domain schema on day one. + +### Write path (commands) + +State-changing actions go through: + +- `POST /api/command` + +Guards applied to every command: + +- signature-authenticated session +- active Human Node eligibility (cached with TTL) +- idempotency (optional `Idempotency-Key`) +- rate limiting (per IP and per address) +- per-era action quotas (optional) +- admin action locks (optional) +- global write freeze (optional) + +### Local dev modes + +- Recommended: `yarn dev:full` (UI + API + `/api/*` proxy) +- DB mode: `DATABASE_URL` + `yarn db:migrate && yarn db:seed` +- Inline seeded mode: `READ_MODELS_INLINE=true` +- Clean/empty mode: `READ_MODELS_INLINE_EMPTY=true` (pages show “No … yet”) + +### TypeScript projects + +The repo intentionally uses two TS projects: + +- UI + shared client types: `tsconfig.json` (covers `src/` + `tests/`) +- Pages Functions API: `functions/tsconfig.json` (covers `functions/` + local helper `.d.ts` typing) + +This keeps editor tooling for Cloudflare Pages Functions isolated while leaving the frontend TS config lean. + +Goal: keep a tight, professional set of docs that answers: + +- What is being built (scope, assumptions, non-goals) +- How it works (architecture, data model, state machines) +- How the UI talks to it (API contract) +- How to run and operate it (local dev, admin/ops runbook) +- What is implemented now vs intentionally deferred + +## Reading order + +1. `docs/simulation/vortex-simulation-scope-v1.md` — v1 scope, explicit non-goals, and what “done” means +2. `docs/simulation/vortex-simulation-modules.md` — module map (paper → docs → code) +3. `docs/simulation/vortex-simulation-processes.md` — domain processes to model (product-level) +4. `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` — proposal wizard template architecture (project vs system flows) +5. `docs/simulation/vortex-simulation-state-machines.md` — formal state machines, invariants, and derived metrics +6. `docs/simulation/vortex-simulation-tech-architecture.md` — technical architecture (runtime + DB + API shape) +7. `docs/simulation/vortex-simulation-data-model.md` — DB tables and how reads/writes/events map to them +8. `docs/simulation/vortex-simulation-api-contract.md` — frozen DTO contracts consumed by the UI +9. `docs/simulation/vortex-simulation-local-dev.md` — local dev commands and env vars +10. `docs/ops/vortex-simulation-ops-runbook.md` — admin endpoints, safety controls, and operational workflows +11. `docs/simulation/vortex-simulation-implementation-plan.md` — phased plan + current status +12. `docs/simulation/vortex-simulation-v1-constants.md` — v1 constants shared by code and tests +13. `docs/paper/vortex-1.0-paper.md` — working, adapted reference copy of the Vortex 1.0 paper (used for audits) +14. `docs/simulation/vortex-simulation-paper-alignment.md` — paper vs simulation audit notes (what matches, what’s deferred) + +## Doc conventions + +### Voice and tone + +- Write as “we / our system”, not “you should…”. +- Prefer precise language over persuasive language. +- Keep “why” in the doc where it matters (scope/ADR), not sprinkled everywhere. + +### Truth hierarchy + +- **API truth:** `docs/simulation/vortex-simulation-api-contract.md` + `src/types/api.ts` +- **Scope truth:** `docs/simulation/vortex-simulation-scope-v1.md` +- **Behavior truth:** `docs/simulation/vortex-simulation-state-machines.md` (rules + invariants) +- **Operational truth:** `docs/ops/vortex-simulation-ops-runbook.md` + +### Status tags + +When a section mixes implemented + planned behavior, label it explicitly: + +- `Implemented (v1)` +- `Planned (v2+)` diff --git a/docs/ops/vortex-simulation-ops-runbook.md b/docs/ops/vortex-simulation-ops-runbook.md new file mode 100644 index 0000000..d23c5c6 --- /dev/null +++ b/docs/ops/vortex-simulation-ops-runbook.md @@ -0,0 +1,124 @@ +# Vortex Simulation Backend — Ops Runbook (v1) + +This document is the operational reference for running the simulation backend as a public demo: safety controls, admin endpoints, and data reset workflows. + +## Local vs production runtime + +- Production: Cloudflare Pages Functions (`functions/`) +- Local: Node runner (`yarn dev:api`) with the UI proxy (`yarn dev` / `yarn dev:full`) + +Local dev details: `docs/simulation/vortex-simulation-local-dev.md`. + +## Admin auth + +Admin endpoints require an `x-admin-secret` header with `ADMIN_SECRET`, unless `DEV_BYPASS_ADMIN=true` is set for local dev. + +## Safety controls (writes) + +Write commands run through `POST /api/command`. The system supports four layers of write blocking: + +1. **Deploy-time kill switch**: `SIM_WRITE_FREEZE=true` +2. **Admin global freeze**: `POST /api/admin/writes/freeze` +3. **Address action locks**: `POST /api/admin/users/lock` / `unlock` +4. **Rate limiting / quotas**: + - per-minute command rate limits (IP + address) + - optional per-era action quotas + +## Admin endpoints (v1) + +### Time (simulation clock) + +- `GET /api/clock` +- `POST /api/clock/advance-era` +- `POST /api/clock/rollup-era` + +### Moderation / ops + +- `GET /api/admin/stats` +- `POST /api/admin/writes/freeze` +- `POST /api/admin/users/lock` +- `POST /api/admin/users/unlock` +- `GET /api/admin/users/locks` +- `GET /api/admin/users/:address` +- `GET /api/admin/audit` + +Details of request/response DTOs: `docs/simulation/vortex-simulation-api-contract.md`. + +## Incident playbooks + +### Stop all writes immediately + +Options, from strongest to weakest: + +1. Set `SIM_WRITE_FREEZE=true` in the deployment environment and redeploy. +2. Call `POST /api/admin/writes/freeze` with `{ freeze: true }`. +3. Lock a specific address via `POST /api/admin/users/lock`. + +### Rate-limit abuse / spam + +Adjust: + +- `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP` +- `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS` +- optional per-era quotas: + - `SIM_MAX_POOL_VOTES_PER_ERA` + - `SIM_MAX_CHAMBER_VOTES_PER_ERA` + - `SIM_MAX_COURT_ACTIONS_PER_ERA` + - `SIM_MAX_FORMATION_ACTIONS_PER_ERA` + +### Inspect a suspicious user + +Use: + +- `GET /api/admin/users/:address` to view: + - gate status cache (if present) + - action locks + - per-era counters + - recent audit events (DB mode) + +### Audit what happened + +- `GET /api/admin/audit` + +In DB mode, admin actions are also logged to the `events` table as `admin.action.v1`. + +## Data reset workflows + +### Clean UI (no content) without touching the DB + +Run with: + +- `READ_MODELS_INLINE_EMPTY=true` + +List endpoints return `{ items: [] }` and singleton endpoints return minimal defaults. + +### Wipe simulation data in Postgres (keep schema) + +Run: + +- `yarn db:clear` + +This truncates simulation tables and keeps migrations/schema intact. + +### Seed deterministic demo content + +Run: + +- `yarn db:seed` + +This populates `read_models` and seeds the initial event stream. + +## Operational metrics + +`GET /api/admin/stats` returns a small snapshot intended for dashboards: + +- current era +- active governors baseline +- configured rate limits / quotas +- write-freeze state +- basic counts (events, votes, cases) + +## Known v1 limitations + +- Many list/detail reads are served from `read_models` and overlaid with live counters. +- This is deliberate: it keeps the UI contract stable while normalized domain tables and event-driven projections evolve. diff --git a/docs/paper/vortex-1.0-paper.md b/docs/paper/vortex-1.0-paper.md new file mode 100644 index 0000000..83317e8 --- /dev/null +++ b/docs/paper/vortex-1.0-paper.md @@ -0,0 +1,427 @@ +# Vortex 1.0 + +Note: this is a working reference copy imported into the repo for ongoing audits against the simulation backend and UI mockups. It contains small, explicitly requested adaptations (e.g., no sub-chambers) and may include duplicated sections if they were pasted twice upstream. + +"In the sciences, the authority of thousands of opinions is not worth as much as one tiny spark of reason in an individual man." + +**- Galileo Galilei** + +## **Synopsis** + +Vortex is the main governing body (GB) of Humanode. As stated in the Humanode whitepaper v0.9.5 in 4 years time after the mainnet launch all the authority of Humanode Core will be transferred to Vortex and dispersed among its governing entities. Vortex aims to be an egalitarian GB where the voting power is equally distributed among its participants. In contrast with many other chains that heavily rely on PoS or PoW in voting power distribution, Humanode is reliant on its cryptobiometric infrastructure to determine that its governors are living and unique human beings thus enabling equal power distribution among them. + +# Basis of Vortex + +Vortex is based on five major principles through which the main organizational branches are structured: + +1. **Cognitocracy** +2. **Meritocracy** +3. **Local determinism** +4. **Constant deterrence** +5. **Power detachment resilience** + +**Cognitocracy** (from Latin verb _cognoscere_ ‘to know’ and from Ancient Greek _kratos_ 'rule') - a legislative model based on granting the voting rights only to those who are able to bring constructive and deployable innovation (Cognitocrats). It is a system that tries to concentrate decision-making capabilities in the hands of those who have proven to be professional and creative enough to receive the rights to vote on matters of a certain specialization. Throughout this paper words “Cognitocrat” and “Governor” are used interchangeably, as one can’t become a governor without being a cognitocrat. + +Vortex aims to be a cognitocratric meritocracy where merit of innovation and optimization is separately evaluated from the merit of functional work. Both cognitocracy and meritocracy aim to concentrate power in the hands of those who have proof of proficiency in a specialization. Vortex aims to take into account the quantitative and qualitative data of contribution of governors to the Humanode network, through various forms of Proof-of-Time and Proof-of-Devotion that will act as cornerstones of a governor's emancipation from the Nominee to the Citizen status. + +Ideology is usually a system of ideas or ideals that aims to regulate the economy and political processes of human society through a certain prism of principles and instruments. Local determinism denies ideology, as ideology is more a means of gaining political power rather than a viable approach to good decision-making. Local determinism acknowledges that any field of expertise has a tremendous amount of details and intricacies and that solutions that actually work efficiently on the ground don't usually align with the ideology that governs it. As long as a solution works and does so efficiently it doesn’t matter which side of the political spectrum it might be associated with. + +One of the underlying principles of Vortex is the principle of constant deterrence. As any governing system, Vortex is a dynamic one, where balances and narratives constantly shift. The game changes faster than any checks and balances structure can evolve to deter the constantly changing meta. The principle of constant deterrence implies that individuals that comprise the governing body must actively seek centralization threats and mitigate them, refrain from delegating their vote and maximize their direct participation in governing processes. To carry out this deterrence efficiently there must be a transparent way of telling in which state the governing body is. Vortex will present the governors with a dedicated app allowing them not only to participate in governance, but also to clearly see based on data what state the system is in. By combining it with the principle of an active quorum, where only those governors who are actively participating are counted in the quorum, we hypothetically get a dynamic, flexible, transparent and active system of constant deterrence that would be aimed to hold at bay any force such as populists, oligarchs, collusionist, etc that might be trying to centralize efforts of the system for their own personal advantage. + +Power detachment is a serious issue of most blockchain governing entities out there. The fact is that the real power is usually distributed among top validators, as an inherent principle from capital-based Sybil-resistant protocols, and core teams of projects, while governance is misrepresented by active community members and some stakeholders. In an effort to make governance seem egalitarian there are even cases where chains introduced 1 human = 1 vote chambers on top of the existing PoS but this led to even more misrepresentation. Humanode aims to minimize detachment by making sure that every node is an individual, by providing the underlying principle of equality of all the nodes in terms of validation power, by making sure that governors are only composed of Humanode validators and by maximizing validator participation in governance. The detachment will still be there as there is almost zero chance that all validators partake in the governance, so the amount of governors will be less than the amount of validators. At the same time it will not be a democratic facade, behind which top stakeholders and core team members can rule as they see fit due to real power being concentrated in their hands. + +## Vortex structure + +Vortex consists of three major parts: + +1. **Proposal pools** +2. **Vortex** +3. **Formation** + +Proposal pools and voting chambers belong to the legislative branch. Formation belongs to the executive branch. + +

Figure 1. Vortex structure

+ +## Vortex Roles + +Vortex consists of Human nodes, Delegators, and Governors-cognitocrats. + +1. **Human node** — a person who has gone through proper biometric processing, participates in blockchain consensus and receives network transaction fees but does not participate in governance. +2. **Governor** — a human node who participates in voting procedures according to governing requirements. If governing requirements are not met, the protocol converts him back to a non-governing human node automatically. +3. **Delegator** — a Governor who decides to delegate his voting power to another Governor. + +# Cognitocracy + +There are several primary objectives of cognitocracy. First, it is to form an elitogenesis sequence where the elite of the society is formed through demonstrating creative brilliance that evolves into real-world benefits. Second, to create a just competitive ecosystem for specialists that would constantly drive creativity and innovation to higher levels. Third, to make sure that there is a separation of power based on proof of knowledge and specialization, rather than a popularity contest. Fourth, to foster a cooperative environment that drives optimization and creativity to its highest potential. Fifth, to create an evolving intellectual barrier for obtaining voting rights and legislative mandate. + +This will be an ongoing research for quite some time, so please consider everything laid out in this paper to be hypothetical and highly experimental as there is an absolute lack of any empirical evidence or data to back up most of the hypotheses. This paper will go through a tremendous update after a real-life cognitocratic system will have been built and functioned for quite some time to gather data. + +Delving into the philosophical base we can outline five major principles that molded into the approach behind the design of cognitocracy. + +1. Technocracy +2. Meritocracy +3. Intellectual qualifications for obtaining voting rights +4. Hybrid of direct and representative democracies +5. Liquid democracy + +Undeniably technological development is one of the most influential processes that affects human species. Arguably, the most pivotal moments in our history were determined by our technological evolution. Technocracy implies that the decision-makers are selected based on their technological knowledge and technology-oriented methods of solving issues. It is often criticized for being elitist in nature as technocracy is closely associated with technological oligarchs that concentrate capital and emerging tech to get a grasp on the future development of their systems and thus have an undisputed rule over critical governing instruments or bodies. Cognitocracy inherits the principles of technological innovation and concentration of power in the hands of those who strive for innovation and technological development but puts aside the plutocratic trait of a common technocracy. + +Meritocracy has always been described as an ultimate form of governance. From Aristotle and Plato to Oliver Cromwell and Tomas Jefferson, hundreds of philosophers and key historical figures throughout history have claimed that merit must be the decisive factor in determining who is fit to rule. Unfortunately, as with any other idealistic philosophy, the world has never seen a real meritocracy in action. Favoritism, nepotism, blood-based capital inheritance and some other factors have been obstructing meritocracy from being enacted for a prolonged period of time. Cognitocracy cannot function without meritocracy. Cognitocracy derives from the very principle of merit but with a strong inclination towards creativity and innovation rather than functionality and working experience. The relationship between the two models isof the utmost importance for Humanode governance and will shape how the future governing apparatus is formed and is regulated. + +Introducing intellectual qualifications for obtaining voting rights has been a long-standing theme of debates all over the democratic world since the widespread adoption of democracy. There are two different cases here. First, the baseline intellectual tests for admittance of voters to broader elections. Second, the proof of expertise in a certain field to be eligible for a legislative position. The first case doesn’t concern cognitocracy, as there are no elections and no voting rights for those who haven’t proven their creative merit. The second one is where the challenge stands. Usually, this problem is addressed through obtaining diplomas in universities that satisfy unwritten requirements to enroll into a legislative body, but on paper not a single leading democracy of the modern world requires proof of one's intellectual capabilities in any form, while an overwhelming quantity of MP’s have some kind of higher education. Due to how most of the educational systems function, in today's world, ownership of a diploma of a higher education is in no way evidence of possession of innovative brilliance or decision-making capabilities which are necessary for effective legislature. Cognitocracy aims to establish intellectual barriers for obtaining voting rights in legislation with the difference that the test to understand one’s merit is conducted on the spot through proposals and not through a third-party institution of any kind. More about the enrollment procedure can be read below. + +Cognitocracy aims to maximize the direct participation of eligible voters in the governing process while acknowledging the delegation of vote as a necessary instrument to express political will without constant participation. Delegation is a critical variable, implementing a cognitocratic system without this instrument makes the system explicitly elitist. This question is addressed in more detail in a separate section below. + +In the case of cognitocracy, liquid democracy is applied to voice delegation, as there are no elections. Cognitocracts can only delegate their power to other cognitocrats. Such an approach makes the voting system more dynamic and reactive. The voice can be retracted at any given moment. Compared to elective mandate one's voice is not burnt away if their candidate loses the race. Utilization of liquid democracy brings other benefits such as reduced polarization as it allows voters to support representatives based on specific issues rather than aligning with a particular party or ideology. This can lead to more professional and issue-specific decision-making. The ability to change delegation at any time allows voters to respond dynamically to changing circumstances or evolving opinions. This adaptability ensures that representation remains aligned with the cognitocrats' current preferences. + +Cognitocracy blends various underlying principles of the abovementioned postulates but puts the prevalence of an inventive, decisive, rational and intelligent mind above all else. + +## Chambers of Vortex + +### Specialization Chambers + +Cognitocracy functions through Specialization Chambers (SC) each representing a distinct field of expertise. These Chambers are governed by specialists - human nodes who had their innovative proposal accepted by a qualified majority of voters within a specific chamber. The fundamental principle of the Chambers is that only those who have convincingly demonstrated their creative merit to the majority of specialists in their respective field are granted the right to vote on matters related to that field. SCs aim to shard the legislative process to make it more professional and efficient giving the decision-making capabilities only to those who have undisputedly proven their intellectual properties to the majority of specialists. + +An egalitarian cognitocracy that follows the original postulates of Humanode would have a strict invariant of 1 governor-cognitocract = 1 vote so that scales of power remain balanced and so that the voting power is distributed and decentralized as much as possible. 1 human = 1 node = 1 cognitocrat = 1 vote. + +Let’s go over an example. Consider, for instance, the Programming chamber. While various subdivisions exist such as protocol, front-end, cryptobiometrics, etc., their consideration is presently omitted. To attain the status of a legitimate governor in the Programming chamber, one must present a proposal characterized by innovativeness, practicality, and realistic achievability for approval by the governors. The governors involved in the voting procedure have previously demonstrated their intellectual capabilities and field expertise through the approval of their own proposals and thus are cognitocrats. Consequently, only engineers with established specialization are granted the privilege to vote, thereby notably mitigating the probability of endorsing flawed or non-professional proposals. + +This approach sets a system where only those Humanode validators who can pass the intellectual barrier get the right to vote and become governors, but at the same time all governors are always equal in terms of voting power. + +The design of the SC suggests that their main purposes are: + +- Parallelisation of consensus without losing the quality of decision-making +- Egalitarian distribution of decision-making power among the cognitocrats +- Maintenance of an intellectual barrier for emancipation of voters +- Dynamic distribution of vote delegation + +This structure in some way might resemble ministries but has three major differences. First, usually ministries are a part of an executive branch while chambers are a legislative one. Second, ministries are formed from the outside, either a parliament or a president or a governing body sets their structure, while branches in cognitocracy are self-formed and self-regulated. Third, every ministry might have its own unique structure while chambers in cognitocracy maintain a uniform hierarchy structure. + +The voting thresholds for accepting a proposal will be set at a qualified majority of 66.6% + 1 vote. Meaning that 66.6% + 1 casted vote including delegated ones are in favor of a proposal. The quorum will be considered assembled if 33% of active governors of the chamber partake in the voting. + +The voting procedure, various thresholds and quorum mechanics are described in more detail below. + +Cognitocracy strives to be a dynamic system. With chambers arising only as a necessity to address a rising throughput of challenges and proposals from the same field. + +### General Chamber + +Besides SCs there exists a General Chamber (GC) that incorporates all cognitocrat-governors that reside in a system regardless of their field of expertise. A GC acts as a venue for proposal submissions that affect a system as a whole. Any ruling of a GC is absolute and must surpass SCs in its legislative power as it would represent a democratic consensus of the whole system. + +In rare cases, a GC can also be used as a method for forced admittance of a proposal to a SC. For example, if a SC proposal was locally declined several times then a proposer could submit it to a GC and if the cognitocrats vote to accept it then it will be enforced upon the SC and this proposer would still become a cognitocrat. The difficulty in this is that getting the quorum in a GC is much harder than in a SC and cognitocrats will be reluctant to contradict the opinion of experts in the respective SCs to enforce a proposal that was declined by the experts locally. + +### Chamber structure + +Vortex uses a General Chamber (GC) and Specialization Chambers (SC). There are no sub-chambers. + +### Chamber inception + +SC creation follows the same steps as any other voting process - a proposal must be made and voted upon. There are several small differences though. First, only an established governor-cognitocrat can propose to form an SC. Second, a number of cognitocrats must be nominated to become the first cognitocrats in that chamber. Third, an approach to receive Cognitocratic Measure must be chosen. Read in detail below. After the chamber is created the newly established cognitocracts can admit new members as usual. + +### Chamber dissolution + +There are two ways to dissolve a chamber. First, through a proposal inside the SC. Second, through a proposal in the GC. Same as with inception, only cognitocrats can make such a proposal. The dissolution can be purely functional, when cognitocrats decide that an SC has become irrelevant, or it can be a vote of censure. If there is suspicion about a chamber being corrupt or some other malicious reason, a GC vote can decide to implement a motion of non-confidence. + +For all types of dissolution the procedure follows the guidelines of the ordinary voting procedure. With a small exception that during a GC vote of censure the cognitocrats who are a part of the targeted SC can’t partake in the voting and are not counted in the quorum. + +The penalties for residing in the dissolved SC are contextual and should take into consideration not only the properties of the system itself but also the actual case of why the chamber is dissolved. + +### Proposal Pools + +Proposal pools act as means to filter out crucial proposals and implement the quorum of attention. As mentioned before, every chamber has a proposal pool attached to it. The General Chamber also has its own proposal pool. + +Proposals are submitted in a free form to the proposal pool of a respective SC. Active governors act as scouts that go through the submitted proposals and either upvote or downvote those proposals that they consider to be either useful or harmful. As soon as a proposal receives attention from 22% of active governors in a chamber, but not less than 10% of upvotes from governors, it gets propelled to a chamber where the voting procedure begins. + +Unless a proposal passes the necessary threshold to move onto the SC it remains in the proposal pool. The proposer is able to withdraw the proposal from the pool at any time, but receives a certain cooldown before being able to cast the same proposal once again. Every voting chamber is able to determine the cooldown necessary for various types of proposals submitted into its proposal pool. + +The biggest distinction between the Voting Chambers and the proposal pool is the fact that delegated votes are not counted in the proposal pool. Thus 22% quorum of attention casted would come from real human active governors only. Such an approach curtails the ability of popular governors with a big following and a lot of delegated voices to drive the narrative and the agenda of a chamber. + +Proposal pools act as drivers of the attention of the ecosystem participants. They allow for the community of governors to decide which proposals must be voted upon urgently and which proposals should be revised. + +# Voting, Delegation and Quorum + +### Quorum of attention + +This quorum is applied in every proposal pool in the system. Proposals that receive upvotes or downvotes from 22% of active governors in a SC, but not less than 10% of upvotes, are immediately conveyed to a Chamber for voting. + +### Quorum of vote + +This quorum is applied in the chambers. A quorum is reached if at least 33% of the Governors vote on a proposal. If 66.6% + 1 of the Governors, within the quorum, vote to approve a proposal, then Vortex will consider it approved. This means that 22% of the governors in the chamber will be the necessary minimum to approve a proposal. Human nodes that do not participate in governance are not counted in reaching a quorum. The voting power of each governor is equal to 1 + the votes of his Delegators. + +Any proposal that is pulled out of the proposal pool gets a week to be voted upon in the respective chamber. + +## Vote delegation and quorum + +Cognitocratic vote delegation is one of the most controversial topics laid out in this paper as permittance of vote delegation opens up a pandora's box of power concentration behind an individual cognitocrat. Conditioning here is intertwined with how a system approaches to gather a quorum. + +If there is an absolute quorum where all the governing agents are considered during the count then not implementing a vote delegation entails risk of voters apathy - not being able to gather a necessary quorum to make any decision. On the other hand, if it is enabled then it makes the risk of centralization and corruption much higher as it is much easier to affect inert and apathetic voters to centralize around certain individuals. “Delegate and forget”.\ +\ +If there is an active quorum where only active governing members are included into the quorum count then not implementing the vote delegation makes the whole system very elitist as only those cognitocrats who have time and resources to dedicate themselves fully to governance will have the right to vote. In this case, it doesn’t even make sense to enable vote delegation only from active participants as if one is an active governor why delegate your vote in the first place if one is actively governing? + +The most interesting case, and the one Humanode is going to utilize, is counting only active governors in the quorum but allowing non-active cognitocracts to delegate their voice to any active cognitocrat. This type of a system tries to balance the elitism of cognitocrats with irrationality of a broader audience as delegation is allowed but only for those who have proven their merit. What is also unique about this delegation is that one can only delegate his voice to a fellow cognitocrat from the same chamber. Thus even delegation of voice becomes specialized. + +### Veto rights + +There should be a Veto system in place to prevent cases where the majority is wrong. This Veto should be temporary or still breakable with several attempts. It should be done so that there is no mechanism to stop the accepted democratic consensus, but slow one down to reconsider. + +In Vortex the veto power is distributed among the cognitocrats with the highest Local Cognitocractic Measure (LCM) in their respective chambers. The LCM is described in more detail below. For example, if there are 12 specialized chambers the veto power would be distributed among 12 individuals who have obtained the highest LCM. If 66.6% + 1 person decides that some proposal should be vetoed, then the proposal will be sent back to vortex for two weeks. The temporary veto can be set twice. If the proposal gets approved for the third time then no veto can be implemented on it anymore. + +This Veto mechanism is a necessary tool of deterrence not only in the cases of minority vs majority, but also from direct attacks on a governing system from various vectors. + +### Voting procedure + +

Figure 3. Voting procedure

+ +# Cognitocratic measure + +Cognitocratic measure (CM) is an attempt to objectify the contribution of each cognitocrat to the system as a whole. It’s a numerical score that is received by a cognitocrat every time a proposition is accepted by a chamber. Instead of just voting “Yes”, voters must also input a number (for the simplicity of the example let's assume that it is on a scale from 1 to 10). The average number that is derived from all the inputs converts into the CM received by the proposer. While CM tries to objectify contribution it is still a subjective measure that wouldn’t and shouldn’t in any way directly empower the mandate of any particular cognitocrat. Instead, it subjectively demonstrates to others the magnitude of contribution of a particular cognitocrat. It would be logical to state that the bigger the CM the more contribution was committed. + +### Cognitocratic measure multiplier + +As a cognitocratic system consists of multiple chambers depending on the specialization, cognitocractic measures received from different chambers can’t have the same value. A CM of 5 received in the Chambers of Consensus and Cryptobiometrics can’t be the same as a CM of 5 received in the chamber of Social Relations and Marketing. The relation between these two measures is severely contextual as a cognitocractic system can value Nuclear Physics more than Marketing or vice-versa. For this reason, a multiplier set for each chamber would help define the proportions between contributions to different chambers. + +**Local Cognitocractic Measure (LCM)** subjectively demonstrates the amount of contribution from a cognitocrat in a specific chamber. + +**Multiplied Cognitocratic Measure (MCM)** is LCM multiplied by the chamber multiplier. + +**Absolute Cognitocratic Measure (ACM)** represents the sum of all MCM’s received by a cognitocrat in various chambers. + +Absolute Cognitocratic Measure formula is as follows, + +$$ +ACM = \sum\_{i = 1}^{n}{LCM\_{Chamber(i)} \cdot M\_{Chamber(i)}} +$$ + +Where **i** is a specific chamber and **M** is a chamber’s multiplier. + +Let’s give a specific example.\ +\ +Bob has 5 LCM in a Philosophy SC and 10 LCM in SC of Finance.\ +\ +Multiplier for Philosophy SC is 3. Multiplier for Finance SC is 5.\ +\ +Thus, + +$$ +ACM = (3 \cdot 5) + (10 \cdot 5) = 65 +$$ + +Bob’s ACM is equal to 65. + +### Setting the Multiplier + +As CM plays an important role in projecting the contribution of a cognitocrat to other cognitocrats, the process of setting the multiplier for the chambers becomes crucial as it changes the balance of contribution projection. There could be various forms of such a procedure but as it was set in the beginning of this paper I will concentrate on describing the most egalitarian form conceivable. + +Every single cognitocrat can set a multiplicator on a scale from 1 to 100 for any chamber other than those where he received LCM. The average multiplier calculated from submissions becomes the Chamber Multiplier. In other words, it's the collective perception of the value brought to a system by a chamber that is represented by the average multiplier that is set by all cognitocrats but those who reside in the chamber itself.\ +\ +If a cognitocrat has received LCMs in multiple chambers then he is locked out from setting multipliers in all of those chambers. + +

Figure 4. Setting the multiplier

+ +As can be seen on this Figure every cognitocrat sets a multiplier to every other SC they are not a part of. Eve and Mallory cast multipliers of 2 and 6 to SC1 which on average sets it to 4. Alice, Bob and Mallory cast multipliers of 2, 2 and 5 to SC2 which on average sets it to 3. Alice and Eve cast multipliers of 8 and 4 to SC3 which on average sets it to 6. + +### Cognitocratic measure in cases of SC inception or dissolution + +During an inception there are three outcomes that might be put in place by a proposer upon an approval of creation of a new SC : + +- A proposer (and nominees) receives ACM in a GC and both the proposer and nominees become cognitocrats in a created chamber. As in the case of any other proposition in a GC an average number submitted by approving cognitocrats becomes a CM received by the proposer. +- A proposer (and nominees) receives LCM in the created SC with the difference that the number is aggregated from a GC vote. +- A proposer doesn’t receive any CM but still becomes a cognitocrat in a newly created SC. + +Dissolution comes with a separate set of outcomes that are also very contextual. There are two major cases: + +- Cognitocrats from a dissolved chamber retain their LCMs. Even if the SC doesn’t exist anymore the CM retains some legacy value that is either adjustable as others or frozen. +- Cognitocrats from a dissolved chamber lose all of their CM associated with that certain SC. This case is highly associated with a vote of censure. + +
+ +# Formation + +Vortex governs the Humanode by deciding on key parameters through the voting power of human nodes. Formation is a part of the Humanode. It is a special grant-based development system providing grants, investments, service agreements, and projects to build. It is dedicated to supporting the Humanode network and all related technologies. It will be available for interaction through the Vortex DAO app. + +Any human node can join Formation to make a grant proposal or apply to become a part of a team that already develops an approved proposal. Proposals by non-human nodes can only reach Formation if one of the governing human nodes decides to nominate them. Such limitations allow us to protect devoted followers and contributors to the Humanode network. + +Human nodes create proposals, allocate funds for their implementation that are sourced from the community and the treasury, and take coordinated action to see the proposals implemented properly. Governors upvote and downvote them. We assume that 2% of fees go to Formation as the network begins to function. Then, the proposers, i.e., Vortex, will regularly determine the percentage of the fees going to Formation. + +It is worth noting that there is no need for participation in governance to partake in Formation. Any bioauthorized human node is allowed to join any project. + +The Humanode network’s DAO supports a number of different proposal directions. + +Generally, Formation funds: + +- **Research:** Advancing basic and applied research in cryptobiometrics, cryptographic primitives, distributed systems, consensus mechanisms, smart-contract layers, biometric modalities, liveness detection, encrypted search, and matching operations.
+- **Development and Product:** Development turns research into software, while Product turns it into user experiences. Formation is primarily interested in technologies that expand the Humanode network, its potential, capabilities, and security, as well as the ecosystem, from decentralized finance and non-fungible tokens to decentralized courts. +- **Social Good & Community:** Formation supports community members to bring awareness to open-source, decentralized networks, and biometric technologies, and scale community outreach for the Humanode network. The Formation funds are mainly used to maintain the network. + +### Assembling a team + +We understand how crucial it is to find and coordinate people that are willing to support the + +proliferation of the Humanode network. That is why we are developing a special team-assembly + +procedure in the Vortex DAO app that will allow those whose proposals were approved by Vortex to find passionate professionals to assemble their team from the members of the international Humanode community. All the proposer has to do is send a digital offer to any other human node that he thinks is a good fit for his projects. Their proposal must have the public address of the potential member, and it should state working objectives and conditions and have a smart contract that locks some part of the grant for that person in particular and saves the data onchain. + +There can be: + +- **Full team projects**, with predetermined participants; +- **The team is partially assembled**, with spots that can be filled later from the community; +- **No one in the team yet**, with all the slots available for the community. + +It is worth noting that there is no need for participation in governance to partake in Formation. Any bioauthorized human node is allowed to join any project. + +
+ +# Conclusion + +Vortex is a cognitocratic-meritocratic governing system with proposition rights emancipation based on various proofs of dedication towards the ecosystem. The parallelization of consensus through specialized chambers, that work through proposals voted upon only by proven specialists, is intertwined with the proposal right emancipation system that would allow gradual decentralization of power. + +The more actively governor-cognitocrats participate in the governance - the more robust, democratic and decentralized the chain becomes. Each and every human node is safeguarding the system from centralization and Sybils by maintaining cryptobiometric Sybil-defense. Only by active participation and direct expression of your will can the network become and remain decentralized. Potential governors must approach their duties with utmost efficiency and do everything in their capabilities to actively deter centralization efforts for personal or purely mercantile reasons. + +
+ +# Discussion + +### Gradual decentralization + +Obviously, the Humanode network will rely heavily on the activity of its Governors. Besides building the technological solutions stated in this paper, the Humanode core will promote full transparency of governing processes and transactions, design and deploy decentralized governing processes, participate heavily in the Humanode community, and make development proposals. The Proposal Pool System/Vortex–Formation governance stack was designed by the Humanode core to create a hybrid Proof-of-Time/Proof-of-Devotion/Proof-of-Human-Existence safeguarded network. This implementation allows us to lower the influence of the problems that affect any system that tries to integrate democratic procedures: + +1. Voter apathy is a very widespread problem that entangles every single voting system. The biggest part of this problem is the inability to reach a quorum. The Humanode network demands governance participation in proposals and voting from Governors and proof of existence from all human nodes. Those Governors who do not fulfill monthly governing conditions (either they did not make proposals or did not vote on any proposal) are automatically converted to non-governing. Quorum is reached if 33% of Governors vote upon a proposal, so it means that only voices of those who actively participate in governance are calculated to reach a quorum. +2. Masses are often mistaken. It is common sense that a small, dedicated group of professionals with years of experience would be able to give a more precise and correct opinion on a particular voting matter than a mass of people with different backgrounds and education. To balance the democratic approach with professional education and experience, Humanode core came up with a hybrid Proof-of-Time/Proof-of-Dedication governance system named “Vortex”, in which Governors have different tiers. They can be promoted in tiers if certain requirements are met. This way the protocol gives more tools and proposal rights to those who have more experience and have proven their devotion through Formation. The necessity to have your proposal approved before becoming a Governor acts as a Proof-of-Devotion step that uplifts the quality of Governors and acts as an important layer of defense against Sybil attacks. +3. Inability to directly delegate your vote to any other voter in a system creates many different forms of how the voting procedures take place. The very systems of how electoral delegates are chosen have loopholes that allow political tricks such as gerrymandering and filibustering. Governing human nodes are designed to be equal in voting power; at the same time, the voting mechanisms allow you to delegate your vote to any other human node without boundaries. A Governor’s voting power equals 1+ the number of delegations he has. + +### The iron law of oligarchy + +“Who says organization, says oligarchy.”\ +“Historical evolution mocks all the prophylactic measures that have been adopted for the prevention of oligarchy.”\ +\ +\&#xNAN;**_- Robert Michels_** + +This hypothesis was developed by the German sociologist Robert Michels in his 1911 book, ’Political Parties.’ It states that any organizational form inevitably leads to oligarchy as an ’iron law’. Michels researched the fact that large and complex organizations cannot function efficiently if they are governed through direct democracy. Because of this, power within such organizations is always delegated to a group of individuals. + +In Michels’s understanding, any organization eventually is run by a class of leaders regardless of their morals or political stance. Monarchies and republics, democracies and autocracies, political parties, labor unions, and corporations, etc. have a nobility class, administrators, executives, spokespersons, or political strategists. Michels stated that only rarely do representatives of these classes truly act as servants of the people. In most cases, people become pawns in never-ending games of power balancing, networking, and survival. Regardless of the inception principles, the ruling class will always emerge and in time it will inevitably grow to dominate the organization’s power structures. The consolidation of power occurs for many different reasons, but one of the most common ways is through controlling access to information. + +Michels argues that any decentralized attempts to verify the credibility of leadership are predetermined to fail, as power gives different tools to control and corrupt any process of verification. Many different mechanisms allow serious influence on the outcome of democratically made decisions like the media. Michels stated that the official goal of representative democracy of eliminating elite rule was impossible, that representative democracy is a facade legitimizing the rule of a particular elite, and that elite rule, which he refers to as oligarchy, is inevitable. + +This law is directly applied to modern elites. The financial network is always a complex multilayer construct that requires a great deal of administrative and organizational power. According to Michels, such a system would inevitably become oligarchic. While designing the basic principles of the Humanode network and Vortex, the Humanode core was faced with a challenge to find a delicate balance between organizational efficiency and the democratic involvement of the masses. We believe that a combination of voting power equality, unbiased intellectual barriers, direct delegation, Proof-Of-Time, Proof-of-Devotion, and proof-of-human existence would make a very balanced and just system, but it will not solve the problem of ‘Iron Oligarchy,’ as a leadership class will definitely emerge. + +Fiat credit-cycle systems have large financial entities, PoW networks are faced with miner cartels, PoS systems have validator oligopolies, and Humanode has Citizens and research groups. Governors have different proposal rights based on different tiers. Citizens have absolute freedom in proposal creation as they can put forth an idea of any type and some even wield a right to veto any decision that is approved by Vortex twice. Legate and Citizen freedom of authority is balanced out by the voting mechanism that requires a quorum and an absolute majority of those voting for a proposal to be approved. As the absolute majority of Governors is required for a decision to be approved, it negates the ability of Legates and Consuls to approve something against the will of the majority of voters. + +In a perfect world where all participants of the network actively govern, this balancing effort should be just enough to minimize the influence of any type of oligopoly that might emerge in the Humanode network, but we do not live in a perfect world. The apathy of voters is a scourge to most of the voting systems that exist and creates the necessity of vote delegation, which has its own advantages and disadvantages. + +### Vote delegation + +Problems of vote delegation have always accompanied any large democratic system. The core problem of democracies in their purest form is that they are very vulnerable to the Byzantine Generals Problem (BGP). Any system has a critical point of failure. Large systems tend to have several or dozens. Because of this, any democratic system requires institutions built on top to protect those critical points. These institutions limit the direct voting of the masses on crucial matters. There are four main reasons why these limitations are a necessity. + +1. Strategic resources, critical points, and stability. Any system has a sensitive part. For example, some countries wield nuclear arsenals and have democratic political systems. The vote on the deployment of nuclear weaponry is commonly restricted to a very small group of individuals. It makes sense that such an important spectrum would be heavily guarded against any angle of attack, especially the BGP. That is why this part of the system requires consolidation of power and an autocratic approach in decision making. Besides weapons of mass destruction, there are financial, energetic, military, trading, diplomatic, intelligence, etc. chokepoints that unless safeguarded can be used by the enemies of that system to cause catastrophic events and lead to destabilization. Natural autocracy rises in the chokepoints of strategic value. +2. Apathy of voters and effectiveness. Lack of caring among voters in voting procedures can lead to a halt in governance, as most voting requires some kind of a quorum. If apathy is strong enough to stop a quorum from being raised then the governance process stops until a quorum is reached. Some operations and decisions require the constant active involvement of voters, which is where delegation comes in hand. Ordinary people do not want or have time to participate in governance, which is why in representative democracies citizens can cast their vote to elect representatives that are actively involved in decision making. The fewer people participate in voting, the easier it is to coordinate. +3. Technological limitations. Before the digital era, there was no effective way to conduct voting procedures, as communications were not as developed as they are now. Without proper confirmation of identity and support of modern tech, it was hard to imagine a way to conduct large direct voting without putting strain on administrative resources. Delegating to a politically active person negates the necessity of using sophisticated technologies to conduct legislative procedures. +4. Misrepresentation. In most democracies your vote is restricted by the region you are geographically located in, meaning that you can cast a vote for a nominee tied to your constituency, but he might not get elected, meaning that your vote was practically burned and a person that you did not vote for might be representing you. Most governing systems lack the freedom of vote delegation, as you cannot directly delegate your voice to a particular person. + +While devising the voting procedures for Vortex, the Humanode core has kept in mind the principles mentioned above. The Governor tier system safeguards critical points by limiting the abilities of the electorate to create proposals but at the same time, the autocratic chokepoint is balanced out by requiring a quorum of Governors to approve created proposals. The influence of apathy of voters is limited by demanding voting activity from human nodes to be counted as Governors. This way only active participants of the network are counted in reaching a quorum. The technological progress in DAO deployment and biometric processing in the last decade has brought forward a way to overcome the obstacles of the past connected to direct voting procedures and the uniqueness of voters. Delegation of voting power is chamber-scoped: a governor can delegate their vote to another eligible governor within the same chamber. We acknowledge that even with modern approaches to voting and technological breakthroughs, a delegation mechanism in the Humanode network is a natural necessity. + +The digital revolution has paved the way for technologies that allow us to create systems with liquid representative democracies. Compared to traditional representative democracies, a voter can re-cast his vote any time he wants, without the necessity to wait for years to do it again. Vote delegation can be changed anytime. Delegated PoS (DPoS) protocols implemented liquid democracy for delegating transaction validation operations to professional entities. As the validators are safeguarding the protocol and receive a commission for their operation, the voter’s choice is usually driven by economic incentives: how the commission size, uptime, and security of the delegate’s server might reflect on the voter’s earnings. Is that enough to choose an opinion representative in a decentralized network? Most DPoS networks have a strict unbounding period that can last up to two weeks or even months. This measure is a necessity to safeguard the system from manipulated panic-based market crashes where Delegators undelegate their tokens and sell them in fear of losing value. In the Humanode network, voting power is not entangled with a token, which is why there is no need for unbounding periods. Any time a human node wishes to re-cast or simply retrace its delegation it can be done instantly. + +### Populist tide and professional backslide + +It is commonly acknowledged that any voting system faces the problem of too much populism. Hypothetically there are two major approaches to how populism is perceived: + +- Populism poses a threat to democratic stability. According to recent studies, conducted by Jordan Kyle and Yascha Mounk of the Tony Blair Institute for Global Change, one of the key findings they have had is that populists are far more likely to damage democracy. Overall, 23 percent of populists cause significant democratic backsliding, compared with 6 percent of nonpopulist democratically elected leaders (J. Kyle & Y. Mounk, 2018). In other words, populist governments are about four times more likely than non-populist ones to harm democratic institutions. +- Populism is a necessary corrective mechanism that addresses popular problems and limits the power of elites. + +Regardless of which view is more accurate, populism is acknowledged to be a very powerful tool to gather the support of the masses in democratic systems. The main danger perceived by the Humanode core is the rise of populists. Individuals that know how to be popular do not necessarily have the intelligence, professional qualities, experience, or profound knowledge on the subjects they have to make decisions upon on a regular basis. + +In the Humanode network, every human node has a voting power of 1. Voting delegation in Humanode allows governors to delegate their voting power to another governor in the same chamber. Governor power equals 1+ the number of delegations from other governors. Such a system allows crowdsourcing possibilities as delegation is liquid and not regionally bound. As in any other democratic system, individuals that possess oratory, diplomatic skills and are backed by influential media sources have an advantage in the Humanode network. An introvert with sociopathic tendencies possessing a very professional skill set for decision-making operations will most likely receive less support than a good negotiator, orator, and crowd controller that possesses a mediocre skill set. This is slightly balanced out by the fact that human nodes must have an accepted proposal before they become Governors. Thus Governors should be less affected by populist media, as they have a confirmed intellectual skill set that allowed them to create a useful proposal accepted by the Governors of Humanode. + +In Vortex voting procedures, Governors have disproportionate voting power and those Governors that have more delegations have more power. The professional backslide in our understanding poses a threat to the effectiveness, progressiveness, and constant optimization of governance. We fear that without Proof-of-Devotion, which is in a way a proof of having some kind of professional skill set, any democratic system faces becoming a plutocracy, where the wealthiest members control influential and credible media sources to direct the opinion of masses and drive support to candidates of their choosing. + +Proof-of-Devotion might bring a small balance to populism upheaval, as it demands participation in Formation to receive proposal rights on critical matters. Nevertheless, Consuls wielding huge delegations will inevitably emerge and their stance in decision-making mechanisms will be very strong. The only way to limit their influence is the direct and active participation of human nodes in governing processes. The more Governors that do not delegate their vote and actively participate in governance the less authority can be accumulated in the hands of those that seek it. + +### Attack vectors on Cognitocratic core + +#### Plutocracy + +Plutocracy is a state of a governing system where most of the decision-making capabilities are affected or concentrated in the hands of large capital owners. The bigger the plutocratic effect on the system the more power and effort are diverted from continuous optimization, innovation and professional development to supporting and lobbying the interest of capital holders with an intention of maximizing profits through legislative interference. + +Plutocracy is not rooted in any established political philosophy because every single political system on the planet is scourged by it. It’s less of a political system rather than a constant pressure from the elites whose power is derived from the wealth they own. Without transparency and proper deterrence any political system can become a plutocracy by being corrupted from the inside. Traditionally, the most vulnerable spot is elections. Political actors provide lobbying in exchange for electoral support and donations to a warchest. Little by little plutocrats infiltrate all branches of the government, including the legislative. In the end, instead of having a professional system “of the people, by the people, for the people” composed of specialists promoted based on their merit, modern political systems end up with political actors that struggle for power by pleasing their plutocratic overlords + +Cognitocracy is also vulnerable to plutocracy like any other system, but compared to other models it has some benefits that help to keep the plutocrats at bay and deter their influence. + +- There are no elections in a cognitocracy. No position that is elected through a popularity contest. All Cognitocracts have the same voting power so there is no point of direct exchange of electoral support for lobbying. +- If plutocrats want to help out a cognitocrat for him to lobby their interest in the future they have to divert resources to foster an actual creative innovation so that it is accepted by specialists. So instead of just spending capital on marketing. +- Media campaign doesn’t play such a crucial role in voting in a cognitocracy. As a proposer is addressing a professional minority, it is more crucial to prepare and deliver a good paper than to have a massive public image. This is achievable without any external help, thus lowering the influence of plutocrats. + +#### Cognitocractic populism + +Populism is a political approach that seeks to appeal to the interests and sentiments of the general population, often by presenting itself as a champion of the common people against a perceived elite or establishment. Notoriously, to gain power populists might support a popular resolution that satisfies the needs of the masses, but in reality is harmful and counterproductive. + +The very essence of cognitocracy addresses the issue of populism as its effects are limited by making sure that only specialists get to vote. It becomes way harder for populists to amass power as instead of appealing to the masses they would have to appeal to cognitocrats. Although one could argue that potential populists would still find a way by appealing to the most popular problems faced by cognitocrats. + +Promoting education and media literacy is the most long-term effective way of combating populist power grab. They both foster critical thinking, help potential voters distinguish between reliable and unreliable sources of information. Encourage fact-checking and critical evaluation of news and information. + +There is a hypothetical assumption (as there is no data yet) that a cognitocrat who was able to come up with a specialized creative innovation or optimization and was accepted by other cognitocrats will be less susceptible to false media pretenses and much more empirical in critical thinking than a broader population voter. An intellectual barrier that potential cognitocrats have to pass through also acts as an anti-populism barrier as, hypothetically, cognitocrats must personally reach some level of critical thinking and evaluation to actually come up with an innovation. + +#### Cognitocratic drain + +Cognitocratic drain is a potential state in which a certain field has so much innovation and optimization implemented that it becomes very hard to come up with something creative to accept new cognitocrats. This state can lead to several critical changes that might affect the efficiency of a SC. + +It can lead to: + +1. Lowering the barrier for admittance. Proposals become not as innovative or professional as they were before thus lowering the quality of cognitocrats admitted. +2. Emergence of innovative but non-practical proposals and their admittance. +3. Cartelization of a chamber without admittance of new cognitocrats and an emergent hierarchy. + +If such tendencies arise in a SC it might be viable to either dissolve this chamber or merge it with another. This problem represents the very root of how cognitocracy functions. As mentioned above cognitocracy aims to be a dynamic system that creates and dissolves chambers according to the prevalent needs and throughput of proposals of a certain specialization. If the size of a chamber becomes disproportionately larger than the actual decision-making needs and potential innovation that this chamber might bring then it will inevitably fall into the state of cognitocratic drain which will certainly lead to above-mentioned consequences and pose a threat to a cognitocratic system as a whole. + +[_That's all for now. If you read everything till the end then you are now my personal hero! Thank you so much!_](#user-content-fn-1)[^1] + +[^1]: + +
+ +# Meritocratic measure + +Meritocratic Measure (MM) is an attempt to represent the merit of functional work and delivery in the Humanode ecosystem. While Cognitocratic Measure (CM) is received for having a proposal accepted in a chamber (i.e., demonstrating innovation and decision-making), MM is received through participation in Formation (i.e., executing work and delivering outcomes). + +MM is a numerical score that is received by a contributor after completing a Formation task or milestone. After a delivery is submitted, governors (or reviewers designated by the relevant chamber) provide a rating on a scale (for the simplicity of the example, from 1 to 10). The average rating converts into MM received by the contributor. + +MM does not grant extra voting power. It acts as a reputation and merit signal that helps: + +- Demonstrate execution ability and reliability over time. +- Inform proposition rights emancipation (Proof-of-Devotion) alongside Proof-of-Time and Proof-of-Governance. +- Provide context in Invision insights when evaluating proposers and teams. + +
+ +# Courts and disputes + +Any governance system operating in an adversarial environment needs a structured way to handle disputes. Courts in Vortex exist to process conflicts, ambiguity, and suspected abuse in a transparent, procedural way, without reducing the entire system to informal social pressure. + +Courts handle disputes such as: + +- Delegation disputes and alleged delegation abuse. +- Milestone disputes in Formation (e.g., “delivered but not usable”, “unlock contested”). +- Identity integrity disputes (e.g., PoBU anomalies and suspected coordinated enrolment attempts). +- Governance process disputes (e.g., procedural issues, ambiguous rules, repeated abuse patterns). + +Courts operate through cases with a clear lifecycle: + +1. Case filing (reports + subject + trigger). +2. Evidence and proceedings (claims, evidence list, and planned actions). +3. A dedicated jury session and deliberation window. +4. A verdict and recommended actions (remediation steps, governance follow-ups, warnings, or escalation to a chamber proposal when needed). + +Court outcomes must be auditable and should not silently mutate history. Court actions and verdicts should be recorded as events that can be reviewed later by governors and by the community. + +
+ +# Invision + +Invision is the reputation and system-state lens of Vortex. Its goal is to support the principle of constant deterrence by making the system measurable and legible: governors should be able to see what is happening, who is performing, where risks exist, and how governance is trending over time. + +Invision provides an “Insight” layer that aggregates signals such as: + +- Proposal history (submitted, accepted, rejected, abandoned). +- Delivery history (milestones completed, delays, contested milestones, returned budget). +- Governance participation (vote participation over time, comments and review activity). +- Delegation signals (delegations held, delegation changes, concentration indicators). + +Invision is informational: it does not change the 1 human = 1 vote invariant. It exists to improve decision-making quality, reduce uncertainty, and help governors reason about trust, risk, and centralization pressure with concrete data rather than narratives alone. diff --git a/docs/simulation/vortex-simulation-api-contract.md b/docs/simulation/vortex-simulation-api-contract.md new file mode 100644 index 0000000..e32797e --- /dev/null +++ b/docs/simulation/vortex-simulation-api-contract.md @@ -0,0 +1,1424 @@ +# Vortex Simulation Backend — API Contract v1 + +This document freezes the **JSON contracts** the backend serves so the UI can render from `/api/*` responses consistently. + +Notes: + +- These are **DTOs** (network-safe JSON), not React UI models. +- All DTOs are JSON-safe (no `ReactNode`, no `Date`, no functions). +- Read endpoints are served in two modes: + - DB mode: reads from Postgres `read_models` (seeded by `scripts/db-seed.ts`) plus overlays from normalized tables (votes/formation/courts/era) and canonical domain tables where applicable. + - Inline mode: `READ_MODELS_INLINE=true` serves the same payloads from the in-repo seed builder (`db/seed/readModels.ts`) for local dev/tests without a DB. + - Empty mode: `READ_MODELS_INLINE_EMPTY=true` forces empty/default payloads (used for clean local dev and “no content yet” UX). + +## Conventions + +- IDs are stable slugs (e.g. `engineering`, `evm-dev-starter-kit`, `dato`). +- Timestamps are ISO strings. +- List endpoints return `{ items: [...] }` and may add cursors later. +- When the backing read-model entry does not exist, list endpoints return `{ items: [] }` (HTTP 200). Some singleton endpoints return a minimal empty object (documented below). +- Cursors are opaque and may be backed by different underlying stores (read models vs event log). Clients should treat `nextCursor` as an opaque string and pass it back unchanged. + +## Auth + gating + +Already implemented in `functions/api/*`: + +- `GET /api/health` → `{ ok: true, service: string, time: string }` +- `POST /api/auth/nonce` → `{ address }` → `{ nonce }` (+ `vortex_nonce` cookie) +- `POST /api/auth/verify` → `{ address, nonce, signature }` (+ `vortex_session` cookie) +- `POST /api/auth/logout` +- `GET /api/me` +- `GET /api/gate/status` + +Eligibility (v1): + +- The backend checks Humanode mainnet RPC and considers an address eligible if it is in the current validator set (`Session::Validators`). +- The Humanode RPC URL is resolved in this order: + 1. `HUMANODE_RPC_URL` (Pages Functions runtime env) + 2. `/sim-config.json` → `humanodeRpcUrl` (repo-configured runtime config served from `public/`) +- If neither is configured, the gate returns `eligible: false` with `reason: "rpc_not_configured"`. + +Chamber voting eligibility (v1): + +- `chamber.vote` is additionally restricted by **chamber membership**: + - specialization chamber `X`: eligible if the human has at least one accepted proposal in `X` + - General chamber: eligible if the human has at least one accepted proposal in any chamber +- Genesis bootstrap is configured via `/sim-config.json` → `genesisChamberMembers` (a mapping of `chamberId -> [addresses]` treated as eligible from day one). + +Chambers (v1): + +- Chambers are canonical (`chambers` table). +- Genesis chambers are configured via `/sim-config.json` → `genesisChambers` and are auto-seeded when the table is empty. + +## Write endpoints (Phase 6+) + +### `POST /api/command` + +All state-changing operations are routed through a single command endpoint. Each command requires: + +- a valid session cookie (`vortex_session`) +- eligibility (active human node via RPC gating), unless dev bypass is enabled + +Idempotency: + +- Clients may pass an `Idempotency-Key` (or `idempotency-key`) header. +- If the same key is sent again with the same request body, the stored response is returned. +- If the same key is re-used with a different request body, the API returns HTTP `409`. + +Rate limiting: + +- `POST /api/command` is rate limited: + - per IP: `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP` (default `180`) + - per address: `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS` (default `60`) +- When rate limited, the API returns HTTP `429`: + +```json +{ + "error": { + "message": "Rate limited", + "scope": "ip | address", + "retryAfterSeconds": 30, + "resetAt": "2026-01-01T00:00:00.000Z" + } +} +``` + +Action locks: + +- Writes can be temporarily disabled for an address via admin action locks (`user_action_locks`). +- When locked, the API returns HTTP `403`: + +```json +{ + "error": { + "message": "Action locked", + "code": "action_locked", + "lock": { + "address": "5f... (lowercased)", + "reason": "optional", + "lockedUntil": "2026-01-01T00:00:00.000Z" + } + } +} +``` + +Era quotas: + +- Writes can be capped per era per address (to prevent spam while the community tests the simulation). +- Limits are configured via env vars: + - `SIM_MAX_POOL_VOTES_PER_ERA` + - `SIM_MAX_CHAMBER_VOTES_PER_ERA` + - `SIM_MAX_COURT_ACTIONS_PER_ERA` + - `SIM_MAX_FORMATION_ACTIONS_PER_ERA` +- When a quota is exceeded, the API returns HTTP `429`: + +```json +{ + "error": { + "message": "Era quota exceeded", + "code": "era_quota_exceeded", + "era": 0, + "kind": "poolVotes | chamberVotes | courtActions | formationActions", + "limit": 1, + "used": 1 + } +} +``` + +Additional implemented commands (Phase 12): + +- `proposal.draft.save` +- `proposal.draft.delete` +- `proposal.submitToPool` + These are gated the same way as other writes (session + eligibility). + +#### Command: `proposal.draft.save` + +Request: + +```ts +type ProposalDraftFormPayload = { + templateId?: "project" | "system"; + title: string; + chamberId: string; + summary: string; + what: string; + why: string; + how: string; + metaGovernance?: { + action: "chamber.create" | "chamber.dissolve"; + chamberId: string; + title?: string; + multiplier?: number; + genesisMembers?: string[]; + }; + timeline: { id: string; title: string; timeframe: string }[]; + outputs: { id: string; label: string; url: string }[]; + budgetItems: { id: string; description: string; amount: string }[]; + aboutMe: string; + attachments: { id: string; label: string; url: string }[]; + agreeRules: boolean; + confirmBudget: boolean; +}; + +Notes: + +- This is the v1 “single big form” draft payload used by the current wizard implementation. +- `templateId` is optional; if omitted the backend infers `"system"` when `metaGovernance` is present, otherwise `"project"`. +- The backend now validates drafts using a template-aware discriminant (project vs system) so system proposals can omit project-only fields; missing fields are normalized to defaults for storage. +- Planned (v2+): drafts will continue to evolve into a template-driven discriminated union (project vs system-change flows), with full backend/schema separation. The target architecture and rollout phases are documented in: + - `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` + +type ProposalDraftSaveCommand = { + type: "proposal.draft.save"; + payload: { draftId?: string; form: ProposalDraftFormPayload }; + idempotencyKey?: string; +}; +``` + +Response: + +```ts +type ProposalDraftSaveResponse = { + ok: true; + type: "proposal.draft.save"; + draftId: string; + updatedAt: string; +}; +``` + +Notes: + +- If `draftId` is omitted, the backend generates a new draft ID. + +#### Command: `proposal.draft.delete` + +Request: + +```ts +type ProposalDraftDeleteCommand = { + type: "proposal.draft.delete"; + payload: { draftId: string }; + idempotencyKey?: string; +}; +``` + +Response: + +```ts +type ProposalDraftDeleteResponse = { + ok: true; + type: "proposal.draft.delete"; + draftId: string; + deleted: boolean; +}; +``` + +#### Command: `proposal.submitToPool` + +Request: + +```ts +type ProposalSubmitToPoolCommand = { + type: "proposal.submitToPool"; + payload: { draftId: string }; + idempotencyKey?: string; +}; +``` + +Response: + +```ts +type ProposalSubmitToPoolResponse = { + ok: true; + type: "proposal.submitToPool"; + draftId: string; + proposalId: string; +}; +``` + +Notes: + +- Submission validates that required fields are present (same constraints as the UI wizard). +- The chamber must be valid and active: + - if `draft.chamberId` is unknown, the API returns HTTP `400` with `code: "invalid_chamber"`. + - if `draft.chamberId` points to a dissolved chamber, the API returns HTTP `409` with `code: "chamber_dissolved"`. +- On success, the backend: + - creates a new proposal in the proposal pool by writing `proposals:list` and `proposals:${proposalId}:pool` read models, + - marks the draft as submitted so it no longer appears under drafts. + +#### Command: `pool.vote` + +Request: + +```ts +type PoolVoteDirection = "up" | "down"; +type PoolVoteCommand = { + type: "pool.vote"; + payload: { proposalId: string; direction: PoolVoteDirection }; + idempotencyKey?: string; +}; +``` + +Response: + +```ts +type PoolVoteResponse = { + ok: true; + type: "pool.vote"; + proposalId: string; + direction: PoolVoteDirection; + counts: { upvotes: number; downvotes: number }; +}; +``` + +Notes: + +- If the proposal is not currently in the pool stage, the API returns HTTP `409` (the pool phase is closed once the proposal advances). +- Pool eligibility is enforced (paper-aligned): + - only governors can upvote/downvote in proposal pools + - specialization pools are additionally chamber-scoped: + - voting requires eligibility for that chamber (accepted proposal in that chamber) or genesis membership + - General pool voting requires eligibility in any chamber (accepted proposal in any chamber) or genesis membership + - when ineligible, the API returns HTTP `403` with `code: "pool_vote_ineligible"` and the target `chamberId` +- When pool quorum thresholds are met, the backend auto-advances the proposal from **pool → vote** by updating the `proposals:list` read model. + - If `proposals:${proposalId}:chamber` does not exist yet, it is created from the pool page payload as a minimal placeholder so the UI can render the chamber vote view. + +#### Command: `chamber.vote` + +Request: + +```ts +type ChamberVoteChoice = "yes" | "no" | "abstain"; +type ChamberVoteCommand = { + type: "chamber.vote"; + payload: { proposalId: string; choice: ChamberVoteChoice; score?: number }; + idempotencyKey?: string; +}; +``` + +Response: + +```ts +type ChamberVoteResponse = { + ok: true; + type: "chamber.vote"; + proposalId: string; + choice: ChamberVoteChoice; + counts: { yes: number; no: number; abstain: number }; +}; +``` + +Notes: + +- If the proposal is not currently in the vote stage, the API returns HTTP `409`. +- If the proposal is assigned to a dissolved chamber and was created after the chamber was dissolved, the API returns HTTP `409` with `code: "chamber_dissolved"`. +- Chamber eligibility is enforced (paper-aligned): + - voting in a specialization chamber requires an accepted proposal in that chamber + - voting in General requires an accepted proposal in any chamber + - when ineligible, the API returns HTTP `403` with `code: "chamber_vote_ineligible"` and the target `chamberId` +- `score` is optional and only allowed when `choice === "yes"` (HTTP `400` otherwise). This is the v1 CM input. +- The chamber page read endpoint overlays live vote totals from stored votes (so `votes` and `engagedGovernors` update immediately). +- When quorum + passing are met, the backend either: + - advances immediately to **build**, or + - opens a bounded **veto window** and marks the proposal as “passed (pending veto)”. + - If a veto window is opened, the proposal is finalized to **build** by `POST /api/clock/tick` once the window ends (unless veto is applied). +- When a proposal passes, CM is awarded off-chain: + - the average `score` across yes votes is converted into points + - a CM award record is stored in `cm_awards` (unique per proposal) + - `/api/humans` and `/api/humans/:id` overlay the derived ACM delta from awards + +#### Command: `veto.vote` + +Veto exists as a bounded, temporary slow-down window after a proposal passes chamber vote. + +Request: + +```ts +type VetoVoteChoice = "veto" | "keep"; +type VetoVoteCommand = { + type: "veto.vote"; + payload: { proposalId: string; choice: VetoVoteChoice }; + idempotencyKey?: string; +}; +``` + +Response: + +```ts +type VetoVoteResponse = { + ok: true; + type: "veto.vote"; + proposalId: string; + choice: VetoVoteChoice; + counts: { veto: number; keep: number }; + threshold: number; +}; +``` + +Notes: + +- This command is only valid when a proposal is in `vote` stage and a veto window is open (HTTP `409` otherwise). +- Only veto holders can cast this vote (HTTP `403` otherwise). +- If `counts.veto >= threshold`, the backend: + - clears chamber votes and veto votes for the proposal + - increments `veto_count` + - pauses voting for the veto delay window (then the vote stage re-opens automatically) + - emits feed + timeline events for auditability + +#### Command: `chamber.multiplier.submit` + +Multiplier voting is used to set chamber multipliers based on outsider submissions. + +Request: + +```ts +type ChamberMultiplierSubmitCommand = { + type: "chamber.multiplier.submit"; + payload: { chamberId: string; multiplierTimes10: number }; // 1..100 (represents 0.1..10.0) + idempotencyKey?: string; +}; +``` + +Response: + +```ts +type ChamberMultiplierSubmitResponse = { + ok: true; + type: "chamber.multiplier.submit"; + chamberId: string; + submission: { multiplierTimes10: number }; + aggregate: { submissions: number; avgTimes10: number | null }; + applied: null | { + updated: boolean; + prevMultiplierTimes10: number; + nextMultiplierTimes10: number; + }; +}; +``` + +Notes: + +- Only governors can submit multipliers (HTTP `403` otherwise). +- Submissions are outsiders-only: + - if an address has LCM history in the target chamber, submission is rejected (HTTP `400`). +- Aggregation (v1): average of all submissions for the chamber, rounded to an integer. +- The canonical chamber multiplier (`chambers.multiplier_times10`) is updated to the aggregate average. + +#### Command: `delegation.set` + +Request: + +```ts +type DelegationSetCommand = { + type: "delegation.set"; + payload: { chamberId: string; delegateeAddress: string }; + idempotencyKey?: string; +}; +``` + +Response: + +```ts +type DelegationSetResponse = { + ok: true; + type: "delegation.set"; + chamberId: string; + delegatorAddress: string; + delegateeAddress: string; + updatedAt: string; +}; +``` + +Notes: + +- Delegation is chamber-scoped (v1): a user can set one delegatee per chamber. +- Cycles and self-delegation are rejected (HTTP `400`). +- Delegator eligibility is enforced: + - for General: delegator must be a governor (has an accepted proposal in any chamber) + - for a specialization chamber: delegator must be eligible in that chamber +- Delegatee eligibility is enforced: + - for General: delegatee must be a governor (has an accepted proposal in any chamber) + - for a specialization chamber: delegatee must be eligible in that chamber + +#### Command: `delegation.clear` + +Request: + +```ts +type DelegationClearCommand = { + type: "delegation.clear"; + payload: { chamberId: string }; + idempotencyKey?: string; +}; +``` + +Response: + +```ts +type DelegationClearResponse = { + ok: true; + type: "delegation.clear"; + chamberId: string; + delegatorAddress: string; + cleared: boolean; +}; +``` + +#### Command: `formation.join` + +Request: + +```ts +type FormationJoinCommand = { + type: "formation.join"; + payload: { proposalId: string; role?: string }; + idempotencyKey?: string; +}; +``` + +Response: + +```ts +type FormationJoinResponse = { + ok: true; + type: "formation.join"; + proposalId: string; + teamSlots: { filled: number; total: number }; +}; +``` + +Notes: + +- If the proposal is not currently in the build stage, the API returns HTTP `409`. +- If the proposal does not require Formation (`formationEligible === false`), the API returns HTTP `409` with `code: "formation_not_required"`. +- If team slots are full, the API returns HTTP `409`. +- This command emits a feed event (stage: `build`). + +#### Command: `formation.milestone.submit` + +Request: + +```ts +type FormationMilestoneSubmitCommand = { + type: "formation.milestone.submit"; + payload: { proposalId: string; milestoneIndex: number; note?: string }; + idempotencyKey?: string; +}; +``` + +Response: + +```ts +type FormationMilestoneSubmitResponse = { + ok: true; + type: "formation.milestone.submit"; + proposalId: string; + milestoneIndex: number; + milestones: { completed: number; total: number }; +}; +``` + +Notes: + +- If the proposal is not currently in the build stage, the API returns HTTP `409`. +- If the proposal does not require Formation (`formationEligible === false`), the API returns HTTP `409` with `code: "formation_not_required"`. +- `milestoneIndex` is 1-based. +- Submitting does not automatically increase `completed` until it is unlocked. +- This command emits a feed event (stage: `build`). + +#### Command: `formation.milestone.requestUnlock` + +Request: + +```ts +type FormationMilestoneRequestUnlockCommand = { + type: "formation.milestone.requestUnlock"; + payload: { proposalId: string; milestoneIndex: number }; + idempotencyKey?: string; +}; +``` + +Response: + +```ts +type FormationMilestoneRequestUnlockResponse = { + ok: true; + type: "formation.milestone.requestUnlock"; + proposalId: string; + milestoneIndex: number; + milestones: { completed: number; total: number }; +}; +``` + +Notes: + +- If the proposal is not currently in the build stage, the API returns HTTP `409`. +- If the proposal does not require Formation (`formationEligible === false`), the API returns HTTP `409` with `code: "formation_not_required"`. +- Unlocking requires a prior submit (HTTP `409` if not submitted). +- Double-unlock is rejected (HTTP `409`). +- This command emits a feed event (stage: `build`). + +## Read endpoints + +These endpoints are implemented under `functions/api/*`. + +In v1, most reads start from `read_models` (DB mode) or the inline seed (inline mode), then apply overlays from normalized state (votes, formation, courts, era) and canonical tables where needed. + +Proposals note: + +- Proposal endpoints may prefer canonical proposals (Phase 14+) and fall back to `read_models` for seeded legacy payloads: + - `GET /api/proposals` + - `GET /api/proposals/:id/pool` + - `GET /api/proposals/:id/chamber` + - `GET /api/proposals/:id/formation` + +## Admin/simulation endpoints + +These endpoints are intended for simulation control (local dev, cron jobs, and admin tools). + +- All admin endpoints require `x-admin-secret: $ADMIN_SECRET` unless `DEV_BYPASS_ADMIN=true`. + +- `GET /api/clock` +- `POST /api/clock/advance-era` +- `POST /api/clock/rollup-era` (computes per-era statuses and next-era active governor set) +- `POST /api/clock/tick` (automation hook: rollup + optional era auto-advance) +- `POST /api/admin/users/lock` (temporarily disables writes for an address) +- `POST /api/admin/users/unlock` +- `GET /api/admin/users/locks` (lists active locks) +- `GET /api/admin/users/:address` (inspection: era counters, quotas, remaining, lock) +- `GET /api/admin/audit` (admin actions audit log) +- `GET /api/admin/stats` (admin operational stats) +- `POST /api/admin/writes/freeze` (toggle write freeze) + +### `POST /api/clock/tick` + +This is the simulation “cron” entrypoint. It is safe to call repeatedly; rollups are idempotent per-era and era advancement is guarded by a “due” check unless forced. + +Request: + +```ts +type PostClockTickRequest = { + forceAdvance?: boolean; // advance even if the era is not due + rollup?: boolean; // default true +}; +``` + +Response: + +```ts +type PostClockTickResponse = { + ok: true; + now: string; + eraSeconds: number; + due: boolean; + advanced: boolean; + fromEra: number; + toEra: number; + endedWindows?: Array<{ + proposalId: string; + stage: "pool" | "vote"; + endedAt: string; + emitted: boolean; // true only once per (proposalId, stage, endedAt) + }>; + rollup?: { + era: number; + rolledAt: string; + requirements: { + poolVotes: number; + chamberVotes: number; + courtActions: number; + formationActions: number; + }; + requiredTotal: number; + activeGovernorsNextEra: number; + usersRolled: number; + statusCounts: Record< + "Ahead" | "Stable" | "Falling behind" | "At risk" | "Losing status", + number + >; + }; +}; +``` + +Notes: + +- When `SIM_ENABLE_STAGE_WINDOWS=true`, `POST /api/clock/tick` can also emit (deduped) feed events when a proposal’s pool/vote window has ended, and returns those in `endedWindows` for visibility/debugging. +- When a proposal passes chamber vote and enters a veto window, `POST /api/clock/tick` finalizes it to `build` once the veto window ends (unless veto has been applied). + +### `POST /api/admin/users/lock` + +```ts +type PostAdminUserLockRequest = { + address: string; + lockedUntil: string; // ISO timestamp + reason?: string; +}; + +type PostAdminUserLockResponse = { ok: true }; +``` + +### `POST /api/admin/users/unlock` + +```ts +type PostAdminUserUnlockRequest = { address: string }; +type PostAdminUserUnlockResponse = { ok: true }; +``` + +### `GET /api/admin/users/locks` + +```ts +type GetAdminUserLocksResponse = { + items: Array<{ address: string; lockedUntil: string; reason: string | null }>; +}; +``` + +### `GET /api/admin/users/:address` + +```ts +type EraQuotaConfigDto = { + maxPoolVotes: number | null; + maxChamberVotes: number | null; + maxCourtActions: number | null; + maxFormationActions: number | null; +}; + +type GetAdminUserResponse = { + address: string; + era: number; + counts: { + poolVotes: number; + chamberVotes: number; + courtActions: number; + formationActions: number; + }; + quotas: EraQuotaConfigDto; + remaining: { + poolVotes: number | null; + chamberVotes: number | null; + courtActions: number | null; + formationActions: number | null; + }; + lock: { address: string; lockedUntil: string; reason: string | null } | null; +}; +``` + +### `GET /api/admin/audit` + +```ts +type AdminAuditActionDto = "user.lock" | "user.unlock"; +type AdminAuditItemDto = { + id: string; + action: AdminAuditActionDto; + targetAddress: string; + lockedUntil?: string; + reason?: string | null; + timestamp: string; +}; + +type GetAdminAuditResponse = { + items: AdminAuditItemDto[]; + nextCursor?: string; // DB mode uses event seq +}; +``` + +### `POST /api/admin/writes/freeze` + +```ts +type PostAdminWritesFreezeRequest = { enabled: boolean }; +type PostAdminWritesFreezeResponse = { ok: true; writesFrozen: boolean }; +``` + +### `GET /api/admin/stats` + +The response is intended for ops/debugging. It can evolve, but it stays JSON-safe and stable enough for manual inspection. + +```ts +type GetAdminStatsResponse = { + currentEra: number; + writesFrozen: boolean; + config: { + rateLimitsPerMinute: { + perIpPerMinute: number; + perAddressPerMinute: number; + }; + eraQuotas: { + maxPoolVotes: number | null; + maxChamberVotes: number | null; + maxCourtActions: number | null; + maxFormationActions: number | null; + }; + dynamicActiveGovernors: boolean; + }; +}; +``` + +### `GET /api/clock` + +```ts +type GoverningStatusDto = + | "Ahead" + | "Stable" + | "Falling behind" + | "At risk" + | "Losing status"; + +type EraRollupMetaDto = { + era: number; + rolledAt: string; + requiredTotal: number; + requirements: { + poolVotes: number; + chamberVotes: number; + courtActions: number; + formationActions: number; + }; + activeGovernorsNextEra: number; +}; + +type GetClockResponse = { + currentEra: number; + activeGovernors: number; + currentEraRollup?: EraRollupMetaDto; +}; +``` + +### Chambers + +#### `GET /api/chambers` + +Returns the chambers directory cards. + +```ts +type ChamberPipelineDto = { pool: number; vote: number; build: number }; +type ChamberStatsDto = { + governors: string; + acm: string; + mcm: string; + lcm: string; +}; +type ChamberDto = { + id: string; + name: string; + multiplier: number; + stats: ChamberStatsDto; + pipeline: ChamberPipelineDto; +}; + +type GetChambersResponse = { items: ChamberDto[] }; +``` + +Query params: + +- `includeDissolved=true` (optional): include dissolved chambers in the list (default is active-only). + +#### `GET /api/chambers/:id` + +Returns the chamber detail model. + +```ts +type ChamberProposalStageDto = "upcoming" | "live" | "ended"; +type ChamberProposalDto = { + id: string; + title: string; + meta: string; + summary: string; + lead: string; + nextStep: string; + timing: string; + stage: ChamberProposalStageDto; +}; + +type ChamberGovernorDto = { + id: string; + name: string; + tier: string; + focus: string; +}; +type ChamberThreadDto = { + id: string; + title: string; + author: string; + replies: number; + updated: string; +}; +type ChamberChatMessageDto = { id: string; author: string; message: string }; +type ChamberStageOptionDto = { value: ChamberProposalStageDto; label: string }; + +type GetChamberResponse = { + proposals: ChamberProposalDto[]; + governors: ChamberGovernorDto[]; + threads: ChamberThreadDto[]; + chatLog: ChamberChatMessageDto[]; + stageOptions: ChamberStageOptionDto[]; +}; +``` + +Notes: + +- In v1, `governors` is projected from canonical membership (`chamber_memberships`) plus `/sim-config.json` → `genesisChamberMembers`. +- In v1, `proposals` is projected from canonical proposals: + - pool → `upcoming` + - vote → `live` + - build → `ended` (meta may render as “Formation” or “Passed” depending on `formationEligible`). + +### Factions + +#### `GET /api/factions` + +```ts +type FactionRosterTagDto = + | { kind: "acm"; value: number } + | { kind: "mm"; value: number } + | { kind: "text"; value: string }; + +type FactionRosterMemberDto = { + humanNodeId: string; + role: string; + tag: FactionRosterTagDto; +}; + +type FactionDto = { + id: string; + name: string; + description: string; + members: number; + votes: string; + acm: string; + focus: string; + goals: string[]; + initiatives: string[]; + roster: FactionRosterMemberDto[]; +}; + +type GetFactionsResponse = { items: FactionDto[] }; +``` + +#### `GET /api/factions/:id` + +Returns `FactionDto`. + +### Formation + +#### `GET /api/formation` + +```ts +type FormationMetricDto = { label: string; value: string; dataAttr: string }; +type FormationCategoryDto = "all" | "research" | "development" | "social"; +type FormationStageDto = "live" | "gathering" | "completed"; + +type FormationProjectDto = { + id: string; + title: string; + focus: string; + proposer: string; + summary: string; + category: FormationCategoryDto; + stage: FormationStageDto; + budget: string; + milestones: string; + teamSlots: string; +}; + +type GetFormationResponse = { + metrics: FormationMetricDto[]; + projects: FormationProjectDto[]; +}; +``` + +### Invision + +#### `GET /api/invision` + +```ts +type InvisionGovernanceMetricDto = { label: string; value: string }; +type InvisionGovernanceStateDto = { + label: string; + metrics: InvisionGovernanceMetricDto[]; +}; +type InvisionEconomicIndicatorDto = { + label: string; + value: string; + detail: string; +}; +type InvisionRiskSignalDto = { title: string; status: string; detail: string }; +type InvisionChamberProposalDto = { + title: string; + effect: string; + sponsors: string; +}; + +type GetInvisionResponse = { + governanceState: InvisionGovernanceStateDto; + economicIndicators: InvisionEconomicIndicatorDto[]; + riskSignals: InvisionRiskSignalDto[]; + chamberProposals: InvisionChamberProposalDto[]; +}; +``` + +### My governance + +#### `GET /api/my-governance` + +```ts +type MyGovernanceEraActionDto = { + label: string; + done: number; + required: number; +}; +type MyGovernanceEraActivityDto = { + era: string; + required: number; + completed: number; + actions: MyGovernanceEraActionDto[]; + timeLeft: string; +}; + +type GetMyGovernanceResponse = { + eraActivity: MyGovernanceEraActivityDto; + myChamberIds: string[]; + rollup?: { + era: number; + rolledAt: string; + status: GoverningStatusDto; + requiredTotal: number; + completedTotal: number; + isActiveNextEra: boolean; + activeGovernorsNextEra: number; + }; +}; +``` + +Notes: + +- Anonymous users get the base `read_models` payload. +- When authenticated, the backend overlays `eraActivity.era` and each action’s `done` count from `era_user_activity` for the current era. +- Per-era action counters are incremented only on first-time actions per entity (e.g. changing a vote does not count as another action). +- If the current era has been rolled up, the response includes a `rollup` object derived from `era_rollups` and `era_user_status`. + +### Proposals (list) + +#### `GET /api/proposals?stage=pool|vote|build|draft` + +Returns the proposals page cards (collapsed/expanded content comes from this DTO). + +```ts +type ProposalStageDto = "draft" | "pool" | "vote" | "build"; +type ProposalToneDto = "ok" | "warn"; + +type ProposalStageDatumDto = { + title: string; + description: string; + value: string; + tone?: ProposalToneDto; +}; +type ProposalStatDto = { label: string; value: string }; + +type ProposalListItemDto = { + id: string; + title: string; + meta: string; + stage: ProposalStageDto; + summaryPill: string; + summary: string; + stageData: ProposalStageDatumDto[]; + stats: ProposalStatDto[]; + proposer: string; + proposerId: string; + chamber: string; + tier: "Nominee" | "Ecclesiast" | "Legate" | "Consul" | "Citizen"; + proofFocus: "pot" | "pod" | "pog"; + tags: string[]; + keywords: string[]; + date: string; + votes: number; + activityScore: number; + ctaPrimary: string; + ctaSecondary: string; +}; + +type GetProposalsResponse = { items: ProposalListItemDto[] }; +``` + +### Proposal pages + +These endpoints map 1:1 to the current stage pages in the UI. + +#### `GET /api/proposals/:id/pool` + +```ts +type InvisionInsightDto = { role: string; bullets: string[] }; + +type PoolProposalPageDto = { + title: string; + proposer: string; + proposerId: string; + chamber: string; + focus: string; + tier: string; + budget: string; + cooldown: string; + formationEligible: boolean; + teamSlots: string; + milestones: string; + upvotes: number; + downvotes: number; + attentionQuorum: number; // e.g. 0.22 + activeGovernors: number; // era baseline + upvoteFloor: number; + rules: string[]; + attachments: { id: string; title: string }[]; + teamLocked: { name: string; role: string }[]; + openSlotNeeds: { title: string; desc: string }[]; + milestonesDetail: { title: string; desc: string }[]; + summary: string; + overview: string; + executionPlan: string[]; + budgetScope: string; + invisionInsight: InvisionInsightDto; +}; +``` + +#### `GET /api/proposals/:id/chamber` + +```ts +type ChamberProposalPageDto = { + title: string; + proposer: string; + proposerId: string; + chamber: string; + budget: string; + formationEligible: boolean; + teamSlots: string; + milestones: string; + timeLeft: string; + votes: { yes: number; no: number; abstain: number }; + attentionQuorum: number; + passingRule: string; + engagedGovernors: number; + activeGovernors: number; + attachments: { id: string; title: string }[]; + teamLocked: { name: string; role: string }[]; + openSlotNeeds: { title: string; desc: string }[]; + milestonesDetail: { title: string; desc: string }[]; + summary: string; + overview: string; + executionPlan: string[]; + budgetScope: string; + invisionInsight: InvisionInsightDto; +}; +``` + +#### `GET /api/proposals/:id/formation` + +```ts +type FormationProposalPageDto = { + title: string; + chamber: string; + proposer: string; + proposerId: string; + budget: string; + timeLeft: string; + teamSlots: string; + milestones: string; + progress: string; + stageData: { title: string; description: string; value: string }[]; + stats: { label: string; value: string }[]; + lockedTeam: { name: string; role: string }[]; + openSlots: { title: string; desc: string }[]; + milestonesDetail: { title: string; desc: string }[]; + attachments: { id: string; title: string }[]; + summary: string; + overview: string; + executionPlan: string[]; + budgetScope: string; + invisionInsight: InvisionInsightDto; +}; +``` + +#### `GET /api/proposals/:id/timeline` + +Returns the event-backed “what happened” timeline for a proposal. + +```ts +type ProposalTimelineEventTypeDto = + | "proposal.submitted" + | "proposal.stage.advanced" + | "pool.vote" + | "chamber.vote" + | "formation.join" + | "formation.milestone.submitted" + | "formation.milestone.unlockRequested" + | "chamber.created" + | "chamber.dissolved"; + +type ProposalTimelineItemDto = { + id: string; + type: ProposalTimelineEventTypeDto; + title: string; + detail?: string; + actor?: string; + timestamp: string; // ISO +}; + +type GetProposalTimelineResponse = { items: ProposalTimelineItemDto[] }; +``` + +Notes: + +- Optional query string: `?limit=...` (default `100`, max `500`). +- Backed by the append-only `events` table: + - `events.type = "proposal.timeline.v1"` + - `events.entityType = "proposal"` + - `events.entityId = proposalId` + +Notes: + +- The payload is overlaid with Formation state: + - `teamSlots`, `milestones`, and `progress` are computed from stored Formation state. + - joined team members are appended to `lockedTeam` (as short addresses). + +#### Command: `court.case.report` + +Request: + +```ts +type CourtCaseReportCommand = { + type: "court.case.report"; + payload: { caseId: string }; + idempotencyKey?: string; +}; +``` + +Response: + +```ts +type CourtCaseReportResponse = { + ok: true; + type: "court.case.report"; + caseId: string; + reports: number; + status: "jury" | "live" | "ended"; +}; +``` + +Notes: + +- If the case ID is unknown, the API returns HTTP `404`. +- Reports are per-user (reporting twice does not increment the count). +- Status can transition **jury → live** once enough reports are collected (v1 threshold). +- Emits a feed event (stage: `courts`). + +#### Command: `court.case.verdict` + +Request: + +```ts +type CourtCaseVerdictCommand = { + type: "court.case.verdict"; + payload: { caseId: string; verdict: "guilty" | "not_guilty" }; + idempotencyKey?: string; +}; +``` + +Response: + +```ts +type CourtCaseVerdictResponse = { + ok: true; + type: "court.case.verdict"; + caseId: string; + verdict: "guilty" | "not_guilty"; + status: "jury" | "live" | "ended"; + totals: { guilty: number; notGuilty: number }; +}; +``` + +Notes: + +- Verdicts are only allowed when the case is **live** (HTTP `409` otherwise). +- Verdicts are one-per-user (re-voting updates the voter’s verdict). +- Status can transition **live → ended** once enough distinct verdicts are collected (v1 threshold). +- Emits a feed event (stage: `courts`). + +### Proposal drafts + +#### `GET /api/proposals/drafts` + +```ts +type ProposalDraftListItemDto = { + id: string; + title: string; + chamber: string; + tier: string; + summary: string; + updated: string; +}; + +type GetProposalDraftsResponse = { items: ProposalDraftListItemDto[] }; +``` + +#### `GET /api/proposals/drafts/:id` + +```ts +type ProposalDraftDetailDto = { + title: string; + proposer: string; + chamber: string; + focus: string; + tier: string; + budget: string; + formationEligible: boolean; + teamSlots: string; + milestonesPlanned: string; + summary: string; + rationale: string; + budgetScope: string; + invisionInsight: InvisionInsightDto; + checklist: string[]; + milestones: string[]; + teamLocked: { name: string; role: string }[]; + openSlotNeeds: { title: string; desc: string }[]; + milestonesDetail: { title: string; desc: string }[]; + attachments: { title: string; href: string }[]; +}; +``` + +### Courts + +#### `GET /api/courts` + +```ts +type CourtCaseStatusDto = "jury" | "live" | "ended"; +type CourtCaseDto = { + id: string; + title: string; + subject: string; + triggeredBy: string; + status: CourtCaseStatusDto; + reports: number; + juryIds: string[]; + opened: string; // dd/mm/yyyy +}; + +type GetCourtsResponse = { items: CourtCaseDto[] }; +``` + +#### `GET /api/courts/:id` + +```ts +type CourtCaseDetailDto = CourtCaseDto & { + parties: { role: string; humanId: string; note?: string }[]; + proceedings: { claim: string; evidence: string[]; nextSteps: string[] }; +}; +``` + +### Human nodes + +#### `GET /api/humans` + +```ts +type HumanTierDto = "nominee" | "ecclesiast" | "legate" | "consul" | "citizen"; +type HumanNodeDto = { + id: string; + name: string; + role: string; + chamber: string; + factionId: string; + tier: HumanTierDto; + acm: number; + mm: number; + memberSince: string; + formationCapable?: boolean; + active: boolean; + formationProjectIds?: string[]; + tags: string[]; +}; + +type GetHumansResponse = { items: HumanNodeDto[] }; +``` + +#### `GET /api/humans/:id` + +Mirrors `db/seed/fixtures/humanNodeProfiles.ts` but remains JSON-safe. + +```ts +type ProofKeyDto = "time" | "devotion" | "governance"; +type ProofSectionDto = { + title: string; + items: { label: string; value: string }[]; +}; +type HeroStatDto = { label: string; value: string }; +type QuickDetailDto = { label: string; value: string }; +type GovernanceActionDto = { + title: string; + action: string; + context: string; + detail: string; +}; +type HistoryItemDto = { + title: string; + action: string; + context: string; + detail: string; + date: string; +}; +type ProjectCardDto = { + title: string; + status: string; + summary: string; + chips: string[]; +}; + +type HumanNodeProfileDto = { + id: string; + name: string; + governorActive: boolean; + humanNodeActive: boolean; + governanceSummary: string; + heroStats: HeroStatDto[]; + quickDetails: QuickDetailDto[]; + proofSections: Record; + governanceActions: GovernanceActionDto[]; + projects: ProjectCardDto[]; + activity: HistoryItemDto[]; + history: string[]; +}; +``` + +### Feed + +#### `GET /api/feed?cursor=...&stage=...` + +```ts +type FeedStageDto = "pool" | "vote" | "build" | "courts" | "thread" | "faction"; +type FeedToneDto = "ok" | "warn"; + +type FeedStageDatumDto = { + title: string; + description: string; + value: string; + tone?: FeedToneDto; +}; + +type FeedStatDto = { label: string; value: string }; + +type FeedItemDto = { + id: string; + title: string; + meta: string; + stage: FeedStageDto; + summaryPill: string; + summary: string; // plain text or Markdown + stageData?: FeedStageDatumDto[]; + stats?: FeedStatDto[]; + proposer?: string; + proposerId?: string; + ctaPrimary?: string; + ctaSecondary?: string; + href?: string; + timestamp: string; +}; + +type GetFeedResponse = { items: FeedItemDto[]; nextCursor?: string }; +``` diff --git a/docs/simulation/vortex-simulation-data-model.md b/docs/simulation/vortex-simulation-data-model.md new file mode 100644 index 0000000..5b509f0 --- /dev/null +++ b/docs/simulation/vortex-simulation-data-model.md @@ -0,0 +1,240 @@ +# Vortex Simulation Backend — Data Model (v1) + +This document explains how v1 state is stored in Postgres and how that storage maps onto reads, writes, and the feed. + +The schema is implemented in `db/schema.ts` with migrations under `db/migrations/`. + +## Design principles + +- Keep an **append-only event log** for audit and feed. +- Keep **write state** in normalized tables where it matters (votes, formation, courts, era counters). +- Keep the UI stable via a transitional **read-model bridge** (`read_models`) until full projections exist. + +## Transitional read model bridge + +### `read_models` + +Purpose: + +- Store JSON payloads that directly match the DTOs in `docs/simulation/vortex-simulation-api-contract.md`. + +Modes: + +- DB mode: read from `read_models` in Postgres (requires `DATABASE_URL`). +- Inline seeded mode: `READ_MODELS_INLINE=true` serves the same payloads without a DB. +- Clean-by-default mode: `READ_MODELS_INLINE_EMPTY=true` forces empty/default payloads. + +In v1, many pages are primarily served from `read_models`, with live overlays from normalized tables. + +## Identity and gating + +### Users + +- `users`: off-chain account records keyed by address. + +### Eligibility cache + +- `eligibility_cache`: TTL cached RPC results for `GET /api/gate/status`. + +## Events and audit + +### `events` + +Append-only event stream used for: + +- feed items +- admin audit trail +- per-entity history pages (v1: proposal timeline) + +In v1, events are emitted both by user commands and by admin endpoints. + +In v1, proposal history is stored as `events` entries: + +- `events.type = "proposal.timeline.v1"` +- `events.entityType = "proposal"` +- `events.entityId = ` + +## Votes + +### Pool votes + +- `pool_votes`: one row per `(proposalId, voterAddress)` representing the latest up/down direction. + +### Chamber votes + +- `chamber_votes`: one row per `(proposalId, voterAddress)` representing the latest yes/no/abstain choice. +- Optional `score` is stored for yes votes (v1 CM input). + +### Veto votes + +Veto is a bounded, temporary slow-down window after a proposal passes chamber vote. + +- `veto_votes`: one row per `(proposalId, voterAddress)` representing the latest veto council choice: + - `choice = "veto" | "keep"` + +### CM awards + +- `cm_awards`: one row per proposal that passes chamber vote, derived from the average yes `score`. + +## Proposal drafts (Phase 12) + +Proposal creation is stored as author-owned drafts: + +- `proposal_drafts`: one row per draft: + - `id` (draft slug) + - `author_address` + - `payload` (the wizard form, JSON) + - `submitted_at` / `submitted_proposal_id` once submitted into the pool + +Planned (v2+): + +- The draft `payload` will evolve from a single “project-shaped” object with optional `metaGovernance` into a **discriminated union** aligned with proposal wizard templates (project vs system-change flows). +- The wizard architecture and phased migration strategy are described in: + - `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` + +## Proposals (Phase 14) + +Canonical proposals table (first step away from `read_models` as source of truth): + +- `proposals`: one row per proposal: + - `id` (proposal slug) + - `stage` (`pool | vote | build` in v1) + - `author_address` + - `title`, `summary`, `chamber_id` + - `payload` (jsonb; stage-agnostic proposal content in v1, derived from the draft payload) + - veto fields (v1): + - `veto_count` + - `vote_passed_at`, `vote_finalizes_at` + - `veto_council`, `veto_threshold` + - `created_at`, `updated_at` + +In Phase 14, reads begin preferring this table (with `read_models` as a compatibility fallback for seeded legacy DTOs). + +## Proposal stage denominators (Phase 28) + +To keep quorum math stable when eras advance mid-stage, proposal quorums use a stage-entry denominator snapshot: + +- `proposal_stage_denominators`: one row per `(proposalId, stage)` where `stage` is `pool` or `vote`. + - `era`: the era when the proposal entered that stage. + - `active_governors`: the active-governor denominator captured at stage entry. + - `captured_at`: timestamp for audit/debug. + +Reads: + +- Proposal list items and proposal pages prefer the stage-entry denominator when present. +- If no snapshot exists (legacy data), the current era baseline is used as a fallback. + +Writes: + +- The snapshot is captured exactly once per `(proposalId, stage)` and never overwritten. + +## Chambers (Phase 18–21) + +Canonical chambers live in: + +- `chambers`: + - `id`, `title` + - `status` (`active | dissolved`) + - `multiplierTimes10` (integer; e.g. `15` = `1.5`) + - `createdByProposalId`, `dissolvedByProposalId` + - `metadata` (jsonb; room for future fields without schema churn) + +Voting eligibility (paper-aligned, v1-enforced) is stored in: + +- `chamber_memberships`: + - primary key `(chamberId, address)` + - `grantedByProposalId` (when the membership was granted via an accepted proposal) + - `source` (v1: `accepted_proposal`) + +Dissolution never deletes history. It changes chamber status and restricts new writes (e.g., new proposals) while preserving audit trails. + +## Delegation (Phase 29) + +Delegation is a chamber-scoped liquid graph that affects **chamber vote weights** but never affects proposal-pool attention. + +Tables: + +- `delegations`: current delegation graph, keyed by `(chamber_id, delegator_address)` → `delegatee_address`. +- `delegation_events`: append-only history of delegation changes (`set` / `clear`) for audit/debug. + +Vote tally semantics (v1): + +- Chamber votes are stored per-voter in `chamber_votes` as before. +- When computing chamber vote counts, the current delegation graph is applied: + - each voter contributes weight `1 + delegatedVoices`, + - a delegator’s voice only counts if the delegator **did not cast a vote** themselves. + +## Chamber multiplier voting (Phase 31) + +Paper intent: chamber multipliers are set by outsiders (those who do not have LCM history in the chamber). + +Tables: + +- `chamber_multiplier_submissions`: current outsider submissions, keyed by `(chamber_id, voter_address)` with: + - `multiplier_times10` (integer 1..100, representing `0.1..10.0`) + +Aggregation (v1): + +- The multiplier applied to a chamber is the rounded average of all submissions. +- The canonical chamber record is updated in `chambers.multiplier_times10`. + +Display semantics (v1): + +- CM award history (`cm_awards`) is immutable. +- Views that display ACM/MCM compute MCM using the current chamber multiplier (do not rewrite historical award rows). + +## Formation + +Formation stores the mutable parts that can’t remain a static mock: + +- `formation_projects`: per-proposal counters/baselines +- `formation_team`: additional joiners and roles +- `formation_milestones`: per-milestone status (`todo`/`submitted`/`unlocked`) +- `formation_milestone_events`: append-only milestone action history + +## Courts + +Courts store: + +- `court_cases`: case headers and status bucket +- `court_reports`: per-address reports (and optional notes) +- `court_verdicts`: per-address verdicts (guilty/not guilty) + +## Era tracking + +Era tracking supports “My Governance” and rollups: + +- `clock_state`: current era +- `era_snapshots`: per-era aggregates (v1: active governors baseline) +- `era_user_activity`: per-era action counters per address, used for: + - quotas + - my-governance progress + - rollups +- `era_rollups`: per-era rollup output (computed status buckets and next-era counts) +- `era_user_status`: per-address derived rollup status for a specific era + +## Ops controls + +### Idempotency + +- `idempotency_keys`: stored request/response pairs keyed by idempotency key. + +### Rate limiting + +- `api_rate_limits`: per-IP and per-address buckets for `POST /api/command`. + +### Action locks + +- `user_action_locks`: temporary write bans for an address (admin-controlled). + +### Global write freeze + +- `admin_state`: small key/value store for global toggles (including write freeze). + +## What’s expected to change in v2+ + +- Continue migrating away from the read-model bridge (`read_models`) so all pages are served from canonical tables + projections. +- Add chamber multiplier voting state: + - `chamber_multiplier_submissions` (or equivalent) +- Add Meritocratic Measure (MM) history (Formation delivery scoring): + - `mm_awards` (or equivalent per-milestone ratings + derived totals) diff --git a/docs/simulation/vortex-simulation-implementation-plan.md b/docs/simulation/vortex-simulation-implementation-plan.md new file mode 100644 index 0000000..1cc9566 --- /dev/null +++ b/docs/simulation/vortex-simulation-implementation-plan.md @@ -0,0 +1,1476 @@ +# Vortex Simulation Backend — Implementation Plan + +This plan turns `docs/simulation/vortex-simulation-processes.md` + `docs/simulation/vortex-simulation-tech-architecture.md` into an executable roadmap that stays aligned with the current UI. + +For a paper-aligned module map (paper → docs → code), see `docs/simulation/vortex-simulation-modules.md`. + +## Current status (what exists in the repo right now) + +Implemented (v1 simulation backend): + +- Cloudflare Pages Functions under `functions/` +- Auth + gate (wallet signature + mainnet eligibility): + - `GET /api/health` + - `POST /api/auth/nonce` (sets `vortex_nonce` cookie) + - `POST /api/auth/verify` (sets `vortex_session` cookie; Substrate signature verification) + - `POST /api/auth/logout` + - `GET /api/me` + - `GET /api/gate/status` (Humanode mainnet RPC gating; dev bypass supported) +- Cookie-signed nonce + session helpers (requires `SESSION_SECRET`) +- Dev toggles for local progress: + - `DEV_BYPASS_SIGNATURE`, `DEV_BYPASS_GATE`, `DEV_ELIGIBLE_ADDRESSES`, `DEV_INSECURE_COOKIES` +- Local dev notes: `docs/simulation/vortex-simulation-local-dev.md` +- Test harness + CI: + - `yarn test` (Node’s built-in test runner) + - CI runs `yarn test` via `.github/workflows/code.yml` + - API tests: `tests/api-*.test.js` +- v1 decisions + contracts (kept aligned with the UI): + - v1 constants: `docs/simulation/vortex-simulation-v1-constants.md` + - API contract: `docs/simulation/vortex-simulation-api-contract.md` + - DTO types: `src/types/api.ts` +- Postgres (Drizzle) schema + migrations + seed scripts: + - Drizzle config: `drizzle.config.ts` + - Schema: `db/schema.ts` + - Seed script: `scripts/db-seed.ts` (writes read-model payloads into `read_models` + seeds `events`) + - DB scripts: `yarn db:generate`, `yarn db:migrate`, `yarn db:seed` + - Clear script: `yarn db:clear` (wipe data, keep schema) + - Seed tests: `tests/db-seed.test.js`, `tests/migrations.test.js` +- Read endpoints for all pages (Phase 4 read-model bridge): + - `functions/api/*` serves Chambers, Proposals, Feed, Courts, Humans, Factions, Formation, Invision, My Governance + - Clean-by-default mode supported (`READ_MODELS_INLINE_EMPTY=true`), with a shared UI empty state bar (`src/components/NoDataYetBar.tsx`) +- Event log backbone: + - `events` table + schemas + projector; Feed can be served from DB events in DB mode + - Tests: `tests/events-seed.test.js`, `tests/feed-event-projector.test.js` +- Write slices via `POST /api/command` (auth + gate + idempotency + live overlays): + - Proposal pool voting (`pool.vote`) + pool → vote auto-advance + - Chamber voting (`chamber.vote`) + CM awards + vote → build auto-advance (quorum + passing; Formation is optional) + - Formation v1 (`formation.join`, `formation.milestone.submit`, `formation.milestone.requestUnlock`) + - Courts v1 (`court.case.report`, `court.case.verdict`) + - Era snapshots + per-era activity counters (`/api/clock/*` + `/api/my-governance`) + - Era rollups + tier statuses (`POST /api/clock/rollup-era`) +- Hardening + ops controls: + - Rate limiting, per-era quotas, idempotency conflict detection + - Admin tools: action locks, audit/inspection, stats, global write freeze + - Tests: `tests/api-command-*.test.js`, `tests/api-admin-*.test.js` + +Implemented (UI supporting the simulation backend): + +- Proposal creation wizard v2 (template-driven): + - Template registry: `src/pages/proposals/proposalCreation/templates/registry.ts` + - Templates: + - `project` (full flow: Essentials → Plan → Budget → Review) + - `system` (General chamber only; skips budget: Setup → Rationale → Review) + - Tests: `tests/proposal-wizard-template-registry.test.js` + +Not implemented (intentional v1 gaps): + +- Replacing transitional `read_models` with fully normalized domain tables + event-driven projections +- Time-windowed stage logic (vote windows, scheduled transitions) beyond manual/admin clock ops +- Delegation flows and any “real” forum/thread product (threads remain minimal) + +## Guiding principles + +- Ship a **thin vertical slice** first: auth → gate → read models → one write action → feed events. +- Keep domain logic **pure and shared** (state machine + events). The API is a thin adapter. +- Prefer **deterministic**, testable transitions; avoid “magic UI-only numbers”. +- Enforce gating on **every write**: “browse open, write gated”. +- Minimize UI churn: keep the frozen DTOs (`docs/simulation/vortex-simulation-api-contract.md` + `src/types/api.ts`) stable while the backend transitions from `read_models` to normalized tables + an event log. + +## Testing requirement (applies to every phase) + +Each phase is considered “done” only when tests are added and run. + +Testing layers: + +1. **Unit tests** (pure TS): state machines, invariants, calculations (quorums, passing rules, tier rules). +2. **API integration tests**: call Pages Functions handlers with `Request` objects and assert status/JSON/cookies. +3. **DB integration tests** (once DB exists): migrations apply, basic queries work, constraints enforced. + +Test execution policy: + +- Add a `yarn test` script and run it after each feature batch. +- Keep CI in sync (extend `.github/workflows/code.yml` to run `yarn test` and `yarn build` once tests exist). + +Tooling note: Pages Functions handlers are tested directly via `Request` objects (no browser/manual flow needed for API testing). + +## Execution sequence (phases in order) + +This is the order we’ll follow from now on, based on what’s already landed. + +1. **Phase 0 — Lock v1 decisions (DONE)** +2. **Phase 1 — Freeze API contracts (DTOs) (DONE)** +3. **Phase 2a — API skeleton (DONE)** +4. **Phase 2b — Test harness for API + domain (DONE)** +5. **Phase 2c — DB skeleton + migrations + seed-from-fixtures (DONE)** +6. **Phase 3 — Auth + eligibility gate (DONE)** +7. **Phase 4 — Read models first (all pages, clean-by-default) (DONE)** +8. **Phase 5 — Event log backbone (DONE)** +9. **Phase 6 — First write slice (pool voting) (DONE)** +10. **Phase 7 — Chamber vote + CM awarding (DONE)** +11. **Phase 8 — Formation v1 (DONE)** +12. **Phase 9 — Courts v1 (DONE)** +13. **Phase 10a — Era snapshots + activity counters (DONE)** +14. **Phase 10b — Era rollups + tier statuses (DONE for v1)** +15. **Phase 11 — Hardening + moderation** +16. **Phase 12 — Proposal drafts + submission (DONE)** +17. **Phase 13 — Eligibility via `Session::Validators` (DONE)** +18. **Phase 14 — Canonical domain tables + projections (DONE)** +19. **Phase 15 — Deterministic state transitions (DONE)** +20. **Phase 16 — Time windows + automation (DONE)** +21. **Phase 17 — Chamber voting eligibility + Formation optionality (DONE)** +22. **Phase 18 — Chambers lifecycle (create/dissolve) (DONE)** +23. **Phase 19 — Chamber detail projections (DONE)** +24. **Phase 20 — Dissolved chamber enforcement (DONE)** +25. **Phase 21 — Chambers directory projections (pipeline/stats) (DONE)** +26. **Phase 22 — Meta-governance chamber.create seeding (backend) (DONE)** +27. **Phase 23 — Proposal drafts (UI ↔ backend) (DONE)** +28. **Phase 24 — Meta-governance proposal type (UI) (DONE)** +29. **Phase 25 — Proposal pages projected from canonical state (DONE)** +30. **Phase 26 — Proposal history timeline (DONE)** +31. **Phase 27 — Active governance v2 (derive and persist active governor set per era) (DONE)** +32. **Phase 28 — Quorum engine v2 (era-derived denominators + paper thresholds) (DONE)** +33. **Phase 29 — Delegation v1 (graph + history + chamber vote weighting) (DONE)** +34. **Phase 30 — Veto v1 (temporary slow-down + attempt limits) (DONE)** +35. **Phase 31 — Chamber multiplier voting v1 (outside-of-chamber aggregation) (DONE)** +36. **Phase 32 — Paper alignment audit pass (process-by-process)** +37. **Phase 33 — Testing readiness v3 (scenario harness + end-to-end validation) (IN PROGRESS)** +38. **Phase 34 — Meritocratic Measure (MM) v1 (post-V3, Formation delivery scoring)** +39. **Phase 35 — Proposal wizard v2 W1 (template runner + registry) (DONE)** +40. **Phase 36 — Proposal wizard v2 W2 (system.chamberCreate flow) (DONE — `system` template v1)** +41. **Phase 37 — Proposal wizard v2 W3 (backend discriminated drafts) (DONE)** +42. **Phase 38 — Proposal wizard v2 W4 (migrate drafts + simplify validation) (DONE)** +43. **Phase 39 — Proposal wizard v2 W5 (cleanup + extension points) (DONE)** + +### Proposal wizard v2 phases (Phases 35–39) + +In parallel to the main backend phases, the proposal wizard is moving toward template-driven flows so that system-change proposals (like chamber creation) do not share project-only steps/fields. + +Reference: + +- `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` (Wizard v2 track W1–W5) + +Notes: + +- These phases are primarily UI/schema refactors and can be executed after Phase 23 (drafts) without blocking the main governance modules. +- The goal is to avoid “one big form” drift and make system-change proposals (like chamber creation) collect only the fields required to create/render the chamber. + +## Phase 0 — Lock v1 decisions (required before DB + real gate) + +Locked for v1 (based on current decisions): + +1. Database: **Postgres** (Neon-compatible serverless Postgres). +2. Gating source: **Humanode mainnet RPC** (no Subscan dependency for v1). +3. Active Human Node rule: address is in the current validator set (`Session::Validators`) on Humanode mainnet. +4. Era length: **configured by us off-chain** (a simulation constant), not a chain parameter. + +Deliverable: a short “v1 constants” section committed to docs or config. + +Tests: + +- None required (doc-only), but we must record decisions so later tests can assert exact thresholds/constants. + +## V3 — Testing readiness (what “ready to test” means) + +V3 is the point where the simulation can be tested as a coherent system against the Vortex 1.0 model (and against our updated paper reference copy), not just as disconnected UI pages. + +V3 is “ready for testing” when: + +- All core governance modules required for chamber/proposal testing are implemented and wired end-to-end: + - **active governance**: “active governors next era” is computed at rollup and persisted + - **quorum engine**: pool + vote quorums use era-derived denominators and are consistent across endpoints/pages + - **proposals**: draft → pool → vote → accepted is testable deterministically (Formation optional) + - **chambers**: General + specialization chambers exist; creation/dissolution is proposal-driven (meta-governance) + - **delegation**: chamber vote weighting works; pool attention remains direct-only + - **veto**: temporary slow-down exists and is auditable/bounded + - **multipliers**: outsider aggregation updates chamber multipliers without rewriting CM award history +- A paper alignment audit has been run process-by-process and deviations are explicitly recorded. +- A scenario harness exists so the above can be validated deterministically (API-level end-to-end tests; no browser required). + +Not required for V3: + +- Meritocratic Measure (MM). MM can be built after V3 without blocking proper testing of proposals/chambers/quorums/delegation/veto. + +V3 phases (to reach testing readiness): + +1. Phase 27 — Active governance v2 +2. Phase 28 — Quorum engine v2 +3. Phase 29 — Delegation v1 +4. Phase 30 — Veto v1 +5. Phase 31 — Chamber multiplier voting v1 +6. Phase 32 — Paper alignment audit pass +7. Phase 33 — Testing readiness harness (scenario-driven) +8. Phase 34 — Meritocratic Measure (MM) v1 (post-V3) + +## Phase 1 — Define contracts that mirror the UI (1–2 days) + +The UI renders from `/api/*` reads. The contract is frozen so backend and frontend stay aligned while the implementation evolves. + +Contract location: + +- `docs/simulation/vortex-simulation-api-contract.md` (human-readable source of truth) +- `src/types/api.ts` (TS source of truth for DTOs) + +1. Define response DTOs that match the current UI needs: + - Chambers directory card: id/name/multiplier + stats + pipeline. + - Chamber detail: stage-filtered proposals + governors + threads/chat. + - Proposals list: the exact data currently rendered in collapsed/expanded cards. + - Proposal pages: PP / Chamber vote / Formation page models. + - Courts list + courtroom page model. + - Human nodes list + profile model. + - Feed item model (the card layout currently used). +2. Decide how IDs work across the system (proposalId, chamberId, humanId) and make them consistent. + +Deliverable: a short “API contract v1” section (types + endpoint list) that the backend must satisfy. + +Tests: + +- Add unit tests that validate DTO payload shapes against deterministic seed fixtures (smoke: “fixture data can be encoded into the DTOs without loss”). + +## Phase 2a — API skeleton (DONE) + +Delivered in this repo: + +- Pages Functions routes: `health`, `auth`, `me`, `gate` +- Cookie-signed nonce/session (requires `SESSION_SECRET`) +- Dev bypass knobs while we build real auth/gate + +Tests (implemented): + +- `GET /api/health` returns `{ ok: true }`. +- `POST /api/auth/nonce` returns a nonce and sets a `vortex_nonce` cookie. +- `POST /api/auth/verify`: + - rejects invalid signatures when bypass is disabled + - succeeds and sets `vortex_session` for valid signatures (or when bypass is enabled) +- `GET /api/me` reflects authentication state +- `GET /api/gate/status` returns `not_authenticated` when logged out + +## Phase 2b — Test harness for API + domain (DONE) + +Implementation: + +- `tests/` folder + `yarn test` script are in place. +- Tests import Pages Functions handlers directly and exercise them with synthetic `Request` objects. +- CI runs `yarn test` (see `.github/workflows/code.yml`). + +## Phase 2c — DB skeleton (1–3 days) + +Implemented so far: + +1. Drizzle config + Postgres schema: + - `drizzle.config.ts` + - `db/schema.ts` + - generated migration under `db/migrations/` +2. Seed-from-mocks into `read_models`: + - `db/seed/readModels.ts` (pure seed builder) + - `scripts/db-seed.ts` + - `yarn db:seed` (requires `DATABASE_URL`) +3. Tests: + - `tests/migrations.test.js` asserts core tables are present in the migration. + - `tests/db-seed.test.js` asserts the seed is deterministic, unique-keyed, and JSON-safe. +4. Transitional read endpoints (Phase 2c/4 bridge): + - Read-model store: `functions/_lib/readModelsStore.ts` (DB mode via `DATABASE_URL` + inline mode via `READ_MODELS_INLINE=true`) + - Endpoints: `GET /api/chambers`, `GET /api/proposals`, `GET /api/courts`, `GET /api/humans` (+ per-entity detail routes) +5. Simulation clock (admin-only for advancement): + - `GET /api/clock` + - `POST /api/clock/advance-era` (requires `ADMIN_SECRET` via `x-admin-secret`, unless `DEV_BYPASS_ADMIN=true`) + +Ops checklist (to validate Phase 2c against a real DB): + +- Create a Postgres DB (v1: Neon) and set `DATABASE_URL`. +- Run: `yarn db:migrate && yarn db:seed`. +- Verify reads are served from Postgres by unsetting `READ_MODELS_INLINE`. + +Deliverable: deployed API that responds and can connect to the DB. + +Tests: + +- Migrations apply cleanly on a fresh DB. +- Seed job is idempotent (run twice yields the same IDs/state). +- Read endpoints return deterministic results from seeded data. + +## Phase 3 — Auth + eligibility gate (3–7 days) + +1. `POST /api/auth/nonce`: + - store nonce with expiry + - rate limit per IP/address +2. `POST /api/auth/verify`: + - verify signature + - create/find `users` row + - create session cookie/JWT +3. `GET /api/gate/status`: + - read session address + - query eligibility via RPC (`Session::Validators` in v1) + - cache result with TTL (`eligibility_cache`) +4. Frontend wiring: + - show wallet connect/disconnect + gate status in the sidebar (Polkadot extension) + - disable all write buttons unless eligible (and show a short reason on hover) + - allow non-eligible users to browse everything + +Frontend flag: + +- `VITE_SIM_AUTH` controls the sidebar wallet panel and client-side gating UI (default enabled; set `VITE_SIM_AUTH=false` to disable). + +Deliverable: users can log in; the UI knows if they’re eligible; buttons are blocked for non-eligible users. + +Tests: + +- Nonce expires; nonce is single-use. +- Nonce issuance is rate-limited per IP. +- Signature verification passes for valid signatures and fails for invalid ones. +- Eligibility check caches with TTL and returns consistent `expiresAt`. +- Write endpoints that change state are introduced in later phases; Phase 3 only gates UI interactions and exposes `/api/me` + `/api/gate/status`. + +## Phase 4 — Read models first (3–8 days) + +Goal: keep the app fully read-model driven via `/api/*` while the backend transitions from the `read_models` bridge to normalized tables + an event log. + +Read endpoints covered in this phase: + +1. Chambers + - `GET /api/chambers` + - `GET /api/chambers/:id` +2. Proposals + - `GET /api/proposals?stage=...` + - `GET /api/proposals/:id/pool` + - `GET /api/proposals/:id/chamber` + - `GET /api/proposals/:id/formation` + - `GET /api/proposals/drafts` + - `GET /api/proposals/drafts/:id` +3. Feed + - `GET /api/feed?cursor=...&stage=...` (cursor can land later; stage filtering is already supported) +4. Courts + - `GET /api/courts` + - `GET /api/courts/:id` +5. Human nodes + - `GET /api/humans` + - `GET /api/humans/:id` +6. Factions + - `GET /api/factions` + - `GET /api/factions/:id` +7. Singletons/dashboards + - `GET /api/formation` + - `GET /api/invision` + - `GET /api/my-governance` + +Frontend: + +- Use the existing `src/lib/apiClient.ts` wrapper (typed helpers, error handling). +- Keep visuals stable; the data source remains `/api/*`. +- Empty-by-default UX: when the backend returns an empty list, pages show “No … yet” (no fixture fallbacks). + +Deliverable: app renders from backend reads across all pages, with clean empty-state behavior by default. + +Tests: + +- API contract stability checks (seeded inline mode returns DTO-shaped payloads). +- Empty-mode checks: list endpoints return `{ items: [] }` and singleton endpoints return minimal defaults when the read-model store is empty (`READ_MODELS_INLINE_EMPTY=true`). + +## Phase 5 — Event log (feed) as the backbone (2–6 days) + +1. Create `events` table (append-only). +2. Define event types (union) and payload schemas (zod). +3. Implement a simple “projector”: + - basic derived feed cards from events + - cursors for pagination +4. Backfill initial events from seeded mock data (so the feed isn’t empty on day 1). + - Use `db/seed/fixtures/*` as the deterministic starting point for the initial backfill. + +Deliverable: feed is powered by real events; pages can also show histories from the event stream. + +Tests: + +- Events are append-only (no updates/deletes). +- Projector determinism: given the same event stream, derived feed cards are identical. + +## Phase 6 — First write slice: Proposal pool voting (4–10 days) + +1. Implement `POST /api/command` with: + - auth required + - gating required (`isActiveHumanNode`) + - idempotency key support +2. Implement `pool.vote` command: + - write pool vote with unique constraint (proposalId + voter address) + - return updated upvote/downvote counts + - overlay live counts in `GET /api/proposals/:id/pool` + - compute quorum thresholds and stage transitions (pool → vote) +3. Frontend: + - ProposalPP page upvote/downvote calls API + - optimistic UI optional (but must reconcile) + +Current status: + +- Implemented: + - `POST /api/command` + `pool.vote` with idempotency + - `pool_votes` storage (DB mode) with in-memory fallback for tests/dev without a DB + - Proposal pool page reads overlay the live vote counts + - Pool quorum evaluator (`evaluatePoolQuorum`) and pool → vote auto-advance when thresholds are met + - the proposal stage is advanced in the canonical `proposals` table and mirrored into the `proposals:list` read model (compat) + - if the chamber page read model is missing, it is created from the pool page payload + - Pool voting is rejected when a proposal is no longer in the pool stage (HTTP 409) + - ProposalPP UI calls `pool.vote` and refetches the pool page on success +- Not implemented yet: + - centralized state machine for transitions (beyond v1 stage updates) + +Deliverable: users can perform one real action (pool vote) and see it in metrics + feed. + +Tests: + +- One vote per user per proposal (idempotency + uniqueness). +- Pool metrics computed correctly from votes + era baselines. +- Stage transition triggers exactly once when thresholds are met. + +## Phase 7 — Chamber vote (decision) + CM awarding (5–14 days) + +1. Add `chamber.vote` command: + - yes/no/abstain + - quorum + passing rule evaluation + - emit events +2. On pass: + - transition to Formation if eligible + - award CM (LCM per chamber) and recompute derived ACM +3. Frontend: + - ProposalChamber becomes real + +Deliverable: end-to-end proposal lifecycle from pool → vote (pass/fail) is operational. + +Tests: + +- Vote constraints (one vote per user, valid choices). +- Quorum + passing calculation accuracy (including rounding rules like 66.6%). +- CM awarding updates LCM/MCM/ACM deterministically after acceptance. + +Current status: + +- Implemented: + - `chamber.vote` command via `POST /api/command` (auth + gate + idempotency) + - `chamber_votes` storage (DB mode) with in-memory fallback for tests/dev without a DB + - Chamber page reads overlay live vote counts in `GET /api/proposals/:id/chamber` + - Vote → build auto-advance when quorum + passing are met and `formationEligible === true` + - the proposal stage is advanced in the canonical `proposals` table and mirrored into the `proposals:list` read model (compat) + - if the formation page read model is missing, it is generated from the chamber page payload + - CM awarding v1: + - `score` (1–10) can be attached to yes votes + - when a proposal passes, the average yes score is converted into CM points and recorded in `cm_awards` + - human ACM is derived as a baseline from read models plus a delta from `cm_awards` (overlaid in `/api/humans*`) +- Not implemented yet: + - rejection / fail path and time-based vote windows + - richer CM economy (per-chamber breakdowns, ACM/LCM/MCM surfaces across all pages, parameter tuning) + +## Phase 8 — Formation v1 (execution) (5–14 days) + +1. Formation project row is created when proposal enters Formation. +2. `formation.join` fills team slots. +3. `formation.milestone.submit` records deliverables. +4. `formation.milestone.requestUnlock` emits an event; acceptance can be mocked initially. +5. Formation metrics and pages read from DB/events. + +Deliverable: Formation pages become real and emit feed events. + +Tests: + +- Team slots cannot exceed total. +- Milestone unlock rules enforced (cannot unlock before request; cannot double-unlock). + +Current status: + +- Implemented: + - Formation tables: `formation_projects`, `formation_team`, `formation_milestones`, `formation_milestone_events` + - Commands: + - `formation.join` + - `formation.milestone.submit` + - `formation.milestone.requestUnlock` + - Formation read overlays in `GET /api/proposals/:id/formation` (team slots + milestone counts + progress) + - Minimal UI wiring on the Formation proposal page (actions call `/api/command`) +- Tests: + - `tests/api-command-formation.test.js` + +## Phase 9 — Courts v1 (disputes) (5–14 days) + +1. `court.case.report` creates or increments cases. +2. Case state machine: Jury → Session live → Ended (driven by time or thresholds). +3. `court.case.verdict` records guilty/not-guilty. +4. Outcome hooks (v1): + - hold/release a milestone unlock request + - flag identity as “restricted” (simulation only) + +Deliverable: courts flow works and affects off-chain simulation outcomes. + +Tests: + +- Case state machine transitions are valid only. +- Verdict is single-per-user and only allowed in appropriate case states. +- Outcome hooks apply the intended flags (hold/release/restrict). + +Current status: + +- Implemented: + - Courts tables: `court_cases`, `court_reports`, `court_verdicts` + - Commands: + - `court.case.report` + - `court.case.verdict` + - Courts read overlays: + - `GET /api/courts` + - `GET /api/courts/:id` + - Minimal UI wiring: + - Courtroom `Report` action and verdict buttons call `/api/command` +- Tests: + - `tests/api-command-courts.test.js` + +## Phase 10a — Era snapshots + activity counters (DONE) + +Goal: make “time” and “activity” real, without changing UI contracts. + +Implemented: + +- Tables: + - `era_snapshots` (per-era aggregates, including `activeGovernors`) + - `era_user_activity` (per-era counters for actions) +- Active governors baseline: + - `SIM_ACTIVE_GOVERNORS` (or `VORTEX_ACTIVE_GOVERNORS`) sets the default baseline. + - Defaults to `150` if unset/invalid. +- `POST /api/clock/advance-era` ensures the next `era_snapshots` row exists. +- Proposal page overlays: + - `GET /api/proposals/:id/pool` and `GET /api/proposals/:id/chamber` override `activeGovernors` from the current era snapshot. +- My Governance overlay: + - `GET /api/my-governance` returns the base read model for anonymous users. + - When authenticated, the response overlays per-era `done` counts from `era_user_activity` (mapped by action label). +- Era counters are incremented only on first-time actions: + - Vote updates do not inflate era activity (e.g. changing an upvote to a downvote stays a single action). + +Tests: + +- `tests/api-era-activity.test.js` (per-era action counting and reset across `advance-era`). + +## Phase 10b — Era rollups + tier statuses (DONE for v1) + +1. Implement cron rollup: + - freeze era action counts + - compute `isActiveGovernorNextEra` + - compute tier decay + statuses (Ahead/Stable/Falling behind/At risk/Losing status) + - update quorum baselines +2. Store `era_snapshots` and emit `era.rolled` events. + +Deliverable: system “moves” with time and feels like governance. + +Tests: + +- Rollup is deterministic and idempotent for a given era window. +- Tier status mapping (Ahead/Stable/Falling behind/At risk/Losing status) matches policy. + +Current status: + +- Implemented: + - `POST /api/clock/rollup-era` (admin/simulation endpoint) + - `GET /api/clock` includes `activeGovernors` and `currentEraRollup` when a rollup exists + - `GET /api/my-governance` includes `rollup` for authenticated users when the current era is rolled + - Rollup tables: `era_rollups`, `era_user_status` + - Configurable per-era requirements via env: + - `SIM_REQUIRED_POOL_VOTES` (default `1`) + - `SIM_REQUIRED_CHAMBER_VOTES` (default `1`) + - `SIM_REQUIRED_COURT_ACTIONS` (default `0`) + - `SIM_REQUIRED_FORMATION_ACTIONS` (default `0`) + - Era snapshot baseline updates: + - rollups write next era’s `era_snapshots.active_governors` from `activeGovernorsNextEra` +- Tests: + - `tests/api-era-rollup.test.js` + - `tests/api-my-governance-rollup.test.js` + +Notes: + +- Tier decay is tracked separately (future iteration) — v1 rollups compute per-era status + next-era active set only. + +## Phase 11 — Hardening + moderation (DONE for v1) + +- Rate limiting (per IP/address) and anti-spam (per-era quotas). +- Auditability: make all state transitions and changes event-backed. +- Admin tools: manual “advance era”, seed data, freeze/unfreeze. +- Observability: logs + basic metrics for rollups and gating failures. +- Moderation controls (off-chain): + - temporary action lock for a user + - court-driven restrictions flags (simulation) + +Current status: + +- `POST /api/command` rate limiting: + - per IP: `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP` + - per address: `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS` + - storage: `api_rate_limits` (DB mode) or in-memory buckets (inline mode) +- Per-era quotas (anti-spam): + - `SIM_MAX_POOL_VOTES_PER_ERA` + - `SIM_MAX_CHAMBER_VOTES_PER_ERA` + - `SIM_MAX_COURT_ACTIONS_PER_ERA` + - `SIM_MAX_FORMATION_ACTIONS_PER_ERA` + - enforcement uses the same “counted actions” as rollups (`era_user_activity`) +- Action locks: + - storage: `user_action_locks` (DB mode) or in-memory locks (inline mode) + - enforcement: all `POST /api/command` writes return HTTP `403` when locked + - admin endpoints: + - `POST /api/admin/users/lock` + - `POST /api/admin/users/unlock` + - inspection endpoints: + - `GET /api/admin/users/locks` + - `GET /api/admin/users/:address` + - audit: + - `GET /api/admin/audit` + - DB mode logs as `events.type = "admin.action.v1"` +- Operational admin endpoints: + - `GET /api/admin/stats` (basic metrics + config snapshot) + - `POST /api/admin/writes/freeze` (toggle write-freeze state) + - deploy-time kill switch: `SIM_WRITE_FREEZE=true` +- Tests: + - `tests/api-command-rate-limit.test.js` + - `tests/api-command-action-lock.test.js` + - `tests/api-command-era-quotas.test.js` + - `tests/api-admin-tools.test.js` + - `tests/api-admin-write-freeze.test.js` + +Notes: + +- `POST /api/clock/*` remains the admin surface for simulation time operations; `POST /api/admin/*` is for moderation/ops. + +## Suggested implementation order (lowest risk / highest value) + +1. Auth + gate +2. Read models for Chambers + Proposals + Feed +3. Event log +4. Pool voting +5. Chamber voting + CM awarding +6. Formation +7. Courts +8. Era rollups + tier statuses + +## Milestone definition for “proto-vortex launch” + +Minimum viable proto-vortex for community: + +- Login with wallet signature +- Eligibility gate from mainnet +- Read-only browsing for all users +- Eligible users can: + - upvote/downvote in pool + - vote yes/no/abstain in chamber vote +- Feed shows real events +- Era rollup runs at least manually (admin endpoint) + +## Notes specific to the current UI + +- The UI already has the key surfaces for v1: + - `ProposalCreation` wizard (draft), ProposalPP (pool), ProposalChamber (vote), ProposalFormation (formation), Courts/Courtroom (courts). +- Keep returning API payloads that match the frozen DTOs so UI components remain stable. + +## Post-v1 roadmap (v2+) + +v1 is a complete, community-playable simulation slice. The next phases focus on replacing transitional components (`read_models`-driven state) with canonical domain tables and a fuller write model, while keeping the current UI DTOs stable. + +## Phase 12 — Proposal drafts + submission (DONE) + +Goal: make the ProposalCreation wizard a real write path (drafts stored in DB, submitted into the pool), without requiring a backend redesign. + +Deliverables: + +- Commands (via `POST /api/command`): + - `proposal.draft.save` (create/update a draft) + - `proposal.draft.delete` + - `proposal.submitToPool` (transition a draft into `pool`) +- Reads: + - `GET /api/proposals/drafts` + - `GET /api/proposals/drafts/:id` + - drafts appear as real data (not seed-only) in DB mode +- Minimal validation that matches the wizard gates (required fields for submission). +- Emit events: + - `proposal.draft.saved`, `proposal.submittedToPool` + +Tests: + +- Draft save is idempotent (Idempotency-Key) and never duplicates. +- Submission enforces required fields and stage (`draft` → `pool` only). +- Non-eligible users can browse drafts only if explicitly allowed (default: drafts are private to the author). + +Current status: + +- `proposal_drafts` table exists (migration + schema). +- `POST /api/command` implements `proposal.draft.save`, `proposal.draft.delete`, `proposal.submitToPool`. +- Draft read endpoints support author-owned drafts in DB mode and memory drafts in non-DB mode, with fixture fallback in `READ_MODELS_INLINE=true`. +- ProposalCreation UI saves drafts via the backend and submits drafts into the proposal pool. +- Tests added: `tests/api-command-drafts.test.js`. + +## Phase 13 — Eligibility via `Session::Validators` (DONE) + +Goal: gate writes based on the **current validator set** on Humanode mainnet (instead of attempting to infer “activeness” via `ImOnline::*`). + +Deliverables: + +- Mainnet gate reads: + - Use `Session::Validators` as the single source of truth for “active Human Node” eligibility. + - Store and cache the result in `eligibility_cache` (DB mode) or memory (no-DB mode), same as today. +- Error / reason codes: + - Standardize on a single negative reason when not in the validator set (e.g. `not_in_validator_set`). +- Local dev: + - Keep `DEV_BYPASS_GATE` and `DEV_ELIGIBLE_ADDRESSES` for local iteration. + +Tests: + +- `GET /api/gate/status` returns `eligible: true` when the address is included in the RPC-returned `Session::Validators`. +- Caching works (second call does not re-hit RPC in memory mode). +- Non-validator address returns `eligible: false` with the expected reason code. + +## Phase 14 — Canonical domain tables + projections (DONE) + +Goal: start migrating away from `read_models` as the “source of truth” by introducing canonical tables for entities that are actively mutated (starting with proposals). + +Deliverables: + +- Introduce canonical tables (v1 order): + - `proposals` (canonical state: stage, chamber, proposer, formation eligibility, etc.) + - `proposal_drafts` (author-owned draft write model) + - optional: `proposal_stage_transitions` (append-only, derived from events) +- Add a projector layer that generates the existing read DTOs from canonical tables/events, writing either: + - derived DTO payloads into `read_models` (compat mode), or + - serving DTOs directly from projector queries (preferred once stable). + +Tests: + +- Projection determinism: same canonical inputs → identical DTO outputs. +- Backwards compatibility: existing endpoints continue returning the same DTO shape. + +Current status: + +- `proposals` table exists (migration + schema). +- `proposal.submitToPool` writes a canonical proposal row (and only writes proposal DTOs into `read_models` when a `read_models` store is available). +- Proposal page reads prefer canonical proposals (pool/chamber/formation), falling back to `read_models` only for seeded legacy proposals. +- Pool → vote and vote → build auto-advance update the canonical proposal stage via compare-and-set transitions. + +## Phase 15 — Deterministic state transitions (DONE) + +Goal: centralize all proposal stage logic in a single, testable state machine (rather than scattered “read model patching”). + +Deliverables: + +- A single transition authority for proposals: + - `draft` → `pool` (submit) + - `pool` → `vote` (quorum met) + - `vote` → `build` (passing met + formation eligible) + - explicit fail paths (v2 decision): `pool`/`vote` rejection or expiry +- All transitions emit events and are enforced (HTTP `409` on invalid transition). + +Tests: + +- Transition matrix coverage (allowed vs forbidden transitions). +- Regression tests for quorum and rounding edges (e.g. 66.6%). + +Current status: + +- A v1 state machine module exists (`functions/_lib/proposalStateMachine.ts`) with the core quorum-based advance rules. +- Commands validate stage against canonical proposals first (falling back to `read_models` for legacy seeded proposals). +- `pool.vote` and `chamber.vote` can auto-advance proposals even when `read_models` are missing, by using canonical proposal state as the source of truth. +- Canonical stage transitions are enforced via `transitionProposalStage(...)` (compare-and-set + transition validation), with coverage in tests. + +## Phase 16 — Time windows + automation (DONE) + +Goal: move from “admin-driven clock ops only” to scheduled simulation behavior. + +Deliverables: + +- Cron-based era ops: + - a single “cron entrypoint” endpoint: `POST /api/clock/tick` + - rollup the current era (idempotent) + - optionally advance era when “due” (time-based; configurable) +- Optional vote windows: + - ability to enable/disable stage windows via env (`SIM_ENABLE_STAGE_WINDOWS`) + - reject new votes when `pool` or `vote` windows end (v1 behavior; no automatic stage change) + - deterministic rule for “what happens on expiry” (v2 decision: auto-close/auto-reject vs “stuck”) + +Tests: + +- Clock advancement is idempotent and monotonic. +- Rollups remain deterministic even when scheduled. + +Current status: + +- `POST /api/clock/tick` exists and can run the rollup and (optionally) advance era when due. +- Stage windows are implemented behind `SIM_ENABLE_STAGE_WINDOWS`: + - `pool.vote` and `chamber.vote` return HTTP `409` after the configured windows end. + - `GET /api/proposals` and `GET /api/proposals/:id/chamber` compute `timeLeft` from the canonical proposal stage timestamp when enabled (`"Ended"` once the window is over). + - `POST /api/clock/tick` emits a deduped feed event when a proposal’s `pool` or `vote` window has ended (and returns those in the `endedWindows` response field for visibility). + +## Phase 17 — Chamber voting eligibility + Formation optionality (DONE) + +Goal: align chambers with the Vortex 1.0 model: + +- specialization chambers are votable only by humans who have an **accepted proposal in that chamber** +- General chamber is votable only by humans who have an **accepted proposal in any chamber** +- quorum fractions remain **global**, but denominators are **chamber-scoped** (active governors eligible for that chamber in the era, captured on stage entry) +- not all accepted proposals require Formation (Formation is optional) + +Definitions (v1): + +- “Accepted proposal” means: **passed chamber vote**. +- “Formation required” is a proposal-type property; acceptance does not imply a Formation project must exist. + +Deliverables: + +1. Chamber participation model + - genesis participants/roles (seeded at genesis) + - earned eligibility: + - accepted proposal in chamber X → eligible to vote in X (specialization) + - accepted proposal in any chamber → eligible to vote in General + - no decay/expiration of eligibility (separate from “active governor next era” quorum baselines) +2. Enforce eligibility in writes + - `chamber.vote` must reject when the voter is not eligible for the proposal’s lead chamber. + - The rule applies to **General** and **specialization** chambers. +3. Decouple acceptance from Formation + - chamber vote passing moves a proposal to “accepted” regardless of whether Formation is required. + - Formation actions and Formation page behavior are enabled only when the proposal is Formation-required. + +Tests: + +- Eligibility enforcement: + - voting in a specialization chamber without eligibility is rejected + - voting in General without “any accepted proposal” is rejected + - eligibility is granted after a proposal is accepted +- Formation optionality: + - a non-Formation proposal can still become accepted + - Formation actions are rejected when Formation is not required + +Current status: + +- Chamber membership table added: + - schema: `db/schema.ts` (`chamber_memberships`) + - migration: `db/migrations/0016_chamber_memberships.sql` + - store: `functions/_lib/chamberMembershipsStore.ts` +- Eligibility is enforced in writes: + - `POST /api/command` → `chamber.vote` rejects HTTP `403` when the voter is not eligible for the proposal’s lead chamber. + - Dev bypass: `DEV_BYPASS_CHAMBER_ELIGIBILITY=true` (local/testing only). +- Genesis bootstrap: + - `/sim-config.json` can list `genesisChamberMembers` to allow the first chamber votes before any proposals are accepted. + - Tests/local dev can override config via `SIM_CONFIG_JSON`. +- Eligibility is granted on acceptance: + - when a proposal passes chamber vote (vote → build), the proposer gains: + - specialization membership for that chamber (if not `general`) + - General eligibility (`general`) +- Acceptance is decoupled from Formation: + - passing chamber vote advances vote → build regardless of Formation requirement + - Formation state is only seeded when `formationEligible=true` + - Formation commands are rejected when Formation is not required +- Tests: + - `tests/api-chamber-eligibility.test.js` + +## Phase 18 — Chambers lifecycle (create/dissolve) (DONE) + +Goal: model chamber creation and dissolution per Vortex 1.0 as **General chamber** proposals. + +Deliverables: + +- Canonical `chambers` table (id, title, status, createdAt, dissolvedAt, multiplier, metadata). +- Commands/events for: + - create chamber (General chamber proposal outcome) + - dissolve chamber (General chamber proposal outcome; preserve history) +- Read endpoints (replace read-model-only chamber list/detail with canonical + projections). + +Tests: + +- Chamber create/dissolve changes canonical chamber status and read endpoints reflect it. +- Votes and proposals continue to resolve `chamberId` correctly when a chamber is dissolved (history preserved). + +Current status: + +- Canonical `chambers` table exists: + - schema: `db/schema.ts` + - migration: `db/migrations/0017_chambers.sql` +- Genesis chambers are configured via `public/sim-config.json` → `genesisChambers` and auto-seeded when the table is empty. +- Read endpoints are canonical: + - `GET /api/chambers` builds from canonical chambers (empty in `READ_MODELS_INLINE_EMPTY=true` mode). + - `GET /api/chambers/:id` resolves canonical chambers (still returns a minimal detail model in v1). +- Chamber lifecycle is simulated as a General-chamber proposal outcome: + - accepted General proposals with `payload.metaGovernance.action` in `{ "chamber.create", "chamber.dissolve" }` create/dissolve chambers. +- Tests: + - `tests/api-chambers-lifecycle.test.js` + +## Phase 19 — Chamber detail projections (DONE) + +Goal: make `GET /api/chambers/:id` a true projection from canonical state (no chamber read-model drift). + +Deliverables: + +- Project proposal status list from canonical proposals: + - pool → upcoming + - vote → live + - build → ended (meta “Formation” vs “Passed” depends on `formationEligible`) +- Project chamber roster from canonical chamber memberships + genesis members: + - specialization chamber roster = members for that chamber + genesis members for that chamber + - General chamber roster = union of all memberships + genesis members +- Keep threads/chat placeholders as empty arrays (until the forum model exists). + +Tests: + +- `GET /api/chambers/:id` returns roster derived from memberships/genesis. + +Current status: + +- `GET /api/chambers/:id` is projected from canonical stores: + - proposals come from canonical `proposals` (pool → upcoming, vote → live, build → ended) + - roster comes from canonical `chamber_memberships` plus `genesisChamberMembers` + - General roster is the union of all chamber memberships plus all genesis members +- Tests: + - `tests/api-chamber-detail-projection.test.js` + +## Phase 20 — Dissolved chamber enforcement (DONE) + +Goal: define and enforce what “dissolved chamber” means for writes in v1. + +v1 rule: + +- dissolved chambers are not selectable for new proposal submissions +- proposals created before dissolution can finish their lifecycle (votes allowed) + +Deliverables: + +- `proposal.submitToPool` rejects drafts targeting a dissolved chamber. +- `chamber.vote` rejects votes only for the (should-not-exist) case where a proposal was created after the chamber was dissolved. +- Preserve history: dissolved chambers remain in canonical storage and can still be referenced by old proposals. + +Tests: + +- cannot submit a new proposal into a dissolved chamber +- voting remains possible on pre-existing proposals created before dissolution +- voting is rejected for proposals created after dissolution (defensive invariant) + +Current status: + +- Enforcement is implemented in `functions/api/command.ts`: + - `proposal.submitToPool` returns: + - `400` `invalid_chamber` when `draft.chamberId` is unknown + - `409` `chamber_dissolved` when the chamber exists but is not active + - `chamber.vote` returns `409` `chamber_dissolved` when `proposal.createdAt > chamber.dissolvedAt` +- Tests: + - `tests/api-chamber-dissolution.test.js` + +## Phase 21 — Chambers directory projections (pipeline/stats) (DONE) + +Goal: ensure `GET /api/chambers` is a stable projection of canonical state across both DB mode and inline mode. + +Deliverables: + +- Pipeline counts (`pool/vote/build`) projected from canonical proposals. +- Chamber stats projected from canonical state: + - governors: derived from canonical memberships + genesis members (General = union) + - ACM/LCM/MCM: derived from CM awards + multipliers. +- Support `includeDissolved=true` query param (default remains active-only). + +Tests: + +- `GET /api/chambers` returns correct pipeline/stats in inline mode with canonical proposals + CM awards. +- `includeDissolved=true` includes dissolved chambers that are excluded by default. + +Current status: + +- `functions/api/chambers/index.ts` supports `includeDissolved=true`. +- Projections are implemented in `functions/_lib/chambersStore.ts` for both DB and inline mode. +- Tests: + - `tests/api-chambers-index-projection.test.js` + +## Phase 22 — Meta-governance chamber.create seeding (backend) (DONE) + +Goal: allow chamber creation to be driven by a General proposal outcome and immediately become usable (no chicken-and-egg for voting). + +Deliverables: + +- Drafts can include an optional `metaGovernance` payload describing: + - `chamber.create` (id/title/multiplier + optional `genesisMembers`) + - `chamber.dissolve` (id) +- Submission validation: + - meta-governance proposals must be in the General chamber + - create rejects existing chambers; dissolve rejects unknown/already-dissolved chambers +- On acceptance of a General `chamber.create` proposal: + - create the canonical chamber entry + - seed initial membership in `chamber_memberships` for: + - proposer address (always) + - `metaGovernance.genesisMembers` (optional) + +Tests: + +- A General `chamber.create` proposal can pass and produces a new chamber visible in `/api/chambers`. +- Seeded members can vote in the newly created chamber immediately. + +Current status: + +- Draft schema supports `metaGovernance` in `functions/_lib/proposalDraftsStore.ts`. +- `proposal.submitToPool` validates meta-governance payloads in `functions/api/command.ts`. +- On acceptance, the backend seeds `chamber_memberships` for proposer + genesis members. +- Tests: + - `tests/api-command-chamber-create-members.test.js` + +## Phase 23 — Proposal drafts (UI ↔ backend) (DONE) + +Goal: make the proposal creation wizard use the real backend drafts so “drafts → submit → proposal” is end-to-end through the UI. + +Deliverables: + +- `src/pages/proposals/ProposalCreation.tsx` calls: + - `proposal.draft.save` (create/update draft) instead of only localStorage + - stores the server `draftId` locally to continue editing +- Drafts list and detail become the canonical entry point for submissions: + - drafts created in the wizard show up under `/app/proposals/drafts` + - “Submit to pool” uses `proposal.submitToPool` and navigates to `/app/proposals/:id/pp` + +Tests: + +- UI wiring is smoke-tested via API integration tests (draft save + submit already covered) plus a minimal client-side test if needed. + +Current status: + +- `src/pages/proposals/ProposalCreation.tsx` saves drafts via `proposal.draft.save` when the user is eligible and retains the `draftId` locally for continued edits. +- The wizard UI is split into `src/pages/proposals/proposalCreation/*` step components + storage/sync helpers (so the page orchestrator stays small). +- “Submit proposal” now saves (if needed) and submits via `proposal.submitToPool`, then navigates to `/app/proposals/:id/pp`. + +## Phase 24 — Meta-governance proposal type (UI) (DONE) + +Goal: expose meta-governance proposals in the proposal wizard so chambers can be created/dissolved without manual API calls. + +Deliverables: + +- Add a proposal “kind” selector: + - normal proposals (any chamber) + - General meta-governance: + - create chamber + - dissolve chamber +- Wizard writes `metaGovernance` into the draft payload and enforces the additional fields client-side. +- Meta-governance drafts can submit with zero budget items (budget is optional for system-change proposals). + +Tests: + +- UI submits a chamber.create draft that is accepted and results are visible on chambers pages. + +Current status: + +- `src/pages/proposals/proposalCreation/steps/EssentialsStep.tsx` exposes the “System change (General)” kind and collects `metaGovernance` fields. +- `draftIsSubmittable` allows meta-governance drafts to be submitted without budget items (still requires rules confirmations). +- Tests: + - `tests/api-command-meta-governance-no-budget.test.js` + +## Phase 25 — Proposal pages projected from canonical state (DONE) + +Goal: ensure `/api/proposals` and proposal pages are projections from canonical proposals + overlays (not brittle, seed-only read models). + +Deliverables: + +- `/api/proposals` list is derived from canonical proposals with live overlays for votes, formation, and era context. +- `/api/proposals/:id/pp|chamber|formation` are derived from canonical proposal payloads and normalized tables (votes/formation). +- Eliminate “page-only summary/headers drift”: proposal pages should use the same structural sections across stages, populated from the canonical proposal payload. + +Tests: + +- Proposal list and proposal pages render from canonical state in inline mode and DB mode. + +Current status: + +- Read endpoints prefer canonical proposals (`proposals` table / in-memory store) and compute live overlays from normalized tables (pool votes, chamber votes, formation). +- Proposal stage pages share a consistent header component: + - `src/components/ProposalPageHeader.tsx` + - wired into: + - `src/pages/proposals/ProposalPP.tsx` + - `src/pages/proposals/ProposalChamber.tsx` + - `src/pages/proposals/ProposalFormation.tsx` +- Tests: + - `tests/api-proposals-canonical-precedence.test.js` (canonical proposal takes precedence over seeded read models). + +## Phase 26 — Proposal history timeline (DONE) + +Goal: make proposals auditable and explainable by exposing a single “what happened” timeline. + +Deliverables: + +- Event-backed proposal history: + - submission + - pool votes + threshold met + - chamber votes + pass/fail + - formation joins + milestone actions + - court actions referencing the proposal (when applicable) +- A consistent timeline component in the UI (read-only). + +Tests: + +- Timeline output is deterministic given the same events. + +Current status: + +- Timeline events are stored in the append-only `events` table as `proposal.timeline.v1` entries keyed by proposal ID: + - `functions/_lib/proposalTimelineStore.ts` + - `functions/api/proposals/[id]/timeline.ts` +- Commands append timeline events for proposal lifecycle actions (submission, votes, stage advancement, formation actions, and chamber create/dissolve side-effects): + - `functions/api/command.ts` +- Proposal pages render the timeline consistently: + - `src/components/ProposalSections.tsx` → `ProposalTimelineCard` + - `src/pages/proposals/ProposalPP.tsx` + - `src/pages/proposals/ProposalChamber.tsx` + - `src/pages/proposals/ProposalFormation.tsx` +- Tests: + - `tests/api-proposal-timeline.test.js` + +## Phase 27 — Active governance v2 (derive and persist active governor set per era) (DONE) + +Goal: define “active governor” precisely, derive it at rollup, and persist it as the canonical basis for quorums and UI denominators. + +Deliverables: + +- Define “active governor” for era N as a composition of: + - eligibility gate (active Human Node / validator address), and + - previous-era governing activity (configured per-era requirements), so the system can compute “active for next era”. +- Persist the active set size and (optionally) membership: + - `activeGovernorsBaseline` (count) becomes the era denominator source of truth. + - Optional: persist a membership list for audit/debug (not required for v2 quorums, but useful for ops). +- Ensure `POST /api/clock/rollup-era` is the single place that computes next-era baselines, and all reads consume those baselines (no divergent denominators across endpoints/pages). + +Tests: + +- Unit tests for “active governor” derivation from: + - gate status + era activity counters + requirements. +- API integration tests to ensure: + - `GET /api/clock` returns the same baseline that proposal/courts/pages use for denominators. + +Current status: + +- Era activity counters are stored per era (in-memory or Postgres): + - `functions/_lib/eraStore.ts` +- `rollupEra` computes per-address status and the next-era active denominator, and persists both: + - `functions/_lib/eraRollupStore.ts` writes: + - `era_rollups.active_governors_next_era` + - `era_user_status.is_active_next_era` +- “Active next era” is computed as: + - meets the configured activity requirements for the rolled-up era, AND + - is a Humanode validator (membership in `Session::Validators`) unless explicitly bypassed for local dev. +- The Humanode validator set is read via RPC: + - `functions/_lib/humanodeRpc.ts` (`state_getStorage` for `Session::Validators`) +- The rollup endpoint also updates the next era’s snapshot baseline so proposal/quorum reads stay consistent: + - `functions/api/clock/rollup-era.ts` + - `functions/api/clock/tick.ts` +- Address handling is case-sensitive: + - SS58 addresses are not lowercased anywhere; addresses are treated as opaque identifiers and only `trim()` is applied. + +Tests: + +- `tests/api-era-rollup.test.js` (rollup is idempotent and computes counts) +- `tests/api-era-rollup-validator-gate.test.js` (active governors are filtered by `Session::Validators`) + +## Phase 28 — Quorum engine v2 (era-derived denominators + paper thresholds) (DONE) + +Goal: drive all quorum math from the active-governor denominator computed in Phase 27, and decide paper-alignment thresholds. + +Deliverables: + +- Make pool + chamber quorum evaluation use a single explicit denominator source per proposal stage: + - Stage-entry denominator snapshots stored in `proposal_stage_denominators` (one row per `(proposalId, stage)` for `pool` and `vote`). + - When a snapshot exists, quorum math and UI denominators use it (prevents drift when eras advance mid-stage). + - If a snapshot is missing (legacy data), fall back to the current era baseline. +- Decide and document paper-alignment knobs: + - pool attention quorum: aligned to paper `22%` (v1) + - vote window: aligned to paper `7 days` +- Ensure UI surfaces that show “X / needed” and “% / threshold%” derive from the same denominator snapshot (no mixed sources). + +Tests: + +- Unit tests for quorum math against explicit denominators (pool + chamber). +- API integration tests to ensure: + - denominators shown on proposal pages are stable across era rollups (no drift) + - stage transitions evaluate thresholds against the same denominator they display. + +Implemented: + +- `db/schema.ts` + migration: `proposal_stage_denominators` +- `functions/_lib/proposalStageDenominatorsStore.ts` (DB-backed, with memory fallback when `DATABASE_URL` is not set) +- `functions/api/command.ts` captures denominators at stage entry and uses them for advancement checks +- `functions/api/proposals/*` reads prefer stage denominators for pool/vote pages and list items +- Test: `tests/api-quorum-stage-denominators.test.js` + +## Phase 29 — Delegation v1 (graph + history + chamber vote weighting) (DONE) + +Goal: implement delegation as a first-class module so chamber votes can aggregate weight, while proposal pool attention remains strictly direct. + +Deliverables: + +- `delegations` (canonical graph) + `delegation_events` (append-only history). +- Commands: + - `delegation.set` + - `delegation.clear` +- Invariants: + - no self-delegation + - no cycles + - at most one delegatee per delegator per chamber +- Chamber vote weighting: + - vote weight = `1 + delegatedVoices` (paper intent) + - delegation affects chamber vote counts/quorum math, but not pool attention mechanics. + - a delegator’s voice only counts if the delegator did **not** cast a chamber vote themselves. + +Tests: + +- Unit tests for cycle detection and vote weight aggregation. +- API integration tests for set/clear + weighted chamber vote aggregation. + +Implemented: + +- Tables + migrations: + - `db/schema.ts`: `delegations`, `delegation_events` + - `db/migrations/0019_delegations.sql` +- Store: + - `functions/_lib/delegationsStore.ts` +- Commands: + - `POST /api/command`: `delegation.set`, `delegation.clear` +- Weighted chamber vote counts: + - `functions/_lib/chamberVotesStore.ts` uses `delegations` to compute weighted `{ yes, no, abstain }` when `chamberId` is known. +- Tests: + - `tests/delegations-cycle.test.js` + - `tests/api-delegation-weighted-votes.test.js` + +## Phase 30 — Veto v1 (temporary slow-down + attempt limits) (DONE) + +Goal: implement a paper-aligned temporary veto slow-down that is auditable and bounded. + +Deliverables: + +- Command(s) to record veto actions and the required threshold to trigger them. +- Proposal state machine extension: + - veto sends proposal back for a cool-down window + - veto attempt count is bounded; after N approvals no veto applies. +- Timeline + feed events for veto actions. + +Tests: + +- Unit tests for veto attempt counting and state transitions. +- API integration tests for veto flows and timeline output. + +Implemented: + +- DB: + - Migration: `db/migrations/0020_veto.sql` + - Tables/columns: `db/schema.ts` (`proposals` veto fields + `veto_votes`) +- Veto council derivation (paper intent: top LCM holders): + - `functions/_lib/vetoCouncilStore.ts` computes one holder per chamber (highest accumulated LCM from `cm_awards`). + - Threshold is computed as `floor(2/3*n) + 1` and snapshotted onto the proposal. +- Veto voting: + - `functions/_lib/vetoVotesStore.ts` stores votes in `veto_votes` (DB mode) with a safe in-memory fallback. + - `POST /api/command`: `veto.vote` records votes, emits timeline events, and applies veto when threshold is reached. +- Stage behavior: + - When chamber vote passes, the proposal can enter a pending-veto window (`vote_passed_at` / `vote_finalizes_at`). + - `POST /api/clock/tick` finalizes to `build` once the veto window ends (if no veto was applied). + - Veto is bounded per proposal (`veto_count` max applies = 2). +- Tests: + - `tests/api-veto.test.js` + - `tests/migrations.test.js` asserts `veto_votes` table exists + +## Phase 31 — Chamber multiplier voting v1 (outside-of-chamber aggregation) (DONE) + +Goal: implement paper-aligned multiplier setting (outsiders-only aggregation) without rewriting historical CM awards. + +Deliverables: + +- Multiplier submissions table + aggregation rule (v1: simple average + rounding). +- Outsider rule enforcement (cannot submit for chambers where the address has LCM history). +- Multiplier change history events. + +Tests: + +- Unit tests for outsider eligibility and aggregation. +- API tests that multipliers affect MCM/ACM views without mutating prior award events. + +Implemented: + +- DB: + - Migration: `db/migrations/0021_chamber_multiplier_submissions.sql` + - Table: `chamber_multiplier_submissions` (one submission per `(chamber_id, voter_address)`) +- Store: + - `functions/_lib/chamberMultiplierSubmissionsStore.ts` +- Command: + - `POST /api/command`: `chamber.multiplier.submit` + - outsiders-only enforcement (LCM history blocks submissions) + - aggregation rule: rounded average applied to `chambers.multiplier_times10` +- Views: + - CM awards remain immutable; ACM/MCM views are recomputed using current multipliers +- Tests: + - `tests/api-chamber-multiplier-voting.test.js` + +## Phase 32 — Paper alignment audit pass (process-by-process) + +Goal: run a deliberate paper-vs-simulation audit for every major process and reconcile docs/constants before “production-like” testing. + +Deliverables: + +- Update `docs/simulation/vortex-simulation-paper-alignment.md` with the resolved decisions. +- Update `docs/simulation/vortex-simulation-v1-constants.md` if thresholds change. +- Update UI copy/labels where the paper language is more precise. + +Tests: + +- None required (doc-only), but any behavior changes required by the audit must ship with tests in the relevant phase. + +## Phase 33 — Testing readiness v3 (scenario harness + end-to-end validation) (IN PROGRESS) + +Goal: add a deterministic, repeatable testing harness that validates the full governance loop across modules without relying on browser-driven manual testing. + +Deliverables: + +- A small set of “golden flow” scenarios, expressed as API calls: + - proposal draft → submit → pool votes → advance → chamber vote → pass/fail → (optional) Formation actions + - General meta-governance proposal that creates a specialization chamber (and grants genesis membership as configured) + - era rollup produces the next-era active-governor baseline used in quorum denominators + - delegation impacts chamber vote weighting (but not pool attention mechanics) + - veto sends an accepted proposal back through the bounded slow-down flow + - multiplier voting affects MCM/ACM views without rewriting award events + - MM updates on Formation delivery scoring and appears in My Governance/Invision +- Optional: a scriptable seed “scenario pack” for manual UI verification in DB mode (kept separate from production seed). + +Tests: + +- Add scenario-based integration tests that: + - set up a minimal DB state + - execute command sequences + - assert invariants and derived values at each step (statuses, denominators, stage transitions, event logs). + +Current status: + +- Added a baseline scenario test for project proposals: + - `tests/scenario-governance-loop.test.js` + +## Phase 34 — Meritocratic Measure (MM) v1 (post-V3, Formation delivery scoring) + +Goal: model delivery merit earned through Formation in a way that can feed into tiers and Invision, without blocking the core governance loop testing. + +Deliverables: + +- MM events tied to Formation milestone outcomes (review scoring + aggregation). +- Per-address MM views and Invision signals. + +Tests: + +- Unit tests for MM aggregation. +- API tests for MM visibility in `GET /api/my-governance` and `GET /api/invision`. + +Proposal wizard v2 track (Phases 35–39) + +The current UI implementation supports meta-governance, but it still uses a largely “single big form” shape that mixes project fields with system-change fields. For long-term maintainability (and a cleaner chamber-creation UX), the wizard is moving to a template-driven design where proposal types have distinct step flows and payload shapes. + +Reference: + +- `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` (Wizard v2 track W1–W5) + +Track summary (high-level): + +- A template runner + registry so proposal types can define their own steps. +- A dedicated `system.chamberCreate` flow that only collects fields needed to create and render a chamber. +- A discriminated union draft schema in the backend, with compatibility for legacy drafts during migration. + +Phases: + +- Phase 35–39 (Proposal wizard v2 W1–W5), as listed in the execution sequence above. + +## Phase 35 — Proposal wizard v2 W1 (template runner + registry) (DONE) + +Goal: extract the proposal creation flow into a template runner so different proposal kinds can have different step flows without turning `ProposalCreation.tsx` into a branching monolith. + +Deliverables: + +- A template registry that is safe to import from Node tests (no JSX in the template layer). +- A template runner in `src/pages/proposals/ProposalCreation.tsx` that delegates: + - step order + labels + - step-to-step navigation constraints (Next/Back) + - submit gating (`canSubmit`) +- Persist template id in local draft storage. + +Current status: + +- Template registry: + - `src/pages/proposals/proposalCreation/templates/registry.ts` + - `src/pages/proposals/proposalCreation/templates/types.ts` +- Templates implemented: + - `src/pages/proposals/proposalCreation/templates/project.ts` + - `src/pages/proposals/proposalCreation/templates/system.ts` +- Runner integration: + - `src/pages/proposals/ProposalCreation.tsx` + - local storage helpers: `src/pages/proposals/proposalCreation/storage.ts` +- Tests: + - `tests/proposal-wizard-template-registry.test.js` + +## Phase 36 — Proposal wizard v2 W2 (system.chamberCreate flow) (DONE — `system` template v1) + +Goal: make chamber creation proposals feel like system proposals (not project proposals), while still producing a payload the backend can accept today. + +Deliverables: + +- A dedicated **system** flow that: + - forces `chamberId = "general"` + - skips the Budget step + - hides project-only optional sections (timeline/outputs) for system proposals +- Keep `metaGovernance` in the draft payload so existing backend finalizers apply. + +Current status: + +- UI “Kind” selector switches the template: + - `project` (Essentials → Plan → Budget → Review) + - `system` (Setup → Rationale → Review) +- `metaGovernance` fields are still collected in Essentials (action, chamber id, title, multiplier, genesis members). + +## Phase 37 — Proposal wizard v2 W3 (backend discriminated drafts) (DONE) + +Goal: stop requiring project-oriented fields for system proposals and make backend validation match the template. + +Deliverables: + +- Add a discriminant to the stored draft payload (`templateId`) and validate as a union. +- Separate required fields per draft kind: + - `project`: keeps project-required text + budget checks. + - `system`: requires system action fields; project-only fields can be omitted. +- Normalize missing system fields to defaults so payloads remain stable for existing UI readers. + +Current status: + +- `proposalDraftFormSchema` is now a template-aware discriminated union with preprocessing: + - `project` vs `system` templates + - template inference when `templateId` is missing + - defaults applied for optional system fields +- Draft storage normalizes payloads via the schema so later consumers always see consistent arrays/strings. +- Tests: + - `tests/api-command-system-draft-minimal.test.js` + +## Phase 38 — Proposal wizard v2 W4 (migrate drafts + simplify validation) (DONE) + +Goal: migrate stored drafts (DB + local) so the UI and backend no longer carry legacy branches. + +Deliverables: + +- Migration strategy: + - Map old drafts to `project` by default. + - Map drafts with `metaGovernance` to `system`. +- Simplify template logic by removing “mixed” validation branches. + +Tests: + +- Migration tests and a small “legacy draft still loads” smoke check. + +Current status: + +- Draft payloads are normalized on read: + - DB: `listDrafts`/`getDraft` backfill `templateId` when missing. + - Memory: legacy payloads are normalized and cached. +- Project wizard validation no longer handles system proposals. +- Tests: + - `tests/proposal-draft-migration.test.js` + +## Phase 39 — Proposal wizard v2 W5 (cleanup + extension points) (DONE) + +Goal: keep the wizard extensible without reintroducing branching logic everywhere. + +Deliverables: + +- Add extension points for additional system actions without inflating the project flow. +- Keep system-specific fields out of the project flow. + +Tests: + +- Wizard system template validation (project fields are no longer required). + +Current status: + +- System action metadata is centralized in `systemActions.ts`. +- System proposals no longer require project-only fields (`what/why`). +- System review summary renders only system-specific fields. +- Tests: + - `tests/proposal-wizard-system-template.test.js` diff --git a/docs/simulation/vortex-simulation-local-dev.md b/docs/simulation/vortex-simulation-local-dev.md new file mode 100644 index 0000000..3c7eb70 --- /dev/null +++ b/docs/simulation/vortex-simulation-local-dev.md @@ -0,0 +1,170 @@ +# Vortex Simulation Backend — Local Dev (Node API runner + UI proxy) + +Production deploys the API as **Cloudflare Pages Functions** under `functions/`. Local development runs the same handlers in Node via `scripts/dev-api-node.mjs` so the UI can call `/api/*` without relying on `wrangler pages dev`. + +## Endpoints (current skeleton) + +- `GET /api/health` +- `POST /api/auth/nonce` → `{ address }` → `{ nonce }` (sets `vortex_nonce` cookie) +- `POST /api/auth/verify` → `{ address, nonce, signature }` (sets `vortex_session` cookie) +- `POST /api/auth/logout` +- `GET /api/me` +- `GET /api/gate/status` +- Read endpoints (Phase 2c/4 bridge; backed by `read_models`): + - `GET /api/chambers` + - `GET /api/chambers/:id` + - `GET /api/proposals?stage=...` + - `GET /api/proposals/:id/pool` + - `GET /api/proposals/:id/chamber` + - `GET /api/proposals/:id/formation` + - `GET /api/proposals/drafts` + - `GET /api/proposals/drafts/:id` + - `GET /api/courts` + - `GET /api/courts/:id` + - `GET /api/humans` + - `GET /api/humans/:id` + - `GET /api/factions` + - `GET /api/factions/:id` + - `GET /api/formation` + - `GET /api/invision` +- `GET /api/my-governance` +- `GET /api/clock` (simulation time snapshot) +- `POST /api/clock/advance-era` (admin-only; increments era by 1) +- `POST /api/clock/rollup-era` (admin-only; computes next-era active set + tier statuses) +- `POST /api/admin/users/lock` (admin-only; temporarily disables writes for an address) +- `POST /api/admin/users/unlock` (admin-only) +- `GET /api/admin/users/locks` (admin-only) +- `GET /api/admin/users/:address` (admin-only) +- `GET /api/admin/audit` (admin-only) +- `GET /api/admin/stats` (admin-only) +- `POST /api/admin/writes/freeze` (admin-only; toggles global write freeze) +- `POST /api/command` (write commands; gated) + +## Required env vars + +These env vars are read by the API runtime (Pages Functions in production, Node runner locally). + +- `SESSION_SECRET` (required): used to sign `vortex_nonce` and `vortex_session` cookies. +- `DATABASE_URL` (required for Phase 2c+): Postgres connection string (v1 expects Neon-compatible serverless Postgres). +- `ADMIN_SECRET` (required for admin endpoints): must be provided via `x-admin-secret` header (unless `DEV_BYPASS_ADMIN=true`). +- Humanode mainnet RPC URL can be configured in either place: + - `HUMANODE_RPC_URL` (recommended for deployments), or + - `public/sim-config.json` via `humanodeRpcUrl` (repo-configured runtime value). + +For convenience, this repo ships with a default `humanodeRpcUrl` pointing to the public Humanode mainnet explorer RPC. + +Local dev note: + +- When using the Node API runner (`yarn dev:api` / `yarn dev:full`), the API server exposes `GET /sim-config.json` by reading `public/sim-config.json`, so real gating works without setting `HUMANODE_RPC_URL`. + +- Chamber voting bootstrap (optional): + - `public/sim-config.json` → `genesisChamberMembers` can list initial eligible voters per `chamberId` (including `general`). + - This is needed to allow the first specialization chamber votes before anyone has an accepted proposal. + +- Chambers bootstrap (recommended): + - `public/sim-config.json` → `genesisChambers` defines the initial chamber set (id/title/multiplier). + - The backend auto-seeds these into the canonical `chambers` table when the table is empty. + +- `SIM_ACTIVE_GOVERNORS` (optional): active governors baseline used for quorum math (defaults to `150`). +- `SIM_REQUIRED_POOL_VOTES` (optional): per-era required pool actions (defaults to `1`). +- `SIM_REQUIRED_CHAMBER_VOTES` (optional): per-era required chamber actions (defaults to `1`). +- `SIM_REQUIRED_COURT_ACTIONS` (optional): per-era required court actions (defaults to `0`). +- `SIM_REQUIRED_FORMATION_ACTIONS` (optional): per-era required formation actions (defaults to `0`). +- Era baseline updates: `/api/clock/rollup-era` sets the next era’s `activeGovernors` baseline from rollup results (`activeGovernorsNextEra`). +- `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP` (optional): per-minute IP limit for `POST /api/command` (defaults to `180`). +- `SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS` (optional): per-minute address limit for `POST /api/command` (defaults to `60`). +- `SIM_MAX_POOL_VOTES_PER_ERA` (optional): maximum counted pool actions per era per address (unset/0 = unlimited). +- `SIM_MAX_CHAMBER_VOTES_PER_ERA` (optional): maximum counted chamber vote actions per era per address (unset/0 = unlimited). +- `SIM_MAX_COURT_ACTIONS_PER_ERA` (optional): maximum counted court actions per era per address (unset/0 = unlimited). +- `SIM_MAX_FORMATION_ACTIONS_PER_ERA` (optional): maximum counted formation actions per era per address (unset/0 = unlimited). +- `SIM_WRITE_FREEZE` (optional): if `true`, blocks all `POST /api/command` writes regardless of admin state (deploy-time kill switch). +- Phase 16 automation and time windows: + - `SIM_ERA_SECONDS` (optional): tick “due” threshold in seconds (defaults to 7 days). + - `SIM_ENABLE_STAGE_WINDOWS` (optional): when `true`, enforce per-stage pool/vote windows and compute `timeLeft` from canonical timestamps. + - `SIM_POOL_WINDOW_SECONDS` (optional): pool stage window in seconds (defaults to 7 days). + - `SIM_VOTE_WINDOW_SECONDS` (optional): vote stage window in seconds (defaults to 7 days). + - `SIM_NOW_ISO` (optional): override “current time” for test/debug. + +## Frontend build flags + +- `VITE_SIM_AUTH` controls the sidebar wallet panel + client-side gating UI. + - Default: enabled (set `VITE_SIM_AUTH=false` to disable). + - Requires a Substrate wallet browser extension (polkadot{.js}) for message signing with Humanode (HMND) SS58 addresses. + +## Dev-only toggles + +- `DEV_BYPASS_SIGNATURE=true` to accept any signature (demo/dev mode). +- `DEV_BYPASS_GATE=true` to mark any signed-in user as eligible (demo/dev mode). +- `DEV_BYPASS_CHAMBER_ELIGIBILITY=true` to skip chamber membership checks for `chamber.vote` (demo/dev mode). +- `DEV_ELIGIBLE_ADDRESSES=addr1,addr2,...` allowlist for eligibility when `DEV_BYPASS_GATE` is false. +- `DEV_INSECURE_COOKIES=true` to allow auth cookies over plain HTTP (local dev only). +- `READ_MODELS_INLINE=true` to serve read endpoints from the in-repo seed builder (no DB required). +- `READ_MODELS_INLINE_EMPTY=true` to force an empty read-model store (useful for “clean UI” local dev without touching a DB). +- `DEV_BYPASS_ADMIN=true` to allow admin endpoints locally without `ADMIN_SECRET`. +- `SIM_CONFIG_JSON='{"humanodeRpcUrl":"...","genesisChamberMembers":{"engineering":["5..."]}}'` to override `/sim-config.json` in tests or local dev. + +## Running locally (recommended) + +### Option A (one command) + +- `yarn dev:full` (starts a local API server on `:8788`, starts the app on rsbuild dev, and proxies `/api/*`). + +### Option B (two terminals) + +**Terminal 1 (API)** + +1. Start the local API server (default port `8788`): + +`yarn dev:api` + +`yarn dev:api` starts with real signature verification and real gating by default. For a quick demo mode: + +- `DEV_BYPASS_SIGNATURE=true DEV_BYPASS_GATE=true yarn dev:api` + +**Terminal 2 (UI)** + +2. Run the UI with a dev-server proxy to the API: + +`yarn dev` + +Open the provided local URL and call endpoints under `/api/*`. + +Notes: + +- `yarn dev` proxies `/api/*` to `http://127.0.0.1:8788` (config: `rsbuild.config.ts`). +- If you see `ECONNREFUSED` in the UI dev server logs, the backend is not running on `:8788` (start it with `yarn dev:api`). +- Real gating uses `DEV_BYPASS_GATE=false` and a configured Humanode mainnet RPC URL (env var or `public/sim-config.json`). +- The Node API runner defaults to **empty read models** when `DATABASE_URL` is not set (the UI should show “No … yet” on content pages). +- To use the seeded fixtures locally (no DB), run with `READ_MODELS_INLINE=true`. +- To force empty reads even if something is seeding locally, run with `READ_MODELS_INLINE_EMPTY=true`. + +### Wrangler-based dev (optional) + +`yarn dev:api:wrangler` runs `wrangler pages dev` against `./dist` and serves the same `/api/*` routes. + +## Type checking + +- UI + client types: `yarn exec tsc -p tsconfig.json --noEmit` +- Pages Functions API: `yarn exec tsc -p functions/tsconfig.json --noEmit` + +## DB (Phase 2c) + +DB setup uses the read-model bridge seeded from `db/seed/fixtures/*`: + +- Generate migrations: `yarn db:generate` +- Apply migrations: `yarn db:migrate` (requires `DATABASE_URL`) +- Seed into `read_models` and the `events` table: `yarn db:seed` (requires `DATABASE_URL`) + - Also truncates `chambers`, `chamber_memberships`, `pool_votes`, `chamber_votes`, `cm_awards`, `idempotency_keys`, Formation tables, Courts tables, and Era tables so repeated seeds stay deterministic. + +### Clearing all data (keep schema) + +To wipe the simulation data without dropping tables: + +- `yarn db:clear` (requires `DATABASE_URL`) + +This truncates the simulation tables and leaves the schema/migrations intact. + +### Clean-by-default vs seeded content + +- Clean-by-default: run without `READ_MODELS_INLINE` and without running `yarn db:seed` (or wipe a seeded DB via `yarn db:clear`). +- Seeded content: run `yarn db:seed` (DB mode) or `READ_MODELS_INLINE=true` (no DB). diff --git a/docs/simulation/vortex-simulation-modules.md b/docs/simulation/vortex-simulation-modules.md new file mode 100644 index 0000000..2d80c39 --- /dev/null +++ b/docs/simulation/vortex-simulation-modules.md @@ -0,0 +1,603 @@ +# Vortex Simulation — Modules (Paper → Docs → Code) + +This document defines the major modules we are building, based on: + +- the Vortex 1.0 paper reference (`docs/paper/vortex-1.0-paper.md`) +- the simulation domain docs (`docs/simulation/vortex-simulation-processes.md`, `docs/simulation/vortex-simulation-state-machines.md`) +- the current repo implementation (frontend under `src/`, backend under `functions/`, DB in `db/`). + +Goal: keep a stable long-term architecture where each module has: + +- a clear responsibility boundary +- an API surface (read endpoints and/or commands) +- canonical state (tables) + append-only history (events) +- tests that pin behavior + +## Module index + +1. Identity & Sessions (auth) +2. Eligibility Gate (mainnet read) +3. Simulation Config (runtime config + genesis bootstrap) +4. Clock & Era Accounting (simulation time) +5. Quorum Engine (pool + chamber vote math) +6. Proposals (drafts → pool → vote → build) +7. Chambers (catalog + membership + meta-governance) +8. Cognitocratic Measure (CM) (LCM/MCM/ACM projection) +9. Formation (execution layer) +10. Courts (disputes) +11. Feed & Events (audit + activity stream) +12. Invision (insights) +13. Human Profiles (directory + detail) +14. Admin & Safety Controls (public demo hardening) +15. Tiers & Proposition Rights (governor ladder) +16. Delegation (voice delegation + weighting) +17. Veto (temporary slow-down) +18. Chamber Multiplier Voting (outside-of-chamber aggregation) +19. Meritocratic Measure (MM) (Formation delivery merit) + +Where possible we keep “domain logic” in pure helpers under `functions/_lib/*` so it can later be extracted into a shared domain package. + +--- + +## 1) Identity & Sessions (auth) + +**Paper intent** + +- Prove address control to perform governance actions. + +**Backend** + +- Endpoints: + - `POST /api/auth/nonce` + - `POST /api/auth/verify` + - `POST /api/auth/logout` + - `GET /api/me` +- Core files: + - `functions/_lib/auth.ts`, `functions/_lib/nonceStore.ts` + - `functions/_lib/signatures.ts`, `functions/_lib/tokens.ts`, `functions/_lib/cookies.ts` + - `functions/api/auth/*.ts`, `functions/api/me.ts` +- State: + - cookie-backed nonce + session + +**Frontend** + +- Core files: + - `src/app/auth/AuthContext.tsx` + - `src/lib/polkadotExtension.ts` + - `src/lib/apiClient.ts` + +**Tests** + +- `tests/api-auth-nonce.test.js` +- `tests/api-auth-signature.test.js` +- `tests/api-auth.test.js` +- `tests/auth-ui-connect-errors.test.js` + +--- + +## 2) Eligibility Gate (mainnet read) + +**Paper intent** + +- Only real Humanode participants should be able to take actions. + +**Simulation v1 rule** + +- Eligible if the address is in Humanode mainnet `Session::Validators`. + +**Backend** + +- Endpoint: + - `GET /api/gate/status` +- Core files: + - `functions/_lib/gate.ts`, `functions/_lib/humanodeRpc.ts`, `functions/_lib/simConfig.ts` + - `functions/api/gate/status.ts` +- State: + - cached eligibility (DB mode) + TTL; falls back to memory in non-DB mode + +**Frontend** + +- Status is consumed by the auth UI and used to gate UI actions. + +**Tests** + +- `tests/api-gate.test.js` +- `tests/api-gate-rpc.test.js` + +--- + +## 3) Simulation Config (runtime config + genesis bootstrap) + +**Paper intent** + +- Genesis membership exists (initial governors and initial chambers). + +**Backend** + +- Source: + - `/sim-config.json` (`public/sim-config.json`) +- Core files: + - `functions/_lib/simConfig.ts` +- Responsibilities: + - provide a runtime Humanode RPC URL fallback + - seed the initial chamber set (when empty) + - provide genesis chamber members for early voting eligibility + +**Frontend** + +- Reads `/sim-config.json` implicitly through backend behavior (not directly). + +--- + +## 4) Clock & Era Accounting (simulation time) + +**Paper intent** + +- Only active governors are counted in quorum baselines; participation matters across time windows. + +**Backend** + +- Endpoints: + - `GET /api/clock` + - `POST /api/clock/tick` + - `POST /api/clock/advance-era` + - `POST /api/clock/rollup-era` +- Core files: + - `functions/_lib/clockStore.ts`, `functions/_lib/eraStore.ts`, `functions/_lib/eraRollupStore.ts` + - `functions/_lib/eraQuotas.ts`, `functions/_lib/stageWindows.ts`, `functions/_lib/v1Constants.ts` + - `functions/api/clock/*.ts` +- State: + - current era, per-era activity counters, era rollup results, next-era baselines + +**Frontend** + +- Used by “My Governance” and for quorum denominators on proposal pages. +- Pages: + - `src/pages/MyGovernance.tsx` + +**Tests** + +- `tests/api-era-activity.test.js` +- `tests/api-era-rollup.test.js` +- `tests/api-my-governance-rollup.test.js` +- `tests/api-stage-windows.test.js` +- `tests/api-clock-tick.test.js` + +--- + +## 5) Quorum Engine (pool + chamber vote math) + +**Paper intent** + +- Pool: quorum of attention (engagement + upvote floor). +- Vote: quorum of vote + passing threshold. +- Delegation affects vote weight (not implemented in v1). + +**Backend** + +- Core files: + - `functions/_lib/poolQuorum.ts` + - `functions/_lib/chamberQuorum.ts` + - `functions/_lib/proposalStateMachine.ts` + - `functions/_lib/v1Constants.ts` +- Note: + - The quorum engine is driven by an era-specific “active governors baseline”, then filtered to the proposal’s chamber eligibility set (General = any governor; specialization = members eligible for that chamber). + +**Tests** + +- `tests/pool-quorum.test.js` +- `tests/chamber-quorum.test.js` +- `tests/proposal-stage-transition.test.js` + +--- + +## 6) Proposals (drafts → pool → vote → build) + +**Paper intent** + +- Proposal pool filters attention; chamber vote decides acceptance; Formation is optional for execution. + +**Backend** + +- Read endpoints: + - `GET /api/proposals` + - `GET /api/proposals/:id/pool` + - `GET /api/proposals/:id/chamber` + - `GET /api/proposals/:id/formation` + - `GET /api/proposals/:id/timeline` + - `GET /api/proposals/drafts` + - `GET /api/proposals/drafts/:id` +- Write path: + - `POST /api/command` + - `proposal.draft.save` + - `proposal.draft.delete` + - `proposal.submitToPool` + - `pool.vote` + - `chamber.vote` +- Core files: + - `functions/api/proposals/*` + - `functions/api/command.ts` + - `functions/_lib/proposalDraftsStore.ts`, `functions/_lib/proposalsStore.ts` + - `functions/_lib/poolVotesStore.ts`, `functions/_lib/chamberVotesStore.ts` + - `functions/_lib/proposalProjector.ts`, `functions/_lib/proposalTimelineStore.ts` +- State: + - canonical `proposals` + `proposal_drafts` + - votes tables + - timeline events in `events` (`proposal.timeline.v1`) + +**Frontend** + +- Pages: + - `src/pages/proposals/Proposals.tsx` + - `src/pages/proposals/ProposalCreation.tsx` + - `src/pages/proposals/ProposalDrafts.tsx` + - `src/pages/proposals/ProposalDraft.tsx` + - `src/pages/proposals/ProposalPP.tsx` + - `src/pages/proposals/ProposalChamber.tsx` + - `src/pages/proposals/ProposalFormation.tsx` +- Shared UI: + - `src/components/ProposalPageHeader.tsx` + - `src/components/ProposalSections.tsx` + - `src/lib/apiClient.ts` + +**Wizard architecture** + +- v1 uses a “single big form” draft payload with optional `metaGovernance`. +- Planned (v2+): migrate to a template-driven wizard (project vs system flows) with a discriminated draft schema: + - `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` + +**Tests** + +- `tests/api-command-drafts.test.js` +- `tests/api-command-pool-vote.test.js` +- `tests/api-command-chamber-vote.test.js` +- `tests/api-proposals-canonical-precedence.test.js` +- `tests/api-proposal-timeline.test.js` + +--- + +## 7) Chambers (catalog + membership + meta-governance) + +**Paper intent** + +- General + specialization chambers; chamber creation/dissolution is proposal-driven. + +**Simulation v1 rule** + +- Chamber creation/dissolution is modeled as a meta-governance proposal outcome in the General chamber. +- Voting eligibility is earned by accepted proposals (paper-aligned eligibility rule). + +**Backend** + +- Endpoints: + - `GET /api/chambers` + - `GET /api/chambers/:id` +- Core files: + - `functions/_lib/chambersStore.ts` + - `functions/_lib/chamberMembershipsStore.ts` + - `functions/api/chambers/*` + - `functions/api/command.ts` (meta-governance side-effects) +- State: + - `chambers` (canonical) + - `chamber_memberships` (eligibility) + +**Frontend** + +- Pages: + - `src/pages/chambers/Chambers.tsx` + - `src/pages/chambers/Chamber.tsx` + +**Tests** + +- `tests/api-chambers-lifecycle.test.js` +- `tests/api-chamber-eligibility.test.js` +- `tests/api-chamber-dissolution.test.js` +- `tests/api-chambers-index-projection.test.js` +- `tests/api-chamber-detail-projection.test.js` + +--- + +## 8) Cognitocratic Measure (CM) (LCM/MCM/ACM projection) + +**Paper intent** + +- Yes voters submit an additional numeric score; proposer receives CM based on the average. +- Multipliers map chamber value to global contribution; ACM is aggregate. + +**Simulation v1** + +- CM is awarded once per passed proposal via yes-vote score average. +- Multipliers are configured on canonical chambers (no multiplier voting yet). + +**Backend** + +- Core files: + - `functions/_lib/cmAwardsStore.ts` + - `functions/api/command.ts` (award on pass) + +**Tests** + +- `tests/chamber-votes-score.test.js` + +--- + +## 9) Formation (execution layer) + +**Paper intent** + +- Formation is an execution layer; any bioauthorized human node can participate. + +**Simulation v1** + +- Formation is optional per proposal (`formationEligible`). + +**Backend** + +- Endpoint: + - `GET /api/formation` + - `GET /api/proposals/:id/formation` +- Commands: + - `formation.join` + - `formation.milestone.submit` + - `formation.milestone.requestUnlock` +- Core files: + - `functions/_lib/formationStore.ts` + - `functions/api/formation/index.ts` + +**Frontend** + +- Pages: + - `src/pages/formation/Formation.tsx` + - proposal stage pages render Formation sections + +**Tests** + +- `tests/api-command-formation.test.js` + +--- + +## 10) Courts (disputes) + +**Paper reference** + +- Courts and disputes are described in `docs/paper/vortex-1.0-paper.md` (working reference copy, with an added section). + +**Backend** + +- Endpoints: + - `GET /api/courts` + - `GET /api/courts/:id` +- Commands: + - `court.case.report` + - `court.case.verdict` +- Core files: + - `functions/_lib/courtsStore.ts` + - `functions/api/courts/*` + +**Frontend** + +- Pages: + - `src/pages/courts/Courts.tsx` + - `src/pages/courts/Courtroom.tsx` + +**Tests** + +- `tests/api-command-courts.test.js` + +--- + +## 11) Feed & Events (audit + activity stream) + +**Paper intent** + +- “Constant deterrence” requires transparency and auditability. + +**Backend** + +- Endpoint: + - `GET /api/feed` +- Core files: + - `functions/_lib/eventsStore.ts`, `functions/_lib/eventSchemas.ts` + - `functions/_lib/feedEventProjector.ts` + - `functions/_lib/appendEvents.ts` +- State: + - append-only `events` table + +**Frontend** + +- Page: + - `src/pages/feed/Feed.tsx` + +**Tests** + +- `tests/api-feed.test.js` +- `tests/feed-event-projector.test.js` +- `tests/events-seed.test.js` + +--- + +## 12) Invision (insights) + +**Paper reference** + +- The paper motivates transparency/deterrence; “Invision” is our name for the insights surface in the UI and is described in `docs/paper/vortex-1.0-paper.md` (working reference copy, with an added section). + +**Backend** + +- Endpoint: + - `GET /api/invision` +- Core files: + - `functions/api/invision/index.ts` + +**Frontend** + +- Page: + - `src/pages/invision/Invision.tsx` + +--- + +## 13) Human Profiles (directory + detail) + +**Backend** + +- Endpoints: + - `GET /api/humans` + - `GET /api/humans/:id` + - `GET /api/my-governance` +- Core files: + - `functions/api/humans/*` + - `functions/api/my-governance/index.ts` + - `functions/_lib/userStore.ts` + +**Frontend** + +- Pages: + - `src/pages/human-nodes/HumanNodes.tsx` + - `src/pages/human-nodes/HumanNode.tsx` + - `src/pages/MyGovernance.tsx` + +--- + +## 14) Admin & Safety Controls (public demo hardening) + +**Backend** + +- Endpoints: + - `GET /api/admin/stats` + - `GET /api/admin/audit` + - `GET /api/admin/users/:address` + - `GET /api/admin/users/locks` + - `POST /api/admin/users/lock` + - `POST /api/admin/users/unlock` + - `POST /api/admin/writes/freeze` +- Core files: + - `functions/_lib/apiRateLimitStore.ts` + - `functions/_lib/idempotencyStore.ts` + - `functions/_lib/actionLocksStore.ts` + - `functions/_lib/adminAuditStore.ts` + - `functions/_lib/adminStateStore.ts` + +**Tests** + +- `tests/api-admin-tools.test.js` +- `tests/api-admin-write-freeze.test.js` +- `tests/api-command-rate-limit.test.js` +- `tests/api-command-action-lock.test.js` +- `tests/api-command-era-quotas.test.js` + +--- + +## 15) Tiers & Proposition Rights (governor ladder) + +**Paper intent** + +- Proposition rights are not equal across all governors; they are tied to tiers and merit (PoT/PoD/PoG–like paths). +- Tiers do not change the “1 human = 1 vote” invariant; they change what can be proposed. + +**Simulation v1** + +- Tier labels and status buckets are surfaced in the UI and derived through era rollups. +- Proposition rights are not fully enforced across all proposal types yet (v2+ hardening). + +**Backend** + +- Endpoints: + - `GET /api/my-governance` + - `POST /api/clock/rollup-era` +- Core files: + - `functions/_lib/eraRollupStore.ts` + - `functions/_lib/eraQuotas.ts` + - `functions/api/my-governance/index.ts` + +**Frontend** + +- Page: + - `src/pages/MyGovernance.tsx` + +**Tests** + +- `tests/api-my-governance-rollup.test.js` +- `tests/api-era-rollup.test.js` + +--- + +## 16) Delegation (voice delegation + weighting) + +**Paper intent** + +- Delegation exists and affects vote aggregation (delegatee voting power grows with delegations). + +**Simulation status** + +Implemented in v1: + +- Delegation graph + invariants (no cycles, no self-delegation), chamber-scoped. +- Delegation history events (auditable). +- Chamber vote weight aggregation: + - vote weight = `1 + delegatedVoices` + - a delegator’s voice only counts if the delegator did **not** cast a chamber vote themselves + - delegation affects chamber voting only; pool attention remains direct-only. + +--- + +## 17) Veto (temporary slow-down) + +**Paper intent** + +- Veto exists as a temporary slow-down mechanism, tied to top LCM holders per chamber. + +**Simulation status** + +Implemented in v1: + +- When a chamber vote passes, the proposal can enter a **veto window** (instead of advancing immediately). +- Veto holders are derived from CM data: + - One holder per chamber: the address with the highest accumulated **LCM** in that chamber (from `cm_awards`). + - The veto council is snapshotted onto the proposal at vote-pass time (`proposals.veto_council`). +- Threshold: + - `floor(2/3 * councilSize) + 1` veto votes are required. +- If the threshold is met during the veto window: + - chamber votes are cleared + - veto votes are cleared + - proposal `veto_count` increments + - voting is paused for `2 weeks` and then re-opens (proposal `updated_at` is set to the re-open time). +- If the veto window ends without a veto: + - the proposal is finalized and advanced to `build` by `POST /api/clock/tick`. +- Max veto applies per proposal: `2` (after that, vote-pass finalizes immediately). + +--- + +## 18) Chamber Multiplier Voting (outside-of-chamber aggregation) + +**Paper intent** + +- Chamber multipliers should be set by cognitocrats outside the chamber (scale 1–100). + +**Simulation status** + +Implemented in v1: + +- Outsider submissions are stored in `chamber_multiplier_submissions` (one per `(chamber_id, voter_address)`). +- Command: `POST /api/command` → `chamber.multiplier.submit`. +- Outsider rule enforcement: + - an address cannot submit for a chamber where it has LCM history (`cm_awards` as proposer). +- Aggregation rule (v1): rounded average of submissions is applied to `chambers.multiplier_times10`. +- CM award history remains immutable; ACM/MCM views are computed using the current chamber multipliers. + +--- + +## 19) Meritocratic Measure (MM) (Formation delivery merit) + +**Paper intent** + +- MM represents delivery merit earned through Formation participation. + +**Simulation status** + +- Not implemented as a first-class subsystem in v1 (the UI can still show Formation progress). + +Planned deliverables (v2+): + +- MM events tied to Formation milestone outcomes +- aggregation into per-address MM views +- Invision signals that incorporate MM without changing voting power diff --git a/docs/simulation/vortex-simulation-paper-alignment.md b/docs/simulation/vortex-simulation-paper-alignment.md new file mode 100644 index 0000000..acfaf01 --- /dev/null +++ b/docs/simulation/vortex-simulation-paper-alignment.md @@ -0,0 +1,171 @@ +# Vortex Simulation — Alignment With Vortex 1.0 Paper (Audit Notes) + +This document compares: + +- `docs/paper/vortex-1.0-paper.md` (working reference copy) and +- the simulation docs (`docs/simulation/vortex-simulation-*.md`) and +- the current implementation (`functions/`, `db/schema.ts`, `src/`). + +Goal: make it explicit what is **paper-aligned**, what is **deliberately simplified in v1**, and what is **not implemented yet**. + +## Summary (high-signal) + +- Proposal pool attention quorum is **paper: 22% engaged + ≥10% upvotes** vs **simulation v1: 22% engaged + ≥10% upvotes**. +- Chamber vote quorum is **paper: 33%** and **simulation v1: 33%** (aligned). +- Passing rule is **paper: 66.6% + 1** vs **simulation v1: 66.6% + 1** (aligned; strict supermajority). +- Vote weight via delegation is **paper: yes (governor power = 1 + delegations)** and **simulation v1: implemented for chamber vote weighting** (pool attention remains direct-only). +- Veto is **paper: yes** vs **simulation v1: implemented** (temporary slow-down with bounded applies). +- Chamber multiplier voting is **paper: yes (1–100, set by outsiders)** vs **simulation v1: implemented** (outsider submissions + aggregation updates canonical multipliers). +- Stage windows are **paper: vote stage = 1 week** vs **simulation v1: pool = 7 days, vote = 7 days (defaults; configurable)**. + +## Detailed comparison + +### Chambers + +**Paper** + +- Two chamber types: General Chamber (GC) + Specialization Chambers (SC). +- Chamber inception/dissolution is proposal-driven. +- Paper describes both SC-driven and GC-driven dissolution, including a “vote of censure” variant. + +**Simulation v1 (current implementation)** + +- Canonical chambers exist in `db/schema.ts` as `chambers` with `status = active | dissolved`. +- Chambers are seeded from `/sim-config.json` (`public/sim-config.json`) when the DB table is empty. +- Chamber create/dissolve exists as a **meta-governance proposal** action and is enforced as **General-only**: + - `functions/api/command.ts` rejects meta-governance proposals unless `chamberId === "general"`. +- Dissolution is **General-only** (v1 rule) and does not delete history. + +**Not yet modeled (paper)** + +- SC-side dissolution flows and censure exclusions (“target chamber members not counted in quorum”). +- Chamber “sub-chambers” are removed from the paper reference copy by design decision (not in v1). + +### Proposal pools (quorum of attention) + +**Paper** + +- Proposal pool is an attention filter: + - “upvotes or downvotes from 22% of active governors”, and + - “not less than 10% of upvotes”. +- Delegated votes are not counted in proposal pools. + +**Simulation v1** + +- Quorum math is implemented in `functions/_lib/poolQuorum.ts`: + - `V1_POOL_ATTENTION_QUORUM_FRACTION = 0.22` (22%) + - `V1_POOL_UPVOTE_FLOOR_FRACTION = 0.1` (10%) +- Pool voting is restricted to governors (addresses with at least one accepted proposal in any chamber). +- Delegation exists but is not applied to proposal-pool attention (pool votes remain direct-only, paper intent). + +**Paper divergence (explicit)** + +- Paper uses 22% attention; v1 simulation uses 22% attention. + +### Chamber vote (quorum of vote + passing) + +**Paper** + +- Quorum: 33% of active governors vote. +- Passing: qualified majority “66.6% + 1” of cast votes (including delegated ones). + +**Simulation v1** + +- Quorum math is implemented in `functions/_lib/chamberQuorum.ts`: + - `V1_CHAMBER_QUORUM_FRACTION = 0.33` + - `V1_CHAMBER_PASSING_FRACTION = 2/3` (66.6%), applied as a strict “66.6% + 1 yes vote” rule +- Delegation is implemented and affects chamber vote aggregation: + - vote weight = `1 + delegatedVoices` + - a delegator’s voice only counts if that delegator did **not** cast a chamber vote themselves. + +### Delegation + +**Paper** + +- Delegation exists and affects vote power aggregation: + - governor power equals `1 + number_of_delegations`. +- Delegation is chamber-scoped: governors delegate within the same chamber. + +**Simulation v1** + +- Delegation graph + history are implemented: + - `delegations` + `delegation_events` tables + - commands: `delegation.set`, `delegation.clear` +- Delegation affects chamber vote aggregation only (pool attention remains direct-only). + +### Veto + +**Paper** + +- Veto exists as a temporary slow-down mechanism. +- Veto power is tied to top LCM holders per chamber. + +**Simulation v1** + +- Implemented as a bounded “pending veto” window after a proposal passes chamber vote: + - When vote quorum + passing are met, the proposal does not advance immediately. + - The backend snapshots: + - `vote_passed_at`, `vote_finalizes_at` (veto window end), + - `veto_council` (one holder per chamber: top LCM holder), + - `veto_threshold` (`floor(2/3*n) + 1`). + - Veto votes are recorded during the window (`veto_votes` table). + - If veto threshold is reached: + - chamber votes are cleared + - veto votes are cleared + - `veto_count` increments + - voting is paused for the veto delay window and then re-opens (via a future `updated_at`). + - If the window ends without a veto: + - the accepted proposal is finalized and advances to `build` (via `POST /api/clock/tick`). + - Veto applies are bounded (`max = 2`); after that, accepted votes finalize immediately. + +### CM and multipliers + +**Paper** + +- CM is awarded when a proposition is accepted; yes voters also input a numeric score (example scale 1–10). +- Chamber multipliers are set by outsiders (example scale 1–100). +- LCM/MCM/ACM relationships are defined with ACM as Σ(LCM × multiplier). + +**Simulation v1** + +- Yes-vote scoring exists, and CM awards are computed on pass: + - `functions/api/command.ts` computes `avgScore` and awards a CM event once per proposal. + - `lcmPoints = round(avgScore * 10)`, `mcmPoints = lcmPoints * multiplier`. +- Multipliers are stored on the canonical chamber record (`multiplierTimes10`) and can be updated via outsider submissions: + - `chamber_multiplier_submissions` stores one submission per `(chamber, voter)`. + - the chamber multiplier is updated to the rounded average of all submissions. + - CM award history remains immutable; ACM/MCM views can be recomputed from `lcmPoints` and the current multipliers. + +### Formation + +**Paper** + +- Formation is an execution layer; any bioauthorized human node can participate. + +**Simulation v1** + +- Formation actions exist (`formation.join`, `formation.milestone.submit`, `formation.milestone.requestUnlock`) and are gated by “active human node” eligibility (validator set membership). + +### Courts and disputes + +**Paper** + +- Courts and disputes are described in `docs/paper/vortex-1.0-paper.md` (working reference copy, with an added section). + +**Simulation v1** + +- Courts are modeled and implemented as an off-chain dispute system with report/verdict commands and auditable case state. + +### Invision + +**Paper** + +- “Deterrence” and transparency are described conceptually; this repo’s paper reference copy also includes an “Invision” section that matches the UI’s concept. + +**Simulation v1** + +- Invision exists as a derived “system state / reputation lens” endpoint and page (`GET /api/invision`). + +## Action list (what to change next to be more paper-aligned) + +1. Review whether any other pool quorum details differ from the paper. diff --git a/docs/vortex-simulation-processes.md b/docs/simulation/vortex-simulation-processes.md similarity index 56% rename from docs/vortex-simulation-processes.md rename to docs/simulation/vortex-simulation-processes.md index 15947a0..6296840 100644 --- a/docs/vortex-simulation-processes.md +++ b/docs/simulation/vortex-simulation-processes.md @@ -1,6 +1,8 @@ # Vortex Simulation Backend — Processes to Model -This document defines the **domain processes** a Vortex backend simulation should model to match the UI mockups in this repo. This is **not** an on-chain implementation; it emulates “how Vortex would work” with deterministic rules + simulated time. +This document defines the domain processes the Vortex simulation backend models to match the UI mockups in this repo. This is not an on-chain implementation; it emulates “how Vortex would work” off-chain with deterministic rules + simulated time. + +For formal rules and invariants, see `docs/simulation/vortex-simulation-state-machines.md`. ## 0.1) On-chain vs off-chain boundary (proto-vortex architecture) @@ -14,6 +16,11 @@ This implies an architecture with: - Off-chain authentication (wallet signature) + on-chain eligibility checks. - An authoritative off-chain state machine for all governance flows. +Implementation mapping: + +- v1 scope and what is already implemented: `docs/simulation/vortex-simulation-scope-v1.md` +- the phased build roadmap (including planned v2+): `docs/simulation/vortex-simulation-implementation-plan.md` + ## 0) Goals and non-goals ### Goals @@ -59,14 +66,21 @@ Recommended modeling: For v1 we use **Humanode mainnet RPC only** (no Subscan dependency). -Eligibility rule (v1): an address is an **active Human Node** if it appears as active in the chain’s **`im_online` pallet** (online reporting / heartbeat). +Eligibility rule (v1): an address is an **active Human Node** if it is in the current validator set on Humanode mainnet (`Session::Validators`). Implication: - Browsing is open to everyone. - Any state-changing action requires: 1. proof of address control (wallet signature session), and - 2. a fresh “active via `im_online`” eligibility check (cached with TTL). + 2. a fresh “active validator” eligibility check (cached with TTL). + +Guardrails (off-chain): + +- Even eligible actors can spam. The simulation enforces basic hardening controls: + - rate limiting on write endpoints (per IP and per address), and + - per-era quotas for counted governance actions, and + - optional admin action locks that temporarily disable all writes for an address. ### 1.2 Governance time @@ -121,6 +135,176 @@ Simulation requirements: - Chamber multipliers (for CM math). - Per-chamber pipeline counts: pool / vote / formation. +#### What a chamber is (in this simulation) + +A **chamber** is a named specialization domain that: + +- tags a proposal with a “lead domain” (`chamberId`) +- defines the **review/vote constituency** (who is expected/allowed to vote in the chamber stage) +- defines a **CM multiplier (M)** used to scale contribution scores across domains + +In other words: + +- Pool stage answers: “Is this proposal worth attention at all?” +- Chamber stage answers: “Do the domain specialists accept it?” +- Formation answers (when applicable): “Can it be executed and tracked?” + +#### Chamber types: General vs specialization + +There are two kinds of chambers: + +- **General chamber**: meta-governance chamber. + - Everyone can vote in General only after they have at least one **accepted proposal** in any chamber. +- **Specialization chambers**: Design/Engineering/Economics/Marketing/Product. + - Only domain participants can vote (see eligibility below). + +#### How chambers are created (v1) + +In v1, the set of chambers is treated as a **genesis configuration** (fixed set of chambers used across the UI). + +- the set of chambers is fixed (Design/Engineering/Economics/Marketing/General/Product) +- `chamberId` is a stable string identifier (used in URLs, DTOs, and DB rows) +- multipliers are adjustable (simulated via the CM Panel) + +Governance rule (paper-aligned): + +- **Chamber creation happens only through a proposal that went through the General chamber.** + +That proposal contains: + +- chamber id/name +- multiplier +- genesis roles/memberships (addresses + roles), represented in the simulation: + - v1 bootstrap: `public/sim-config.json` → `genesisChamberMembers` + - chamber.create proposals may also include `payload.metaGovernance.genesisMembers` to seed initial memberships for the new chamber + +Future (v2+): chamber creation/dissolution becomes fully canonical (not read-model seeded), but the rule stays the same. + +- add a new chamber definition +- set initial multiplier +- define initial membership rules and quorum baseline rules + +#### How chambers function (end-to-end) + +1. **Draft** + - A proposal draft is authored and assigns a `chamberId`. +2. **Proposal pool (attention)** + - Proposal competes for attention in the **proposal pool of its target chamber**. + - Threshold math uses a **denominator snapshot** captured when the proposal enters the pool: + - specialization pool: active governors **eligible for that chamber** in the current era + - General pool: active governors **eligible to vote in General** in the current era +3. **Chamber vote** + - The chamber is the lead domain for the vote stage. + - In v1, quorum fractions are **global**, but the **denominator is chamber-scoped** (active governors eligible for that chamber in the era, captured on stage entry). + - CM scoring is collected here (optional 1–10 input), then awarded on success. +4. **Formation** + - Formation is **optional**. A proposal is considered accepted once it passes the chamber vote, but only some proposal types open a Formation project. + +#### Role in the system + +Chambers are the main mechanism that keeps Vortex “specialization-based” instead of purely global governance: + +- reduces noise (people vote where they have context) +- makes CM comparable across domains (multiplier) +- provides a natural place for domain-specific norms (what “spam” means, acceptance criteria, etc.) + +#### Chamber participation and voting eligibility (paper-aligned rule) + +Voting is not weighted; this is eligibility only (still 1 human = 1 vote). + +Preconditions for any write action: + +1. The actor is an **active Human Node** (on-chain eligibility gate). +2. The actor has the relevant chamber voting eligibility (where applicable). + +Note: v1 currently does not block actions based on “active governor this era” status; “active governor” is used for quorum baselines and rollups. + +Eligibility to vote in chambers is earned by accepted proposals: + +- **Specialization chamber `X`**: a human can vote in `X` if they have at least one **accepted proposal in chamber `X`**. +- **General chamber**: a human can vote in General if they have at least one **accepted proposal in any chamber**. + +### 1.4 Proposals (two axes) + +Proposals are structured “change requests” authored by governors. There are two key axes in v1 that matter to modeling and UI: + +1. **Scope axis (system vs project)** + - **System-change proposals**: affect the simulation itself (a variable or entity the system enforces automatically). Example: **chamber creation/dissolution** via a General proposal. + - **Project proposals**: describe work outside the system (deliver a toolkit, docs, marketing sprint, pallet implementation). The simulation tracks outcomes, but it does not “force-implement” external work beyond its governance lifecycle and accounting. +2. **Proposition-rights axis (tier/type)** + - The _right to propose_ differs by governing tier and by proposal type (e.g. Basic / Administrative / Fee). This axis is modeled separately from the “scope” axis above. + +Genesis exception: + +- genesis roles/memberships are treated as eligible from day one for their chamber(s) via `public/sim-config.json` → `genesisChamberMembers`. + +Wizard note: + +- The proposal wizard is evolving toward template-driven flows (project vs system-change), so chamber creation proposals collect only chamber-defining fields while project proposals retain the multi-step “Who/What/Why/How/How much” flow: + - `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` + +#### Chamber dissolution (paper-aligned rule) + +- Chambers can be dissolved only through a proposal in the **General chamber**. +- General cannot be dissolved. +- Dissolution never deletes history. It changes canonical chamber status and preserves audit trails. +- Dissolved chamber behavior (v1 rule): + - No new proposals can be submitted into a dissolved chamber. + - Proposals that were created before dissolution can continue their lifecycle (including chamber voting). + +#### How chambers are represented in the code today (current state) + +Chambers are now **canonical** (Phase 18): + +- DB table: `chambers` +- Genesis seeding: + - `public/sim-config.json` → `genesisChambers` + - the backend auto-seeds these into `chambers` if the table is empty +- API: + - `functions/api/chambers/index.ts` returns the canonical list (with derived stats/pipeline where possible) + - `functions/api/chambers/[id].ts` returns a minimal canonical detail model (proposals/governors/threads/chat can be empty in v1) +- UI: + - `src/pages/chambers/Chambers.tsx` renders the directory from `GET /api/chambers` + - `src/pages/chambers/Chamber.tsx` renders the detail page from `GET /api/chambers/:id` + +Canonical links: + +- proposal drafts and canonical proposals carry `chamberId` (`proposal_drafts.chamber_id`, `proposals.chamber_id`) +- CM awarding uses `chamberId` and pulls multipliers from canonical chambers (fallback to read-model multipliers in legacy mode) + +Canonical voting eligibility is now modeled and enforced: + +- DB table: `chamber_memberships` (granted on proposal acceptance) +- Enforcement: `POST /api/command` → `chamber.vote` checks membership before recording a vote. +- Rule: + - specialization chamber → must have an accepted proposal in that chamber + - general chamber → must have an accepted proposal in any chamber + +#### Target representation (next audit-driven step) + +To match the chamber model more precisely, chambers are modeled as canonical tables: + +- `chambers`: + - `id`, `name`, `multiplierTimes10`, optional `createdAt`, optional `createdBy` +- `chamber_memberships` (already present in v1): + - `address`, `chamberId`, `grantedByProposalId`, `source`, `createdAt` + - future: add optional `role` and `leftAt` for dissolution/merges (without deleting history) + +From those, the UI’s chamber “stats” and “pipeline” should be derived from canonical state: + +- pipeline counts = number of proposals grouped by (`chamberId`, `stage`) +- governors count = active chamber members (and/or active governors within chamber for the era) +- LCM/MCM/ACM = derived from CM award events + multiplier configuration + +## 2) Next process audits (order) + +This is the sequence to audit and then implement, so the simulation matches the Vortex 1.0 model: + +1. **Chamber governance** (this section): creation/dissolution rules via General chamber proposals. +2. **Chamber participation**: canonical “accepted proposal → chamber voting eligibility” and “accepted proposal anywhere → General eligibility”. +3. **Formation optionality**: ensure “accepted” does not imply “formation exists” and treat Formation as a conditional sub-flow. +4. **Quorum baselines**: confirm global quorum math uses “active governors next era” and enforce “active human node” gating + “active governor” rules consistently. + ### 1.4 Cognitocratic Measure (CM) CM is a reputation-like contribution score: @@ -144,18 +328,24 @@ Simulation requirements: - Tier progression rules (PoT / PoD / PoG–like requirements). - Tier decay + statuses (Ahead / Stable / Falling behind / At risk / Losing status). -- Eligibility gating (what you can propose/do by tier). +- Eligibility gating (what actions are available by tier). ### 1.6 Delegation -- Delegator chooses a delegatee for representation (still 1 human = 1 vote conceptually). +- Delegator chooses a delegatee to cast their voice (chamber-scoped). Simulation requirements: - Delegation graph (one delegator → one delegatee; cycles disallowed). +- Eligibility: both delegator and delegatee must be eligible governors in the same chamber. - Delegation metadata for courts (events, timing windows, alleged abuse scenarios). - Ability to toggle delegation on/off for a simulation era. +Paper alignment note: + +- The paper describes delegation as aggregating voting power (delegatee power equals `1 + delegations`). +- v1 implements delegation for **chamber vote weighting** (pool attention remains direct-only). + ### 1.7 Proposals Proposal lifecycle stages shown in the UI: @@ -171,6 +361,31 @@ Simulation requirements: - Per-stage metrics shown in UI (quorums, floors, vote split, budgets, milestones, team slots). - Attachments metadata (links only; no file storage required for the simulation). +#### When a proposal is “accepted” + +Paper-aligned rule for v1 simulation: + +- A proposal is considered **accepted** once it **passes the chamber vote**. +- Formation is optional; acceptance does not imply that a Formation project must exist. + +Implications: + +- Chamber voting eligibility (“can vote here”) is earned by having an accepted proposal. +- The General chamber is unlocked by having any accepted proposal in any chamber. + +#### Formation optionality (modeling note) + +In the current UI, the stages are always rendered as Draft → Pool → Chamber vote → Formation. + +To keep DTOs and routes stable while allowing “no formation” proposals, the simulation should model: + +- `formationRequired: boolean` (proposal-type dependent) +- Only when `formationRequired=true`: + - a Formation project is created + - Formation actions/milestones are enabled + +For `formationRequired=false`, the “Formation” page can render a minimal “No formation required” view and a history of the accepted vote. + ### 1.8 Formation (execution layer) Formation manages implementation after approval: @@ -247,7 +462,7 @@ Eligibility check (authoritative gating): Two proofs (explicit): - Proof A: “User controls address X” (nonce + signature; SIWE-style but chain-agnostic). -- Proof B: “Address X is an active Human Node” (mainnet read via RPC; `im_online` pallet). +- Proof B: “Address X is an active Human Node” (mainnet read via RPC; v1 reads `Session::Validators`). Eligibility claim (cached): @@ -285,9 +500,19 @@ At each era boundary: - Close voting windows that expired. - Evaluate pool thresholds and advance eligible proposals. - Compute governor activity (actions done vs required). -- Update tier decay streaks + derive status (Ahead/Stable/etc.). +- Derive per-era governing status buckets (Ahead/Stable/Falling behind/At risk/Losing status). +- Compute the “active governor” set for the next era (v1: requirement-based, configurable off-chain). - Recompute CM aggregates and chamber stats. +v1 rollup inputs: + +- Per-era action requirements are configured off-chain (env): + - `SIM_REQUIRED_POOL_VOTES` + - `SIM_REQUIRED_CHAMBER_VOTES` + - `SIM_REQUIRED_COURT_ACTIONS` + - `SIM_REQUIRED_FORMATION_ACTIONS` +- The rollup can be triggered manually (admin) and is designed to be idempotent for a given era window. + Outputs: - Updated “My Governance” metrics and statuses. @@ -316,6 +541,7 @@ Gates: - Attention quorum: engaged governors >= threshold (absolute and/or %). - Upvote floor: upvotes >= threshold (absolute and/or %). +- Eligibility: only governors can upvote/downvote in proposal pools (i.e. you must have at least one accepted proposal in any chamber). Outputs: @@ -459,14 +685,14 @@ Simulation requirements: ### 4.3 Back-end stack (practical) - API: Cloudflare Workers (or Pages Functions) -- DB: Postgres (Neon/Supabase) or Cloudflare D1 (v1 OK) +- DB: Postgres (v1: Neon-compatible serverless Postgres) - Event log: append-only `events` table (feed + audit trail) - Jobs: Cron triggers for era rollups - Optional: Durable Objects for race-free updates (double-vote prevention, counters, quorum snapshots) ### 4.3 Determinism knobs -- Seeded random generator for “simulated activity” (if you want autopopulation). +- Seeded random generator for “simulated activity” (optional autopopulation). - Manual override controls for testing scenarios (force quorum met, force court verdict, etc.). ## 5) Next step: pick “v1 processes” diff --git a/docs/simulation/vortex-simulation-proposal-wizard-architecture.md b/docs/simulation/vortex-simulation-proposal-wizard-architecture.md new file mode 100644 index 0000000..0eb726e --- /dev/null +++ b/docs/simulation/vortex-simulation-proposal-wizard-architecture.md @@ -0,0 +1,269 @@ +# Vortex Simulation: Proposal Wizard Architecture + +This document defines the long-term structure for the proposal creation wizard so that different proposal types can have different flows, while still producing a single canonical “proposal draft” payload that the backend can validate and submit. + +## Goals + +- Support multiple proposal types with different required fields and step flows. +- Keep the UI scalable (adding a new proposal type should not require rewriting the whole wizard). +- Ensure the backend enforces the same rules as the UI (single source of truth for draft validation). +- Make “system-change” proposals (e.g., chamber creation) reflect the exact data they mutate in the simulation. + +## Proposal Kind vs. Proposal Rights + +There are two separate axes: + +1. **Proposal rights / tier gating** (Basic, Administrative, Fee, etc.) + +- This is about _who can submit what_, based on tier/rights rules. + +2. **Proposal kind (wizard + payload shape)** + +- This is about _what the proposal does to the simulation_ and therefore what fields the wizard should collect. + +This document focuses on (2). + +## Canonical Model: Discriminated Draft Union + +All drafts should include a discriminant that selects the template and payload shape: + +- `kind: "project"` — proposals that represent work/projects; may or may not require Formation. +- `kind: "system"` — proposals that directly change simulation state (system variables/entities). + +For `kind: "system"`, the `systemAction` (or similar) further specifies the exact system mutation, for example: + +- `systemAction: "chamber.create"` +- `systemAction: "chamber.dissolve"` + +The draft payload becomes a discriminated union in both places: + +- Frontend draft state (wizard) +- Backend validation (`functions/_lib/proposalDraftsStore.ts`) + +## Template Registry (Wizard Definition) + +The wizard should be driven by a registry of templates (one per proposal flow). + +Each template defines: + +- `id` — stable template identifier (e.g. `project`, `system`) +- `label` and `description` +- `stepOrder` + UI labels +- `compute(draft)` — derived validation and “can submit” gating + +In v1 (current code), templates are intentionally **pure TS** so they can be imported by Node tests without JSX transpilation. React step components remain shared and are selected by `StepKey` (`essentials` / `plan` / `budget` / `review`). + +Suggested folder layout: + +- `src/pages/proposals/proposalCreation/templates/` + - `project.ts` + - `system.ts` +- `src/pages/proposals/proposalCreation/steps/` (shared step components) + +The main wizard page (`src/pages/proposals/ProposalCreation.tsx`) should become “template runner” glue: + +- Determine current template (from query param / initial choice / saved draft) +- Render the template’s current step component +- Persist draft state and step +- Call `apiProposalDraftSave` and `apiProposalSubmitToPool` using `template.toApiForm(draft)` + +## Flow: System Proposal — Chamber Creation + +Chamber creation is a **system-change** proposal, and in the simulation it is only valid as a **General chamber** proposal (the wizard should force `chamberId = "general"`). + +### Template ID + +- `system` (v1) + +### Steps + +1. **Setup** + - `metaGovernance.action = "chamber.create"` + - `metaGovernance.chamberId` (new chamber id/slug) + - `metaGovernance.title` (display title) + - `metaGovernance.multiplier` (initial multiplier) + - plus the current backend-required rationale fields (`title`, `what`, `why`) + +2. **Rationale** + - `how` (required by current backend validation for all drafts) + - In v1 UI, `timeline` and `outputs` are hidden for system proposals to keep the form focused. + +3. **Review & submit** + - Show exactly what will happen on acceptance: + - a new chamber entity is created + - proposer + genesis members become chamber members + +### What is intentionally NOT collected + +- Budget items (system proposals skip the budget step) +- Project-oriented “Where” links (outputs) +- Milestone timeline (hidden in v1 for system proposals) + +Those are meaningful for projects, not for creating the chamber entity itself. Project-only fields are now optional for system drafts (see W3/W4). + +### Backend integration point + +On acceptance (General chamber, vote → build), the backend already finalizes system actions: + +- `functions/_lib/proposalFinalizer.ts` + - `createChamberFromAcceptedGeneralProposal(...)` + - membership seeding from `metaGovernance.genesisMembers` + proposer + +The wizard’s responsibility is to produce the correct `metaGovernance` payload only. + +## Flow: Project Proposal — Normal Proposal Creation + +This is the “general” proposal creation flow used for proposals that represent work. + +### Template ID + +- `project` + +### Steps (v1) + +1. **Essentials** + - `title` + - `chamberId` (target chamber pool) + - `summary` + - `what` + - `why` + - Optional: `aboutMe` + +2. **Plan** + - `how` + - `timeline[]` (milestones) + - `outputs[]` + - `attachments[]` (recommended) + +3. **Budget & Formation** + - `formationEligible` (explicit field, if/when we stop inferring it) + - `budgetItems[]` + - confirmations: `agreeRules`, `confirmBudget` + +4. **Review & submit** + - “Save draft” always available + - “Submit” enabled only when `isSubmittable(draft)` passes + +### Backend integration point + +Project proposals are submitted from drafts via: + +- `functions/api/command.ts` (`proposal.submitToPool`) +- draft storage + validation: + - `functions/_lib/proposalDraftsStore.ts` (`proposalDraftFormSchema`, `draftIsSubmittable`) + +## Address Handling (HMND) + +All addresses should be treated as Humanode (HMND) SS58 strings. + +Rules: + +- Persist and display canonical addresses as `hm...` (Humanode SS58 format). +- Compare addresses by decoded public key, not by raw string, to avoid SS58 prefix mismatches. + +Implementation helpers: + +- `functions/_lib/address.ts` + - `HUMANODE_SS58_FORMAT = 5234` + - `canonicalizeHmndAddress(address)` + - `addressesReferToSameKey(a, b)` + +## Implementation phases (Wizard v2 track) + +The wizard refactor is intentionally staged so the UI can stay usable while the draft schema evolves. + +Status: + +- W1–W5 completed (see `docs/simulation/vortex-simulation-implementation-plan.md`, Phases 35–39). + +### W1 — Template runner + registry (plumbing) + +- Introduce a template registry (`templates/*`) and make `ProposalCreation.tsx` a template runner. +- Keep the existing “project” flow behavior but move orchestration behind a template interface. +- Persist the selected template id in draft storage (local + server draft payload). + +Tests: + +- Minimal unit test for template registry invariants (unique ids, stable step ordering). + +### W2 — System template v1 (`system`) + +- Implement the dedicated system flow: + - forces `chamberId = "general"` + - skips the budget step and hides project-only optional sections + - keeps `metaGovernance` in the draft payload so existing backend finalizers apply (no backend changes required) + +Tests: + +- API scenario test: create chamber.create draft → submit to pool → pass quorum/vote → chamber appears in `/api/chambers`. + +### W3 — Split the backend draft schema into a discriminated union + +- Convert the backend draft payload (`proposalDraftFormSchema`) into a discriminated union matching template ids. +- Keep a compatibility adapter for old drafts until they are migrated/cleared. + +Tests: + +- Schema-level tests: + - old draft payloads still parse (compat mode) + - new template payloads parse and enforce the correct required fields + +Current status: + +- Implemented in `functions/_lib/proposalDraftsStore.ts`: + - `templateId` discriminant with preprocessing + defaults + - system drafts can omit project-only fields + +### W4 — Migrate stored drafts + simplify project validation + +- Migrate persisted drafts (DB + local storage) to the new discriminated shape where feasible. +- Remove “fake” required fields for system proposals and remove “system-only” branching from the project flow. + +Tests: + +- Draft submit tests for both `project` and `system.chamberCreate` (required fields + chamber constraints). + +Current status: + +- Stored draft payloads are normalized on read: + - DB: `listDrafts`/`getDraft` backfill `templateId` when missing. + - Memory: legacy payloads are normalized and cached. +- Project wizard validation no longer handles system proposals. +- Tests: + - `tests/proposal-draft-migration.test.js` + +### W5 — Cleanup + extension points + +- Remove legacy branches and deprecate the old “single big form” shape. +- Add extension points for additional system actions (e.g., `system.chamberDissolve`) without inflating the base project flow. + +Tests: + +- Coverage stays stable (no feature regression in `proposal.draft.save` / `proposal.submitToPool` / proposal pages). + +Current status: + +- System action metadata is centralized in `systemActions.ts`. +- System proposals no longer require project-only fields (`what/why`). +- System review summary renders only system-specific fields. +- Tests: + - `tests/proposal-wizard-system-template.test.js` + +## Migration Notes (from current shape) + +Current state uses a “single big form” with `metaGovernance` optional (see `functions/_lib/proposalDraftsStore.ts`). + +To migrate cleanly: + +1. Introduce the discriminant (`kind` + template id). +2. Split the backend draft schema into a discriminated union. +3. Keep a compatibility adapter temporarily (if needed) that maps old drafts into `kind: "project"` until old drafts are gone. + +## Definition of “Done” for this refactor + +- The wizard supports at least: + - `project` + - `system` +- `system` does not show the project-only budget step (and keeps optional project sections out of the way). +- Backend validation matches the template rules (no fake required fields). +- Submitting a chamber creation proposal produces a payload that the finalizer can apply without extra UI hacks. diff --git a/docs/simulation/vortex-simulation-scope-v1.md b/docs/simulation/vortex-simulation-scope-v1.md new file mode 100644 index 0000000..fd7474a --- /dev/null +++ b/docs/simulation/vortex-simulation-scope-v1.md @@ -0,0 +1,142 @@ +# Vortex Simulation Backend — Scope (v1) + +This document defines the **v1 scope** of the Vortex simulation backend shipped from this repo. It makes the boundary between “implemented now” vs “intentionally deferred” explicit. + +## Purpose + +Ship a community-playable governance simulation that: + +- Uses Humanode mainnet only as a read-only **eligibility gate** +- Runs all governance logic off-chain with deterministic rules +- Produces an auditable history (events + derived read views) +- Powers the UI exclusively via `/api/*` + +## Hard boundary (on-chain vs off-chain) + +- On-chain (read-only): determine whether an address is an **active Human Node** +- Off-chain (authoritative): everything else (proposals, votes, courts, formation, CM/tiers, feed/history) + +## What “done” means for v1 + +v1 is “done” when: + +- The UI can run clean-by-default with empty content and still be usable (no mock-only fallbacks). +- A signed-in, eligible address can execute end-to-end actions and see them reflected: + - pool voting + - chamber voting + - formation join + milestone actions + - courts reporting + verdict +- The feed is event-backed and reflects real actions. +- Era accounting exists: + - per-era counters are tracked + - rollup produces status buckets and next-era active set size +- Safety controls exist for a public demo: + - rate limits + - per-era quotas + - action locks + - write freeze +- Tests cover the above behavior. + +## Implemented (v1) + +### Identity and gating + +- Session auth: wallet signs a nonce (Substrate signature verification). +- Eligibility: Humanode mainnet RPC reads of `Session::Validators` (current validator set membership). +- Cached gate status with TTL; browsing is open, writes are gated. + +### Reads + +- `/api/*` read endpoints exist for all UI pages. +- Read-model bridge exists: + - DB mode reads from Postgres `read_models` + - inline seeded mode (`READ_MODELS_INLINE=true`) + - clean-by-default empty mode (`READ_MODELS_INLINE_EMPTY=true`) + +### Writes (command-based) + +- All writes route through `POST /api/command`. +- Commands implemented in v1: + - `pool.vote` + - `chamber.vote` (yes/no/abstain + optional score on yes) + - `formation.join` + - `formation.milestone.submit` + - `formation.milestone.requestUnlock` + - `court.case.report` + - `court.case.verdict` + - `proposal.draft.save` + - `proposal.draft.delete` + - `proposal.submitToPool` + +Note on proposal wizard UX: + +- v1 includes a working proposal wizard and supports meta-governance payloads for chamber creation/dissolution. +- Planned (v2+): restructure the wizard into template-driven flows so system-change proposals (like chamber creation) do not share project-only steps/fields: + - `docs/simulation/vortex-simulation-proposal-wizard-architecture.md` + +### Events and history + +- Append-only `events` table. +- Feed can be served from DB events (DB mode). +- Proposal pages expose a per-proposal timeline (`GET /api/proposals/:id/timeline`) backed by `events` entries of type `proposal.timeline.v1`. +- Admin actions also emit audit events. + +### Era accounting + +- Current era stored in DB (simulation clock). +- Per-era activity counters per address. +- Manual/admin era advance and era rollup endpoints. +- Rollup outputs: + - per-address status bucket for the era window + - computed next-era `activeGovernors` size (optionally written as the next baseline) + +### Canonical proposals and deterministic transitions + +- Canonical proposals exist in `proposals` (with `read_models` as a compatibility fallback). +- Stage transitions are deterministic and centralized (single transition authority) and enforced by the write path. +- Optional time windows exist for stage expiry and “time left” UX. + +### Canonical chambers and membership + +- Chambers are canonical in `chambers` (seeded from `/sim-config.json`). +- Chamber voting eligibility is enforced via `chamber_memberships`: + - specialization chamber: requires at least one accepted proposal in that chamber + - general chamber: requires at least one accepted proposal in any chamber +- Meta-governance proposals in the General chamber can create/dissolve chambers (v1 simulation rule). + +### Ops controls (public demo safety) + +- Command rate limits (per IP + per address). +- Optional per-era action quotas (per address). +- Address-level action locks (admin). +- Global write freeze (admin) + deploy-time kill switch (`SIM_WRITE_FREEZE=true`). + +## Not in scope (v1) + +These are intentionally deferred: + +- Completing the migration away from the `read_models` bridge for all entities (full projections across every page). +- Delegation flows (graph rules, UI, disputes beyond court-case text). +- Veto rights (and any “slow-down” mechanics for repeatedly approved proposals). +- Chamber multiplier-setting mechanics (including “outside-of-chamber” voting). +- Meritocratic Measure (MM) as a first-class modeled subsystem (Formation delivery scoring and MM history). +- A real forum/threads product (threads remain minimal and simulation-only). +- Bioauth epoch uptime as a first-class modeled subsystem (epochs are defined conceptually but not fully simulated as canonical state). +- “Real tokenomics”: rewards, balances, staking, slashing correctness. + +## Planned after v1 (v2+) + +These are the next build targets after v1 (see `docs/simulation/vortex-simulation-implementation-plan.md` for the full phased checklist): + +- Delegation v1 (set/clear + history + court references). +- Veto v1 (paper-aligned) and a minimal “proposal sent back” lifecycle. +- Chamber multipliers v1 (paper-aligned “outside-of-chamber” multiplier voting). +- Meritocratic Measure v1 (Formation delivery ratings → MM history and Invision signals). +- More event-backed projections (less `read_models`). + +## Sources of truth + +- v1 constants: `docs/simulation/vortex-simulation-v1-constants.md` +- API contract: `docs/simulation/vortex-simulation-api-contract.md` +- State machines + invariants: `docs/simulation/vortex-simulation-state-machines.md` +- Implementation status: `docs/simulation/vortex-simulation-implementation-plan.md` diff --git a/docs/simulation/vortex-simulation-state-machines.md b/docs/simulation/vortex-simulation-state-machines.md new file mode 100644 index 0000000..2192496 --- /dev/null +++ b/docs/simulation/vortex-simulation-state-machines.md @@ -0,0 +1,249 @@ +# Vortex Simulation Backend — State Machines (v1) + +This document formalizes the **state machines, invariants, and derived metrics** the simulation backend enforces. + +The UI can evolve, but these rules define what the simulation “means”. + +## Conventions + +- “Address” means a Substrate address string (the session identity). +- “Eligible” means “active Human Node” as returned by `GET /api/gate/status`. +- “Era” means the simulation’s governance accounting window. +- Command writes happen via `POST /api/command` only. + +## Global invariants (v1) + +### Write invariants + +- Every command write requires: + 1. authenticated session + 2. eligibility (unless dev bypass is enabled) + 3. not globally write-frozen (admin state or `SIM_WRITE_FREEZE=true`) + 4. not action-locked for the address + 5. within rate limit + optional per-era quotas +- Idempotency is supported via `Idempotency-Key`. Reuse with different payload returns HTTP `409`. + +### Read invariants + +- Read endpoints are safe for unauthenticated users. +- “Clean-by-default” mode exists (`READ_MODELS_INLINE_EMPTY=true`) and pages must remain usable without seeded content. + +## Proposal lifecycle (v1) + +### Stages + +The UI uses these stages: + +- `draft` +- `pool` (proposal pool / attention) +- `vote` (chamber vote) +- `build` (formation) + +In v1, stage is represented in the proposals list read model and may be auto-advanced by command evaluation. + +### Pool voting (`pool.vote`) + +Command: + +- `type: "pool.vote"` +- `payload: { proposalId, direction: "up" | "down" }` + +Invariants: + +- One vote per `(proposalId, voterAddress)`. +- Re-voting overwrites the prior direction for that pair. +- The command returns updated up/down counts for the proposal. +- The command is rejected if the proposal is not in stage `pool` (HTTP `409`). + +Derived metrics: + +- Upvotes / downvotes are computed from `pool_votes`. +- Quorum thresholds are parameterized by: + - active governors baseline (`SIM_ACTIVE_GOVERNORS` or per-era snapshot) + - pool quorum constants (see `docs/simulation/vortex-simulation-v1-constants.md`) + +Transition (implemented v1 behavior): + +- When pool quorum is met, the backend updates the `proposals:list` read model: + - stage `pool` → `vote` + - it also ensures `proposals:${id}:chamber` exists (created as a minimal placeholder derived from the pool page payload if missing) + +### Chamber voting (`chamber.vote`) + +Command: + +- `type: "chamber.vote"` +- `payload: { proposalId, choice: "yes" | "no" | "abstain", score?: number }` + +Invariants: + +- One vote per `(proposalId, voterAddress)`. +- `score` is only allowed when `choice === "yes"`. +- The command is rejected if the proposal is not in stage `vote` (HTTP `409`). + +Derived metrics: + +- yes/no/abstain totals are computed from `chamber_votes`. +- passing/quorum rules are parameterized by: + - active governors baseline (`SIM_ACTIVE_GOVERNORS` or per-era snapshot) + - vote quorum + passing constants (see `docs/simulation/vortex-simulation-v1-constants.md`) + +Transition (implemented v1 behavior): + +- When quorum + passing are met: + - if the proposal is `formationEligible === true`, the backend updates the proposals list read model: stage `vote` → `build` + - it also ensures `proposals:${id}:formation` exists (created as a minimal placeholder derived from the chamber page payload if missing) + +### CM awarding (on pass) + +Input: + +- yes votes may include `score` (1–10). + +Awarding rule (v1): + +- When a proposal passes chamber vote, the backend records a single `cm_awards` row (unique per proposal). +- Award points are derived from the average yes `score` (exact mapping is a v1 constant). +- Human profiles are served as: + - baseline CM numbers from read models + - plus a delta derived from `cm_awards` overlays + +## Formation (v1) + +### Join (`formation.join`) + +Command: + +- `type: "formation.join"` +- `payload: { proposalId, role?: string }` + +Invariants: + +- Only allowed when proposal is in stage `build` (HTTP `409` otherwise). +- Team slots cannot exceed total. + +### Milestone submit (`formation.milestone.submit`) + +Command: + +- `type: "formation.milestone.submit"` +- `payload: { proposalId, milestoneIndex, note?: string }` + +Invariants: + +- Only allowed in stage `build`. +- Milestone index must exist for the project. +- Submitting does not unlock funds in v1; it records a submission event. + +### Unlock request (`formation.milestone.requestUnlock`) + +Command: + +- `type: "formation.milestone.requestUnlock"` +- `payload: { proposalId, milestoneIndex, note?: string }` + +Invariants: + +- Only allowed in stage `build`. +- Cannot request unlock for a milestone that is already unlocked. + +## Courts (v1) + +### Report (`court.case.report`) + +Command: + +- `type: "court.case.report"` +- `payload: { caseId, note?: string }` + +Invariants: + +- Reporting increments a reports counter and appends a report record. +- Cases have a status bucket surfaced to the UI (`jury`, `live`, `ended`). +- v1 does not attempt to model full legal procedure; it records actions and exposes a readable “proceedings” view. + +### Verdict (`court.case.verdict`) + +Command: + +- `type: "court.case.verdict"` +- `payload: { caseId, verdict: "guilty" | "not_guilty" }` + +Invariants: + +- One verdict per `(caseId, voterAddress)`. +- Verdict is only allowed in allowed case statuses (v1 defines allowed states; enforced by the API). + +## Era accounting (v1) + +### Per-era counters + +Each write command increments the per-era activity counter for the address in `era_user_activity`, by kind: + +- pool votes +- chamber votes +- court actions +- formation actions + +### Rollup (`POST /api/clock/rollup-era`) + +Rollup computes: + +- per-address status bucket for the current era window: + - Ahead / Stable / Falling behind / At risk / Losing status +- next-era active governors baseline: + - either configured constant or derived dynamically (if enabled) + +Rollup invariants: + +- Deterministic: given the same stored activity counters and constants, output is identical. +- Idempotent for a given era. + +## What’s intentionally missing (v1) + +These are intentionally deferred from v1. The state machines above assume they do not exist. + +### Delegation (v2+) + +Not implemented in v1. + +Planned invariants: + +- No self-delegation. +- No cycles (delegation graph must remain acyclic). +- Delegation changes are event-backed (courts can reference the full history). +- Delegation affects chamber vote weight, but must not affect pool attention mechanics. + +### Veto rights (v2+) + +Not implemented in v1. + +Planned behavior (paper-aligned intent): + +- Veto power is tied to top LCM holders per chamber. +- Veto is temporary, limited in count, and slows down acceptance rather than blocking it indefinitely. +- Veto actions are event-backed and auditable. + +### Chamber multiplier setting (v2+) + +Not implemented in v1. + +Planned behavior (paper-aligned intent): + +- Multiplier submissions are made by cognitocrats outside the chamber. +- Chamber multiplier is derived from submissions (aggregation rules are a v2 decision). +- Changes to multipliers must be event-backed and should not rewrite CM history (ACM is re-derived). + +### Meritocratic Measure (MM) (v2+) + +Not implemented in v1. + +Planned behavior: + +- MM is earned through Formation delivery and milestone outcomes. +- MM does not change voting power. +- MM contributes to PoD-like tier progression and to Invision insights. + +### Future court hooks (beyond v1) + +- Outcome hooks that affect Formation unlocks, reputation/cred flags, and visibility (simulation only). diff --git a/docs/simulation/vortex-simulation-tech-architecture.md b/docs/simulation/vortex-simulation-tech-architecture.md new file mode 100644 index 0000000..5db9c48 --- /dev/null +++ b/docs/simulation/vortex-simulation-tech-architecture.md @@ -0,0 +1,451 @@ +# Vortex Simulation Backend — Tech Architecture + +This document maps `docs/simulation/vortex-simulation-processes.md` onto a technical architecture that fits this repo (React app + Cloudflare Pages Functions in production, with a Node runner for local dev). + +For a paper-aligned “module map” that links product concepts to concrete code, start with `docs/simulation/vortex-simulation-modules.md`. + +For the DB table inventory, see `docs/simulation/vortex-simulation-data-model.md`. For ops controls and admin endpoints, see `docs/ops/vortex-simulation-ops-runbook.md`. + +## 1) Stack (recommended) + +### Languages + +- **TypeScript** end-to-end (web + API + shared domain engine). +- **SQL** for persistent state and analytics. + +### Runtime + hosting + +- **Cloudflare Pages**: existing frontend hosting. +- **Cloudflare Workers**: API runtime (REST + optional SSE). +- **Cron Triggers**: era rollups / scheduled jobs (implemented as an explicit `/api/clock/tick` endpoint that a scheduler calls). +- **Durable Objects (optional but recommended)**: race-free state transitions for voting/pool/court actions. + +### Database + +- **Chosen for v1: Postgres** (Neon-compatible serverless Postgres) for user history, analytics, and relational integrity. + +Important: because the API runtime is Cloudflare Workers/Pages Functions (edge), v1 should use a Postgres provider that supports **serverless/HTTP connectivity** from edge runtimes. + +- Recommended: **Neon Postgres** (works with `@neondatabase/serverless` + Drizzle). + +### Libraries / tools + +- **Drizzle ORM** (Postgres). +- **zod** (request validation; used as needed). +- **@polkadot/util-crypto** (+ **@polkadot/keyring** in tests) for Substrate signature verification and SS58 address handling. +- **wrangler** for Workers deployment (already in repo). + +### External reads (gating) + +- Humanode mainnet via **RPC** (v1). + +## 2) High-level architecture + +### Components + +- **Web app (React/TS/Tailwind)**: UI + calls API. +- **API (Worker)**: + - `auth`: nonce + signature verification + - `gate`: mainnet eligibility checks + TTL caching + - `commands`: apply state transitions (write operations) + - `reads`: serve derived views (feed, proposal pages, profiles) +- **Domain engine (shared TS module)**: + - pure functions implementing state machines, invariants, and event emission + - no network calls; no DB calls +- **DB**: + - canonical state where implemented (votes, formation, courts, era accounting, idempotency, sessions) + - transitional `read_models` payloads for page DTOs while migrating toward canonical domain tables (v2) + - append-only event log (feed/audit) +- **Scheduler**: + - era boundary rollups (governor activity, quorums, tier statuses, CM updates) + - optional era auto-advance when the era is “due” by configured time (`SIM_ERA_SECONDS`) + - optional stage-window closure notifications (when `SIM_ENABLE_STAGE_WINDOWS=true`, `POST /api/clock/tick` emits a deduped feed event when a proposal’s pool/vote window ends) + +### Key principle: authoritative writes + +All state-changing actions go through the API and are validated against: + +1. signature-authenticated user session +2. eligibility gate (active human node) +3. domain invariants (stage constraints, one-vote rules, etc.) + +## 3) Suggested code modules (implementation shape) + +This repo is currently a single frontend app. The backend can live alongside it as: + +- `functions/api/*` (Pages Functions routes) +- `functions/_lib/*` (shared server helpers) +- `functions/cloudflare.d.ts` (local typing for `PagesFunction` in editors) +- `functions/tsconfig.json` (separate TS project for `functions/`) +- `db/*` (Drizzle schema + migrations) +- `scripts/*` (seed/import jobs) +- `src/server/domain/*` (future: shared domain engine) + +If the repo is later split into a monorepo, these become: + +- `packages/domain` +- `apps/api` +- `apps/web` + +## 4) API surface (v1) + +### Authentication + +- `POST /api/auth/nonce` → `{ address }` → `{ nonce }` +- `POST /api/auth/verify` → `{ address, nonce, signature }` → session cookie/JWT +- `POST /api/auth/logout` + +### Gating + +- `GET /api/gate/status` → `{ eligible: boolean, reason?: string, expiresAt: string }` + +Eligibility source (v1): + +- Query Humanode mainnet RPC for “active human node” status via `Session::Validators` (current validator set membership). + +### Reads + +- `GET /api/feed?cursor=...&stage=...` +- `GET /api/chambers` +- `GET /api/chambers/:id` +- `GET /api/proposals?stage=...` +- `GET /api/proposals/:id/pool` +- `GET /api/proposals/:id/chamber` +- `GET /api/proposals/:id/formation` +- `GET /api/proposals/:id/timeline` +- `GET /api/proposals/drafts` +- `GET /api/proposals/drafts/:id` +- `GET /api/courts` +- `GET /api/courts/:id` +- `GET /api/humans` +- `GET /api/humans/:id` +- `GET /api/clock` (simulation time) +- `GET /api/me` (profile + eligibility snapshot) + +### Writes (commands) + +Prefer a single command endpoint so invariants are centralized: + +- `POST /api/command` → `{ type, payload, idempotencyKey? }` + +Implemented in v1: + +- `proposal.draft.save` (Phase 12) +- `proposal.draft.delete` (Phase 12) +- `proposal.submitToPool` (Phase 12) +- `pool.vote` (upvote/downvote) +- `chamber.vote` (yes/no/abstain + optional CM score 1–10 on yes votes) +- `formation.join` +- `formation.milestone.submit` +- `formation.milestone.requestUnlock` +- `court.case.report` +- `court.case.verdict` +- `delegation.set` +- `delegation.clear` + +Planned (v2+) examples: + +- + +## 5) Data model (tables) — minimal set + +These tables support the workflows and auditability; the system starts lean and expands as features move off the `read_models` bridge. + +### Identity / auth + +- `users` (account): `id`, `address`, `displayName`, `createdAt` +- `auth_nonces`: `address`, `nonce`, `expiresAt`, `usedAt` +- `sessions` (if not JWT-only): `id`, `userId`, `expiresAt` +- `idempotency_keys`: stores request/response pairs for `POST /api/command` retries + +### Eligibility cache (mainnet gating) + +- `eligibility_cache`: + - `address` + - `isActiveHumanNode` (boolean) + - `checkedAt`, `checkedAtBlock?` + - `source` (`rpc`) + - `expiresAt` + - `reasonCode?` + +### Transitional read models (Phase 2c → Phase 4 bridge) + +To avoid rewriting the UI while we build normalized tables + an event log, we seed mock-equivalent payloads into a single table: + +- `read_models`: `{ key, payload (jsonb), updatedAt }` + +This allows early `GET /api/...` endpoints to serve the exact DTOs expected by `docs/simulation/vortex-simulation-api-contract.md` while we progressively replace `read_models` with real projections. + +Local dev modes for reads: + +- DB mode: reads start from `read_models` using `DATABASE_URL` and may prefer canonical domain tables where applicable (e.g. proposals). +- Inline fixtures: `READ_MODELS_INLINE=true` (no DB). +- Clean/empty mode: `READ_MODELS_INLINE_EMPTY=true` (list endpoints return `{ items: [] }` and singleton endpoints return minimal defaults). + +### Governance time + +Current repo: + +- `clock_state`: `currentEra`, `updatedAt` + +Implemented: + +- `era_snapshots`: per-era aggregates (v1: `activeGovernors`) +- `era_user_activity`: per-era counters per address (pool/chamber/courts/formation actions) +- `era_rollups`: per-era rollup output (requirements + active set size for next era) +- `era_user_status`: per-era derived status per address (Ahead/Stable/At risk/etc.) +- `epoch_uptime`: optional (per address, per epoch/week) if Bioauth uptime is modeled in v1/v2 + +### Current tables (implemented) + +- `read_models`: transitional DTO storage for the current UI +- `proposals`: canonical proposal rows (Phase 14) +- `chambers`: canonical chambers (Phase 18) +- `chamber_memberships`: voting eligibility granted by accepted proposals (Phase 17) +- `events`: append-only feed/audit log +- `pool_votes`: unique (proposalId, voterAddress) → up/down +- `chamber_votes`: unique (proposalId, voterAddress) → yes/no/abstain + optional `score` (1–10) on yes votes +- `cm_awards`: CM awards emitted when proposals pass (unique per proposal) +- `idempotency_keys`: stored responses for idempotent command retries +- `era_snapshots`: per-era aggregates (v1: active governors baseline) +- `era_user_activity`: per-era action counters per address +- `era_rollups`: per-era rollup output (requirements + active set size for next era) +- `era_user_status`: per-era derived status per address +- `formation_projects`: per-proposal Formation counters/baselines +- `formation_team`: extra Formation joiners (beyond seed baseline) +- `formation_milestones`: per-proposal milestone status (`todo`/`submitted`/`unlocked`) +- `formation_milestone_events`: append-only milestone submissions/unlock requests +- `proposal_drafts`: author-owned proposal drafts (Phase 12) +- `delegations`: chamber-scoped delegation graph (Phase 29) +- `delegation_events`: append-only delegation history (Phase 29) + +### Optional future domain tables (v2+) + +- `proposal_stage_transitions`: append-only transition history (v1 transitions exist, but are not stored as a dedicated transitions table) +- `proposal_attachments`: `proposalId`, `title`, `href` +- `cm_lcm`: per-chamber LCM materialization (v1 derives ACM deltas from `cm_awards`) +- `tiers`: materialized tier state (v1 derives statuses via era rollups) + +Current repo behavior: + +- `pool_votes` exists and is written via `POST /api/command` (`pool.vote`). +- `chamber_votes` exists and is written via `POST /api/command` (`chamber.vote`). +- `cm_awards` exists and is written when proposals pass chamber vote (derived from average yes `score`). +- Read pages overlay live counts: + - `GET /api/proposals/:id/pool` overlays upvotes/downvotes from `pool_votes` + - `GET /api/proposals/:id/chamber` overlays yes/no/abstain from `chamber_votes` +- Stage transitions are applied deterministically by a single transition authority: + - canonical proposals are updated in `proposals` + - compatibility DTO payloads in `read_models` may also be updated to keep the UI stable +- Proposal timeline is event-backed: + - `GET /api/proposals/:id/timeline` + - `events.type = "proposal.timeline.v1"` + +### Formation + +Implemented (v1): + +- Commands: + - `formation.join` fills team slots (capped by total). + - `formation.milestone.submit` marks a milestone as submitted (does not increase completion yet). + - `formation.milestone.requestUnlock` unlocks a submitted milestone (mock acceptance for v1). +- Read overlay: + - `GET /api/proposals/:id/formation` overlays `teamSlots`, `milestones`, and `progress` from Formation state. +- Tables: + - `formation_projects`: `proposalId`, totals + baselines derived from the Formation read model + - `formation_team`: `(proposalId, memberAddress)` join records (beyond the baseline) + - `formation_milestones`: `(proposalId, milestoneIndex)` state + - `formation_milestone_events`: append-only milestone events + +### Courts + +Implemented (v1): + +- Commands: + - `court.case.report` (per-user report; updates `reports` + can open a live session) + - `court.case.verdict` (guilty/not_guilty; one-per-user; only when live; ends after enough verdicts) +- Tables: + - `court_cases`: current status + baseline report count (seeded from read model) + - `court_reports`: per-user report uniqueness + - `court_verdicts`: per-user verdicts +- Read overlay: + - `GET /api/courts` and `GET /api/courts/:id` overlay live `reports` + `status` from stored state + +Planned (later phases): + +- `court_evidence`: `caseId`, `title`, `href`, `addedByUserId`, `createdAt` +- `court_outcomes`: `caseId`, `result`, `recommendationsJson` + +### Delegation + +Implemented (v1): + +- Commands: + - `delegation.set` + - `delegation.clear` +- Tables: + - `delegations`: `(chamber_id, delegator_address) → delegatee_address` + - `delegation_events`: append-only history (`set` / `clear`) +- Semantics: + - delegation affects **chamber vote weighting** only + - proposal-pool attention remains direct-only + - a delegator’s voice only counts if the delegator did **not** cast a chamber vote themselves + +### Feed / audit trail + +- `events` (append-only): + - `id`, `type`, `actorUserId?`, `entityType`, `entityId`, `payloadJson`, `createdAt` + +In the current repo implementation, `events` exists as an append-only Postgres table and `GET /api/feed` can be served from it in DB mode. + +## 6) Mapping: processes → modules → APIs → tables/events + +This section maps each workflow from `docs/simulation/vortex-simulation-processes.md` to concrete tech. + +### 2.0 Authentication + gating + +- **Module:** `auth`, `gate` +- **API:** `/api/auth/nonce`, `/api/auth/verify`, `/api/gate/status` +- **Tables:** `users`, `auth_nonces`, `eligibility_cache` +- **Events:** `auth.logged_in`, `gate.checked` + +### 2.0b Request hardening (rate limits + action locks) + +- **Module:** `hardening` +- **API:** + - `POST /api/command` (rate limited per IP + per address) + - `POST /api/command` (optional per-era quotas for counted actions) + - `POST /api/admin/users/lock`, `POST /api/admin/users/unlock` (admin-only) + - `GET /api/admin/users/locks`, `GET /api/admin/users/:address` (inspection) + - `GET /api/admin/audit` (admin actions audit log) + - `GET /api/admin/stats` (ops metrics snapshot) + - `POST /api/admin/writes/freeze` (toggle global write freeze) +- **Tables:** + - `api_rate_limits` (DB mode) + - `era_user_activity` (per-era counters used for quota enforcement and rollups) + - `user_action_locks` (DB mode) + - `events` (DB mode; admin actions are logged as `admin.action.v1`) + - `admin_state` (DB mode; write freeze flag) +- **Notes:** + - Rate limiting and action locks are enforced server-side for all state changes so the simulation stays usable during community testing. + - Era quotas enforce a cap on new counted actions (vote/report/join) while still allowing edits (changing a vote) without consuming additional quota. + - Admin actions are appended to an audit log (memory mode for local dev, `events` table for DB mode). + - A write freeze can be toggled via admin endpoints (and overridden by a deploy-time env kill switch). + +### 2.1 Onboarding (Human → Human Node → Governor) + +Current repo: + +- **Module:** `auth`, `gate` +- **API:** `GET /api/me`, `GET /api/gate/status` +- **Tables:** `users`, `eligibility_cache` + +Planned: + +- **Module:** `identity`, `eligibility`, `tiers` +- **Tables:** `tiers` +- **Events:** `tier.updated` + +### 2.2 Era rollup (cron) + +Current repo: + +- **Module:** `clock`, `eraStore` +- **API:** `GET /api/clock`, `POST /api/clock/advance-era` +- **Tables:** `clock_state`, `era_snapshots`, `era_user_activity` + +Planned: + +- **Module:** `governanceTime`, `tiers`, `cm`, `proposals`, `feed` +- **Tables:** `tiers` (or equivalent tier status table), proposal aggregates, `events` +- **Events:** `era.rolled`, `quorum.baseline_updated`, `proposal.advanced` + +### 2.3 Proposal drafting (wizard) + +Current repo: + +- **Module:** `proposals.draft` +- **API:** `POST /api/command` (`proposal.draft.save`, `proposal.submitToPool`) +- **Tables:** `proposal_drafts`, `proposals` +- **Events:** timeline entries in `events` (`proposal.timeline.v1`) + +### 2.4 Proposal pool (attention) + +- **Module:** `proposals.pool` +- **API:** `POST /api/command` (`pool.vote`) +- **Tables:** `pool_votes`, `events` +- **Derived:** pool quorum metrics computed from votes + era snapshot baselines +- **Events:** `pool.vote_cast`, `pool.quorum_met`, `proposal.moved_to_vote` + +### 2.5 Chamber vote (decision) + +- **Module:** `proposals.vote`, `cm` +- **API:** `POST /api/command` (`chamber.vote`) +- **Tables:** `chamber_votes`, `cm_awards`, `events` (+ transitional `read_models` stage updates) +- **Events:** `vote.cast`, `vote.quorum_met`, `proposal.passed`, `proposal.rejected`, `cm.awarded` + +### 2.6 Formation execution (projects) + +- **Module:** `formation` +- **API:** `POST /api/command` (`formation.join`, `formation.milestone.submit`, `formation.milestone.requestUnlock`) +- **Tables:** `formation_projects`, `formation_team`, `formation_milestones`, `formation_milestone_events` +- **Events:** `formation.joined`, `formation.milestone_submitted`, `formation.unlock_requested`, `formation.milestone_accepted` + +### 2.7 Courts (case lifecycle) + +- **Module:** `courts` +- **API:** `POST /api/command` (`court.case.report`, `court.case.verdict`) +- **Tables:** `court_cases`, `court_reports`, `court_verdicts` +- **Events:** `court.case_opened`, `court.report_added`, `court.session_live`, `court.verdict_cast`, `court.case_closed` + +### 2.8 Delegation management + +- **Module:** `delegation` +- **API:** `POST /api/command` (`delegation.set`, `delegation.clear`) +- **Tables:** `delegations`, `delegation_events` +- **Events:** `delegation.set`, `delegation.cleared` + +### 2.9 Chambers directory + chamber detail + +- **Module:** `chambers` +- **API:** `GET /api/chambers`, `GET /api/chambers/:id` +- **Tables:** `chambers`, `chamber_memberships`, `proposals` (derived counts), optional `read_models` fallback +- **Events:** chamber create/dissolve side-effects are appended via proposal timeline events (and/or feed events, depending on stage) + +### 2.10 Invision insights + +- **Module:** `invision` (derived scoring) +- **API:** `GET /api/humans/:id` (includes insights) +- **Tables:** derived from `events`, proposals/courts/milestones; optionally `invision_snapshots` +- **Events:** `invision.updated` (optional) + +## 7) Concurrency + integrity (why Durable Objects may be needed) + +If multiple users vote at once, race conditions must be prevented: + +- double-voting +- inconsistent quorum counters +- stage transitions happening twice + +Two approaches: + +- **DB constraints + transactions** (Postgres can do this well). +- **Durable Object per entity** (proposal/case) that serializes commands. + +Recommendation: + +- Start with DB constraints + transactions. +- Add DOs for high-contention entities (popular proposals) or for simpler correctness in Worker code. + +## 8) Anti-abuse controls (even for eligible human nodes) + +- Per-era action limits (proposal submissions, reports, etc.) +- Idempotency keys for commands (client retries) +- Rate limiting per address (Worker middleware) +- Court/report spam prevention (minimum stake is out-of-scope unless added as a simulated rule) + +## 9) Migration path from today’s mock data + +- The frontend renders from `/api/*` reads; mock data is not part of the runtime anymore. +- Transitional read-model payloads are maintained as seed fixtures in `db/seed/fixtures/*` (and stored in Postgres `read_models` in DB mode). +- Next migrations continue moving from `read_models` to canonical tables + event-backed projections, while keeping DTOs stable. diff --git a/docs/simulation/vortex-simulation-v1-constants.md b/docs/simulation/vortex-simulation-v1-constants.md new file mode 100644 index 0000000..fc809d9 --- /dev/null +++ b/docs/simulation/vortex-simulation-v1-constants.md @@ -0,0 +1,106 @@ +# Vortex Simulation Backend — v1 Constants + +This file records the v1 decisions used by the simulation backend so implementation and tests share the same assumptions. + +## Stack decisions + +- **Database:** Postgres (v1 recommendation: **Neon**, for edge/serverless connectivity) +- **On-chain read source:** Humanode mainnet RPC (no Subscan dependency for v1) +- **Eligibility (“active Human Node”):** derived from mainnet RPC reads of `Session::Validators` (current validator set membership). The Humanode RPC URL is configured via `HUMANODE_RPC_URL` or `public/sim-config.json`. + +## Simulation time decisions + +- **Era length:** configured off-chain by the simulation (not a chain parameter) + - v1 default: **7 days** (can still be advanced manually via `/api/clock/advance-era`) + - vote/pool stage windows default to: + - pool: **7 days** + - vote: **7 days** +- **Per-era activity requirements:** configured off-chain by env vars (v1 defaults) + - `SIM_REQUIRED_POOL_VOTES=1` + - `SIM_REQUIRED_CHAMBER_VOTES=1` + - `SIM_REQUIRED_COURT_ACTIONS=0` + - `SIM_REQUIRED_FORMATION_ACTIONS=0` + +## Current v1 progress checkpoints + +- Backend exists in the repo (`functions/`, DB schema/migrations under `db/`, seed script under `scripts/`). +- Read endpoints exist and are wired to either: + - Postgres-backed reads from `read_models` (requires `DATABASE_URL` + `yarn db:migrate && yarn db:seed`), or + - Inline seed reads via `READ_MODELS_INLINE=true` (no DB required). + - Empty reads via `READ_MODELS_INLINE_EMPTY=true` (clean UI; list endpoints return `{ items: [] }`). +- DB can be wiped without dropping schema via `yarn db:clear` (requires `DATABASE_URL`). +- Event log scaffold exists as `events` (append-only table) and `GET /api/feed` can be backed by it in DB mode. +- Phase 6 write slice exists: + - `POST /api/command` supports `pool.vote` (auth + gate + idempotency). + - `pool_votes` stores one vote per address per proposal and `GET /api/proposals/:id/pool` overlays live counts. + - Pool quorum evaluation exists (`evaluatePoolQuorum`) and proposals auto-advance from pool → vote by updating the `proposals:list` read model. +- Phase 7 write slice exists: + - `POST /api/command` supports `chamber.vote` (auth + gate + idempotency). + - `chamber_votes` stores one vote per address per proposal and `GET /api/proposals/:id/chamber` overlays live counts. + - Vote quorum + passing evaluation exists (`evaluateChamberQuorum`) and proposals auto-advance from vote → build when quorum + passing are met. + - Formation is optional: formation state is only seeded/usable when `formationEligible` is true on the proposal payload. + - CM awards v1 are recorded in `cm_awards` when proposals pass (derived from average yes `score`), and `/api/humans*` overlays ACM deltas from awards. +- Phase 8 write slice exists: + - Formation tables exist: + - `formation_projects`, `formation_team`, `formation_milestones`, `formation_milestone_events` + - `POST /api/command` supports: + - `formation.join` + - `formation.milestone.submit` + - `formation.milestone.requestUnlock` + - `GET /api/proposals/:id/formation` overlays live Formation state (team slots, milestones, progress). + - Formation commands are rejected when a proposal does not require Formation (`formationEligible=false`). +- Phase 9 write slice exists: + - Courts tables exist: + - `court_cases`, `court_reports`, `court_verdicts` + - `POST /api/command` supports: + - `court.case.report` + - `court.case.verdict` + - `GET /api/courts` and `GET /api/courts/:id` overlay live `reports` and `status`. +- Phase 10a write slice exists: + - Era tracking tables exist: + - `era_snapshots` (per-era active governors baseline) + - `era_user_activity` (per-era action counters per address) + - Active governors baseline defaults to `150` and can be configured via `SIM_ACTIVE_GOVERNORS` (or `VORTEX_ACTIVE_GOVERNORS`). + - Quorum denominators are chamber-scoped: + - when no prior era rollup exists, denominators use `min(activeGovernorsBaseline, eligibleGovernorsInChamber)` + - when a prior era rollup exists, denominators use the rollup’s active set filtered to `eligibleGovernorsInChamber` + - `GET /api/clock` returns the current era + active governors baseline (used as the fallback cap when rollups are missing). + - `GET /api/my-governance` overlays per-era `done` counts for authenticated users. +- Phase 10b write slice exists: + - `POST /api/clock/rollup-era` computes: + - per-era governing status buckets (Ahead/Stable/Falling behind/At risk/Losing status) + - `activeGovernorsNextEra` based on configured requirements + - next era baseline update (next era uses `activeGovernorsNextEra`) + - Rollup output is stored in: + - `era_rollups`, `era_user_status` + +- Phase 17 write slice exists: + - Chamber vote eligibility is enforced (paper-aligned): + - Specialization chamber: vote requires an accepted proposal in that chamber. + - General chamber: vote requires an accepted proposal in any chamber. + - Genesis bootstrap for the first votes can be configured via `public/sim-config.json` (`genesisChamberMembers`). + - Eligibility is persisted in `chamber_memberships` and granted when a proposal is accepted (vote → build transition). + - Dev bypass: `DEV_BYPASS_CHAMBER_ELIGIBILITY=true` disables chamber-membership checks (local/testing only). + +- Phase 18 write slice exists: + - Chambers are canonical in `chambers` (auto-seeded from `public/sim-config.json` → `genesisChambers`). + - General-chamber proposal outcomes can create/dissolve chambers (simulated via proposal payload `metaGovernance`). + +## Paper alignment notes (v1) + +- Pool attention quorum: + - paper: **22% engaged** + **≥10% upvotes** + - simulation v1: **22% engaged** + **≥10% upvotes** +- Vote quorum (33%) is aligned; passing uses **66.6% + 1 yes vote within quorum** in v1. +- Delegation and veto are implemented in v1 (vote-weight aggregation + veto slow-down). +- Chamber multiplier voting is implemented in v1; Meritocratic Measure (MM) is not implemented yet. + +## Post-v1 roadmap (v2+) + +v1 constants are intentionally kept small and testable. The simulation already includes drafts, canonical proposals/chambers, deterministic transitions, optional time windows, and proposal timelines. + +The next paper-aligned expansions (v2+) are: + +- Meritocratic Measure (MM) from Formation delivery/review. + +Source of truth: `docs/simulation/vortex-simulation-implementation-plan.md`. diff --git a/docs/updates/dev-log-1.md b/docs/updates/dev-log-1.md new file mode 100644 index 0000000..b0a0e61 --- /dev/null +++ b/docs/updates/dev-log-1.md @@ -0,0 +1,114 @@ +# Dev Log #1 — Vortex Simulator v0.1 + +**Date:** 2025-12-24 + +## TL;DR + +This update turns Vortex from a **UI-only mock** into a **stateful governance simulator** with a real backend: + +- Wallet auth (signature-based) + **real Humanode mainnet gating** +- Proposal drafts + multi-step wizard (project vs system/meta-governance flows) +- Proposal pools → chamber voting → acceptance (with real quorums/thresholds) +- Chamber lifecycle automation (create/dissolve via General chamber proposals) +- Formation execution for project proposals (team slots + milestones) +- Courts/disputes + a canonical event timeline/feed +- Admin tools for moderation/ops (freeze, user locks, audit trail) + +## What changed (as features) + +### 1) A real backend exists now + +Previous master was mostly front-end mock data. This release adds a working simulation backend (Pages Functions) that the UI talks to via `/api/*`, including: + +- Command API for actions (`pool.vote`, `chamber.vote`, `formation.join`, court actions, admin actions) +- Read endpoints for every page model (proposals, chambers, courts, humans, factions, formation, invision, my-governance) +- A canonical event stream (feed + per-proposal timeline) so the UI reflects what actually happened, in order + +### 2) Real Humanode mainnet gating (validator-only actions) + +The simulator now supports a “real gate” mode: anyone can browse, but actions are blocked unless the connected wallet is an **active validator** on Humanode mainnet. + +Under the hood, gating reads the mainnet validator set (via RPC) and caches results so the UI can show: + +- Wallet status (connected / disconnected) +- Gate status (active / not active) + +### 3) Proposal creation is now “drafts + templates” + +The proposal wizard is no longer a single hardcoded form. It supports: + +- **Drafts**: save anytime, resume later, submit when ready +- **Project proposals**: “execute something” (may go into Formation after acceptance) +- **System/meta-governance proposals**: “change the system” (e.g. create/dissolve a chamber) + +This matters because system proposals should be realized immediately by the simulator (they change the system state), while project proposals represent broader execution work. + +### 4) Proposal Pools work (quorum of attention) + +Each proposal enters the appropriate pool first. Governors can upvote/downvote. + +- Proposals advance from pool → chamber vote when they meet the attention threshold. +- Vote updates are idempotent and counted toward per-era action quotas. + +### 5) Chamber voting works (quorum + 66.6% + 1) + +Once a proposal reaches chamber vote, governors vote **yes/no/abstain**. + +- Quorum is derived from the active-governor denominator for that chamber/era. +- Passing is the strict rule: **66.6% + 1 vote** among those voting. +- Optional vote scoring on “yes” is used for cognitocratic measure (CM) flows. + +### 6) System proposals can create/dissolve chambers (and they appear in the UI) + +The biggest “it feels real” change: a General-chamber system proposal can be: + +Draft → Proposal Pool → Chamber Vote → **Passed** → chamber is created/dissolved + +When a chamber is created, the simulator seeds initial membership (genesis members + proposer), and the new chamber appears on `/app/chambers` immediately. + +### 7) Formation is now an execution module (for project proposals) + +For proposals that require Formation: + +- Formation pages exist and derive from the accepted proposal +- Team slots can be filled (join) +- Milestones can be submitted and unlocks requested (mock execution loop) + +System proposals bypass Formation by design. + +### 8) Courts/disputes are stateful + +Courts now support a real “case lifecycle” in the simulator: + +- Reports increment and can move cases through statuses +- Verdicts are restricted by case status (e.g. only when live) +- Case events show up in feed/timelines + +### 9) My Governance is backed by era logic + +There’s a real concept of “era activity” in the backend now: + +- Actions count toward an era (with quotas) +- Rollups produce per-user status for the next era +- Denominators for quorums are snapshotted per stage so they don’t drift mid-vote + +### 10) Admin tooling exists (so the demo can be run safely) + +Basic moderation/ops endpoints were added for the simulator: + +- Freeze writes (temporary global pause) +- Lock/unlock users (block actions) +- Audit log for admin actions + +## How to try it (quick) + +- Local full-stack (UI + Functions): run `yarn dev:full`. +- If `/api/*` is missing: use the Pages Functions flow described in `docs/vortex-simulation-local-dev.md`. + +## What’s next + +This v0.1 milestone is “the simulator exists”. Next work should focus on deepening correctness and closing the remaining “paper vs simulator” gaps: + +- Active governance rules and quorums fully derived from era rollups +- Completing the module set for end-to-end realism (delegation, veto UX, more proposal types) +- Hardening persistence mode (Postgres) and deployment configuration diff --git a/docs/vortex-simulation-api-contract.md b/docs/vortex-simulation-api-contract.md deleted file mode 100644 index fafdef7..0000000 --- a/docs/vortex-simulation-api-contract.md +++ /dev/null @@ -1,383 +0,0 @@ -# Vortex Simulation Backend — API Contract v1 (Phase 1) - -This document freezes the **JSON contracts** the backend must serve so the current mock-driven UI can migrate to API reads with minimal churn. - -Notes: - -- These are **DTOs** (network-safe JSON), not React UI models. -- Anywhere the UI currently uses `ReactNode` in `src/data/mock/*`, the API will return **strings** (plain text or Markdown) and the UI will render them. -- These endpoints are implemented in Phase 2c in two modes: - - DB mode: reads from Postgres `read_models` (seeded by `scripts/db-seed.ts`). - - Inline mode: `READ_MODELS_INLINE=true` serves the same payloads from the in-repo seed builder (`db/seed/readModels.ts`) for local dev/tests without a DB. -- For local dev/tests without a DB, we currently support `READ_MODELS_INLINE=true` to serve the same payloads from the in-repo seed builder (`db/seed/readModels.ts`). - -## Conventions - -- IDs are stable slugs (e.g. `engineering`, `evm-dev-starter-kit`, `dato`). -- Timestamps are ISO strings. -- List endpoints return `{ items: [...] }` and may add cursors later. - -## Auth + gating - -Already implemented in `functions/api/*`: - -- `GET /api/health` → `{ ok: true, service: string, time: string }` -- `POST /api/auth/nonce` → `{ address }` → `{ nonce }` (+ `vortex_nonce` cookie) -- `POST /api/auth/verify` → `{ address, nonce, signature }` (+ `vortex_session` cookie) -- `POST /api/auth/logout` -- `GET /api/me` -- `GET /api/gate/status` - -Eligibility (v1): - -- The backend checks Humanode mainnet RPC and considers an address eligible if it is **active in `im_online`**. - -## Read endpoints (to implement next) - -These endpoints are implemented under `functions/api/*` and currently read from `read_models` (DB mode) or the inline seed (inline mode). - -### Chambers - -#### `GET /api/chambers` - -Returns the chambers directory cards. - -```ts -type ChamberPipelineDto = { pool: number; vote: number; build: number }; -type ChamberStatsDto = { - governors: string; - acm: string; - mcm: string; - lcm: string; -}; -type ChamberDto = { - id: string; - name: string; - multiplier: number; - stats: ChamberStatsDto; - pipeline: ChamberPipelineDto; -}; - -type GetChambersResponse = { items: ChamberDto[] }; -``` - -#### `GET /api/chambers/:id` - -Returns the chamber detail model. - -```ts -type ChamberProposalStageDto = "upcoming" | "live" | "ended"; -type ChamberProposalDto = { - id: string; - title: string; - meta: string; - summary: string; - lead: string; - nextStep: string; - timing: string; - stage: ChamberProposalStageDto; -}; - -type ChamberGovernorDto = { - id: string; - name: string; - tier: string; - focus: string; -}; -type ChamberThreadDto = { - id: string; - title: string; - author: string; - replies: number; - updated: string; -}; -type ChamberChatMessageDto = { id: string; author: string; message: string }; -type ChamberStageOptionDto = { value: ChamberProposalStageDto; label: string }; - -type GetChamberResponse = { - proposals: ChamberProposalDto[]; - governors: ChamberGovernorDto[]; - threads: ChamberThreadDto[]; - chatLog: ChamberChatMessageDto[]; - stageOptions: ChamberStageOptionDto[]; -}; -``` - -### Proposals (list) - -#### `GET /api/proposals?stage=pool|vote|build|draft` - -Returns the proposals page cards (collapsed/expanded content comes from this DTO). - -```ts -type ProposalStageDto = "draft" | "pool" | "vote" | "build"; -type ProposalToneDto = "ok" | "warn"; - -type ProposalStageDatumDto = { - title: string; - description: string; - value: string; - tone?: ProposalToneDto; -}; -type ProposalStatDto = { label: string; value: string }; - -type ProposalListItemDto = { - id: string; - title: string; - meta: string; - stage: ProposalStageDto; - summaryPill: string; - summary: string; - stageData: ProposalStageDatumDto[]; - stats: ProposalStatDto[]; - proposer: string; - proposerId: string; - chamber: string; - tier: "Nominee" | "Ecclesiast" | "Legate" | "Consul" | "Citizen"; - proofFocus: "pot" | "pod" | "pog"; - tags: string[]; - keywords: string[]; - date: string; - votes: number; - activityScore: number; - ctaPrimary: string; - ctaSecondary: string; -}; - -type GetProposalsResponse = { items: ProposalListItemDto[] }; -``` - -### Proposal pages - -These endpoints map 1:1 to the current stage pages in the UI. - -#### `GET /api/proposals/:id/pool` - -```ts -type InvisionInsightDto = { role: string; bullets: string[] }; - -type PoolProposalPageDto = { - title: string; - proposer: string; - proposerId: string; - chamber: string; - focus: string; - tier: string; - budget: string; - cooldown: string; - formationEligible: boolean; - teamSlots: string; - milestones: string; - upvotes: number; - downvotes: number; - attentionQuorum: number; // e.g. 0.2 - activeGovernors: number; // era baseline - upvoteFloor: number; - rules: string[]; - attachments: { id: string; title: string }[]; - teamLocked: { name: string; role: string }[]; - openSlotNeeds: { title: string; desc: string }[]; - milestonesDetail: { title: string; desc: string }[]; - summary: string; - overview: string; - executionPlan: string[]; - budgetScope: string; - invisionInsight: InvisionInsightDto; -}; -``` - -#### `GET /api/proposals/:id/chamber` - -```ts -type ChamberProposalPageDto = { - title: string; - proposer: string; - proposerId: string; - chamber: string; - budget: string; - formationEligible: boolean; - teamSlots: string; - milestones: string; - timeLeft: string; - votes: { yes: number; no: number; abstain: number }; - attentionQuorum: number; - passingRule: string; - engagedGovernors: number; - activeGovernors: number; - attachments: { id: string; title: string }[]; - teamLocked: { name: string; role: string }[]; - openSlotNeeds: { title: string; desc: string }[]; - milestonesDetail: { title: string; desc: string }[]; - summary: string; - overview: string; - executionPlan: string[]; - budgetScope: string; - invisionInsight: InvisionInsightDto; -}; -``` - -#### `GET /api/proposals/:id/formation` - -```ts -type FormationProposalPageDto = { - title: string; - chamber: string; - proposer: string; - proposerId: string; - budget: string; - timeLeft: string; - teamSlots: string; - milestones: string; - progress: string; - stageData: { title: string; description: string; value: string }[]; - stats: { label: string; value: string }[]; - lockedTeam: { name: string; role: string }[]; - openSlots: { title: string; desc: string }[]; - milestonesDetail: { title: string; desc: string }[]; - attachments: { id: string; title: string }[]; - summary: string; - overview: string; - executionPlan: string[]; - budgetScope: string; - invisionInsight: InvisionInsightDto; -}; -``` - -### Courts - -#### `GET /api/courts` - -```ts -type CourtCaseStatusDto = "jury" | "live" | "ended"; -type CourtCaseDto = { - id: string; - title: string; - subject: string; - triggeredBy: string; - status: CourtCaseStatusDto; - reports: number; - juryIds: string[]; - opened: string; // dd/mm/yyyy -}; - -type GetCourtsResponse = { items: CourtCaseDto[] }; -``` - -#### `GET /api/courts/:id` - -```ts -type CourtCaseDetailDto = CourtCaseDto & { - parties: { role: string; humanId: string; note?: string }[]; - proceedings: { claim: string; evidence: string[]; nextSteps: string[] }; -}; -``` - -### Human nodes - -#### `GET /api/humans` - -```ts -type HumanTierDto = "nominee" | "ecclesiast" | "legate" | "consul" | "citizen"; -type HumanNodeDto = { - id: string; - name: string; - role: string; - chamber: string; - factionId: string; - tier: HumanTierDto; - acm: number; - mm: number; - memberSince: string; - formationCapable?: boolean; - active: boolean; - formationProjectIds?: string[]; - tags: string[]; -}; - -type GetHumansResponse = { items: HumanNodeDto[] }; -``` - -#### `GET /api/humans/:id` - -Mirrors `src/data/mock/humanNodeProfiles.ts` but remains JSON-safe. - -```ts -type ProofKeyDto = "time" | "devotion" | "governance"; -type ProofSectionDto = { - title: string; - items: { label: string; value: string }[]; -}; -type HeroStatDto = { label: string; value: string }; -type QuickDetailDto = { label: string; value: string }; -type GovernanceActionDto = { - title: string; - action: string; - context: string; - detail: string; -}; -type HistoryItemDto = { - title: string; - action: string; - context: string; - detail: string; - date: string; -}; -type ProjectCardDto = { - title: string; - status: string; - summary: string; - chips: string[]; -}; - -type HumanNodeProfileDto = { - id: string; - name: string; - governorActive: boolean; - humanNodeActive: boolean; - governanceSummary: string; - heroStats: HeroStatDto[]; - quickDetails: QuickDetailDto[]; - proofSections: Record; - governanceActions: GovernanceActionDto[]; - projects: ProjectCardDto[]; - activity: HistoryItemDto[]; - history: string[]; -}; -``` - -### Feed - -#### `GET /api/feed?cursor=...&stage=...` - -```ts -type FeedStageDto = "pool" | "vote" | "build" | "courts" | "thread"; -type FeedToneDto = "ok" | "warn"; - -type FeedStageDatumDto = { - title: string; - description: string; - value: string; - tone?: FeedToneDto; -}; - -type FeedStatDto = { label: string; value: string }; - -type FeedItemDto = { - id: string; - title: string; - meta: string; - stage: FeedStageDto; - summaryPill: string; - summary: string; // plain text or Markdown - stageData?: FeedStageDatumDto[]; - stats?: FeedStatDto[]; - proposer?: string; - proposerId?: string; - ctaPrimary?: string; - ctaSecondary?: string; - href?: string; - timestamp: string; -}; - -type GetFeedResponse = { items: FeedItemDto[]; nextCursor?: string }; -``` diff --git a/docs/vortex-simulation-implementation-plan.md b/docs/vortex-simulation-implementation-plan.md deleted file mode 100644 index c7cdbad..0000000 --- a/docs/vortex-simulation-implementation-plan.md +++ /dev/null @@ -1,396 +0,0 @@ -# Vortex Simulation Backend — Implementation Plan (Step-by-step, aligned to current UI) - -This plan turns `docs/vortex-simulation-processes.md` + `docs/vortex-simulation-tech-architecture.md` into an executable roadmap. - -## Current status (what exists in the repo right now) - -Implemented (backend skeleton): - -- Cloudflare Pages Functions under `functions/` -- Minimal API routes: - - `GET /api/health` - - `POST /api/auth/nonce` (sets `vortex_nonce` cookie) - - `POST /api/auth/verify` (sets `vortex_session` cookie; real signature verification not implemented yet) - - `POST /api/auth/logout` - - `GET /api/me` - - `GET /api/gate/status` (eligibility is dev-stubbed for now) -- Cookie-signed nonce + session helpers (requires `SESSION_SECRET`) -- Dev toggles for local progress: - - `DEV_BYPASS_SIGNATURE`, `DEV_BYPASS_GATE`, `DEV_ELIGIBLE_ADDRESSES`, `DEV_INSECURE_COOKIES` -- Local dev notes: `docs/vortex-simulation-local-dev.md` -- Test harness + CI: - - `yarn test` (Node’s built-in test runner) - - CI runs `yarn test` via `.github/workflows/code.yml` - - API tests: `tests/api-*.test.js` -- v1 decisions + contracts: - - v1 constants: `docs/vortex-simulation-v1-constants.md` - - API contract: `docs/vortex-simulation-api-contract.md` - - DTO types: `src/types/api.ts` -- Postgres scaffolding (Phase 2c started): - - Drizzle config: `drizzle.config.ts` - - Schema: `db/schema.ts` - - Initial migration: `db/migrations/0000_nosy_mastermind.sql` - - Seed script: `scripts/db-seed.ts` (writes mock-equivalent payloads into `read_models`) - - Read endpoints (Phase 2c/4 bridge): `functions/api/chambers/*`, `functions/api/proposals/*`, `functions/api/courts/*`, `functions/api/humans/*` - - DB scripts: `yarn db:generate`, `yarn db:migrate`, `yarn db:seed` - - Seed tests: `tests/db-seed.test.js`, `tests/migrations.test.js` - -Not implemented yet: - -- Real signature verification (Proof A) -- Real on-chain eligibility verification (Proof B) via RPC (`im_online`) -- Feed reads (`GET /api/feed`) and event log + domain state machines + any write commands -- Normalized tables/projections beyond the transitional `read_models` bridge - -## Guiding principles - -- Ship a **thin vertical slice** first: auth → gate → read models → one write action → feed events. -- Keep domain logic **pure and shared** (state machine + events). The API is a thin adapter. -- Prefer **deterministic**, testable transitions; avoid “magic UI-only numbers”. -- Enforce gating on **every write**: “browse open, write gated”. -- Minimize UI churn: start by making API responses **match the shapes** currently provided by `src/data/mock/*`, then gradually improve. - -## Testing requirement (applies to every phase) - -We treat every phase as “done” only when tests are added and run. - -Testing layers we’ll use: - -1. **Unit tests** (pure TS): state machines, invariants, calculations (quorums, passing rules, tier rules). -2. **API integration tests**: call Pages Functions handlers with `Request` objects and assert status/JSON/cookies. -3. **DB integration tests** (once DB exists): migrations apply, basic queries work, constraints enforced. - -Test execution policy: - -- Add a `yarn test` script and run it after each feature batch. -- Keep CI in sync (extend `.github/workflows/code.yml` to run `yarn test` and `yarn build` once tests exist). - -Tooling note: - -- We will add a minimal test harness early (next phases) so we can test Pages Functions without relying on manual clicking. - -## Execution sequence (the phases we will implement, in order) - -This is the order we’ll follow from now on, based on what’s already landed. - -1. **Phase 0 — Lock v1 decisions (DONE)** -2. **Phase 1 — Freeze API contracts (DTOs) to match `src/data/mock/*` (DONE)** -3. **Phase 2a — API skeleton (DONE)** -4. **Phase 2b — Test harness for API + domain (DONE)** -5. **Phase 2c — DB skeleton + migrations + seed-from-mocks (IN PROGRESS)** -6. **Phase 3 — Auth + eligibility gate (real Proof A + Proof B)** -7. **Phase 4 — Read models first (Chambers/Proposals/Feed)** -8. **Phase 5 — Event log backbone** -9. **Phase 6 — First write slice (pool voting)** -10. **Phase 7 — Chamber vote + CM awarding** -11. **Phase 8 — Formation v1** -12. **Phase 9 — Courts v1** -13. **Phase 10 — Era rollups + tier statuses** -14. **Phase 11 — Hardening + moderation** - -## Phase 0 — Lock v1 decisions (required before DB + real gate) - -Locked for v1 (based on current decisions): - -1. Database: **Postgres** (Neon/Supabase). -2. Gating source: **Humanode mainnet RPC** (no Subscan dependency for v1). -3. Active Human Node rule: **active via the chain’s `im_online` pallet** (online reporting / heartbeat). -4. Era length: **configured by us off-chain** (a simulation constant), not a chain parameter. - -Deliverable: a short “v1 constants” section committed to docs or config. - -Tests: - -- None required (doc-only), but we must record decisions so later tests can assert exact thresholds/constants. - -## Phase 1 — Define contracts that mirror the UI (1–2 days) - -The UI is currently driven by `src/data/mock/*` (e.g. proposals list cards, proposal pages, chamber detail). Start by freezing the “API contract” so backend and frontend can meet in the middle. - -Contract location: - -- `docs/vortex-simulation-api-contract.md` (human-readable source of truth) -- `src/types/api.ts` (TS source of truth for DTOs) - -1. Define response DTOs that match the current UI needs: - - Chambers directory card: id/name/multiplier + stats + pipeline. - - Chamber detail: stage-filtered proposals + governors + threads/chat. - - Proposals list: the exact data currently rendered in collapsed/expanded cards. - - Proposal pages: PP / Chamber vote / Formation page models. - - Courts list + courtroom page model. - - Human nodes list + profile model. - - Feed item model (the card layout currently used). -2. Decide how IDs work across the system (proposalId, chamberId, humanId) and make them consistent. - -Deliverable: a short “API contract v1” section (types + endpoint list) that the backend must satisfy. - -Tests: - -- Add unit tests that validate DTO shapes against the existing mocks (smoke: “mock data can be encoded into the DTOs without loss”). - -## Phase 2a — API skeleton (DONE) - -Delivered in this repo: - -- Pages Functions routes: `health`, `auth`, `me`, `gate` -- Cookie-signed nonce/session (requires `SESSION_SECRET`) -- Dev bypass knobs while we build real auth/gate - -Tests (implemented): - -- `GET /api/health` returns `{ ok: true }`. -- `POST /api/auth/nonce` returns a nonce and sets a `vortex_nonce` cookie. -- `POST /api/auth/verify`: - - fails with 501 if `DEV_BYPASS_SIGNATURE` is false - - succeeds and sets `vortex_session` if bypass is enabled -- `GET /api/me` reflects authentication state -- `GET /api/gate/status` returns `not_authenticated` when logged out - -## Phase 2b — Test harness for API + domain (DONE) - -Implementation: - -- `tests/` folder + `yarn test` script are in place. -- Tests import Pages Functions handlers directly and exercise them with synthetic `Request` objects. -- CI runs `yarn test` (see `.github/workflows/code.yml`). - -## Phase 2c — DB skeleton (1–3 days) - -Implemented so far: - -1. Drizzle config + Postgres schema: - - `drizzle.config.ts` - - `db/schema.ts` - - generated migration under `db/migrations/` -2. Seed-from-mocks into `read_models`: - - `db/seed/readModels.ts` (pure seed builder) - - `scripts/db-seed.ts` - - `yarn db:seed` (requires `DATABASE_URL`) -3. Tests: - - `tests/migrations.test.js` asserts core tables are present in the migration. - - `tests/db-seed.test.js` asserts the seed is deterministic, unique-keyed, and JSON-safe. -4. Transitional read endpoints (Phase 2c/4 bridge): - - Read-model store: `functions/_lib/readModelsStore.ts` (DB mode via `DATABASE_URL` + inline mode via `READ_MODELS_INLINE=true`) - - Endpoints: `GET /api/chambers`, `GET /api/proposals`, `GET /api/courts`, `GET /api/humans` (+ per-entity detail routes) -5. Simulation clock (admin-only for advancement): - - `GET /api/clock` - - `POST /api/clock/advance-era` (requires `ADMIN_SECRET` via `x-admin-secret`, unless `DEV_BYPASS_ADMIN=true`) - -Ops checklist (to validate Phase 2c against a real DB): - -- Create a Postgres DB (v1: Neon) and set `DATABASE_URL`. -- Run: `yarn db:migrate && yarn db:seed`. -- Verify reads are served from Postgres by unsetting `READ_MODELS_INLINE`. - -Deliverable: deployed API that responds and can connect to the DB. - -Tests: - -- Migrations apply cleanly on a fresh DB. -- Seed job is idempotent (run twice yields the same IDs/state). -- Read endpoints return deterministic results from seeded data. - -## Phase 3 — Auth + eligibility gate (3–7 days) - -1. `POST /api/auth/nonce`: - - store nonce with expiry - - rate limit per IP/address -2. `POST /api/auth/verify`: - - verify signature - - create/find `users` row - - create session cookie/JWT -3. `GET /api/gate/status`: - - read session address - - query eligibility via RPC (`im_online` in v1) - - cache result with TTL (`eligibility_cache`) -4. Frontend wiring: - - add “Connect wallet / Verify” UI (even a simple modal is enough) - - disable all write buttons unless eligible (and show a short reason on hover) - - allow non-eligible users to browse everything - -Deliverable: users can log in; the UI knows if they’re eligible; buttons are blocked for non-eligible users. - -Tests: - -- Nonce expires; nonce is single-use. -- Signature verification passes for valid signatures and fails for invalid ones. -- Eligibility check caches with TTL and returns consistent `expiresAt`. -- Every write endpoint fails with 401 (no session) or 403 (not eligible). - -## Phase 4 — Read models first (3–8 days) - -Goal: replace `src/data/mock/*` progressively with API reads, with minimal UI refactor. - -1. `GET /api/chambers`: - - return chamber list + multipliers + computed stats - - match `src/data/mock/chambers.ts` shape first -2. `GET /api/proposals?stage=...`: - - return proposal list cards for the Proposals page - - match `src/data/mock/proposals.ts` shape first -3. `GET /api/proposals/:id`: - - return proposal page model for ProposalPP/Chamber/Formation - - match `src/data/mock/proposalPages.ts` getters first -4. `GET /api/feed?cursor=...`: - - return feed items derived from `events` - - match `src/data/mock/feed.tsx` shape first - -Frontend: - -- Create a tiny `src/lib/api.ts` fetch wrapper (base URL, typed helpers, error handling). -- Add `src/lib/useApiQuery.ts` (simple cache) or adopt TanStack Query later. -- Swap one page at a time from mock imports to API reads; keep a dev fallback flag if needed. - -Deliverable: app renders from backend reads (at least Chambers + Proposals + Feed). - -Tests: - -- DTO compatibility snapshot tests (API payload matches mock-driven shape). -- Pagination cursors stable (no duplicates/missing items across pages). - -## Phase 5 — Event log (feed) as the backbone (2–6 days) - -1. Create `events` table (append-only). -2. Define event types (union) and payload schemas (zod). -3. Implement a simple “projector”: - - basic derived feed cards from events - - cursors for pagination -4. Backfill initial events from seeded mock data (so the feed isn’t empty on day 1). - -Deliverable: feed is powered by real events; pages can also show histories from the event stream. - -Tests: - -- Events are append-only (no updates/deletes). -- Projector determinism: given the same event stream, derived feed cards are identical. - -## Phase 6 — First write slice: Proposal pool voting (4–10 days) - -1. Implement `POST /api/command` with: - - auth required - - gating required (`isActiveHumanNode`) - - idempotency key support -2. Implement `pool.vote` command: - - write pool vote with unique constraint (proposalId + userId) - - emit `pool.vote_cast` - - update/compute pool metrics - - if gates met, emit `proposal.moved_to_vote` + transition record -3. Frontend: - - ProposalPP page upvote/downvote calls API - - optimistic UI optional (but must reconcile) - -Deliverable: users can perform one real action (pool vote) and see it in metrics + feed. - -Tests: - -- One vote per user per proposal (idempotency + uniqueness). -- Pool metrics computed correctly from votes + era baselines. -- Stage transition triggers exactly once when thresholds are met. - -## Phase 7 — Chamber vote (decision) + CM awarding (5–14 days) - -1. Add `chamber.vote` command: - - yes/no/abstain - - quorum + passing rule evaluation - - emit events -2. On pass: - - transition to Formation if eligible - - award CM (LCM per chamber) and recompute derived ACM -3. Frontend: - - ProposalChamber becomes real - -Deliverable: end-to-end proposal lifecycle from pool → vote (pass/fail) is operational. - -Tests: - -- Vote constraints (one vote per user, valid choices). -- Quorum + passing calculation accuracy (including rounding rules like 66.6%). -- CM awarding updates LCM/MCM/ACM deterministically after acceptance. - -## Phase 8 — Formation v1 (execution) (5–14 days) - -1. Formation project row is created when proposal enters Formation. -2. `formation.join` fills team slots. -3. `formation.milestone.submit` records deliverables. -4. `formation.milestone.requestUnlock` emits an event; acceptance can be mocked initially. -5. Formation metrics and pages read from DB/events. - -Deliverable: Formation pages become real and emit feed events. - -Tests: - -- Team slots cannot exceed total. -- Milestone unlock rules enforced (cannot unlock before request; cannot double-unlock). - -## Phase 9 — Courts v1 (disputes) (5–14 days) - -1. `court.case.report` creates or increments cases. -2. Case state machine: Jury → Session live → Ended (driven by time or thresholds). -3. `court.case.verdict` records guilty/not-guilty. -4. Outcome hooks (v1): - - hold/release a milestone unlock request - - flag identity as “restricted” (simulation only) - -Deliverable: courts flow works and affects off-chain simulation outcomes. - -Tests: - -- Case state machine transitions are valid only. -- Verdict is single-per-user and only allowed in appropriate case states. -- Outcome hooks apply the intended flags (hold/release/restrict). - -## Phase 10 — Era rollups + tier statuses (ongoing) - -1. Implement cron rollup: - - freeze era action counts - - compute `isActiveGovernorNextEra` - - compute tier decay + statuses (Ahead/Stable/Falling behind/At risk/Losing status) - - update quorum baselines -2. Store `era_snapshots` and emit `era.rolled` events. - -Deliverable: system “moves” with time and feels like governance. - -Tests: - -- Rollup is deterministic and idempotent for a given era window. -- Tier status mapping (Ahead/Stable/Falling behind/At risk/Losing status) matches policy. - -## Phase 11 — Hardening + moderation (ongoing) - -- Rate limiting (per IP/address) and anti-spam (per-era quotas). -- Auditability: make all state transitions and changes event-backed. -- Admin tools: manual “advance era”, seed data, freeze/unfreeze. -- Observability: logs + basic metrics for rollups and gating failures. -- Moderation controls (off-chain): - - temporary action lock for a user - - court-driven restrictions flags (simulation) - -## Suggested implementation order (lowest risk / highest value) - -1. Auth + gate -2. Read models for Chambers + Proposals + Feed -3. Event log -4. Pool voting -5. Chamber voting + CM awarding -6. Formation -7. Courts -8. Era rollups + tier statuses - -## Milestone definition for “proto-vortex launch” - -Minimum viable proto-vortex for community: - -- Login with wallet signature -- Eligibility gate from mainnet -- Read-only browsing for all users -- Eligible users can: - - upvote/downvote in pool - - vote yes/no/abstain in chamber vote -- Feed shows real events -- Era rollup runs at least manually (admin endpoint) - -## Notes specific to the current UI - -- The UI already has the key surfaces for v1: - - `ProposalCreation` wizard (draft), ProposalPP (pool), ProposalChamber (vote), ProposalFormation (formation), Courts/Courtroom (courts). -- Start by returning API payloads that match the existing mock getters (`src/data/mock/proposalPages.ts`, etc.) to avoid rewriting UI components. -- Migrate page-by-page: keep visuals stable while the data source shifts from `src/data/mock/*` to `/api/*`. diff --git a/docs/vortex-simulation-local-dev.md b/docs/vortex-simulation-local-dev.md deleted file mode 100644 index fd9146e..0000000 --- a/docs/vortex-simulation-local-dev.md +++ /dev/null @@ -1,68 +0,0 @@ -# Vortex Simulation Backend — Local Dev (Pages Functions) - -This repo uses **Cloudflare Pages** for hosting. Backend endpoints live in **Pages Functions** under `functions/`. - -## Endpoints (current skeleton) - -- `GET /api/health` -- `POST /api/auth/nonce` → `{ address }` → `{ nonce }` (sets `vortex_nonce` cookie) -- `POST /api/auth/verify` → `{ address, nonce, signature }` (sets `vortex_session` cookie) -- `POST /api/auth/logout` -- `GET /api/me` -- `GET /api/gate/status` -- Read endpoints (Phase 2c/4 bridge; backed by `read_models`): - - `GET /api/chambers` - - `GET /api/chambers/:id` - - `GET /api/proposals?stage=...` - - `GET /api/proposals/:id/pool` - - `GET /api/proposals/:id/chamber` - - `GET /api/proposals/:id/formation` - - `GET /api/courts` - - `GET /api/courts/:id` - - `GET /api/humans` - - `GET /api/humans/:id` -- `GET /api/clock` (simulation time snapshot) -- `POST /api/clock/advance-era` (admin-only; increments era by 1) - -## Required env vars - -Pages Functions run server-side; configure these via `wrangler pages dev` (local) or Pages project settings (deploy). - -- `SESSION_SECRET` (required): used to sign `vortex_nonce` and `vortex_session` cookies. -- `DATABASE_URL` (required for Phase 2c+): Postgres connection string (v1 expects Neon-compatible serverless Postgres). -- `ADMIN_SECRET` (required for admin endpoints): must be provided via `x-admin-secret` header (unless `DEV_BYPASS_ADMIN=true`). - -## Dev-only toggles - -Until signature verification and chain gating are implemented: - -- `DEV_BYPASS_SIGNATURE=true` to accept any signature. -- `DEV_BYPASS_GATE=true` to mark any signed-in user as eligible. -- `DEV_ELIGIBLE_ADDRESSES=addr1,addr2,...` allowlist for eligibility when `DEV_BYPASS_GATE` is false. -- `DEV_INSECURE_COOKIES=true` to allow auth cookies over plain HTTP (local dev only). -- `READ_MODELS_INLINE=true` to serve read endpoints from the in-repo mock seed (no DB required). -- `DEV_BYPASS_ADMIN=true` to allow admin endpoints locally without `ADMIN_SECRET`. - -## Running locally (recommended) - -1. Build the frontend: `yarn build` -2. Serve Pages output + functions: - -`wrangler pages dev ./dist --compatibility-date=2024-11-01 --binding SESSION_SECRET=dev-secret --binding DEV_BYPASS_SIGNATURE=true --binding DEV_BYPASS_GATE=true` - -Then open the provided local URL and call endpoints under `/api/*`. - -Notes: - -- `rsbuild dev` does not run Pages Functions; use `wrangler pages dev` for API work. -- Prefer `wrangler pages dev ... --local-protocol=https` for local dev so cookies behave like production. -- If `wrangler` fails with a permissions error writing under `~/.config/.wrangler`, run with a writable config dir, e.g.: - - `XDG_CONFIG_HOME=$(pwd)/.config wrangler pages dev ...` - -## DB (Phase 2c) - -Once you have a Postgres DB, you can generate migrations and seed read-model payloads from today’s mocks: - -- Generate migrations: `yarn db:generate` -- Apply migrations: `yarn db:migrate` (requires `DATABASE_URL`) -- Seed from mocks into `read_models`: `yarn db:seed` (requires `DATABASE_URL`) diff --git a/docs/vortex-simulation-tech-architecture.md b/docs/vortex-simulation-tech-architecture.md deleted file mode 100644 index f25ed86..0000000 --- a/docs/vortex-simulation-tech-architecture.md +++ /dev/null @@ -1,323 +0,0 @@ -# Vortex Simulation Backend — Tech Architecture (Mapped to Processes) - -This document maps the domain workflows in `docs/vortex-simulation-processes.md` onto an implementable technical architecture. - -## 1) Stack (recommended) - -### Languages - -- **TypeScript** end-to-end (web + API + shared domain engine). -- **SQL** for persistent state and analytics. - -### Runtime + hosting - -- **Cloudflare Pages**: existing frontend hosting. -- **Cloudflare Workers**: API runtime (REST + optional SSE). -- **Cron Triggers**: era rollups / scheduled jobs. -- **Durable Objects (optional but recommended)**: race-free state transitions for voting/pool/court actions. - -### Database - -- **Chosen for v1: Postgres** (Neon or Supabase) for “proper” user history, analytics, and relational integrity. -- Optional later: D1 can be used for Cloudflare-only prototypes, but v1 should start on Postgres to avoid a DB migration during the community simulation. - -Important: because the API runtime is Cloudflare Workers/Pages Functions (edge), v1 should use a Postgres provider that supports **serverless/HTTP connectivity** from edge runtimes. - -- Recommended: **Neon Postgres** (works with `@neondatabase/serverless` + Drizzle). - -### Libraries / tools - -- **Drizzle ORM** (Postgres; can also target D1 if needed). -- **zod** (request validation). -- **viem** (or ethers) for signature verification and RPC reads. -- **wrangler** for Workers deployment (already in repo). - -### External reads (gating) - -- Humanode mainnet via **RPC** (v1). - -## 2) High-level architecture - -### Components - -- **Web app (React/TS/Tailwind)**: UI + calls API. -- **API (Worker)**: - - `auth`: nonce + signature verification - - `gate`: mainnet eligibility checks + TTL caching - - `commands`: apply state transitions (write operations) - - `reads`: serve derived views (feed, proposal pages, profiles) -- **Domain engine (shared TS module)**: - - pure functions implementing state machines, invariants, and event emission - - no network calls; no DB calls -- **DB**: - - canonical state (users, proposals, votes, courts, etc.) - - append-only event log (feed/audit) -- **Scheduler**: - - era boundary rollups (governor activity, quorums, tier statuses, CM updates) - -### Key principle: authoritative writes - -All state-changing actions go through the API and are validated against: - -1. signature-authenticated user session -2. eligibility gate (active human node) -3. domain invariants (stage constraints, one-vote rules, etc.) - -## 3) Suggested code modules (implementation shape) - -This repo is currently a single frontend app. The backend can live alongside it as: - -- `functions/api/*` (Pages Functions routes) -- `functions/_lib/*` (shared server helpers) -- `db/*` (Drizzle schema + migrations) -- `scripts/*` (seed/import jobs) -- `src/server/domain/*` (future: shared domain engine) - -If you later split into a monorepo, these become: - -- `packages/domain` -- `apps/api` -- `apps/web` - -## 4) API surface (v1) - -### Authentication - -- `POST /api/auth/nonce` → `{ address }` → `{ nonce }` -- `POST /api/auth/verify` → `{ address, nonce, signature }` → session cookie/JWT -- `POST /api/auth/logout` - -### Gating - -- `GET /api/gate/status` → `{ eligible: boolean, reason?: string, expiresAt: string }` - -Eligibility source (v1): - -- Query Humanode mainnet RPC for online status via the chain’s **`im_online` pallet**. - -### Reads - -- `GET /api/feed?cursor=...&stage=...` -- `GET /api/chambers` -- `GET /api/chambers/:id` -- `GET /api/proposals?stage=...` -- `GET /api/proposals/:id/pool` -- `GET /api/proposals/:id/chamber` -- `GET /api/proposals/:id/formation` -- `GET /api/courts` -- `GET /api/courts/:id` -- `GET /api/humans` -- `GET /api/humans/:id` -- `GET /api/me` (profile + eligibility snapshot) - -### Writes (commands) - -Prefer a single command endpoint so invariants are centralized: - -- `POST /api/command` → `{ type, payload, idempotencyKey? }` - -Examples: - -- `proposal.draft.save` -- `proposal.submitToPool` -- `pool.vote` (upvote/downvote) -- `chamber.vote` (yes/no/abstain + optional CM score) -- `formation.join` -- `formation.milestone.submit` -- `formation.milestone.requestUnlock` -- `court.case.report` -- `court.case.verdict` -- `delegation.set` -- `delegation.clear` - -## 5) Data model (tables) — minimal set - -These tables support the workflows and auditability; you can start lean and expand. - -### Identity / auth - -- `users` (account): `id`, `address`, `displayName`, `createdAt` -- `auth_nonces`: `address`, `nonce`, `expiresAt`, `usedAt` -- `sessions` (if not JWT-only): `id`, `userId`, `expiresAt` - -### Eligibility cache (mainnet gating) - -- `eligibility_cache`: - - `address` - - `isActiveHumanNode` (boolean) - - `checkedAt`, `checkedAtBlock?` - - `source` (`rpc`) - - `expiresAt` - - `reasonCode?` - -### Transitional read models (Phase 2c → Phase 4 bridge) - -To avoid rewriting the UI while we build normalized tables + an event log, we seed mock-equivalent payloads into a single table: - -- `read_models`: `{ key, payload (jsonb), updatedAt }` - -This allows early `GET /api/...` endpoints to serve the exact DTOs expected by `docs/vortex-simulation-api-contract.md` while we progressively replace `read_models` with real projections. - -### Governance time - -- `clock_state`: `currentEpoch`, `currentEra`, `updatedAt` -- `era_snapshots`: per-era aggregates (active governors, quorum baselines, etc.) -- `epoch_uptime`: optional (per address, per epoch/week) if you want Bioauth modeling - -### Chambers + membership - -- `chambers`: `id`, `name`, `multiplier` -- `chamber_membership`: `chamberId`, `userId`, `sinceEra` - -### CM / tiers - -- `cm_lcm`: (`userId`, `chamberId`, `lcm`) -- `tiers`: (`userId`, `tier`, `status`, `streaks`, `updatedAt`) - -### Proposals - -- `proposals`: `id`, `title`, `chamberId`, `stage`, `proposerUserId`, `createdAt`, `updatedAt` -- `proposal_drafts`: `proposalId`, structured form fields, `updatedAt` -- `pool_votes`: `proposalId`, `userId`, `direction` (+1/-1), `createdAt` -- `chamber_votes`: `proposalId`, `userId`, `vote` (yes/no/abstain), `cmScore?`, `createdAt` -- `proposal_stage_transitions`: `proposalId`, `fromStage`, `toStage`, `atEra`, `atTime` -- `proposal_attachments`: `proposalId`, `title`, `href` - -### Formation - -- `formation_projects`: `proposalId`, `stage`, `budgetTotal`, `budgetAllocated`, `teamSlotsTotal`, `teamSlotsFilled` -- `formation_team`: `proposalId`, `userId`, `role`, `status` -- `formation_milestones`: `proposalId`, `milestoneId`, `title`, `status`, `unlockAmount`, `acceptanceNotes` -- `formation_milestone_events`: submissions, disputes, unlock decisions - -### Courts - -- `court_cases`: `id`, `status`, `openedAt`, `subject`, `trigger`, `linkedEntityType`, `linkedEntityId` -- `court_reports`: `caseId`, `userId`, `createdAt` -- `court_evidence`: `caseId`, `title`, `href`, `addedByUserId`, `createdAt` -- `court_verdicts`: `caseId`, `userId`, `verdict`, `createdAt` -- `court_outcomes`: `caseId`, `result`, `recommendationsJson` - -### Delegation - -- `delegations`: `delegatorUserId`, `delegateeUserId`, `createdAt`, `revokedAt?` -- `delegation_events`: append-only changes for audit/courts - -### Feed / audit trail - -- `events` (append-only): - - `id`, `type`, `actorUserId?`, `entityType`, `entityId`, `payloadJson`, `createdAt` - -## 6) Mapping: processes → modules → APIs → tables/events - -This section maps each workflow from `docs/vortex-simulation-processes.md` to concrete tech. - -### 2.0 Authentication + gating - -- **Module:** `auth`, `gate` -- **API:** `/api/auth/nonce`, `/api/auth/verify`, `/api/gate/status` -- **Tables:** `users`, `auth_nonces`, `eligibility_cache` -- **Events:** `auth.logged_in`, `gate.checked` - -### 2.1 Onboarding (Human → Human Node → Governor) - -- **Module:** `identity`, `eligibility`, `tiers` -- **API:** `GET /api/me` (derived view), optional admin `POST /api/admin/sync-eligibility` -- **Tables:** `users`, `eligibility_cache`, `tiers` -- **Events:** `human.verified`, `human_node.eligible`, `tier.updated` - -### 2.2 Era rollup (cron) - -- **Module:** `governanceTime`, `tiers`, `cm`, `proposals`, `feed` -- **API:** none (cron-triggered), optional admin `POST /api/clock/advance-era` -- **Tables:** `clock_state`, `era_snapshots`, `tiers`, `cm_lcm`, `proposal_stage_transitions`, `events` -- **Events:** `era.rolled`, `quorum.baseline_updated`, `proposal.advanced` - -### 2.3 Proposal drafting (wizard) - -- **Module:** `proposals.draft` -- **API:** `POST /api/command` (`proposal.draft.save`, `proposal.submitToPool`) -- **Tables:** `proposal_drafts`, `proposals`, `proposal_stage_transitions`, `proposal_attachments` -- **Events:** `proposal.draft_saved`, `proposal.submitted_to_pool` - -### 2.4 Proposal pool (attention) - -- **Module:** `proposals.pool` -- **API:** `POST /api/command` (`pool.vote`) -- **Tables:** `pool_votes`, `events` -- **Derived:** pool quorum metrics computed from votes + era snapshot baselines -- **Events:** `pool.vote_cast`, `pool.quorum_met`, `proposal.moved_to_vote` - -### 2.5 Chamber vote (decision) - -- **Module:** `proposals.vote`, `cm` -- **API:** `POST /api/command` (`chamber.vote`) -- **Tables:** `chamber_votes`, `proposal_stage_transitions`, `cm_lcm` -- **Events:** `vote.cast`, `vote.quorum_met`, `proposal.passed`, `proposal.rejected`, `cm.awarded` - -### 2.6 Formation execution (projects) - -- **Module:** `formation` -- **API:** `POST /api/command` (`formation.join`, `formation.milestone.submit`, `formation.milestone.requestUnlock`) -- **Tables:** `formation_projects`, `formation_team`, `formation_milestones`, `formation_milestone_events` -- **Events:** `formation.joined`, `formation.milestone_submitted`, `formation.unlock_requested`, `formation.milestone_accepted` - -### 2.7 Courts (case lifecycle) - -- **Module:** `courts` -- **API:** `POST /api/command` (`court.case.report`, `court.case.verdict`, `court.evidence.add`) -- **Tables:** `court_cases`, `court_reports`, `court_evidence`, `court_verdicts`, `court_outcomes` -- **Events:** `court.case_opened`, `court.report_added`, `court.session_live`, `court.verdict_cast`, `court.case_closed` - -### 2.8 Delegation management - -- **Module:** `delegation` -- **API:** `POST /api/command` (`delegation.set`, `delegation.clear`) -- **Tables:** `delegations`, `delegation_events` -- **Events:** `delegation.set`, `delegation.cleared` - -### 2.9 Chambers directory + chamber detail - -- **Module:** `chambers` (read models) -- **API:** `GET /api/chambers`, `GET /api/chambers/:id` -- **Tables:** `chambers`, `chamber_membership`, `era_snapshots`, `cm_lcm`, plus proposal aggregates -- **Events:** none required (derived), but can emit `chamber.stats_updated` on rollup if you materialize. - -### 2.10 Invision insights - -- **Module:** `invision` (derived scoring) -- **API:** `GET /api/humans/:id` (includes insights) -- **Tables:** derived from `events`, proposals/courts/milestones; optionally `invision_snapshots` -- **Events:** `invision.updated` (optional) - -## 7) Concurrency + integrity (why Durable Objects may be needed) - -If multiple users vote at once, you must prevent: - -- double-voting -- inconsistent quorum counters -- stage transitions happening twice - -Two approaches: - -- **DB constraints + transactions** (Postgres can do this well). -- **Durable Object per entity** (proposal/case) that serializes commands. - -Recommendation: - -- Start with DB constraints + transactions. -- Add DOs for high-contention entities (popular proposals) or if you want simpler correctness in Worker code. - -## 8) Anti-abuse controls (even for eligible human nodes) - -- Per-era action limits (proposal submissions, reports, etc.) -- Idempotency keys for commands (client retries) -- Rate limiting per address (Worker middleware) -- Court/report spam prevention (minimum stake is out-of-scope unless you later add it as a simulated rule) - -## 9) Migration path from today’s mock data - -- Keep the frontend pages as-is but replace `src/data/mock/*` reads with API reads incrementally. -- Start with read-only endpoints (`/api/chambers`, `/api/proposals`, `/api/feed`). -- Add auth + gate + disable buttons unless eligible. -- Then enable write commands for pool/vote first (most visible). diff --git a/docs/vortex-simulation-v1-constants.md b/docs/vortex-simulation-v1-constants.md deleted file mode 100644 index 7c0e286..0000000 --- a/docs/vortex-simulation-v1-constants.md +++ /dev/null @@ -1,21 +0,0 @@ -# Vortex Simulation Backend — v1 Constants (Locked Decisions) - -This file records the **locked v1 decisions** from Phase 0 so later implementation and tests can treat them as source-of-truth. - -## Stack decisions - -- **Database:** Postgres (v1 recommendation: **Neon**, for edge/serverless connectivity) -- **On-chain read source:** Humanode mainnet RPC (no Subscan dependency for v1) -- **Eligibility (“active Human Node”):** active via the chain’s `im_online` pallet (online reporting / heartbeat) - -## Simulation time decisions - -- **Era length:** configured off-chain by the simulation (not a chain parameter) - - v1 value: **TBD** (we will set it as a constant/env var when adding the clock/rollups) - -## Current v1 progress checkpoints - -- Phase 2 (backend scaffold) exists in the repo (`functions/`, DB schema/migrations under `db/`, seed script under `scripts/`). -- Read endpoints exist and are wired to either: - - Postgres-backed reads from `read_models` (requires `DATABASE_URL` + `yarn db:migrate && yarn db:seed`), or - - Inline seed reads via `READ_MODELS_INLINE=true` (no DB required). diff --git a/functions/_lib/actionLocksStore.ts b/functions/_lib/actionLocksStore.ts new file mode 100644 index 0000000..993731c --- /dev/null +++ b/functions/_lib/actionLocksStore.ts @@ -0,0 +1,158 @@ +import { eq } from "drizzle-orm"; + +import { userActionLocks } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +export type ActionLock = { + address: string; + reason: string | null; + lockedUntil: string; +}; + +export type ActionLocksStore = { + getActiveLock: (address: string) => Promise; + listActiveLocks: () => Promise; + setLock: (input: { + address: string; + lockedUntil: Date; + reason?: string | null; + }) => Promise; + clearLock: (address: string) => Promise; +}; + +const memoryLocks = new Map< + string, + { lockedUntilMs: number; reason: string | null } +>(); + +function normalizeAddress(address: string): string { + return address.trim(); +} + +function nowMs(): number { + return Date.now(); +} + +export function createActionLocksStore(env: Env): ActionLocksStore { + if (!env.DATABASE_URL || env.READ_MODELS_INLINE === "true") { + return { + getActiveLock: async (address) => { + const normalized = normalizeAddress(address); + const lock = memoryLocks.get(normalized); + if (!lock) return null; + if (lock.lockedUntilMs <= nowMs()) return null; + return { + address: normalized, + reason: lock.reason, + lockedUntil: new Date(lock.lockedUntilMs).toISOString(), + }; + }, + listActiveLocks: async () => { + const now = nowMs(); + const result: ActionLock[] = []; + for (const [address, lock] of memoryLocks.entries()) { + if (lock.lockedUntilMs <= now) continue; + result.push({ + address, + reason: lock.reason, + lockedUntil: new Date(lock.lockedUntilMs).toISOString(), + }); + } + result.sort((a, b) => a.address.localeCompare(b.address)); + return result; + }, + setLock: async ({ address, lockedUntil, reason }) => { + const normalized = normalizeAddress(address); + memoryLocks.set(normalized, { + lockedUntilMs: lockedUntil.getTime(), + reason: reason ?? null, + }); + }, + clearLock: async (address) => { + const normalized = normalizeAddress(address); + memoryLocks.delete(normalized); + }, + }; + } + + const db = createDb(env); + + return { + getActiveLock: async (address) => { + const normalized = normalizeAddress(address); + const rows = await db + .select({ + address: userActionLocks.address, + reason: userActionLocks.reason, + lockedUntil: userActionLocks.lockedUntil, + }) + .from(userActionLocks) + .where(eq(userActionLocks.address, normalized)) + .limit(1); + const row = rows[0]; + if (!row) return null; + if (row.lockedUntil.getTime() <= nowMs()) return null; + return { + address: row.address, + reason: row.reason ?? null, + lockedUntil: row.lockedUntil.toISOString(), + }; + }, + listActiveLocks: async () => { + const now = new Date(); + const rows = await db + .select({ + address: userActionLocks.address, + reason: userActionLocks.reason, + lockedUntil: userActionLocks.lockedUntil, + }) + .from(userActionLocks); + const filtered = rows + .filter((r) => r.lockedUntil.getTime() > now.getTime()) + .map((r) => ({ + address: r.address, + reason: r.reason ?? null, + lockedUntil: r.lockedUntil.toISOString(), + })); + filtered.sort((a, b) => a.address.localeCompare(b.address)); + return filtered; + }, + setLock: async ({ address, lockedUntil, reason }) => { + const normalized = normalizeAddress(address); + const now = new Date(); + await db + .insert(userActionLocks) + .values({ + address: normalized, + lockedUntil, + reason: reason ?? null, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: userActionLocks.address, + set: { lockedUntil, reason: reason ?? null, updatedAt: now }, + }); + }, + clearLock: async (address) => { + const normalized = normalizeAddress(address); + await db + .delete(userActionLocks) + .where(eq(userActionLocks.address, normalized)); + }, + }; +} + +export function clearActionLocksForTests(): void { + memoryLocks.clear(); +} + +export async function setActionLockForTests(input: { + env: Env; + address: string; + lockedUntil: Date; + reason?: string | null; +}): Promise { + await createActionLocksStore(input.env).setLock(input); +} diff --git a/functions/_lib/address.ts b/functions/_lib/address.ts new file mode 100644 index 0000000..1e2fdd0 --- /dev/null +++ b/functions/_lib/address.ts @@ -0,0 +1,48 @@ +import { cryptoWaitReady, decodeAddress } from "@polkadot/util-crypto"; +import { u8aToHex } from "@polkadot/util"; +import { encodeAddress } from "@polkadot/util-crypto"; + +// Humanode mainnet SS58 format (produces `hm...`-prefixed addresses). +export const HUMANODE_SS58_FORMAT = 5234; + +export async function addressToPublicKeyHex( + address: string, +): Promise { + const trimmed = address.trim(); + if (!trimmed) return null; + await cryptoWaitReady(); + try { + return u8aToHex(decodeAddress(trimmed)); + } catch { + return null; + } +} + +export async function canonicalizeHmndAddress( + address: string, +): Promise { + const trimmed = address.trim(); + if (!trimmed) return null; + await cryptoWaitReady(); + try { + // decodeAddress accepts any SS58 format; re-encode into the Humanode prefix. + return encodeAddress(trimmed, HUMANODE_SS58_FORMAT); + } catch { + return null; + } +} + +export async function addressesReferToSameKey( + a: string, + b: string, +): Promise { + const left = a.trim(); + const right = b.trim(); + if (!left || !right) return false; + if (left === right) return true; + const [pkA, pkB] = await Promise.all([ + addressToPublicKeyHex(left), + addressToPublicKeyHex(right), + ]); + return Boolean(pkA && pkB && pkA === pkB); +} diff --git a/functions/_lib/adminAuditStore.ts b/functions/_lib/adminAuditStore.ts new file mode 100644 index 0000000..74d1926 --- /dev/null +++ b/functions/_lib/adminAuditStore.ts @@ -0,0 +1,108 @@ +import { and, desc, eq, lt } from "drizzle-orm"; + +import { events } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +export type AdminAuditAction = + | "user.lock" + | "user.unlock" + | "writes.freeze" + | "writes.unfreeze"; + +export type AdminAuditItem = { + id: string; + action: AdminAuditAction; + targetAddress: string; + lockedUntil?: string; + reason?: string | null; + timestamp: string; +}; + +type MemoryEntry = AdminAuditItem & { createdAtMs: number }; + +const memoryAudit: MemoryEntry[] = []; + +function normalizeAddress(address: string): string { + return address.trim(); +} + +export async function appendAdminAudit( + env: Env, + input: Omit & { + timestamp?: string; + }, +): Promise { + const timestamp = input.timestamp ?? new Date().toISOString(); + const targetAddress = normalizeAddress(input.targetAddress); + const id = `admin:${input.action}:${targetAddress}:${timestamp}`; + const item: AdminAuditItem = { + id, + action: input.action, + targetAddress, + ...(input.lockedUntil ? { lockedUntil: input.lockedUntil } : {}), + ...(input.reason !== undefined ? { reason: input.reason } : {}), + timestamp, + }; + + if (!env.DATABASE_URL) { + memoryAudit.push({ ...item, createdAtMs: Date.now() }); + return item; + } + + const db = createDb(env); + await db.insert(events).values({ + type: "admin.action.v1", + stage: null, + actorAddress: null, + entityType: "admin", + entityId: id, + payload: item, + createdAt: new Date(timestamp), + }); + + return item; +} + +export async function listAdminAudit( + env: Env, + input: { beforeSeq?: number | null; limit: number }, +): Promise<{ items: AdminAuditItem[]; nextSeq?: number }> { + if (!env.DATABASE_URL) { + const sorted = [...memoryAudit].sort( + (a, b) => b.createdAtMs - a.createdAtMs, + ); + const page = sorted + .slice(0, input.limit) + .map(({ createdAtMs: _ms, ...rest }) => rest); + return { items: page }; + } + + const db = createDb(env); + const beforeSeq = input.beforeSeq; + const hasBeforeSeq = beforeSeq !== undefined && beforeSeq !== null; + const whereClause = hasBeforeSeq + ? and( + eq(events.type, "admin.action.v1"), + lt(events.seq, Math.max(0, beforeSeq)), + ) + : eq(events.type, "admin.action.v1"); + + const ordered = await db + .select({ seq: events.seq, payload: events.payload }) + .from(events) + .where(whereClause) + .orderBy(desc(events.seq)) + .limit(input.limit + 1); + const slice = ordered.slice(0, input.limit); + const items = slice.map((r) => r.payload as AdminAuditItem); + const nextSeq = + ordered.length > input.limit ? ordered[input.limit]?.seq : undefined; + + return nextSeq !== undefined ? { items, nextSeq } : { items }; +} + +export function clearAdminAuditForTests(): void { + memoryAudit.length = 0; +} diff --git a/functions/_lib/adminStateStore.ts b/functions/_lib/adminStateStore.ts new file mode 100644 index 0000000..7eaeb5d --- /dev/null +++ b/functions/_lib/adminStateStore.ts @@ -0,0 +1,64 @@ +import { eq } from "drizzle-orm"; + +import { adminState } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +export type AdminStateSnapshot = { + writesFrozen: boolean; +}; + +export type AdminStateStore = { + get: () => Promise; + setWritesFrozen: (writesFrozen: boolean) => Promise; +}; + +let memoryWritesFrozen = false; + +export function createAdminStateStore(env: Env): AdminStateStore { + if (!env.DATABASE_URL || env.READ_MODELS_INLINE === "true") { + return { + get: async () => ({ writesFrozen: memoryWritesFrozen }), + setWritesFrozen: async (writesFrozen) => { + memoryWritesFrozen = writesFrozen; + }, + }; + } + + const db = createDb(env); + const rowId = 1; + + async function ensureRow(): Promise { + await db + .insert(adminState) + .values({ id: rowId, writesFrozen: false }) + .onConflictDoNothing(); + } + + return { + get: async () => { + await ensureRow(); + const rows = await db + .select({ writesFrozen: adminState.writesFrozen }) + .from(adminState) + .where(eq(adminState.id, rowId)) + .limit(1); + return { writesFrozen: rows[0]?.writesFrozen ?? false }; + }, + setWritesFrozen: async (writesFrozen) => { + await ensureRow(); + await db + .insert(adminState) + .values({ id: rowId, writesFrozen, updatedAt: new Date() }) + .onConflictDoUpdate({ + target: adminState.id, + set: { writesFrozen, updatedAt: new Date() }, + }); + }, + }; +} + +export function clearAdminStateForTests(): void { + memoryWritesFrozen = false; +} diff --git a/functions/_lib/apiRateLimitStore.ts b/functions/_lib/apiRateLimitStore.ts new file mode 100644 index 0000000..e32f039 --- /dev/null +++ b/functions/_lib/apiRateLimitStore.ts @@ -0,0 +1,124 @@ +import { eq } from "drizzle-orm"; + +import { apiRateLimits } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; +import { envInt } from "./env.ts"; + +type Env = Record; + +type ConsumeInput = { + bucket: string; + limit: number; + windowSeconds: number; +}; + +type ConsumeResult = + | { ok: true; remaining: number; resetAt: string } + | { ok: false; retryAfterSeconds: number; resetAt: string }; + +export type ApiRateLimitStore = { + consume: (input: ConsumeInput) => Promise; +}; + +const memoryBuckets = new Map(); + +function nowMs(): number { + return Date.now(); +} + +function consumeFromMemory(input: ConsumeInput): ConsumeResult { + const key = input.bucket; + const now = nowMs(); + const windowMs = input.windowSeconds * 1000; + const current = memoryBuckets.get(key); + const resetAtMs = + current && current.resetAtMs > now ? current.resetAtMs : now + windowMs; + const count = current && current.resetAtMs > now ? current.count : 0; + const nextCount = count + 1; + + memoryBuckets.set(key, { count: nextCount, resetAtMs }); + + const resetAt = new Date(resetAtMs).toISOString(); + if (nextCount > input.limit) { + const retryAfterSeconds = Math.max(1, Math.ceil((resetAtMs - now) / 1000)); + return { ok: false, retryAfterSeconds, resetAt }; + } + + return { ok: true, remaining: Math.max(0, input.limit - nextCount), resetAt }; +} + +export function createApiRateLimitStore(env: Env): ApiRateLimitStore { + if (!env.DATABASE_URL || env.READ_MODELS_INLINE === "true") { + return { + consume: async (input) => consumeFromMemory(input), + }; + } + + const db = createDb(env); + + return { + consume: async (input) => { + const now = new Date(); + const windowMs = input.windowSeconds * 1000; + + const rows = await db + .select({ + bucket: apiRateLimits.bucket, + count: apiRateLimits.count, + resetAt: apiRateLimits.resetAt, + }) + .from(apiRateLimits) + .where(eq(apiRateLimits.bucket, input.bucket)) + .limit(1); + const row = rows[0]; + + const active = row && row.resetAt.getTime() > now.getTime(); + const nextResetAt = active + ? row.resetAt + : new Date(now.getTime() + windowMs); + const nextCount = (active ? row.count : 0) + 1; + + await db + .insert(apiRateLimits) + .values({ + bucket: input.bucket, + count: nextCount, + resetAt: nextResetAt, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: apiRateLimits.bucket, + set: { count: nextCount, resetAt: nextResetAt, updatedAt: now }, + }); + + const resetAtIso = nextResetAt.toISOString(); + if (nextCount > input.limit) { + const retryAfterSeconds = Math.max( + 1, + Math.ceil((nextResetAt.getTime() - now.getTime()) / 1000), + ); + return { ok: false, retryAfterSeconds, resetAt: resetAtIso }; + } + return { + ok: true, + remaining: Math.max(0, input.limit - nextCount), + resetAt: resetAtIso, + }; + }, + }; +} + +export function getCommandRateLimitConfig(env: Env): { + perIpPerMinute: number; + perAddressPerMinute: number; +} { + return { + perIpPerMinute: envInt(env, "SIM_COMMAND_RATE_LIMIT_PER_MINUTE_IP") ?? 180, + perAddressPerMinute: + envInt(env, "SIM_COMMAND_RATE_LIMIT_PER_MINUTE_ADDRESS") ?? 60, + }; +} + +export function clearApiRateLimitsForTests(): void { + memoryBuckets.clear(); +} diff --git a/functions/_lib/appendEvents.ts b/functions/_lib/appendEvents.ts new file mode 100644 index 0000000..fffb978 --- /dev/null +++ b/functions/_lib/appendEvents.ts @@ -0,0 +1,82 @@ +import { and, eq } from "drizzle-orm"; + +import { events } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; +import { feedItemSchema, type FeedItemEventPayload } from "./eventSchemas.ts"; +import { + appendMemoryFeedEvent, + hasMemoryFeedEvent, +} from "./feedEventsMemory.ts"; + +type Env = Record; + +export async function feedItemEventExists( + env: Env, + input: { entityType: string; entityId: string }, +): Promise { + if (!env.DATABASE_URL) { + return hasMemoryFeedEvent(input); + } + + const db = createDb(env); + const rows = await db + .select({ seq: events.seq }) + .from(events) + .where( + and( + eq(events.type, "feed.item.v1"), + eq(events.entityType, input.entityType), + eq(events.entityId, input.entityId), + ), + ) + .limit(1); + return rows.length > 0; +} + +export async function appendFeedItemEvent( + env: Env, + input: { + stage: FeedItemEventPayload["stage"]; + actorAddress?: string; + entityType: string; + entityId: string; + payload: FeedItemEventPayload; + }, +): Promise { + const payload = feedItemSchema.parse(input.payload); + + if (!env.DATABASE_URL) { + appendMemoryFeedEvent({ + stage: input.stage, + actorAddress: input.actorAddress ?? null, + entityType: input.entityType, + entityId: input.entityId, + payload, + }); + return; + } + + const db = createDb(env); + await db.insert(events).values({ + type: "feed.item.v1", + stage: input.stage, + actorAddress: input.actorAddress ?? null, + entityType: input.entityType, + entityId: input.entityId, + payload, + createdAt: new Date(payload.timestamp), + }); +} + +export async function appendFeedItemEventOnce( + env: Env, + input: Parameters[1], +): Promise { + const exists = await feedItemEventExists(env, { + entityType: input.entityType, + entityId: input.entityId, + }); + if (exists) return false; + await appendFeedItemEvent(env, input); + return true; +} diff --git a/functions/_lib/auth.ts b/functions/_lib/auth.ts index 89ce1c5..a522674 100644 --- a/functions/_lib/auth.ts +++ b/functions/_lib/auth.ts @@ -1,5 +1,5 @@ import { parseCookieHeader, serializeCookie } from "./cookies.ts"; -import { envBoolean, envCsv, envString } from "./env.ts"; +import { envBoolean, envString } from "./env.ts"; import { randomHex } from "./random.ts"; import { signToken, verifyToken } from "./tokens.ts"; @@ -43,7 +43,7 @@ export async function issueNonce( env: Env, requestUrl: string, address: string, -): Promise<{ nonce: string }> { +): Promise<{ nonce: string; expiresAt: number }> { const secret = getSessionSecret(env); const nonce = randomHex(16); const issuedAt = Date.now(); @@ -66,7 +66,7 @@ export async function issueNonce( }), ); - return { nonce }; + return { nonce, expiresAt }; } export async function verifyNonceCookie( @@ -82,9 +82,15 @@ export async function verifyNonceCookie( if (!payload) return null; if (typeof payload.address !== "string") return null; if (typeof payload.nonce !== "string") return null; + if (typeof payload.issuedAt !== "number") return null; if (typeof payload.expiresAt !== "number") return null; if (Date.now() > payload.expiresAt) return null; - return payload as unknown as NonceToken; + return { + address: payload.address, + nonce: payload.nonce, + issuedAt: payload.issuedAt, + expiresAt: payload.expiresAt, + }; } export async function issueSession( @@ -142,9 +148,14 @@ export async function readSession( const payload = await verifyToken(token, secret); if (!payload) return null; if (typeof payload.address !== "string") return null; + if (typeof payload.issuedAt !== "number") return null; if (typeof payload.expiresAt !== "number") return null; if (Date.now() > payload.expiresAt) return null; - return payload as unknown as Session; + return { + address: payload.address, + issuedAt: payload.issuedAt, + expiresAt: payload.expiresAt, + }; } export type GateResult = { @@ -152,23 +163,3 @@ export type GateResult = { reason?: string; expiresAt: string; }; - -export async function checkEligibility( - env: Env, - address: string, -): Promise { - const eligibleAddresses = new Set( - envCsv(env, "DEV_ELIGIBLE_ADDRESSES").map((a) => a.toLowerCase()), - ); - const now = Date.now(); - const ttlMs = 10 * 60_000; - const expiresAt = new Date(now + ttlMs).toISOString(); - - if (envBoolean(env, "DEV_BYPASS_GATE")) { - return { eligible: true, expiresAt }; - } - - if (eligibleAddresses.has(address.toLowerCase())) - return { eligible: true, expiresAt }; - return { eligible: false, reason: "not_eligible", expiresAt }; -} diff --git a/functions/_lib/base64url.ts b/functions/_lib/base64url.ts index fea5c31..8ebc2cf 100644 --- a/functions/_lib/base64url.ts +++ b/functions/_lib/base64url.ts @@ -2,13 +2,13 @@ export function base64UrlEncode(input: Uint8Array): string { let binary = ""; for (const byte of input) binary += String.fromCharCode(byte); const base64 = btoa(binary); - return base64.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } export function base64UrlDecode(input: string): Uint8Array { const base64 = input - .replaceAll("-", "+") - .replaceAll("_", "/") + .replace(/-/g, "+") + .replace(/_/g, "/") .padEnd(input.length + ((4 - (input.length % 4)) % 4), "="); const binary = atob(base64); const bytes = new Uint8Array(binary.length); diff --git a/functions/_lib/chamberActiveDenominators.ts b/functions/_lib/chamberActiveDenominators.ts new file mode 100644 index 0000000..a0b778b --- /dev/null +++ b/functions/_lib/chamberActiveDenominators.ts @@ -0,0 +1,95 @@ +import { and, eq } from "drizzle-orm"; + +import { eraRollups, eraUserStatus } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; +import { createClockStore } from "./clockStore.ts"; +import { + listAllChamberMembers, + listChamberMembers, +} from "./chamberMembershipsStore.ts"; +import { getActiveAddressesForNextEraFromRollup } from "./eraRollupStore.ts"; + +type Env = Record; + +export async function getEligibleGovernorAddressesForChamber( + env: Env, + input: { chamberId: string; genesisMembers?: string[] | null }, +): Promise> { + const chamberId = input.chamberId.trim().toLowerCase(); + const out = new Set(); + + const members = + chamberId === "general" + ? await listAllChamberMembers(env) + : await listChamberMembers(env, chamberId); + for (const address of members) out.add(address.trim()); + + for (const address of input.genesisMembers ?? []) out.add(address.trim()); + + out.delete(""); + return out; +} + +export async function getActiveGovernorAddressesForCurrentEra( + env: Env, +): Promise | null> { + const clock = createClockStore(env); + const { currentEra } = await clock.get(); + const priorEra = currentEra - 1; + if (priorEra < 0) return null; + + if (!env.DATABASE_URL) { + return getActiveAddressesForNextEraFromRollup(env, { era: priorEra }); + } + + const db = createDb(env); + const rollupExists = await db + .select({ era: eraRollups.era }) + .from(eraRollups) + .where(eq(eraRollups.era, priorEra)) + .limit(1); + if (!rollupExists[0]) return null; + + const rows = await db + .select({ address: eraUserStatus.address }) + .from(eraUserStatus) + .where( + and( + eq(eraUserStatus.era, priorEra), + eq(eraUserStatus.isActiveNextEra, true), + ), + ); + + return new Set(rows.map((r) => r.address.trim()).filter(Boolean)); +} + +export async function getActiveGovernorsDenominatorForChamberCurrentEra( + env: Env, + input: { + chamberId: string; + fallbackActiveGovernors: number; + genesisMembers?: string[] | null; + }, +): Promise { + const chamberId = input.chamberId.trim().toLowerCase(); + + const eligible = await getEligibleGovernorAddressesForChamber(env, { + chamberId, + genesisMembers: input.genesisMembers ?? null, + }); + + const eligibleCount = eligible.size; + if (eligibleCount === 0) return 0; + + const activeSet = await getActiveGovernorAddressesForCurrentEra(env); + if (!activeSet) { + return Math.max(0, Math.min(input.fallbackActiveGovernors, eligibleCount)); + } + + let activeInChamber = 0; + for (const address of activeSet) { + if (eligible.has(address.trim())) activeInChamber += 1; + } + + return Math.max(0, Math.min(activeInChamber, eligibleCount)); +} diff --git a/functions/_lib/chamberMembershipsStore.ts b/functions/_lib/chamberMembershipsStore.ts new file mode 100644 index 0000000..39666ef --- /dev/null +++ b/functions/_lib/chamberMembershipsStore.ts @@ -0,0 +1,174 @@ +import { and, eq, sql } from "drizzle-orm"; + +import { chamberMemberships } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +const memoryByAddress = new Map>(); + +function normalizeChamberId(value: string): string { + return value.trim().toLowerCase(); +} + +function normalizeAddress(value: string): string { + return value.trim(); +} + +export async function hasChamberMembership( + env: Env, + input: { address: string; chamberId: string }, +): Promise { + const address = normalizeAddress(input.address); + const chamberId = normalizeChamberId(input.chamberId); + if (!env.DATABASE_URL) { + const chambers = memoryByAddress.get(address); + if (!chambers) return false; + return chambers.has(chamberId); + } + + const db = createDb(env); + const rows = await db + .select({ chamberId: chamberMemberships.chamberId }) + .from(chamberMemberships) + .where( + and( + eq(chamberMemberships.address, address), + eq(chamberMemberships.chamberId, chamberId), + ), + ) + .limit(1); + return rows.length > 0; +} + +export async function hasAnyChamberMembership( + env: Env, + addressInput: string, +): Promise { + const address = normalizeAddress(addressInput); + if (!env.DATABASE_URL) { + const chambers = memoryByAddress.get(address); + return Boolean(chambers && chambers.size > 0); + } + + const db = createDb(env); + const rows = await db + .select({ n: sql`count(*)` }) + .from(chamberMemberships) + .where(eq(chamberMemberships.address, address)) + .limit(1); + return Number(rows[0]?.n ?? 0) > 0; +} + +export async function listChamberMemberships( + env: Env, + addressInput: string, +): Promise { + const address = normalizeAddress(addressInput); + if (!env.DATABASE_URL) { + return Array.from(memoryByAddress.get(address) ?? []).sort(); + } + + const db = createDb(env); + const rows = await db + .select({ chamberId: chamberMemberships.chamberId }) + .from(chamberMemberships) + .where(eq(chamberMemberships.address, address)); + return rows.map((r) => r.chamberId).sort(); +} + +export async function listChamberMembers( + env: Env, + chamberIdInput: string, +): Promise { + const chamberId = normalizeChamberId(chamberIdInput); + if (!env.DATABASE_URL) { + const members: string[] = []; + for (const [address, chambers] of memoryByAddress.entries()) { + if (chambers.has(chamberId)) members.push(address); + } + return members.sort(); + } + + const db = createDb(env); + const rows = await db + .select({ address: chamberMemberships.address }) + .from(chamberMemberships) + .where(eq(chamberMemberships.chamberId, chamberId)); + return rows.map((r) => r.address).sort(); +} + +export async function listAllChamberMembers(env: Env): Promise { + if (!env.DATABASE_URL) { + return Array.from(memoryByAddress.keys()).sort(); + } + + const db = createDb(env); + const rows = await db + .select({ address: chamberMemberships.address }) + .from(chamberMemberships) + .groupBy(chamberMemberships.address); + return rows.map((r) => r.address).sort(); +} + +export async function ensureChamberMembership( + env: Env, + input: { + address: string; + chamberId: string; + grantedByProposalId?: string | null; + source?: string; + }, +): Promise { + const address = normalizeAddress(input.address); + const chamberId = normalizeChamberId(input.chamberId); + const source = + (input.source ?? "accepted_proposal").trim() || "accepted_proposal"; + + if (!env.DATABASE_URL) { + const chambers = memoryByAddress.get(address) ?? new Set(); + chambers.add(chamberId); + memoryByAddress.set(address, chambers); + return; + } + + const db = createDb(env); + await db + .insert(chamberMemberships) + .values({ + address, + chamberId, + grantedByProposalId: input.grantedByProposalId ?? null, + source, + createdAt: new Date(), + }) + .onConflictDoNothing({ + target: [chamberMemberships.chamberId, chamberMemberships.address], + }); +} + +export async function grantVotingEligibilityForAcceptedProposal( + env: Env, + input: { address: string; chamberId: string | null; proposalId: string }, +): Promise { + await ensureChamberMembership(env, { + address: input.address, + chamberId: "general", + grantedByProposalId: input.proposalId, + source: "accepted_proposal", + }); + + const chamberId = normalizeChamberId(input.chamberId ?? ""); + if (chamberId && chamberId !== "general") { + await ensureChamberMembership(env, { + address: input.address, + chamberId, + grantedByProposalId: input.proposalId, + source: "accepted_proposal", + }); + } +} + +export function clearChamberMembershipsForTests(): void { + memoryByAddress.clear(); +} diff --git a/functions/_lib/chamberMultiplierSubmissionsStore.ts b/functions/_lib/chamberMultiplierSubmissionsStore.ts new file mode 100644 index 0000000..27370ea --- /dev/null +++ b/functions/_lib/chamberMultiplierSubmissionsStore.ts @@ -0,0 +1,142 @@ +import { and, eq, sql } from "drizzle-orm"; + +import { chamberMultiplierSubmissions } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +export type ChamberMultiplierSubmission = { + chamberId: string; + voterAddress: string; + multiplierTimes10: number; + createdAt: Date; + updatedAt: Date; +}; + +const memory = new Map(); + +function keyFor(chamberId: string, voterAddress: string): string { + return `${chamberId.trim().toLowerCase()}:${voterAddress.trim()}`; +} + +export async function upsertChamberMultiplierSubmission( + env: Env, + input: { + chamberId: string; + voterAddress: string; + multiplierTimes10: number; + }, +): Promise<{ submission: ChamberMultiplierSubmission; created: boolean }> { + const chamberId = input.chamberId.trim().toLowerCase(); + const voterAddress = input.voterAddress.trim(); + const multiplierTimes10 = Math.floor(input.multiplierTimes10); + + const now = new Date(); + + if (!env.DATABASE_URL) { + const k = keyFor(chamberId, voterAddress); + const existing = memory.get(k); + if (existing) { + const next: ChamberMultiplierSubmission = { + ...existing, + multiplierTimes10, + updatedAt: now, + }; + memory.set(k, next); + return { submission: next, created: false }; + } + const created: ChamberMultiplierSubmission = { + chamberId, + voterAddress, + multiplierTimes10, + createdAt: now, + updatedAt: now, + }; + memory.set(k, created); + return { submission: created, created: true }; + } + + const db = createDb(env); + + const existingRows = await db + .select({ + chamberId: chamberMultiplierSubmissions.chamberId, + voterAddress: chamberMultiplierSubmissions.voterAddress, + multiplierTimes10: chamberMultiplierSubmissions.multiplierTimes10, + createdAt: chamberMultiplierSubmissions.createdAt, + updatedAt: chamberMultiplierSubmissions.updatedAt, + }) + .from(chamberMultiplierSubmissions) + .where( + and( + eq(chamberMultiplierSubmissions.chamberId, chamberId), + eq(chamberMultiplierSubmissions.voterAddress, voterAddress), + ), + ) + .limit(1); + + await db + .insert(chamberMultiplierSubmissions) + .values({ + chamberId, + voterAddress, + multiplierTimes10, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + chamberMultiplierSubmissions.chamberId, + chamberMultiplierSubmissions.voterAddress, + ], + set: { multiplierTimes10, updatedAt: now }, + }); + + const existing = existingRows[0] ?? null; + const submission: ChamberMultiplierSubmission = { + chamberId, + voterAddress, + multiplierTimes10, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + return { submission, created: existing === null }; +} + +export async function getChamberMultiplierAggregate( + env: Env, + input: { chamberId: string }, +): Promise<{ submissions: number; avgTimes10: number | null }> { + const chamberId = input.chamberId.trim().toLowerCase(); + + if (!env.DATABASE_URL) { + const rows = Array.from(memory.values()).filter( + (row) => row.chamberId === chamberId, + ); + if (rows.length === 0) return { submissions: 0, avgTimes10: null }; + const sum = rows.reduce((acc, row) => acc + row.multiplierTimes10, 0); + const avg = Math.round(sum / rows.length); + return { submissions: rows.length, avgTimes10: avg }; + } + + const db = createDb(env); + const rows = await db + .select({ + count: sql`count(*)`, + avg: sql`avg(${chamberMultiplierSubmissions.multiplierTimes10})`, + }) + .from(chamberMultiplierSubmissions) + .where(eq(chamberMultiplierSubmissions.chamberId, chamberId)) + .limit(1); + + const count = Number(rows[0]?.count ?? 0); + if (count === 0) return { submissions: 0, avgTimes10: null }; + const avg = rows[0]?.avg; + const rounded = Number.isFinite(Number(avg)) ? Math.round(Number(avg)) : null; + return { submissions: count, avgTimes10: rounded }; +} + +export function clearChamberMultiplierSubmissionsForTests(): void { + memory.clear(); +} diff --git a/functions/_lib/chamberQuorum.ts b/functions/_lib/chamberQuorum.ts new file mode 100644 index 0000000..dfe281a --- /dev/null +++ b/functions/_lib/chamberQuorum.ts @@ -0,0 +1,55 @@ +export type ChamberQuorumInputs = { + quorumFraction: number; // fraction, e.g. 0.33 + activeGovernors: number; // denominator + passingFraction: number; // fraction, e.g. 2/3 + minQuorum?: number; // absolute minimum engaged governors (optional) +}; + +export type ChamberCounts = { yes: number; no: number; abstain: number }; + +export type ChamberQuorumResult = { + engaged: number; + quorumNeeded: number; + quorumMet: boolean; + yesFraction: number; + passMet: boolean; + shouldAdvance: boolean; +}; + +export function evaluateChamberQuorum( + inputs: ChamberQuorumInputs, + counts: ChamberCounts, +): ChamberQuorumResult { + const active = Math.max(0, Math.floor(inputs.activeGovernors)); + const quorumFraction = Math.max(0, Math.min(1, inputs.quorumFraction)); + const passingFraction = Math.max(0, Math.min(1, inputs.passingFraction)); + const minQuorum = Math.min( + active, + Math.max(0, Math.floor(inputs.minQuorum ?? 0)), + ); + + const yes = Math.max(0, counts.yes); + const no = Math.max(0, counts.no); + const abstain = Math.max(0, counts.abstain); + const engaged = yes + no + abstain; + + const quorumNeeded = + active > 0 ? Math.max(minQuorum, Math.ceil(active * quorumFraction)) : 0; + const quorumMet = active > 0 ? engaged >= quorumNeeded : false; + + const yesFraction = engaged > 0 ? yes / engaged : 0; + // Passing rule: strict supermajority (e.g. 66.6% + 1 yes vote within quorum). + // In discrete votes this means: yes > passingFraction * engaged. + const passNeeded = + engaged > 0 ? Math.floor(engaged * passingFraction) + 1 : 0; + const passMet = engaged > 0 ? yes >= passNeeded : false; + + return { + engaged, + quorumNeeded, + quorumMet, + yesFraction, + passMet, + shouldAdvance: quorumMet && passMet, + }; +} diff --git a/functions/_lib/chamberVotesStore.ts b/functions/_lib/chamberVotesStore.ts new file mode 100644 index 0000000..68fff6f --- /dev/null +++ b/functions/_lib/chamberVotesStore.ts @@ -0,0 +1,255 @@ +import { and, eq, sql } from "drizzle-orm"; + +import { chamberVotes } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; +import { getDelegationWeightsForChamber } from "./delegationsStore.ts"; + +type Env = Record; + +export type ChamberVoteChoice = 1 | 0 | -1; + +export type ChamberVoteCounts = { + yes: number; + no: number; + abstain: number; +}; + +type StoredChamberVote = { choice: ChamberVoteChoice; score?: number | null }; +const memoryVotes = new Map>(); + +export async function hasChamberVote( + env: Env, + input: { proposalId: string; voterAddress: string }, +): Promise { + const voterAddress = input.voterAddress.trim(); + if (!env.DATABASE_URL) { + const byVoter = memoryVotes.get(input.proposalId); + if (!byVoter) return false; + return byVoter.has(voterAddress); + } + const db = createDb(env); + const existing = await db + .select({ choice: chamberVotes.choice }) + .from(chamberVotes) + .where( + and( + eq(chamberVotes.proposalId, input.proposalId), + eq(chamberVotes.voterAddress, voterAddress), + ), + ) + .limit(1); + return existing.length > 0; +} + +export async function castChamberVote( + env: Env, + input: { + proposalId: string; + voterAddress: string; + choice: ChamberVoteChoice; + score?: number | null; + chamberId?: string; + }, +): Promise<{ counts: ChamberVoteCounts; created: boolean }> { + if (!env.DATABASE_URL) { + const byVoter = + memoryVotes.get(input.proposalId) ?? new Map(); + const voterKey = input.voterAddress.trim(); + const created = !byVoter.has(voterKey); + byVoter.set(voterKey, { + choice: input.choice, + score: input.score ?? null, + }); + memoryVotes.set(input.proposalId, byVoter); + return { + counts: await getChamberVoteCounts(env, input.proposalId, { + chamberId: input.chamberId, + }), + created, + }; + } + + const db = createDb(env); + const voterAddress = input.voterAddress.trim(); + const existing = await db + .select({ choice: chamberVotes.choice }) + .from(chamberVotes) + .where( + and( + eq(chamberVotes.proposalId, input.proposalId), + eq(chamberVotes.voterAddress, voterAddress), + ), + ) + .limit(1); + const created = existing.length === 0; + const now = new Date(); + await db + .insert(chamberVotes) + .values({ + proposalId: input.proposalId, + voterAddress, + choice: input.choice, + score: input.score ?? null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [chamberVotes.proposalId, chamberVotes.voterAddress], + set: { choice: input.choice, score: input.score ?? null, updatedAt: now }, + }); + + return { + counts: await getChamberVoteCounts(env, input.proposalId, { + chamberId: input.chamberId, + }), + created, + }; +} + +export async function getChamberVoteCounts( + env: Env, + proposalId: string, + input?: { chamberId?: string }, +): Promise { + const chamberId = input?.chamberId?.trim().toLowerCase(); + if (!env.DATABASE_URL) { + return chamberId + ? countWeightedFromMemory(env, proposalId, chamberId) + : countMemory(proposalId); + } + + const db = createDb(env); + if (!chamberId) { + const rows = await db + .select({ + yes: sql`sum(case when ${chamberVotes.choice} = 1 then 1 else 0 end)`, + no: sql`sum(case when ${chamberVotes.choice} = -1 then 1 else 0 end)`, + abstain: sql`sum(case when ${chamberVotes.choice} = 0 then 1 else 0 end)`, + }) + .from(chamberVotes) + .where(eq(chamberVotes.proposalId, proposalId)); + + const row = rows[0]; + return { + yes: Number(row?.yes ?? 0), + no: Number(row?.no ?? 0), + abstain: Number(row?.abstain ?? 0), + }; + } + + const voteRows = await db + .select({ + voterAddress: chamberVotes.voterAddress, + choice: chamberVotes.choice, + }) + .from(chamberVotes) + .where(eq(chamberVotes.proposalId, proposalId)); + + const voters = new Set(voteRows.map((r) => r.voterAddress)); + const weights = await getDelegationWeightsForChamber(env, { + chamberId, + excludedDelegators: voters, + }); + + let yes = 0; + let no = 0; + let abstain = 0; + for (const row of voteRows) { + const w = 1 + (weights.get(row.voterAddress) ?? 0); + if (row.choice === 1) yes += w; + if (row.choice === -1) no += w; + if (row.choice === 0) abstain += w; + } + return { yes, no, abstain }; +} + +export async function clearChamberVotesForTests() { + memoryVotes.clear(); +} + +export async function clearChamberVotesForProposal( + env: Env, + proposalId: string, +): Promise { + if (!env.DATABASE_URL) { + memoryVotes.delete(proposalId); + return; + } + + const db = createDb(env); + await db.delete(chamberVotes).where(eq(chamberVotes.proposalId, proposalId)); +} + +function countMemory(proposalId: string): ChamberVoteCounts { + const byVoter = memoryVotes.get(proposalId); + if (!byVoter) return { yes: 0, no: 0, abstain: 0 }; + let yes = 0; + let no = 0; + let abstain = 0; + for (const vote of byVoter.values()) { + if (vote.choice === 1) yes += 1; + if (vote.choice === -1) no += 1; + if (vote.choice === 0) abstain += 1; + } + return { yes, no, abstain }; +} + +async function countWeightedFromMemory( + env: Env, + proposalId: string, + chamberId: string, +): Promise { + const byVoter = memoryVotes.get(proposalId); + if (!byVoter) return { yes: 0, no: 0, abstain: 0 }; + + const voters = new Set(byVoter.keys()); + const weights = await getDelegationWeightsForChamber(env, { + chamberId, + excludedDelegators: voters, + }); + + let yes = 0; + let no = 0; + let abstain = 0; + for (const [voter, vote] of byVoter.entries()) { + const w = 1 + (weights.get(voter) ?? 0); + if (vote.choice === 1) yes += w; + if (vote.choice === -1) no += w; + if (vote.choice === 0) abstain += w; + } + return { yes, no, abstain }; +} + +export async function getChamberYesScoreAverage( + env: Env, + proposalId: string, +): Promise { + if (!env.DATABASE_URL) return getYesScoreAverageFromMemory(proposalId); + + const db = createDb(env); + const rows = await db + .select({ + avg: sql`avg(${chamberVotes.score})`, + }) + .from(chamberVotes) + .where( + sql`${chamberVotes.proposalId} = ${proposalId} and ${chamberVotes.choice} = 1`, + ); + const avg = rows[0]?.avg ?? null; + return avg === null ? null : Number(avg); +} + +function getYesScoreAverageFromMemory(proposalId: string): number | null { + const byVoter = memoryVotes.get(proposalId); + if (!byVoter) return null; + let sum = 0; + let n = 0; + for (const vote of byVoter.values()) { + if (vote.choice !== 1) continue; + if (typeof vote.score !== "number") continue; + sum += vote.score; + n += 1; + } + if (n === 0) return null; + return sum / n; +} diff --git a/functions/_lib/chambersStore.ts b/functions/_lib/chambersStore.ts new file mode 100644 index 0000000..61c493c --- /dev/null +++ b/functions/_lib/chambersStore.ts @@ -0,0 +1,623 @@ +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; + +import { + chambers as chambersTable, + chambers, + chamberMemberships, + cmAwards, + proposals, +} from "../../db/schema.ts"; +import { createDb } from "./db.ts"; +import { getSimConfig } from "./simConfig.ts"; +import { createReadModelsStore } from "./readModelsStore.ts"; +import { + listAllChamberMembers, + listChamberMembers, +} from "./chamberMembershipsStore.ts"; +import { listCmAwards } from "./cmAwardsStore.ts"; +import { listProposals } from "./proposalsStore.ts"; + +type Env = Record; + +export type ChamberStatus = "active" | "dissolved"; + +export type ChamberRecord = { + id: string; + title: string; + status: ChamberStatus; + multiplierTimes10: number; + createdAt: Date; + updatedAt: Date; + dissolvedAt: Date | null; +}; + +const memory = new Map(); + +const DEFAULT_GENESIS_CHAMBERS: { + id: string; + title: string; + multiplier: number; +}[] = [ + { id: "general", title: "General", multiplier: 1.2 }, + { id: "design", title: "Design", multiplier: 1.4 }, + { id: "engineering", title: "Engineering", multiplier: 1.5 }, + { id: "economics", title: "Economics", multiplier: 1.3 }, + { id: "marketing", title: "Marketing", multiplier: 1.1 }, + { id: "product", title: "Product", multiplier: 1.2 }, +]; + +function normalizeId(value: string): string { + return value.trim().toLowerCase(); +} + +async function upsertChambersReadModel( + env: Env, + input: { + action: "create" | "dissolve"; + id: string; + title?: string; + multiplier?: number; + }, +): Promise { + if ( + env.READ_MODELS_INLINE !== "true" && + env.READ_MODELS_INLINE_EMPTY !== "true" + ) { + return; + } + const store = await createReadModelsStore(env).catch(() => null); + if (!store?.set) return; + + const payload = await store.get("chambers:list"); + const existing = + payload && + typeof payload === "object" && + !Array.isArray(payload) && + Array.isArray((payload as { items?: unknown[] }).items) + ? (payload as { items: unknown[] }).items + : []; + + const normalizedId = normalizeId(input.id); + const nextItems = existing.filter((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return true; + return ( + String((item as { id?: string }).id ?? "").toLowerCase() !== normalizedId + ); + }); + + if (input.action === "create") { + const multiplier = + typeof input.multiplier === "number" && Number.isFinite(input.multiplier) + ? input.multiplier + : 1; + nextItems.push({ + id: normalizedId, + name: input.title?.trim() || normalizedId, + multiplier, + stats: { governors: "0", acm: "0", mcm: "0", lcm: "0" }, + pipeline: { pool: 0, vote: 0, build: 0 }, + status: "active", + }); + + await store.set(`chambers:${normalizedId}`, { + proposals: [], + governors: [], + threads: [], + chatLog: [], + stageOptions: [ + { value: "upcoming", label: "Upcoming" }, + { value: "live", label: "Live" }, + { value: "ended", label: "Ended" }, + ], + }); + } + + await store.set("chambers:list", { + ...(payload && typeof payload === "object" && !Array.isArray(payload) + ? payload + : {}), + items: nextItems, + }); +} + +function getGenesisChambersFromConfig( + cfg: unknown, +): typeof DEFAULT_GENESIS_CHAMBERS { + const config = cfg as { + genesisChambers?: { id: string; title: string; multiplier: number }[]; + } | null; + return config?.genesisChambers && config.genesisChambers.length > 0 + ? config.genesisChambers + : DEFAULT_GENESIS_CHAMBERS; +} + +export async function ensureGenesisChambers( + env: Env, + requestUrl: string, +): Promise { + const cfg = await getSimConfig(env, requestUrl); + const genesis = getGenesisChambersFromConfig(cfg); + const now = new Date(); + + if (!env.DATABASE_URL) { + if (memory.size > 0) return; + for (const chamber of genesis) { + const id = normalizeId(chamber.id); + if (!id) continue; + memory.set(id, { + id, + title: chamber.title.trim() || id, + status: "active", + multiplierTimes10: Math.round((chamber.multiplier || 1) * 10), + createdAt: now, + updatedAt: now, + dissolvedAt: null, + }); + } + return; + } + + const db = createDb(env); + const rows = await db + .select({ n: sql`count(*)` }) + .from(chambers) + .limit(1); + if (Number(rows[0]?.n ?? 0) > 0) return; + + await db.insert(chambers).values( + genesis.map((chamber) => ({ + id: normalizeId(chamber.id), + title: chamber.title.trim() || chamber.id, + status: "active", + multiplierTimes10: Math.round((chamber.multiplier || 1) * 10), + createdByProposalId: null, + dissolvedByProposalId: null, + metadata: {}, + createdAt: now, + updatedAt: now, + dissolvedAt: null, + })), + ); +} + +export async function getChamber( + env: Env, + requestUrl: string, + chamberId: string, +): Promise { + await ensureGenesisChambers(env, requestUrl); + const id = normalizeId(chamberId); + + if (!env.DATABASE_URL) return memory.get(id) ?? null; + + const db = createDb(env); + const rows = await db + .select({ + id: chambers.id, + title: chambers.title, + status: chambers.status, + multiplierTimes10: chambers.multiplierTimes10, + createdAt: chambers.createdAt, + updatedAt: chambers.updatedAt, + dissolvedAt: chambers.dissolvedAt, + }) + .from(chambers) + .where(eq(chambers.id, id)) + .limit(1); + const row = rows[0]; + if (!row) return null; + return { + id: row.id, + title: row.title, + status: row.status as ChamberStatus, + multiplierTimes10: row.multiplierTimes10, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + dissolvedAt: row.dissolvedAt ?? null, + }; +} + +export async function listChambers( + env: Env, + requestUrl: string, + input?: { includeDissolved?: boolean }, +): Promise { + await ensureGenesisChambers(env, requestUrl); + const includeDissolved = Boolean(input?.includeDissolved); + + if (!env.DATABASE_URL) { + const rows = Array.from(memory.values()); + return rows + .filter((c) => includeDissolved || c.status === "active") + .sort((a, b) => a.title.localeCompare(b.title)); + } + + const db = createDb(env); + const base = db + .select({ + id: chambers.id, + title: chambers.title, + status: chambers.status, + multiplierTimes10: chambers.multiplierTimes10, + createdAt: chambers.createdAt, + updatedAt: chambers.updatedAt, + dissolvedAt: chambers.dissolvedAt, + }) + .from(chambers); + const rows = includeDissolved + ? await base + : await base.where(eq(chambers.status, "active")); + return rows + .map((row) => ({ + id: row.id, + title: row.title, + status: row.status as ChamberStatus, + multiplierTimes10: row.multiplierTimes10, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + dissolvedAt: row.dissolvedAt ?? null, + })) + .sort((a, b) => a.title.localeCompare(b.title)); +} + +export async function createChamberFromAcceptedGeneralProposal( + env: Env, + requestUrl: string, + input: { + id: string; + title: string; + multiplier?: number; + proposalId: string; + }, +): Promise { + await ensureGenesisChambers(env, requestUrl); + const id = normalizeId(input.id); + if (!id || id === "general") return; + + const now = new Date(); + const multiplierTimes10 = Math.round(((input.multiplier ?? 1) || 1) * 10); + + if (!env.DATABASE_URL) { + if (memory.has(id)) return; + memory.set(id, { + id, + title: input.title.trim() || id, + status: "active", + multiplierTimes10, + createdAt: now, + updatedAt: now, + dissolvedAt: null, + }); + await upsertChambersReadModel(env, { + action: "create", + id, + title: input.title, + multiplier: input.multiplier, + }); + return; + } + + const db = createDb(env); + await db + .insert(chambers) + .values({ + id, + title: input.title.trim() || id, + status: "active", + multiplierTimes10, + createdByProposalId: input.proposalId, + dissolvedByProposalId: null, + metadata: {}, + createdAt: now, + updatedAt: now, + dissolvedAt: null, + }) + .onConflictDoNothing({ target: chambers.id }); + + await upsertChambersReadModel(env, { + action: "create", + id, + title: input.title, + multiplier: input.multiplier, + }); +} + +export async function dissolveChamberFromAcceptedGeneralProposal( + env: Env, + requestUrl: string, + input: { id: string; proposalId: string }, +): Promise { + await ensureGenesisChambers(env, requestUrl); + const id = normalizeId(input.id); + if (!id || id === "general") return; + + const now = new Date(); + + if (!env.DATABASE_URL) { + const existing = memory.get(id); + if (!existing || existing.status === "dissolved") return; + memory.set(id, { + ...existing, + status: "dissolved", + dissolvedAt: now, + updatedAt: now, + }); + await upsertChambersReadModel(env, { action: "dissolve", id }); + return; + } + + const db = createDb(env); + await db + .update(chambers) + .set({ + status: "dissolved", + dissolvedAt: now, + dissolvedByProposalId: input.proposalId, + updatedAt: now, + }) + .where(and(eq(chambers.id, id), isNull(chambers.dissolvedAt))); + + await upsertChambersReadModel(env, { action: "dissolve", id }); +} + +export function parseChamberGovernanceFromPayload(payload: unknown): { + action: "chamber.create" | "chamber.dissolve"; + id: string; + title?: string; + multiplier?: number; +} | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return null; + const record = payload as Record; + const mg = record.metaGovernance; + if (!mg || typeof mg !== "object" || Array.isArray(mg)) return null; + const meta = mg as Record; + const action = typeof meta.action === "string" ? meta.action : ""; + if (action !== "chamber.create" && action !== "chamber.dissolve") return null; + const id = + typeof meta.chamberId === "string" + ? meta.chamberId + : typeof meta.id === "string" + ? meta.id + : ""; + const title = + typeof meta.title === "string" + ? meta.title + : typeof meta.name === "string" + ? meta.name + : undefined; + const multiplier = + typeof meta.multiplier === "number" ? meta.multiplier : undefined; + return { action, id, title, multiplier }; +} + +export async function getChamberMultiplierTimes10( + env: Env, + requestUrl: string, + chamberIdInput: string, +): Promise { + const id = normalizeId(chamberIdInput); + const chamber = await getChamber(env, requestUrl, id); + return chamber?.multiplierTimes10 ?? 10; +} + +export async function projectChamberPipeline( + env: Env, + input: { chamberId: string }, +): Promise<{ pool: number; vote: number; build: number }> { + const chamberId = normalizeId(input.chamberId); + + if (!env.DATABASE_URL) { + const items = await listProposals(env); + let pool = 0; + let vote = 0; + let build = 0; + for (const proposal of items) { + const proposalChamberId = normalizeId(proposal.chamberId ?? "general"); + if (proposalChamberId !== chamberId) continue; + if (proposal.stage === "pool") pool += 1; + else if (proposal.stage === "vote") vote += 1; + else if (proposal.stage === "build") build += 1; + } + return { pool, vote, build }; + } + const db = createDb(env); + + const rows = await db + .select({ + stage: proposals.stage, + count: sql`count(*)`, + }) + .from(proposals) + .where(eq(proposals.chamberId, chamberId)) + .groupBy(proposals.stage); + + let pool = 0; + let vote = 0; + let build = 0; + for (const row of rows) { + const stage = String(row.stage); + if (stage === "pool") pool += Number(row.count); + else if (stage === "vote") vote += Number(row.count); + else if (stage === "build") build += Number(row.count); + } + return { pool, vote, build }; +} + +export async function projectChamberStats( + env: Env, + requestUrl: string, + input: { chamberId: string }, +): Promise<{ governors: number; acm: number; lcm: number; mcm: number }> { + const chamberId = normalizeId(input.chamberId); + const cfg = await getSimConfig(env, requestUrl); + const genesisMembers = cfg?.genesisChamberMembers ?? undefined; + + if (!env.DATABASE_URL) { + const memberAddresses = new Set(); + if (chamberId === "general") { + if (genesisMembers) { + for (const list of Object.values(genesisMembers)) { + for (const addr of list) memberAddresses.add(addr.trim()); + } + } + for (const addr of await listAllChamberMembers(env)) { + memberAddresses.add(addr.trim()); + } + } else { + if (genesisMembers) { + for (const addr of genesisMembers[chamberId] ?? []) + memberAddresses.add(addr.trim()); + } + for (const addr of await listChamberMembers(env, chamberId)) { + memberAddresses.add(addr.trim()); + } + } + + const members = Array.from(memberAddresses); + const governors = members.length; + if (governors === 0) return { governors: 0, acm: 0, lcm: 0, mcm: 0 }; + + const allAwards = await listCmAwards(env, { proposerIds: members }); + const multiplierByChamberId = new Map(); + for (const chamber of await listChambers(env, requestUrl, { + includeDissolved: true, + })) { + multiplierByChamberId.set(chamber.id, chamber.multiplierTimes10); + } + const acmPoints = allAwards.reduce((sum, award) => { + const times10 = multiplierByChamberId.get(award.chamberId) ?? 10; + return sum + Math.round((award.lcmPoints * times10) / 10); + }, 0); + + const chamberAwards = await listCmAwards(env, { + proposerIds: members, + chamberId, + }); + const lcmPoints = chamberAwards.reduce( + (sum, award) => sum + award.lcmPoints, + 0, + ); + const chamberTimes10 = multiplierByChamberId.get(chamberId) ?? 10; + const mcmPoints = chamberAwards.reduce( + (sum, award) => sum + Math.round((award.lcmPoints * chamberTimes10) / 10), + 0, + ); + + const acm = Math.round(acmPoints / 10); + const lcm = Math.round(lcmPoints / 10); + const mcm = Math.round(mcmPoints / 10); + return { governors, acm, lcm, mcm }; + } + + const db = createDb(env); + + const memberAddresses = new Set(); + if (chamberId === "general") { + const rows = await db + .selectDistinct({ address: chamberMemberships.address }) + .from(chamberMemberships); + for (const row of rows) memberAddresses.add(row.address); + if (genesisMembers) { + for (const list of Object.values(genesisMembers)) { + for (const addr of list) memberAddresses.add(addr); + } + } + } else { + const rows = await db + .selectDistinct({ address: chamberMemberships.address }) + .from(chamberMemberships) + .where(eq(chamberMemberships.chamberId, chamberId)); + for (const row of rows) memberAddresses.add(row.address); + if (genesisMembers) { + for (const addr of genesisMembers[chamberId] ?? []) + memberAddresses.add(addr); + } + } + + const members = Array.from(memberAddresses); + const governors = members.length; + if (members.length === 0) return { governors: 0, acm: 0, lcm: 0, mcm: 0 }; + + const acmRows = await db + .select({ + sum: sql`coalesce(sum(round(${cmAwards.lcmPoints} * coalesce(${chambersTable.multiplierTimes10}, ${cmAwards.chamberMultiplierTimes10}, 10) / 10.0)), 0)`, + }) + .from(cmAwards) + .leftJoin(chambersTable, eq(chambersTable.id, cmAwards.chamberId)) + .where(inArray(cmAwards.proposerId, members)); + const chamberRows = await db + .select({ + lcmSum: sql`coalesce(sum(${cmAwards.lcmPoints}), 0)`, + mcmSum: sql`coalesce(sum(round(${cmAwards.lcmPoints} * coalesce(${chambersTable.multiplierTimes10}, ${cmAwards.chamberMultiplierTimes10}, 10) / 10.0)), 0)`, + }) + .from(cmAwards) + .leftJoin(chambersTable, eq(chambersTable.id, cmAwards.chamberId)) + .where( + and( + eq(cmAwards.chamberId, chamberId), + inArray(cmAwards.proposerId, members), + ), + ); + + const acm = Math.round(Number(acmRows[0]?.sum ?? 0) / 10); + const lcm = Math.round(Number(chamberRows[0]?.lcmSum ?? 0) / 10); + const mcm = Math.round(Number(chamberRows[0]?.mcmSum ?? 0) / 10); + + return { governors, acm, lcm, mcm }; +} + +export async function setChamberMultiplierTimes10( + env: Env, + requestUrl: string, + input: { id: string; multiplierTimes10: number }, +): Promise<{ updated: boolean; prevTimes10: number; nextTimes10: number }> { + await ensureGenesisChambers(env, requestUrl); + const id = normalizeId(input.id); + const nextTimes10 = Math.floor(input.multiplierTimes10); + if (!id) return { updated: false, prevTimes10: 10, nextTimes10 }; + + if (!env.DATABASE_URL) { + const existing = memory.get(id); + const prevTimes10 = existing?.multiplierTimes10 ?? 10; + if (!existing || existing.status !== "active") { + return { updated: false, prevTimes10, nextTimes10 }; + } + if (prevTimes10 === nextTimes10) { + return { updated: false, prevTimes10, nextTimes10 }; + } + const now = new Date(); + memory.set(id, { + ...existing, + multiplierTimes10: nextTimes10, + updatedAt: now, + }); + return { updated: true, prevTimes10, nextTimes10 }; + } + + const db = createDb(env); + const row = await db + .select({ + multiplierTimes10: chambers.multiplierTimes10, + status: chambers.status, + }) + .from(chambers) + .where(eq(chambers.id, id)) + .limit(1); + const prevTimes10 = row[0]?.multiplierTimes10 ?? 10; + const status = row[0]?.status ?? null; + if (status !== "active") return { updated: false, prevTimes10, nextTimes10 }; + if (prevTimes10 === nextTimes10) { + return { updated: false, prevTimes10, nextTimes10 }; + } + const now = new Date(); + await db + .update(chambers) + .set({ multiplierTimes10: nextTimes10, updatedAt: now }) + .where(eq(chambers.id, id)); + return { updated: true, prevTimes10, nextTimes10 }; +} + +export function clearChambersForTests(): void { + memory.clear(); +} diff --git a/functions/_lib/clockStore.ts b/functions/_lib/clockStore.ts index 4b9d49a..630ec42 100644 --- a/functions/_lib/clockStore.ts +++ b/functions/_lib/clockStore.ts @@ -8,6 +8,7 @@ type Env = Record; export type ClockSnapshot = { currentEra: number; + updatedAt: string; }; export type ClockStore = { @@ -16,17 +17,22 @@ export type ClockStore = { }; let inlineEra = 0; +let inlineUpdatedAt = new Date().toISOString(); async function ensureClockRow(): Promise { - return { currentEra: inlineEra }; + return { currentEra: inlineEra, updatedAt: inlineUpdatedAt }; } export function createClockStore(env: Env): ClockStore { - if (env.READ_MODELS_INLINE === "true") { + if ( + env.READ_MODELS_INLINE === "true" || + env.READ_MODELS_INLINE_EMPTY === "true" + ) { return { get: async () => ensureClockRow(), advanceEra: async () => { inlineEra += 1; + inlineUpdatedAt = new Date().toISOString(); return ensureClockRow(); }, }; @@ -45,11 +51,17 @@ export function createClockStore(env: Env): ClockStore { async function readRow(): Promise { await upsertDefaultRow(); const rows = await db - .select({ currentEra: clockState.currentEra }) + .select({ + currentEra: clockState.currentEra, + updatedAt: clockState.updatedAt, + }) .from(clockState) .where(eq(clockState.id, clockRowId)) .limit(1); - return { currentEra: rows[0]?.currentEra ?? 0 }; + const updatedAt = rows[0]?.updatedAt + ? rows[0].updatedAt.toISOString() + : new Date(0).toISOString(); + return { currentEra: rows[0]?.currentEra ?? 0, updatedAt }; } async function bumpEra(): Promise { @@ -60,14 +72,15 @@ export function createClockStore(env: Env): ClockStore { .limit(1); const currentEra = rows[0]?.currentEra ?? 0; const nextEra = currentEra + 1; + const now = new Date(); await db .insert(clockState) - .values({ id: clockRowId, currentEra: nextEra }) + .values({ id: clockRowId, currentEra: nextEra, updatedAt: now }) .onConflictDoUpdate({ target: clockState.id, - set: { currentEra: nextEra, updatedAt: new Date() }, + set: { currentEra: nextEra, updatedAt: now }, }); - return { currentEra: nextEra }; + return { currentEra: nextEra, updatedAt: now.toISOString() }; } return { @@ -76,6 +89,11 @@ export function createClockStore(env: Env): ClockStore { }; } +export function clearClockForTests(): void { + inlineEra = 0; + inlineUpdatedAt = new Date().toISOString(); +} + export function assertAdmin(context: { request: Request; env: Env }): void { if (envBoolean(context.env, "DEV_BYPASS_ADMIN")) return; const secret = envString(context.env, "ADMIN_SECRET"); diff --git a/functions/_lib/cmAwardsStore.ts b/functions/_lib/cmAwardsStore.ts new file mode 100644 index 0000000..fedf6d1 --- /dev/null +++ b/functions/_lib/cmAwardsStore.ts @@ -0,0 +1,158 @@ +import { eq, sql } from "drizzle-orm"; + +import { chambers, cmAwards } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +export type CmAwardInput = { + proposalId: string; + proposerId: string; + chamberId: string; + avgScore: number | null; + lcmPoints: number; + chamberMultiplierTimes10: number; + mcmPoints: number; +}; + +export type CmAcmTotals = { acmPoints: number }; + +const memoryAwardsByProposal = new Map(); +const memoryAcmByProposer = new Map(); + +export async function listCmAwards( + env: Env, + input?: { chamberId?: string | null; proposerIds?: string[] | null }, +): Promise { + const chamberId = (input?.chamberId ?? null)?.trim().toLowerCase() ?? null; + const proposerIds = input?.proposerIds ?? null; + const proposerSet = proposerIds ? new Set(proposerIds) : null; + + if (!env.DATABASE_URL) { + return Array.from(memoryAwardsByProposal.values()).filter((award) => { + if (chamberId && award.chamberId !== chamberId) return false; + if (proposerSet && !proposerSet.has(award.proposerId)) return false; + return true; + }); + } + + const db = createDb(env); + const rows = await db + .select({ + proposalId: cmAwards.proposalId, + proposerId: cmAwards.proposerId, + chamberId: cmAwards.chamberId, + avgScore: cmAwards.avgScore, + lcmPoints: cmAwards.lcmPoints, + chamberMultiplierTimes10: cmAwards.chamberMultiplierTimes10, + mcmPoints: cmAwards.mcmPoints, + }) + .from(cmAwards); + + return rows + .map((row) => ({ + proposalId: row.proposalId, + proposerId: row.proposerId, + chamberId: String(row.chamberId).trim().toLowerCase(), + avgScore: row.avgScore === null ? null : Number(row.avgScore), + lcmPoints: row.lcmPoints, + chamberMultiplierTimes10: row.chamberMultiplierTimes10, + mcmPoints: row.mcmPoints, + })) + .filter((award) => { + if (chamberId && award.chamberId !== chamberId) return false; + if (proposerSet && !proposerSet.has(award.proposerId)) return false; + return true; + }); +} + +export async function awardCmOnce( + env: Env, + input: CmAwardInput, +): Promise { + if (!env.DATABASE_URL) { + if (memoryAwardsByProposal.has(input.proposalId)) return; + memoryAwardsByProposal.set(input.proposalId, input); + const prev = memoryAcmByProposer.get(input.proposerId) ?? 0; + memoryAcmByProposer.set(input.proposerId, prev + input.mcmPoints); + return; + } + + const db = createDb(env); + await db + .insert(cmAwards) + .values({ + proposalId: input.proposalId, + proposerId: input.proposerId, + chamberId: input.chamberId, + avgScore: input.avgScore === null ? null : Math.round(input.avgScore), + lcmPoints: input.lcmPoints, + chamberMultiplierTimes10: input.chamberMultiplierTimes10, + mcmPoints: input.mcmPoints, + createdAt: new Date(), + }) + .onConflictDoNothing({ target: cmAwards.proposalId }); +} + +export async function hasLcmHistoryInChamber( + env: Env, + input: { proposerId: string; chamberId: string }, +): Promise { + const proposerId = input.proposerId.trim(); + const chamberId = input.chamberId.trim().toLowerCase(); + if (!proposerId || !chamberId) return false; + + if (!env.DATABASE_URL) { + for (const award of memoryAwardsByProposal.values()) { + if (award.proposerId !== proposerId) continue; + if (award.chamberId !== chamberId) continue; + return true; + } + return false; + } + + const db = createDb(env); + const rows = await db + .select({ proposalId: cmAwards.proposalId }) + .from(cmAwards) + .where( + sql`${cmAwards.proposerId} = ${proposerId} and ${cmAwards.chamberId} = ${chamberId}`, + ) + .limit(1); + return Boolean(rows[0]); +} + +export async function getAcmDelta( + env: Env, + proposerId: string, +): Promise { + if (!env.DATABASE_URL) { + const { getChamberMultiplierTimes10 } = await import("./chambersStore.ts"); + let sum = 0; + for (const award of memoryAwardsByProposal.values()) { + if (award.proposerId !== proposerId) continue; + const times10 = await getChamberMultiplierTimes10( + env, + "https://local.test/api/internal", + award.chamberId, + ); + sum += Math.round((award.lcmPoints * times10) / 10); + } + return sum; + } + + const db = createDb(env); + const rows = await db + .select({ + sum: sql`coalesce(sum(round(${cmAwards.lcmPoints} * coalesce(${chambers.multiplierTimes10}, ${cmAwards.chamberMultiplierTimes10}, 10) / 10.0)), 0)`, + }) + .from(cmAwards) + .leftJoin(chambers, eq(chambers.id, cmAwards.chamberId)) + .where(eq(cmAwards.proposerId, proposerId)); + return Number(rows[0]?.sum ?? 0); +} + +export async function clearCmAwardsForTests() { + memoryAwardsByProposal.clear(); + memoryAcmByProposer.clear(); +} diff --git a/functions/_lib/courtsStore.ts b/functions/_lib/courtsStore.ts new file mode 100644 index 0000000..daf0b96 --- /dev/null +++ b/functions/_lib/courtsStore.ts @@ -0,0 +1,321 @@ +import { and, eq, sql } from "drizzle-orm"; + +import { courtCases, courtReports, courtVerdicts } from "../../db/schema.ts"; +import type { ReadModelsStore } from "./readModelsStore.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +export type CourtStatus = "jury" | "live" | "ended"; +export type CourtVerdict = "guilty" | "not_guilty"; + +type CourtCaseSeed = { + status: CourtStatus; + baseReports: number; + opened: string | null; +}; + +export type CourtOverlay = { + status: CourtStatus; + reports: number; + verdicts: { guilty: number; notGuilty: number }; +}; + +const REPORTS_TO_START_LIVE = 12; +const VERDICTS_TO_END = 12; + +const memoryCases = new Map(); +const memoryReports = new Map>(); +const memoryVerdicts = new Map>(); + +export async function hasCourtReport( + env: Env, + input: { caseId: string; reporterAddress: string }, +): Promise { + const reporter = input.reporterAddress.trim(); + if (!env.DATABASE_URL) { + const set = memoryReports.get(input.caseId); + if (!set) return false; + return set.has(reporter); + } + const db = createDb(env); + const existing = await db + .select({ reporterAddress: courtReports.reporterAddress }) + .from(courtReports) + .where( + and( + eq(courtReports.caseId, input.caseId), + eq(courtReports.reporterAddress, reporter), + ), + ) + .limit(1); + return existing.length > 0; +} + +export async function hasCourtVerdict( + env: Env, + input: { caseId: string; voterAddress: string }, +): Promise { + const voter = input.voterAddress.trim(); + if (!env.DATABASE_URL) { + const map = memoryVerdicts.get(input.caseId); + if (!map) return false; + return map.has(voter); + } + const db = createDb(env); + const existing = await db + .select({ voterAddress: courtVerdicts.voterAddress }) + .from(courtVerdicts) + .where( + and( + eq(courtVerdicts.caseId, input.caseId), + eq(courtVerdicts.voterAddress, voter), + ), + ) + .limit(1); + return existing.length > 0; +} + +export async function ensureCourtCaseSeed( + env: Env, + readModels: ReadModelsStore, + caseId: string, +): Promise { + if (!env.DATABASE_URL) { + const existing = memoryCases.get(caseId); + if (existing) return existing; + const seed = await seedFromReadModel(readModels, caseId); + memoryCases.set(caseId, seed); + if (!memoryReports.has(caseId)) memoryReports.set(caseId, new Set()); + if (!memoryVerdicts.has(caseId)) memoryVerdicts.set(caseId, new Map()); + return seed; + } + + const db = createDb(env); + const existing = await db + .select() + .from(courtCases) + .where(eq(courtCases.id, caseId)) + .limit(1); + if (existing[0]) { + return { + status: normalizeStatus(existing[0].status), + baseReports: existing[0].baseReports, + opened: existing[0].opened ?? null, + }; + } + + const seed = await seedFromReadModel(readModels, caseId); + const now = new Date(); + await db.insert(courtCases).values({ + id: caseId, + status: seed.status, + baseReports: seed.baseReports, + opened: seed.opened, + createdAt: now, + updatedAt: now, + }); + return seed; +} + +export async function getCourtOverlay( + env: Env, + readModels: ReadModelsStore, + caseId: string, +): Promise { + const seed = await ensureCourtCaseSeed(env, readModels, caseId); + + if (!env.DATABASE_URL) { + const reports = (memoryReports.get(caseId)?.size ?? 0) + seed.baseReports; + const verdictMap = memoryVerdicts.get(caseId) ?? new Map(); + const guilty = Array.from(verdictMap.values()).filter( + (v) => v === "guilty", + ).length; + const notGuilty = Array.from(verdictMap.values()).filter( + (v) => v === "not_guilty", + ).length; + const status = computeStatus(seed.status, reports, guilty + notGuilty); + return { + status, + reports, + verdicts: { guilty, notGuilty }, + }; + } + + const db = createDb(env); + const [reportAgg] = await db + .select({ n: sql`count(*)` }) + .from(courtReports) + .where(eq(courtReports.caseId, caseId)); + const [guiltyAgg] = await db + .select({ + n: sql`sum(case when ${courtVerdicts.verdict} = 'guilty' then 1 else 0 end)`, + }) + .from(courtVerdicts) + .where(eq(courtVerdicts.caseId, caseId)); + const [notGuiltyAgg] = await db + .select({ + n: sql`sum(case when ${courtVerdicts.verdict} = 'not_guilty' then 1 else 0 end)`, + }) + .from(courtVerdicts) + .where(eq(courtVerdicts.caseId, caseId)); + + const reports = seed.baseReports + Number(reportAgg?.n ?? 0); + const guilty = Number(guiltyAgg?.n ?? 0); + const notGuilty = Number(notGuiltyAgg?.n ?? 0); + const status = computeStatus(seed.status, reports, guilty + notGuilty); + + if (status !== seed.status) { + await db + .update(courtCases) + .set({ status, updatedAt: new Date() }) + .where(eq(courtCases.id, caseId)); + } + + return { + status, + reports, + verdicts: { guilty, notGuilty }, + }; +} + +export async function reportCourtCase( + env: Env, + readModels: ReadModelsStore, + input: { caseId: string; reporterAddress: string }, +): Promise<{ overlay: CourtOverlay; created: boolean }> { + await ensureCourtCaseSeed(env, readModels, input.caseId); + + const reporter = input.reporterAddress.trim(); + if (!env.DATABASE_URL) { + const set = memoryReports.get(input.caseId) ?? new Set(); + const created = !set.has(reporter); + set.add(reporter); + memoryReports.set(input.caseId, set); + return { + overlay: await getCourtOverlay(env, readModels, input.caseId), + created, + }; + } + + const db = createDb(env); + const existing = await db + .select({ reporterAddress: courtReports.reporterAddress }) + .from(courtReports) + .where( + and( + eq(courtReports.caseId, input.caseId), + eq(courtReports.reporterAddress, reporter), + ), + ) + .limit(1); + const created = existing.length === 0; + await db + .insert(courtReports) + .values({ + caseId: input.caseId, + reporterAddress: reporter, + createdAt: new Date(), + }) + .onConflictDoNothing({ + target: [courtReports.caseId, courtReports.reporterAddress], + }); + + return { + overlay: await getCourtOverlay(env, readModels, input.caseId), + created, + }; +} + +export async function castCourtVerdict( + env: Env, + readModels: ReadModelsStore, + input: { caseId: string; voterAddress: string; verdict: CourtVerdict }, +): Promise<{ overlay: CourtOverlay; created: boolean }> { + const overlay = await getCourtOverlay(env, readModels, input.caseId); + if (overlay.status !== "live") throw new Error("case_not_live"); + + const voter = input.voterAddress.trim(); + if (!env.DATABASE_URL) { + const map = + memoryVerdicts.get(input.caseId) ?? new Map(); + const created = !map.has(voter); + map.set(voter, input.verdict); + memoryVerdicts.set(input.caseId, map); + return { + overlay: await getCourtOverlay(env, readModels, input.caseId), + created, + }; + } + + const db = createDb(env); + const existing = await db + .select({ voterAddress: courtVerdicts.voterAddress }) + .from(courtVerdicts) + .where( + and( + eq(courtVerdicts.caseId, input.caseId), + eq(courtVerdicts.voterAddress, voter), + ), + ) + .limit(1); + const created = existing.length === 0; + const now = new Date(); + await db + .insert(courtVerdicts) + .values({ + caseId: input.caseId, + voterAddress: voter, + verdict: input.verdict, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [courtVerdicts.caseId, courtVerdicts.voterAddress], + set: { verdict: input.verdict, updatedAt: now }, + }); + + return { + overlay: await getCourtOverlay(env, readModels, input.caseId), + created, + }; +} + +export function clearCourtsForTests() { + memoryCases.clear(); + memoryReports.clear(); + memoryVerdicts.clear(); +} + +async function seedFromReadModel( + readModels: ReadModelsStore, + caseId: string, +): Promise { + const payload = await readModels.get(`courts:${caseId}`); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("court_case_missing"); + } + const record = payload as Record; + const status = normalizeStatus(record.status); + const reports = typeof record.reports === "number" ? record.reports : 0; + const opened = typeof record.opened === "string" ? record.opened : null; + return { status, baseReports: reports, opened }; +} + +function normalizeStatus(value: unknown): CourtStatus { + if (value === "jury" || value === "live" || value === "ended") return value; + return "jury"; +} + +function computeStatus( + base: CourtStatus, + reports: number, + verdicts: number, +): CourtStatus { + if (base === "ended") return "ended"; + const effectiveStage: "jury" | "live" = + base === "live" || reports >= REPORTS_TO_START_LIVE ? "live" : "jury"; + if (effectiveStage === "jury") return "jury"; + if (verdicts >= VERDICTS_TO_END) return "ended"; + return "live"; +} diff --git a/functions/_lib/delegationsStore.ts b/functions/_lib/delegationsStore.ts new file mode 100644 index 0000000..e14c7a3 --- /dev/null +++ b/functions/_lib/delegationsStore.ts @@ -0,0 +1,296 @@ +import { and, eq, sql } from "drizzle-orm"; + +import { delegationEvents, delegations } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +export type Delegation = { + chamberId: string; + delegatorAddress: string; + delegateeAddress: string; + createdAt: string; + updatedAt: string; +}; + +type StoredDelegation = { + delegateeAddress: string; + createdAt: string; + updatedAt: string; +}; +const memory = new Map>(); // chamberId -> delegator -> record + +function normalizeChamberId(value: string): string { + return value.trim().toLowerCase(); +} + +function normalizeAddress(value: string): string { + return value.trim(); +} + +export async function getDelegation( + env: Env, + input: { chamberId: string; delegatorAddress: string }, +): Promise { + const chamberId = normalizeChamberId(input.chamberId); + const delegatorAddress = normalizeAddress(input.delegatorAddress); + + if (!env.DATABASE_URL) { + const byDelegator = memory.get(chamberId); + const row = byDelegator?.get(delegatorAddress); + if (!row) return null; + return { + chamberId, + delegatorAddress, + delegateeAddress: row.delegateeAddress, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + const db = createDb(env); + const rows = await db + .select({ + chamberId: delegations.chamberId, + delegatorAddress: delegations.delegatorAddress, + delegateeAddress: delegations.delegateeAddress, + createdAt: delegations.createdAt, + updatedAt: delegations.updatedAt, + }) + .from(delegations) + .where( + and( + eq(delegations.chamberId, chamberId), + eq(delegations.delegatorAddress, delegatorAddress), + ), + ) + .limit(1); + const row = rows[0]; + if (!row) return null; + return { + chamberId: row.chamberId, + delegatorAddress: row.delegatorAddress, + delegateeAddress: row.delegateeAddress, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +export async function setDelegation( + env: Env, + input: { + chamberId: string; + delegatorAddress: string; + delegateeAddress: string; + }, +): Promise { + const chamberId = normalizeChamberId(input.chamberId); + const delegatorAddress = normalizeAddress(input.delegatorAddress); + const delegateeAddress = normalizeAddress(input.delegateeAddress); + + if (!chamberId) throw new Error("delegation_chamber_missing"); + if (!delegatorAddress) throw new Error("delegation_delegator_missing"); + if (!delegateeAddress) throw new Error("delegation_delegatee_missing"); + if (delegatorAddress === delegateeAddress) throw new Error("delegation_self"); + + await assertNoDelegationCycle(env, { + chamberId, + delegatorAddress, + delegateeAddress, + }); + + const now = new Date(); + + if (!env.DATABASE_URL) { + const byDelegator = + memory.get(chamberId) ?? new Map(); + const existing = byDelegator.get(delegatorAddress); + const createdAt = existing?.createdAt ?? now.toISOString(); + const updatedAt = now.toISOString(); + byDelegator.set(delegatorAddress, { + delegateeAddress, + createdAt, + updatedAt, + }); + memory.set(chamberId, byDelegator); + return { + chamberId, + delegatorAddress, + delegateeAddress, + createdAt, + updatedAt, + }; + } + + const db = createDb(env); + const existing = await db + .select({ + createdAt: delegations.createdAt, + }) + .from(delegations) + .where( + and( + eq(delegations.chamberId, chamberId), + eq(delegations.delegatorAddress, delegatorAddress), + ), + ) + .limit(1); + const createdAt = existing[0]?.createdAt ?? now; + + await db + .insert(delegations) + .values({ + chamberId, + delegatorAddress, + delegateeAddress, + createdAt, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [delegations.chamberId, delegations.delegatorAddress], + set: { delegateeAddress, updatedAt: now }, + }); + + await db.insert(delegationEvents).values({ + chamberId, + delegatorAddress, + delegateeAddress, + type: "set", + createdAt: now, + }); + + return { + chamberId, + delegatorAddress, + delegateeAddress, + createdAt: createdAt.toISOString(), + updatedAt: now.toISOString(), + }; +} + +export async function clearDelegation( + env: Env, + input: { chamberId: string; delegatorAddress: string }, +): Promise<{ cleared: boolean }> { + const chamberId = normalizeChamberId(input.chamberId); + const delegatorAddress = normalizeAddress(input.delegatorAddress); + if (!chamberId) throw new Error("delegation_chamber_missing"); + if (!delegatorAddress) throw new Error("delegation_delegator_missing"); + + if (!env.DATABASE_URL) { + const byDelegator = memory.get(chamberId); + if (!byDelegator) return { cleared: false }; + const cleared = byDelegator.delete(delegatorAddress); + return { cleared }; + } + + const db = createDb(env); + const existing = await db + .select({ n: sql`count(*)` }) + .from(delegations) + .where( + and( + eq(delegations.chamberId, chamberId), + eq(delegations.delegatorAddress, delegatorAddress), + ), + ) + .limit(1); + const cleared = Number(existing[0]?.n ?? 0) > 0; + if (!cleared) return { cleared: false }; + + await db + .delete(delegations) + .where( + and( + eq(delegations.chamberId, chamberId), + eq(delegations.delegatorAddress, delegatorAddress), + ), + ); + + await db.insert(delegationEvents).values({ + chamberId, + delegatorAddress, + delegateeAddress: null, + type: "clear", + createdAt: new Date(), + }); + + return { cleared: true }; +} + +export async function getDelegationMapForChamber( + env: Env, + chamberIdInput: string, +): Promise> { + const chamberId = normalizeChamberId(chamberIdInput); + const map = new Map(); // delegator -> delegatee + + if (!env.DATABASE_URL) { + const byDelegator = memory.get(chamberId); + if (!byDelegator) return map; + for (const [delegator, record] of byDelegator.entries()) { + map.set(delegator, record.delegateeAddress); + } + return map; + } + + const db = createDb(env); + const rows = await db + .select({ + delegatorAddress: delegations.delegatorAddress, + delegateeAddress: delegations.delegateeAddress, + }) + .from(delegations) + .where(eq(delegations.chamberId, chamberId)); + for (const row of rows) { + map.set(row.delegatorAddress, row.delegateeAddress); + } + return map; +} + +export async function getDelegationWeightsForChamber( + env: Env, + input: { chamberId: string; excludedDelegators?: Set }, +): Promise> { + const chamberId = normalizeChamberId(input.chamberId); + const excluded = input.excludedDelegators ?? new Set(); + const weights = new Map(); // delegatee -> count + + const map = await getDelegationMapForChamber(env, chamberId); + for (const [delegator, delegatee] of map.entries()) { + if (excluded.has(delegator)) continue; + weights.set(delegatee, (weights.get(delegatee) ?? 0) + 1); + } + return weights; +} + +async function assertNoDelegationCycle( + env: Env, + input: { + chamberId: string; + delegatorAddress: string; + delegateeAddress: string; + }, +): Promise { + const chamberId = input.chamberId; + const delegatorAddress = input.delegatorAddress; + const delegateeAddress = input.delegateeAddress; + + const map = await getDelegationMapForChamber(env, chamberId); + map.set(delegatorAddress, delegateeAddress); + + const seen = new Set(); + let current = delegateeAddress; + while (true) { + if (current === delegatorAddress) throw new Error("delegation_cycle"); + if (seen.has(current)) return; + seen.add(current); + const next = map.get(current); + if (!next) return; + current = next; + } +} + +export function clearDelegationsForTests(): void { + memory.clear(); +} diff --git a/functions/_lib/env.ts b/functions/_lib/env.ts index 69092c5..bd11a82 100644 --- a/functions/_lib/env.ts +++ b/functions/_lib/env.ts @@ -30,3 +30,14 @@ export function envCsv( .map((s) => s.trim()) .filter(Boolean); } + +export function envInt( + env: Record, + key: string, +): number | undefined { + const raw = envString(env, key); + if (!raw) return undefined; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) return undefined; + return parsed; +} diff --git a/functions/_lib/eraQuotas.ts b/functions/_lib/eraQuotas.ts new file mode 100644 index 0000000..fced3b2 --- /dev/null +++ b/functions/_lib/eraQuotas.ts @@ -0,0 +1,31 @@ +import { envInt } from "./env.ts"; + +type Env = Record; + +export type EraQuotaConfig = { + maxPoolVotes: number | null; + maxChamberVotes: number | null; + maxCourtActions: number | null; + maxFormationActions: number | null; +}; + +function normalizeLimit(value: number | undefined): number | null { + if (value === undefined) return null; + if (!Number.isFinite(value) || value <= 0) return null; + return value; +} + +export function getEraQuotaConfig(env: Env): EraQuotaConfig { + return { + maxPoolVotes: normalizeLimit(envInt(env, "SIM_MAX_POOL_VOTES_PER_ERA")), + maxChamberVotes: normalizeLimit( + envInt(env, "SIM_MAX_CHAMBER_VOTES_PER_ERA"), + ), + maxCourtActions: normalizeLimit( + envInt(env, "SIM_MAX_COURT_ACTIONS_PER_ERA"), + ), + maxFormationActions: normalizeLimit( + envInt(env, "SIM_MAX_FORMATION_ACTIONS_PER_ERA"), + ), + }; +} diff --git a/functions/_lib/eraRollupStore.ts b/functions/_lib/eraRollupStore.ts new file mode 100644 index 0000000..595cae5 --- /dev/null +++ b/functions/_lib/eraRollupStore.ts @@ -0,0 +1,507 @@ +import { and, eq, sql } from "drizzle-orm"; + +import { eraRollups, eraUserStatus } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; +import { envBoolean, envCsv } from "./env.ts"; +import { listEraUserActivity } from "./eraStore.ts"; +import { + fetchSessionValidatorsViaRpc, + isSs58OrHexAddressInSet, +} from "./humanodeRpc.ts"; +import { getSimConfig } from "./simConfig.ts"; + +type Env = Record; + +export type GoverningStatus = + | "Ahead" + | "Stable" + | "Falling behind" + | "At risk" + | "Losing status"; + +export type EraRequirements = { + poolVotes: number; + chamberVotes: number; + courtActions: number; + formationActions: number; +}; + +export type EraRollupResult = { + era: number; + rolledAt: string; + requirements: EraRequirements; + requiredTotal: number; + activeGovernorsNextEra: number; + usersRolled: number; + statusCounts: Record; +}; + +type StoredRollup = { + era: number; + requirements: EraRequirements; + requiredTotal: number; + activeGovernorsNextEra: number; + rolledAt: string; +}; + +const memoryRollups = new Map(); +const memoryUserStatuses = new Map< + string, + { + status: GoverningStatus; + requiredTotal: number; + completedTotal: number; + isActiveNextEra: boolean; + } +>(); // key: `${era}:${address}` + +export async function getActiveAddressesForNextEraFromRollup( + env: Env, + input: { era: number }, +): Promise | null> { + if (!env.DATABASE_URL) { + if (!memoryRollups.has(input.era)) return null; + const out = new Set(); + for (const [key, status] of memoryUserStatuses.entries()) { + if (!key.startsWith(`${input.era}:`)) continue; + if (!status.isActiveNextEra) continue; + const address = key.split(":").slice(1).join(":").trim(); + if (address) out.add(address); + } + return out; + } + + const db = createDb(env); + const rollupExists = await db + .select({ era: eraRollups.era }) + .from(eraRollups) + .where(eq(eraRollups.era, input.era)) + .limit(1); + if (!rollupExists[0]) return null; + + const rows = await db + .select({ address: eraUserStatus.address }) + .from(eraUserStatus) + .where( + and( + eq(eraUserStatus.era, input.era), + eq(eraUserStatus.isActiveNextEra, true), + ), + ); + return new Set(rows.map((r) => r.address.trim()).filter(Boolean)); +} + +export async function rollupEra( + env: Env, + input: { era: number; requestUrl?: string }, +): Promise { + const existing = await getEraRollup(env, input.era); + if (existing) { + const statusCounts = await getEraStatusCounts(env, input.era); + const usersRolled = await getEraUsersRolled(env, input.era); + return { + era: existing.era, + rolledAt: existing.rolledAt, + requirements: existing.requirements, + requiredTotal: existing.requiredTotal, + activeGovernorsNextEra: existing.activeGovernorsNextEra, + usersRolled, + statusCounts, + }; + } + + const requirements = getRequirements(env); + const requiredTotal = sumRequirements(requirements); + + const activityRows = await listEraUserActivity(env, { era: input.era }); + + const eligibleAddresses = new Set( + envCsv(env, "DEV_ELIGIBLE_ADDRESSES").map((a) => a.trim()), + ); + const bypassGate = envBoolean(env, "DEV_BYPASS_GATE"); + + let validatorSet: Set | null = null; + if (!bypassGate) { + let envWithRpc: Env = env; + if (!env.HUMANODE_RPC_URL && input.requestUrl) { + const cfg = await getSimConfig(env, input.requestUrl); + const fromCfg = (cfg?.humanodeRpcUrl ?? "").trim(); + if (fromCfg) envWithRpc = { ...env, HUMANODE_RPC_URL: fromCfg }; + } + + const validators = await fetchSessionValidatorsViaRpc(envWithRpc); + validatorSet = new Set(validators); + } + + const userStatuses = activityRows.map((row) => { + const completedTotal = + row.poolVotes + + row.chamberVotes + + row.courtActions + + row.formationActions; + const status = computeGoverningStatus(completedTotal, requiredTotal); + const meetsRequirements = isActiveByRequirements(row, requirements); + const isActiveHumanNode = + bypassGate || + eligibleAddresses.has(row.address.trim()) || + (validatorSet + ? isSs58OrHexAddressInSet(row.address, validatorSet) + : false); + const isActiveNextEra = meetsRequirements && isActiveHumanNode; + return { + ...row, + status, + requiredTotal, + completedTotal, + isActiveNextEra, + }; + }); + + const activeGovernorsNextEra = userStatuses.filter( + (u) => u.isActiveNextEra, + ).length; + + const rolledAt = new Date().toISOString(); + await storeEraRollup(env, { + era: input.era, + requirements, + requiredTotal, + activeGovernorsNextEra, + rolledAt, + userStatuses, + }); + + const statusCounts = userStatuses.reduce( + (acc, u) => { + acc[u.status] += 1; + return acc; + }, + { + Ahead: 0, + Stable: 0, + "Falling behind": 0, + "At risk": 0, + "Losing status": 0, + } as Record, + ); + + return { + era: input.era, + rolledAt, + requirements, + requiredTotal, + activeGovernorsNextEra, + usersRolled: userStatuses.length, + statusCounts, + }; +} + +export async function getEraRollupMeta( + env: Env, + input: { era: number }, +): Promise { + const rollup = await getEraRollup(env, input.era); + if (!rollup) return null; + return { + era: rollup.era, + rolledAt: rollup.rolledAt, + requiredTotal: rollup.requiredTotal, + requirements: rollup.requirements, + activeGovernorsNextEra: rollup.activeGovernorsNextEra, + }; +} + +export async function getEraUserStatus( + env: Env, + input: { era: number; address: string }, +): Promise { + const address = input.address.trim(); + + if (!env.DATABASE_URL) { + const rollup = memoryRollups.get(input.era); + if (!rollup) return null; + const entry = memoryUserStatuses.get(`${input.era}:${address}`); + if (!entry) return null; + return { + era: input.era, + address, + status: entry.status, + requiredTotal: entry.requiredTotal, + completedTotal: entry.completedTotal, + isActiveNextEra: entry.isActiveNextEra, + }; + } + + const db = createDb(env); + const rows = await db + .select({ + status: eraUserStatus.status, + requiredTotal: eraUserStatus.requiredTotal, + completedTotal: eraUserStatus.completedTotal, + isActiveNextEra: eraUserStatus.isActiveNextEra, + }) + .from(eraUserStatus) + .where( + and(eq(eraUserStatus.era, input.era), eq(eraUserStatus.address, address)), + ) + .limit(1); + const row = rows[0]; + if (!row) return null; + const status = String(row.status) as GoverningStatus; + if ( + status !== "Ahead" && + status !== "Stable" && + status !== "Falling behind" && + status !== "At risk" && + status !== "Losing status" + ) { + return null; + } + return { + era: input.era, + address, + status, + requiredTotal: row.requiredTotal, + completedTotal: row.completedTotal, + isActiveNextEra: Boolean(row.isActiveNextEra), + }; +} + +export function clearEraRollupsForTests() { + memoryRollups.clear(); + memoryUserStatuses.clear(); +} + +function computeGoverningStatus( + completed: number, + required: number, +): GoverningStatus { + if (required <= 0) return "Stable"; + if (completed >= required + 2) return "Ahead"; + if (completed >= required) return "Stable"; + const ratio = completed / required; + if (ratio >= 0.75) return "Falling behind"; + if (ratio >= 0.55) return "At risk"; + return "Losing status"; +} + +function isActiveByRequirements( + counts: EraRequirements, + required: EraRequirements, +): boolean { + if (required.poolVotes > 0 && counts.poolVotes < required.poolVotes) + return false; + if (required.chamberVotes > 0 && counts.chamberVotes < required.chamberVotes) + return false; + if (required.courtActions > 0 && counts.courtActions < required.courtActions) + return false; + if ( + required.formationActions > 0 && + counts.formationActions < required.formationActions + ) + return false; + return true; +} + +async function getEraRollup( + env: Env, + era: number, +): Promise { + if (!env.DATABASE_URL) { + return memoryRollups.get(era) ?? null; + } + const db = createDb(env); + const rows = await db + .select({ + era: eraRollups.era, + requiredPoolVotes: eraRollups.requiredPoolVotes, + requiredChamberVotes: eraRollups.requiredChamberVotes, + requiredCourtActions: eraRollups.requiredCourtActions, + requiredFormationActions: eraRollups.requiredFormationActions, + requiredTotal: eraRollups.requiredTotal, + activeGovernorsNextEra: eraRollups.activeGovernorsNextEra, + rolledAt: eraRollups.rolledAt, + }) + .from(eraRollups) + .where(eq(eraRollups.era, era)) + .limit(1); + const row = rows[0]; + if (!row) return null; + return { + era: row.era, + requirements: { + poolVotes: row.requiredPoolVotes, + chamberVotes: row.requiredChamberVotes, + courtActions: row.requiredCourtActions, + formationActions: row.requiredFormationActions, + }, + requiredTotal: row.requiredTotal, + activeGovernorsNextEra: row.activeGovernorsNextEra, + rolledAt: row.rolledAt.toISOString(), + }; +} + +async function getEraStatusCounts( + env: Env, + era: number, +): Promise> { + const base: Record = { + Ahead: 0, + Stable: 0, + "Falling behind": 0, + "At risk": 0, + "Losing status": 0, + }; + if (!env.DATABASE_URL) { + for (const [key, value] of memoryUserStatuses.entries()) { + if (!key.startsWith(`${era}:`)) continue; + base[value.status] += 1; + } + return base; + } + + const db = createDb(env); + const rows = await db + .select({ + status: eraUserStatus.status, + n: sql`count(*)`, + }) + .from(eraUserStatus) + .where(eq(eraUserStatus.era, era)) + .groupBy(eraUserStatus.status); + for (const row of rows) { + const status = String(row.status) as GoverningStatus; + if (status in base) base[status] = Number(row.n ?? 0); + } + return base; +} + +async function getEraUsersRolled(env: Env, era: number): Promise { + if (!env.DATABASE_URL) { + let n = 0; + for (const key of memoryUserStatuses.keys()) { + if (key.startsWith(`${era}:`)) n += 1; + } + return n; + } + const db = createDb(env); + const rows = await db + .select({ n: sql`count(*)` }) + .from(eraUserStatus) + .where(eq(eraUserStatus.era, era)); + return Number(rows[0]?.n ?? 0); +} + +async function storeEraRollup( + env: Env, + input: { + era: number; + requirements: EraRequirements; + requiredTotal: number; + activeGovernorsNextEra: number; + rolledAt: string; + userStatuses: Array< + EraRequirements & { + address: string; + status: GoverningStatus; + requiredTotal: number; + completedTotal: number; + isActiveNextEra: boolean; + } + >; + }, +): Promise { + if (!env.DATABASE_URL) { + memoryRollups.set(input.era, { + era: input.era, + requirements: input.requirements, + requiredTotal: input.requiredTotal, + activeGovernorsNextEra: input.activeGovernorsNextEra, + rolledAt: input.rolledAt, + }); + for (const u of input.userStatuses) { + memoryUserStatuses.set(`${input.era}:${u.address.trim()}`, { + status: u.status, + requiredTotal: input.requiredTotal, + completedTotal: u.completedTotal, + isActiveNextEra: u.isActiveNextEra, + }); + } + return; + } + + const db = createDb(env); + const now = new Date(input.rolledAt); + await db + .insert(eraRollups) + .values({ + era: input.era, + requiredPoolVotes: input.requirements.poolVotes, + requiredChamberVotes: input.requirements.chamberVotes, + requiredCourtActions: input.requirements.courtActions, + requiredFormationActions: input.requirements.formationActions, + requiredTotal: input.requiredTotal, + activeGovernorsNextEra: input.activeGovernorsNextEra, + rolledAt: now, + }) + .onConflictDoNothing({ target: eraRollups.era }); + + if (input.userStatuses.length === 0) return; + await db + .insert(eraUserStatus) + .values( + input.userStatuses.map((u) => ({ + era: input.era, + address: u.address.trim(), + status: u.status, + requiredTotal: input.requiredTotal, + completedTotal: u.completedTotal, + isActiveNextEra: u.isActiveNextEra, + poolVotes: u.poolVotes, + chamberVotes: u.chamberVotes, + courtActions: u.courtActions, + formationActions: u.formationActions, + createdAt: now, + })), + ) + .onConflictDoNothing({ + target: [eraUserStatus.era, eraUserStatus.address], + }); +} + +function sumRequirements(req: EraRequirements): number { + return ( + req.poolVotes + req.chamberVotes + req.courtActions + req.formationActions + ); +} + +function getRequirements(env: Env): EraRequirements { + return { + poolVotes: envInt(env, "SIM_REQUIRED_POOL_VOTES", 1), + chamberVotes: envInt(env, "SIM_REQUIRED_CHAMBER_VOTES", 1), + courtActions: envInt(env, "SIM_REQUIRED_COURT_ACTIONS", 0), + formationActions: envInt(env, "SIM_REQUIRED_FORMATION_ACTIONS", 0), + }; +} + +function envInt(env: Env, key: string, fallback: number): number { + const raw = env[key]; + if (!raw) return fallback; + const n = Number(raw); + if (!Number.isFinite(n)) return fallback; + if (n < 0) return fallback; + return Math.floor(n); +} diff --git a/functions/_lib/eraStore.ts b/functions/_lib/eraStore.ts new file mode 100644 index 0000000..455848d --- /dev/null +++ b/functions/_lib/eraStore.ts @@ -0,0 +1,239 @@ +import { and, eq, sql } from "drizzle-orm"; + +import { eraSnapshots, eraUserActivity } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; +import { V1_ACTIVE_GOVERNORS_FALLBACK } from "./v1Constants.ts"; +import { createClockStore } from "./clockStore.ts"; + +type Env = Record; + +type UserEraCounts = { + poolVotes: number; + chamberVotes: number; + courtActions: number; + formationActions: number; +}; + +type Snapshot = { era: number; activeGovernors: number }; + +const memoryEraSnapshots = new Map(); +const memoryEraActivity = new Map(); // key: `${era}:${address}` + +export async function listEraUserActivity( + env: Env, + input: { era: number }, +): Promise> { + if (!env.DATABASE_URL) { + const rows: Array<{ address: string } & UserEraCounts> = []; + for (const [key, counts] of memoryEraActivity.entries()) { + if (!key.startsWith(`${input.era}:`)) continue; + const address = key.split(":").slice(1).join(":"); + rows.push({ address, ...counts }); + } + return rows; + } + + const db = createDb(env); + const rows = await db + .select({ + address: eraUserActivity.address, + poolVotes: eraUserActivity.poolVotes, + chamberVotes: eraUserActivity.chamberVotes, + courtActions: eraUserActivity.courtActions, + formationActions: eraUserActivity.formationActions, + }) + .from(eraUserActivity) + .where(eq(eraUserActivity.era, input.era)); + return rows.map((r) => ({ + address: r.address, + poolVotes: r.poolVotes, + chamberVotes: r.chamberVotes, + courtActions: r.courtActions, + formationActions: r.formationActions, + })); +} + +export async function ensureEraSnapshot( + env: Env, + era: number, +): Promise { + if (!env.DATABASE_URL) { + const existing = memoryEraSnapshots.get(era); + if (existing) return existing; + const snap = { era, activeGovernors: getActiveGovernorsBaseline(env) }; + memoryEraSnapshots.set(era, snap); + return snap; + } + + const db = createDb(env); + const rows = await db + .select({ + era: eraSnapshots.era, + activeGovernors: eraSnapshots.activeGovernors, + }) + .from(eraSnapshots) + .where(eq(eraSnapshots.era, era)) + .limit(1); + if (rows[0]) { + return { + era: rows[0].era ?? era, + activeGovernors: rows[0].activeGovernors, + }; + } + + const snap = { era, activeGovernors: getActiveGovernorsBaseline(env) }; + await db.insert(eraSnapshots).values({ + era: snap.era, + activeGovernors: snap.activeGovernors, + createdAt: new Date(), + }); + return snap; +} + +export async function setEraSnapshotActiveGovernors( + env: Env, + input: { era: number; activeGovernors: number }, +): Promise { + const era = input.era; + const activeGovernors = input.activeGovernors; + await ensureEraSnapshot(env, era); + + if (!env.DATABASE_URL) { + memoryEraSnapshots.set(era, { era, activeGovernors }); + return; + } + + const db = createDb(env); + await db + .insert(eraSnapshots) + .values({ era, activeGovernors, createdAt: new Date() }) + .onConflictDoUpdate({ + target: eraSnapshots.era, + set: { activeGovernors }, + }); +} + +export async function getActiveGovernorsForCurrentEra( + env: Env, +): Promise { + const clock = createClockStore(env); + const { currentEra } = await clock.get(); + const snap = await ensureEraSnapshot(env, currentEra); + return snap.activeGovernors; +} + +export async function incrementEraUserActivity( + env: Env, + input: { address: string; delta: Partial }, +): Promise { + const clock = createClockStore(env); + const { currentEra } = await clock.get(); + const address = input.address.trim(); + await ensureEraSnapshot(env, currentEra); + + const delta: UserEraCounts = { + poolVotes: input.delta.poolVotes ?? 0, + chamberVotes: input.delta.chamberVotes ?? 0, + courtActions: input.delta.courtActions ?? 0, + formationActions: input.delta.formationActions ?? 0, + }; + + if (!env.DATABASE_URL) { + const key = `${currentEra}:${address}`; + const prev = memoryEraActivity.get(key) ?? { + poolVotes: 0, + chamberVotes: 0, + courtActions: 0, + formationActions: 0, + }; + memoryEraActivity.set(key, { + poolVotes: prev.poolVotes + delta.poolVotes, + chamberVotes: prev.chamberVotes + delta.chamberVotes, + courtActions: prev.courtActions + delta.courtActions, + formationActions: prev.formationActions + delta.formationActions, + }); + return; + } + + const db = createDb(env); + const now = new Date(); + await db + .insert(eraUserActivity) + .values({ + era: currentEra, + address, + poolVotes: delta.poolVotes, + chamberVotes: delta.chamberVotes, + courtActions: delta.courtActions, + formationActions: delta.formationActions, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [eraUserActivity.era, eraUserActivity.address], + set: { + poolVotes: sql`${eraUserActivity.poolVotes} + ${delta.poolVotes}`, + chamberVotes: sql`${eraUserActivity.chamberVotes} + ${delta.chamberVotes}`, + courtActions: sql`${eraUserActivity.courtActions} + ${delta.courtActions}`, + formationActions: sql`${eraUserActivity.formationActions} + ${delta.formationActions}`, + updatedAt: now, + }, + }); +} + +export async function getUserEraActivity( + env: Env, + input: { address: string }, +): Promise<{ era: number; counts: UserEraCounts; activeGovernors: number }> { + const clock = createClockStore(env); + const { currentEra } = await clock.get(); + const snap = await ensureEraSnapshot(env, currentEra); + const address = input.address.trim(); + + if (!env.DATABASE_URL) { + const key = `${currentEra}:${address}`; + const counts = memoryEraActivity.get(key) ?? { + poolVotes: 0, + chamberVotes: 0, + courtActions: 0, + formationActions: 0, + }; + return { era: currentEra, counts, activeGovernors: snap.activeGovernors }; + } + + const db = createDb(env); + const rows = await db + .select({ + poolVotes: eraUserActivity.poolVotes, + chamberVotes: eraUserActivity.chamberVotes, + courtActions: eraUserActivity.courtActions, + formationActions: eraUserActivity.formationActions, + }) + .from(eraUserActivity) + .where( + and( + eq(eraUserActivity.era, currentEra), + eq(eraUserActivity.address, address), + ), + ) + .limit(1); + const row = rows[0]; + const counts: UserEraCounts = { + poolVotes: row?.poolVotes ?? 0, + chamberVotes: row?.chamberVotes ?? 0, + courtActions: row?.courtActions ?? 0, + formationActions: row?.formationActions ?? 0, + }; + return { era: currentEra, counts, activeGovernors: snap.activeGovernors }; +} + +export function clearEraForTests() { + memoryEraSnapshots.clear(); + memoryEraActivity.clear(); +} + +function getActiveGovernorsBaseline(env: Env): number { + const raw = env.SIM_ACTIVE_GOVERNORS ?? env.VORTEX_ACTIVE_GOVERNORS ?? ""; + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed > 0) return Math.round(parsed); + return V1_ACTIVE_GOVERNORS_FALLBACK; +} diff --git a/functions/_lib/eventSchemas.ts b/functions/_lib/eventSchemas.ts new file mode 100644 index 0000000..d5f11f7 --- /dev/null +++ b/functions/_lib/eventSchemas.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +export const toneSchema = z.enum(["ok", "warn"]); + +export const feedStageSchema = z.enum([ + "pool", + "vote", + "build", + "thread", + "courts", + "faction", +]); + +export const feedStageDatumSchema = z.object({ + title: z.string(), + description: z.string(), + value: z.string(), + tone: toneSchema.optional(), +}); + +export const feedStatSchema = z.object({ + label: z.string(), + value: z.string(), +}); + +export const feedItemSchema = z.object({ + id: z.string(), + title: z.string(), + meta: z.string(), + stage: feedStageSchema, + summaryPill: z.string(), + summary: z.string(), + stageData: z.array(feedStageDatumSchema).optional(), + stats: z.array(feedStatSchema).optional(), + proposer: z.string().optional(), + proposerId: z.string().optional(), + ctaPrimary: z.string().optional(), + ctaSecondary: z.string().optional(), + href: z.string().optional(), + timestamp: z.string(), +}); + +export type FeedItemEventPayload = z.infer; diff --git a/functions/_lib/eventsStore.ts b/functions/_lib/eventsStore.ts new file mode 100644 index 0000000..0894b7c --- /dev/null +++ b/functions/_lib/eventsStore.ts @@ -0,0 +1,58 @@ +import { and, desc, eq, lt } from "drizzle-orm"; + +import { events } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; +import type { FeedItemEventPayload } from "./eventSchemas.ts"; +import { projectFeedPageFromEvents } from "./feedEventProjector.ts"; +import { + clearMemoryFeedEventsForTests, + listMemoryFeedEvents, +} from "./feedEventsMemory.ts"; + +type Env = Record; + +export type FeedEventsPage = { + items: FeedItemEventPayload[]; + nextSeq?: number; +}; + +export async function listFeedEventsPage( + env: Env, + input: { stage?: string | null; beforeSeq?: number | null; limit: number }, +): Promise { + if (!env.DATABASE_URL) { + const rows = listMemoryFeedEvents().map((event) => ({ + seq: event.seq, + stage: event.stage, + payload: event.payload, + })); + return projectFeedPageFromEvents(rows, input); + } + + const db = createDb(env); + + const beforeSeq = input.beforeSeq; + const hasBeforeSeq = beforeSeq !== undefined && beforeSeq !== null; + + const whereClause = and( + eq(events.type, "feed.item.v1"), + ...(input.stage ? [eq(events.stage, input.stage)] : []), + ...(hasBeforeSeq ? [lt(events.seq, Math.max(0, beforeSeq))] : []), + ); + + const rows = await db + .select({ + seq: events.seq, + stage: events.stage, + payload: events.payload, + }) + .from(events) + .where(whereClause) + .orderBy(desc(events.seq)) + .limit(input.limit + 1); + return projectFeedPageFromEvents(rows, input); +} + +export function clearFeedEventsForTests(): void { + clearMemoryFeedEventsForTests(); +} diff --git a/functions/_lib/feedEventProjector.ts b/functions/_lib/feedEventProjector.ts new file mode 100644 index 0000000..624797f --- /dev/null +++ b/functions/_lib/feedEventProjector.ts @@ -0,0 +1,43 @@ +import { feedItemSchema, type FeedItemEventPayload } from "./eventSchemas.ts"; + +export type FeedEventRow = { + seq: number; + stage: string | null; + payload: unknown; +}; + +export type FeedProjectPageInput = { + stage?: string | null; + beforeSeq?: number | null; + limit: number; +}; + +export type FeedProjectPageOutput = { + items: FeedItemEventPayload[]; + nextSeq?: number; +}; + +export function projectFeedPageFromEvents( + rows: FeedEventRow[], + input: FeedProjectPageInput, +): FeedProjectPageOutput { + let filtered = [...rows]; + if (input.stage) + filtered = filtered.filter((row) => row.stage === input.stage); + + const beforeSeq = input.beforeSeq; + const hasBeforeSeq = beforeSeq !== undefined && beforeSeq !== null; + if (hasBeforeSeq) { + filtered = filtered.filter((row) => row.seq < Math.max(0, beforeSeq)); + } + + filtered.sort((a, b) => b.seq - a.seq); + + const pageRows = filtered.slice(0, input.limit + 1); + const slice = pageRows.slice(0, input.limit); + const items = slice.map((row) => feedItemSchema.parse(row.payload)); + const nextSeq = + pageRows.length > input.limit ? pageRows[input.limit]?.seq : undefined; + + return nextSeq !== undefined ? { items, nextSeq } : { items }; +} diff --git a/functions/_lib/feedEventsMemory.ts b/functions/_lib/feedEventsMemory.ts new file mode 100644 index 0000000..820af72 --- /dev/null +++ b/functions/_lib/feedEventsMemory.ts @@ -0,0 +1,39 @@ +import type { FeedItemEventPayload } from "./eventSchemas.ts"; + +export type MemoryFeedEvent = { + seq: number; + stage: string | null; + actorAddress: string | null; + entityType: string; + entityId: string; + payload: FeedItemEventPayload; +}; + +let nextSeq = 1; +const memory: MemoryFeedEvent[] = []; + +export function appendMemoryFeedEvent( + input: Omit, +): void { + memory.push({ ...input, seq: nextSeq++ }); +} + +export function listMemoryFeedEvents(): MemoryFeedEvent[] { + return [...memory]; +} + +export function hasMemoryFeedEvent(input: { + entityType: string; + entityId: string; +}): boolean { + return memory.some( + (event) => + event.entityType === input.entityType && + event.entityId === input.entityId, + ); +} + +export function clearMemoryFeedEventsForTests(): void { + memory.length = 0; + nextSeq = 1; +} diff --git a/functions/_lib/formationStore.ts b/functions/_lib/formationStore.ts new file mode 100644 index 0000000..513c7c1 --- /dev/null +++ b/functions/_lib/formationStore.ts @@ -0,0 +1,645 @@ +import { and, eq, sql } from "drizzle-orm"; + +import { + formationMilestoneEvents, + formationMilestones, + formationProjects, + formationTeam, +} from "../../db/schema.ts"; +import type { ReadModelsStore } from "./readModelsStore.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +type FormationProjectSeed = { + teamSlotsTotal: number; + baseTeamFilled: number; + milestonesTotal: number; + baseMilestonesCompleted: number; + budgetTotalHmnd: number | null; + baseBudgetAllocatedHmnd: number | null; +}; + +type FormationMilestoneStatus = "todo" | "submitted" | "unlocked"; + +type FormationSummary = { + teamFilled: number; + teamTotal: number; + milestonesCompleted: number; + milestonesTotal: number; +}; + +const memoryProjects = new Map(); +const memoryTeam = new Map>(); +const memoryMilestones = new Map< + string, + Map +>(); + +export type FormationSeedInput = FormationProjectSeed; + +export async function isFormationTeamMember( + env: Env, + input: { proposalId: string; memberAddress: string }, +): Promise { + const address = input.memberAddress.trim(); + if (!env.DATABASE_URL) { + const team = memoryTeam.get(input.proposalId); + if (!team) return false; + return team.has(address); + } + const db = createDb(env); + const existing = await db + .select({ memberAddress: formationTeam.memberAddress }) + .from(formationTeam) + .where( + and( + eq(formationTeam.proposalId, input.proposalId), + eq(formationTeam.memberAddress, address), + ), + ) + .limit(1); + return existing.length > 0; +} + +export async function getFormationMilestoneStatus( + env: Env, + readModels: ReadModelsStore, + input: { proposalId: string; milestoneIndex: number }, +): Promise { + const seed = await ensureFormationSeed(env, readModels, input.proposalId); + if (input.milestoneIndex < 1 || input.milestoneIndex > seed.milestonesTotal) { + throw new Error("milestone_out_of_range"); + } + + if (!env.DATABASE_URL) { + const milestones = memoryMilestones.get(input.proposalId); + if (!milestones) throw new Error("milestones_missing"); + return milestones.get(input.milestoneIndex) ?? "todo"; + } + + const db = createDb(env); + const rows = await db + .select({ status: formationMilestones.status }) + .from(formationMilestones) + .where( + and( + eq(formationMilestones.proposalId, input.proposalId), + eq(formationMilestones.milestoneIndex, input.milestoneIndex), + ), + ) + .limit(1); + const current = rows[0]?.status; + if (current === "submitted" || current === "unlocked" || current === "todo") { + return current; + } + return "todo"; +} + +export async function ensureFormationSeed( + env: Env, + readModels: ReadModelsStore, + proposalId: string, +): Promise { + if (!env.DATABASE_URL) { + const existing = memoryProjects.get(proposalId); + if (existing) return existing; + const seed = await buildSeedFromReadModel(readModels, proposalId); + memoryProjects.set(proposalId, seed); + const milestoneMap = new Map(); + for (let i = 1; i <= seed.milestonesTotal; i += 1) { + milestoneMap.set( + i, + i <= seed.baseMilestonesCompleted ? "unlocked" : "todo", + ); + } + memoryMilestones.set(proposalId, milestoneMap); + if (!memoryTeam.has(proposalId)) memoryTeam.set(proposalId, new Map()); + return seed; + } + + const db = createDb(env); + const existing = await db + .select() + .from(formationProjects) + .where(eq(formationProjects.proposalId, proposalId)) + .limit(1); + if (existing[0]) { + return { + teamSlotsTotal: existing[0].teamSlotsTotal, + baseTeamFilled: existing[0].baseTeamFilled, + milestonesTotal: existing[0].milestonesTotal, + baseMilestonesCompleted: existing[0].baseMilestonesCompleted, + budgetTotalHmnd: existing[0].budgetTotalHmnd ?? null, + baseBudgetAllocatedHmnd: existing[0].baseBudgetAllocatedHmnd ?? null, + }; + } + + const seed = await buildSeedFromReadModel(readModels, proposalId); + const now = new Date(); + await db.insert(formationProjects).values({ + proposalId, + teamSlotsTotal: seed.teamSlotsTotal, + baseTeamFilled: seed.baseTeamFilled, + milestonesTotal: seed.milestonesTotal, + baseMilestonesCompleted: seed.baseMilestonesCompleted, + budgetTotalHmnd: seed.budgetTotalHmnd, + baseBudgetAllocatedHmnd: seed.baseBudgetAllocatedHmnd, + createdAt: now, + updatedAt: now, + }); + + if (seed.milestonesTotal > 0) { + await db + .insert(formationMilestones) + .values( + Array.from({ length: seed.milestonesTotal }, (_, idx) => { + const milestoneIndex = idx + 1; + return { + proposalId, + milestoneIndex, + status: + milestoneIndex <= seed.baseMilestonesCompleted + ? "unlocked" + : "todo", + createdAt: now, + updatedAt: now, + }; + }), + ) + .onConflictDoNothing({ + target: [ + formationMilestones.proposalId, + formationMilestones.milestoneIndex, + ], + }); + } + + return seed; +} + +export async function ensureFormationSeedFromInput( + env: Env, + input: { proposalId: string; seed: FormationSeedInput }, +): Promise { + if (!env.DATABASE_URL) { + const existing = memoryProjects.get(input.proposalId); + if (existing) return; + memoryProjects.set(input.proposalId, input.seed); + const milestoneMap = new Map(); + for (let i = 1; i <= input.seed.milestonesTotal; i += 1) { + milestoneMap.set( + i, + i <= input.seed.baseMilestonesCompleted ? "unlocked" : "todo", + ); + } + memoryMilestones.set(input.proposalId, milestoneMap); + if (!memoryTeam.has(input.proposalId)) + memoryTeam.set(input.proposalId, new Map()); + return; + } + + const db = createDb(env); + const existing = await db + .select() + .from(formationProjects) + .where(eq(formationProjects.proposalId, input.proposalId)) + .limit(1); + if (existing[0]) return; + + const now = new Date(); + await db.insert(formationProjects).values({ + proposalId: input.proposalId, + teamSlotsTotal: input.seed.teamSlotsTotal, + baseTeamFilled: input.seed.baseTeamFilled, + milestonesTotal: input.seed.milestonesTotal, + baseMilestonesCompleted: input.seed.baseMilestonesCompleted, + budgetTotalHmnd: input.seed.budgetTotalHmnd, + baseBudgetAllocatedHmnd: input.seed.baseBudgetAllocatedHmnd, + createdAt: now, + updatedAt: now, + }); + + if (input.seed.milestonesTotal > 0) { + await db + .insert(formationMilestones) + .values( + Array.from({ length: input.seed.milestonesTotal }, (_, idx) => { + const milestoneIndex = idx + 1; + return { + proposalId: input.proposalId, + milestoneIndex, + status: + milestoneIndex <= input.seed.baseMilestonesCompleted + ? "unlocked" + : "todo", + createdAt: now, + updatedAt: now, + }; + }), + ) + .onConflictDoNothing({ + target: [ + formationMilestones.proposalId, + formationMilestones.milestoneIndex, + ], + }); + } +} + +export function buildV1FormationSeedFromProposalPayload( + payload: unknown, +): FormationSeedInput { + const record = + payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : null; + + const timeline = Array.isArray(record?.timeline) + ? (record?.timeline as unknown[]) + : []; + const budgetItems = Array.isArray(record?.budgetItems) + ? (record?.budgetItems as Array>) + : []; + + const budgetTotalHmnd = budgetItems.reduce((sum, item) => { + const amountRaw = typeof item.amount === "string" ? item.amount : ""; + const n = Number(amountRaw); + if (!Number.isFinite(n) || n <= 0) return sum; + return sum + n; + }, 0); + + return { + teamSlotsTotal: 3, + baseTeamFilled: 1, + milestonesTotal: timeline.length, + baseMilestonesCompleted: 0, + budgetTotalHmnd: budgetTotalHmnd > 0 ? budgetTotalHmnd : null, + baseBudgetAllocatedHmnd: 0, + }; +} + +export async function getFormationSummary( + env: Env, + readModels: ReadModelsStore, + proposalId: string, +): Promise { + const seed = await ensureFormationSeed(env, readModels, proposalId); + if (!env.DATABASE_URL) { + const teamCount = memoryTeam.get(proposalId)?.size ?? 0; + const milestones = memoryMilestones.get(proposalId); + const completed = milestones + ? Array.from(milestones.values()).filter((s) => s === "unlocked").length + : seed.baseMilestonesCompleted; + return { + teamFilled: seed.baseTeamFilled + teamCount, + teamTotal: seed.teamSlotsTotal, + milestonesCompleted: completed, + milestonesTotal: seed.milestonesTotal, + }; + } + + const db = createDb(env); + const [teamAgg] = await db + .select({ n: sql`count(*)` }) + .from(formationTeam) + .where(eq(formationTeam.proposalId, proposalId)); + const [milestoneAgg] = await db + .select({ + n: sql`sum(case when ${formationMilestones.status} = 'unlocked' then 1 else 0 end)`, + }) + .from(formationMilestones) + .where(eq(formationMilestones.proposalId, proposalId)); + + return { + teamFilled: seed.baseTeamFilled + Number(teamAgg?.n ?? 0), + teamTotal: seed.teamSlotsTotal, + milestonesCompleted: Number( + milestoneAgg?.n ?? seed.baseMilestonesCompleted, + ), + milestonesTotal: seed.milestonesTotal, + }; +} + +export async function listFormationJoiners( + env: Env, + proposalId: string, +): Promise<{ address: string; role?: string | null }[]> { + if (!env.DATABASE_URL) { + const team = memoryTeam.get(proposalId); + if (!team) return []; + return Array.from(team.entries()).map(([address, meta]) => ({ + address, + role: meta.role ?? null, + })); + } + + const db = createDb(env); + const rows = await db + .select({ + address: formationTeam.memberAddress, + role: formationTeam.role, + }) + .from(formationTeam) + .where(eq(formationTeam.proposalId, proposalId)); + return rows.map((r) => ({ address: r.address, role: r.role ?? null })); +} + +export async function joinFormationProject( + env: Env, + readModels: ReadModelsStore, + input: { proposalId: string; memberAddress: string; role?: string | null }, +): Promise<{ summary: FormationSummary; created: boolean }> { + const seed = await ensureFormationSeed(env, readModels, input.proposalId); + const address = input.memberAddress.trim(); + + if (!env.DATABASE_URL) { + const team = memoryTeam.get(input.proposalId) ?? new Map(); + const created = !team.has(address); + if (created) { + const current = seed.baseTeamFilled + team.size; + if (current >= seed.teamSlotsTotal) throw new Error("team_full"); + team.set(address, { role: input.role ?? null }); + memoryTeam.set(input.proposalId, team); + } + return { + summary: await getFormationSummary(env, readModels, input.proposalId), + created, + }; + } + + const db = createDb(env); + const existing = await db + .select({ memberAddress: formationTeam.memberAddress }) + .from(formationTeam) + .where( + and( + eq(formationTeam.proposalId, input.proposalId), + eq(formationTeam.memberAddress, address), + ), + ) + .limit(1); + if (existing.length > 0) { + return { + summary: await getFormationSummary(env, readModels, input.proposalId), + created: false, + }; + } + + const currentSummary = await getFormationSummary( + env, + readModels, + input.proposalId, + ); + if (currentSummary.teamFilled >= currentSummary.teamTotal) + throw new Error("team_full"); + + const now = new Date(); + await db + .insert(formationTeam) + .values({ + proposalId: input.proposalId, + memberAddress: address, + role: input.role ?? null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing({ + target: [formationTeam.proposalId, formationTeam.memberAddress], + }); + + return { + summary: await getFormationSummary(env, readModels, input.proposalId), + created: true, + }; +} + +export async function submitFormationMilestone( + env: Env, + readModels: ReadModelsStore, + input: { + proposalId: string; + milestoneIndex: number; + actorAddress: string; + note?: string | null; + }, +): Promise<{ summary: FormationSummary; created: boolean }> { + const seed = await ensureFormationSeed(env, readModels, input.proposalId); + if (input.milestoneIndex < 1 || input.milestoneIndex > seed.milestonesTotal) { + throw new Error("milestone_out_of_range"); + } + + if (!env.DATABASE_URL) { + const milestones = memoryMilestones.get(input.proposalId); + if (!milestones) throw new Error("milestones_missing"); + const current = milestones.get(input.milestoneIndex) ?? "todo"; + if (current === "unlocked") throw new Error("milestone_already_unlocked"); + const created = current !== "submitted"; + if (created) milestones.set(input.milestoneIndex, "submitted"); + return { + summary: await getFormationSummary(env, readModels, input.proposalId), + created, + }; + } + + const db = createDb(env); + const now = new Date(); + const rows = await db + .select({ status: formationMilestones.status }) + .from(formationMilestones) + .where( + and( + eq(formationMilestones.proposalId, input.proposalId), + eq(formationMilestones.milestoneIndex, input.milestoneIndex), + ), + ) + .limit(1); + const current = rows[0]?.status; + if (current === "unlocked") throw new Error("milestone_already_unlocked"); + const created = current !== "submitted"; + + await db + .insert(formationMilestones) + .values({ + proposalId: input.proposalId, + milestoneIndex: input.milestoneIndex, + status: "submitted", + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + formationMilestones.proposalId, + formationMilestones.milestoneIndex, + ], + set: { status: "submitted", updatedAt: now }, + }); + + await db.insert(formationMilestoneEvents).values({ + proposalId: input.proposalId, + milestoneIndex: input.milestoneIndex, + type: "submit", + actorAddress: input.actorAddress, + payload: { note: input.note ?? null }, + createdAt: now, + }); + + return { + summary: await getFormationSummary(env, readModels, input.proposalId), + created, + }; +} + +export async function requestFormationMilestoneUnlock( + env: Env, + readModels: ReadModelsStore, + input: { + proposalId: string; + milestoneIndex: number; + actorAddress: string; + }, +): Promise<{ summary: FormationSummary; created: boolean }> { + const seed = await ensureFormationSeed(env, readModels, input.proposalId); + if (input.milestoneIndex < 1 || input.milestoneIndex > seed.milestonesTotal) { + throw new Error("milestone_out_of_range"); + } + + if (!env.DATABASE_URL) { + const milestones = memoryMilestones.get(input.proposalId); + if (!milestones) throw new Error("milestones_missing"); + const current = milestones.get(input.milestoneIndex) ?? "todo"; + if (current === "unlocked") throw new Error("milestone_already_unlocked"); + if (current === "todo") throw new Error("milestone_not_submitted"); + milestones.set(input.milestoneIndex, "unlocked"); + return { + summary: await getFormationSummary(env, readModels, input.proposalId), + created: true, + }; + } + + const db = createDb(env); + const now = new Date(); + const rows = await db + .select({ status: formationMilestones.status }) + .from(formationMilestones) + .where( + and( + eq(formationMilestones.proposalId, input.proposalId), + eq(formationMilestones.milestoneIndex, input.milestoneIndex), + ), + ) + .limit(1); + const current = rows[0]?.status; + if (current === "unlocked") throw new Error("milestone_already_unlocked"); + if (current === "todo" || current === undefined) + throw new Error("milestone_not_submitted"); + + await db + .insert(formationMilestones) + .values({ + proposalId: input.proposalId, + milestoneIndex: input.milestoneIndex, + status: "unlocked", + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + formationMilestones.proposalId, + formationMilestones.milestoneIndex, + ], + set: { status: "unlocked", updatedAt: now }, + }); + + await db.insert(formationMilestoneEvents).values({ + proposalId: input.proposalId, + milestoneIndex: input.milestoneIndex, + type: "request_unlock", + actorAddress: input.actorAddress, + payload: {}, + createdAt: now, + }); + + return { + summary: await getFormationSummary(env, readModels, input.proposalId), + created: true, + }; +} + +export function clearFormationForTests() { + memoryProjects.clear(); + memoryTeam.clear(); + memoryMilestones.clear(); +} + +async function buildSeedFromReadModel( + readModels: ReadModelsStore, + proposalId: string, +): Promise { + const payload = await readModels.get(`proposals:${proposalId}:formation`); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return { + teamSlotsTotal: 0, + baseTeamFilled: 0, + milestonesTotal: 0, + baseMilestonesCompleted: 0, + budgetTotalHmnd: null, + baseBudgetAllocatedHmnd: null, + }; + } + + const anyPayload = payload as Record; + const team = parseRatio(asString(anyPayload.teamSlots, "")); + const milestones = parseRatio(asString(anyPayload.milestones, "")); + + const stageData = Array.isArray(anyPayload.stageData) + ? anyPayload.stageData + : []; + const budgetEntry = stageData.find( + (entry) => + entry && + typeof entry === "object" && + !Array.isArray(entry) && + String((entry as Record).title ?? "") + .toLowerCase() + .includes("budget"), + ) as Record | undefined; + const budgetPair = parseRatio(asString(budgetEntry?.value, "")); + + return { + teamSlotsTotal: team?.total ?? 0, + baseTeamFilled: team?.filled ?? 0, + milestonesTotal: milestones?.total ?? 0, + baseMilestonesCompleted: milestones?.filled ?? 0, + budgetTotalHmnd: budgetPair ? budgetPair.total : null, + baseBudgetAllocatedHmnd: budgetPair ? budgetPair.filled : null, + }; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function parseRatio(input: string): { filled: number; total: number } | null { + const normalized = input.replace(/HMND/gi, "").trim(); + if (!normalized) return null; + const parts = normalized.split("/").map((p) => p.trim()); + if (parts.length !== 2) return null; + const filled = parseHmndNumber(parts[0]); + const total = parseHmndNumber(parts[1]); + if (filled === null || total === null) return null; + return { filled, total }; +} + +function parseHmndNumber(input: string): number | null { + const s = input.trim().replace(/,/g, "").toLowerCase(); + if (!s) return null; + const match = s.match(/^([0-9]+(?:\.[0-9]+)?)\s*([km])?$/); + if (!match) return null; + const n = Number(match[1]); + if (!Number.isFinite(n)) return null; + const suffix = match[2]; + if (suffix === "k") return Math.round(n * 1_000); + if (suffix === "m") return Math.round(n * 1_000_000); + return Math.round(n); +} diff --git a/functions/_lib/gate.ts b/functions/_lib/gate.ts new file mode 100644 index 0000000..988b857 --- /dev/null +++ b/functions/_lib/gate.ts @@ -0,0 +1,132 @@ +import { eq } from "drizzle-orm"; + +import { eligibilityCache } from "../../db/schema.ts"; +import { envBoolean, envCsv } from "./env.ts"; +import { createDb } from "./db.ts"; +import { isActiveHumanNodeViaRpc } from "./humanodeRpc.ts"; +import { getSimConfig } from "./simConfig.ts"; + +type Env = Record; + +export type GateResult = { + eligible: boolean; + reason?: string; + expiresAt: string; +}; + +const memory = new Map(); + +export async function checkEligibility( + env: Env, + address: string, + requestUrl?: string, +): Promise { + const eligibleAddresses = new Set( + envCsv(env, "DEV_ELIGIBLE_ADDRESSES").map((a) => a.trim()), + ); + + const ttlMs = 10 * 60_000; + const expiresAt = new Date(Date.now() + ttlMs).toISOString(); + + if (envBoolean(env, "DEV_BYPASS_GATE")) return { eligible: true, expiresAt }; + if (eligibleAddresses.has(address.trim())) + return { eligible: true, expiresAt }; + + let envWithRpc: Env = env; + if (!env.HUMANODE_RPC_URL && requestUrl) { + const cfg = await getSimConfig(env, requestUrl); + const fromCfg = (cfg?.humanodeRpcUrl ?? "").trim(); + if (fromCfg) { + envWithRpc = { ...env, HUMANODE_RPC_URL: fromCfg }; + } + } + + if (env.DATABASE_URL) { + const db = createDb(env); + const now = new Date(); + const rows = await db + .select({ + isActive: eligibilityCache.isActiveHumanNode, + expiresAt: eligibilityCache.expiresAt, + reasonCode: eligibilityCache.reasonCode, + }) + .from(eligibilityCache) + .where(eq(eligibilityCache.address, address)) + .limit(1); + const row = rows[0]; + if (row && row.expiresAt.getTime() > now.getTime()) { + return { + eligible: row.isActive === 1, + reason: + row.isActive === 1 ? undefined : (row.reasonCode ?? "not_eligible"), + expiresAt: row.expiresAt.toISOString(), + }; + } + + let eligible = false; + let reason: string | undefined = undefined; + try { + eligible = await isActiveHumanNodeViaRpc(envWithRpc, address); + if (!eligible) reason = "not_in_validator_set"; + } catch (error) { + eligible = false; + const message = (error as Error | null)?.message ?? ""; + reason = message.includes("HUMANODE_RPC_URL") + ? "rpc_not_configured" + : "rpc_error"; + } + + const nextExpires = new Date(Date.now() + ttlMs); + await db + .insert(eligibilityCache) + .values({ + address, + isActiveHumanNode: eligible ? 1 : 0, + checkedAt: new Date(), + source: "rpc", + expiresAt: nextExpires, + reasonCode: eligible ? null : reason, + }) + .onConflictDoUpdate({ + target: eligibilityCache.address, + set: { + isActiveHumanNode: eligible ? 1 : 0, + checkedAt: new Date(), + source: "rpc", + expiresAt: nextExpires, + reasonCode: eligible ? null : reason, + }, + }); + + return { + eligible, + reason: eligible ? undefined : reason, + expiresAt: nextExpires.toISOString(), + }; + } + + const cached = memory.get(address); + if (cached && new Date(cached.expiresAt).getTime() > Date.now()) + return cached; + + let eligible = false; + let reason: string | undefined = undefined; + try { + eligible = await isActiveHumanNodeViaRpc(envWithRpc, address); + if (!eligible) reason = "not_in_validator_set"; + } catch (error) { + eligible = false; + const message = (error as Error | null)?.message ?? ""; + reason = message.includes("HUMANODE_RPC_URL") + ? "rpc_not_configured" + : "rpc_error"; + } + + const result: GateResult = { + eligible, + reason: eligible ? undefined : reason, + expiresAt, + }; + memory.set(address, result); + return result; +} diff --git a/functions/_lib/humanodeRpc.ts b/functions/_lib/humanodeRpc.ts new file mode 100644 index 0000000..997820a --- /dev/null +++ b/functions/_lib/humanodeRpc.ts @@ -0,0 +1,115 @@ +import { xxhashAsHex } from "@polkadot/util-crypto"; +import { cryptoWaitReady, decodeAddress } from "@polkadot/util-crypto"; +import { hexToU8a, u8aToHex } from "@polkadot/util"; + +type Env = Record; + +type JsonRpcResponse = + | { jsonrpc: "2.0"; id: number; result: T } + | { jsonrpc: "2.0"; id: number; error: { code: number; message: string } }; + +function storageKeySessionValidators(): string { + const pallet = xxhashAsHex("Session", 128).slice(2); + const item = xxhashAsHex("Validators", 128).slice(2); + return `0x${pallet}${item}`; +} + +async function rpcCall(rpcUrl: string, method: string, params: unknown[]) { + const res = await fetch(rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + if (!res.ok) throw new Error(`RPC HTTP ${res.status}`); + const json = (await res.json()) as JsonRpcResponse; + if ("error" in json) throw new Error(json.error.message); + return json.result; +} + +function readCompactU32( + bytes: Uint8Array, + offset: number, +): { value: number; offset: number } { + const first = bytes[offset]; + if (first === undefined) throw new Error("SCALE: unexpected EOF"); + const mode = first & 0b11; + if (mode === 0) return { value: first >> 2, offset: offset + 1 }; + if (mode === 1) { + const b1 = bytes[offset + 1]; + if (b1 === undefined) throw new Error("SCALE: unexpected EOF"); + const value = (first >> 2) | (b1 << 6); + return { value, offset: offset + 2 }; + } + if (mode === 2) { + const b1 = bytes[offset + 1]; + const b2 = bytes[offset + 2]; + const b3 = bytes[offset + 3]; + if (b3 === undefined) throw new Error("SCALE: unexpected EOF"); + const value = (first >> 2) | (b1 << 6) | (b2 << 14) | (b3 << 22); + return { value, offset: offset + 4 }; + } + throw new Error("SCALE: compact big-int not supported"); +} + +function decodeVecAccountId32(hex: string | null): Uint8Array[] { + if (!hex || hex === "0x") return []; + const bytes = hexToU8a(hex); + const { value: length, offset } = readCompactU32(bytes, 0); + const accounts: Uint8Array[] = []; + let cursor = offset; + for (let i = 0; i < length; i++) { + const chunk = bytes.slice(cursor, cursor + 32); + if (chunk.length !== 32) throw new Error("SCALE: invalid AccountId"); + accounts.push(chunk); + cursor += 32; + } + return accounts; +} + +function publicKeyHexFromAddress(address: string): string | null { + try { + const subject = decodeAddress(address.trim()); + return u8aToHex(subject); + } catch { + return null; + } +} + +export async function fetchSessionValidatorsViaRpc( + env: Env, +): Promise { + const rpcUrl = env.HUMANODE_RPC_URL; + if (!rpcUrl) throw new Error("HUMANODE_RPC_URL is required"); + + await cryptoWaitReady(); + + const validatorsKey = storageKeySessionValidators(); + const validatorsStorage = await rpcCall( + rpcUrl, + "state_getStorage", + [validatorsKey], + ); + const validators = decodeVecAccountId32(validatorsStorage); + return validators.map((pk) => u8aToHex(pk)); +} + +export function isSs58OrHexAddressInSet( + address: string, + validatorPublicKeysHex: Set, +): boolean { + const pk = publicKeyHexFromAddress(address); + if (!pk) return false; + return validatorPublicKeysHex.has(pk); +} + +export async function isActiveHumanNodeViaRpc( + env: Env, + address: string, +): Promise { + await cryptoWaitReady(); + const subject = decodeAddress(address.trim()); + + const validators = await fetchSessionValidatorsViaRpc(env); + const subjectHex = u8aToHex(subject); + return validators.some((v) => v === subjectHex); +} diff --git a/functions/_lib/idempotencyStore.ts b/functions/_lib/idempotencyStore.ts new file mode 100644 index 0000000..20d0f41 --- /dev/null +++ b/functions/_lib/idempotencyStore.ts @@ -0,0 +1,87 @@ +import { eq } from "drizzle-orm"; + +import { idempotencyKeys } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +type Stored = { + address: string; + request: unknown; + response: unknown; +}; + +const memory = new Map(); + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + const obj = value as Record; + const keys = Object.keys(obj).sort((a, b) => a.localeCompare(b)); + return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`; +} + +export async function getIdempotencyResponse( + env: Env, + input: { key: string; address: string; request: unknown }, +): Promise< + | { hit: true; response: unknown } + | { hit: false } + | { hit: false; conflict: true } +> { + if (!env.DATABASE_URL) { + const existing = memory.get(input.key); + if (!existing) return { hit: false }; + if (existing.address !== input.address) + return { hit: false, conflict: true }; + if (stableStringify(existing.request) !== stableStringify(input.request)) { + return { hit: false, conflict: true }; + } + return { hit: true, response: existing.response }; + } + + const db = createDb(env); + const rows = await db + .select({ + address: idempotencyKeys.address, + request: idempotencyKeys.request, + response: idempotencyKeys.response, + }) + .from(idempotencyKeys) + .where(eq(idempotencyKeys.key, input.key)) + .limit(1); + const row = rows[0]; + if (!row) return { hit: false }; + if (row.address !== input.address) return { hit: false, conflict: true }; + if (stableStringify(row.request) !== stableStringify(input.request)) { + return { hit: false, conflict: true }; + } + return { hit: true, response: row.response }; +} + +export async function storeIdempotencyResponse( + env: Env, + input: { key: string; address: string; request: unknown; response: unknown }, +): Promise { + if (!env.DATABASE_URL) { + memory.set(input.key, { + address: input.address, + request: input.request, + response: input.response, + }); + return; + } + + const db = createDb(env); + await db.insert(idempotencyKeys).values({ + key: input.key, + address: input.address, + request: input.request, + response: input.response, + createdAt: new Date(), + }); +} + +export function clearIdempotencyForTests() { + memory.clear(); +} diff --git a/functions/_lib/nonceStore.ts b/functions/_lib/nonceStore.ts new file mode 100644 index 0000000..c886967 --- /dev/null +++ b/functions/_lib/nonceStore.ts @@ -0,0 +1,132 @@ +import { and, eq, gt, isNull } from "drizzle-orm"; + +import { authNonces } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +export type NonceStore = { + create: (input: { + address: string; + nonce: string; + requestIp?: string; + expiresAt: Date; + }) => Promise; + consume: (input: { + address: string; + nonce: string; + }) => Promise< + | { ok: true } + | { ok: false; reason: "not_found" | "expired" | "used" | "mismatch" } + >; + canIssue: (input: { + address: string; + requestIp?: string; + }) => Promise< + | { ok: true } + | { ok: false; reason: "rate_limited"; retryAfterSeconds: number } + >; +}; + +const memory = new Map< + string, + { address: string; expiresAt: number; usedAt?: number; requestIp?: string } +>(); +const memoryIssuedAt = new Map(); + +function nowMs(): number { + return Date.now(); +} + +export function createNonceStore(env: Env): NonceStore { + if (!env.DATABASE_URL) { + return { + canIssue: async ({ requestIp }) => { + if (!requestIp) return { ok: true }; + const now = nowMs(); + const windowMs = 60_000; + const limit = 20; + const issued = memoryIssuedAt.get(requestIp) ?? []; + const next = issued.filter((t) => now - t < windowMs); + if (next.length >= limit) { + return { ok: false, reason: "rate_limited", retryAfterSeconds: 60 }; + } + next.push(now); + memoryIssuedAt.set(requestIp, next); + return { ok: true }; + }, + create: async ({ address, nonce, expiresAt, requestIp }) => { + memory.set(nonce, { + address, + expiresAt: expiresAt.getTime(), + requestIp, + }); + }, + consume: async ({ address, nonce }) => { + const row = memory.get(nonce); + if (!row) return { ok: false, reason: "not_found" }; + if (row.address !== address) return { ok: false, reason: "mismatch" }; + if (row.usedAt) return { ok: false, reason: "used" }; + if (nowMs() > row.expiresAt) return { ok: false, reason: "expired" }; + row.usedAt = nowMs(); + return { ok: true }; + }, + }; + } + + const db = createDb(env); + + return { + canIssue: async ({ requestIp }) => { + if (!requestIp) return { ok: true }; + const now = new Date(); + const windowStart = new Date(now.getTime() - 60_000); + const limit = 20; + const rows = await db + .select({ nonce: authNonces.nonce }) + .from(authNonces) + .where( + and( + eq(authNonces.requestIp, requestIp), + gt(authNonces.createdAt, windowStart), + ), + ) + .limit(limit + 1); + if (rows.length > limit) { + return { ok: false, reason: "rate_limited", retryAfterSeconds: 60 }; + } + return { ok: true }; + }, + create: async ({ address, nonce, requestIp, expiresAt }) => { + await db.insert(authNonces).values({ + nonce, + address, + requestIp, + expiresAt, + }); + }, + consume: async ({ address, nonce }) => { + const rows = await db + .select({ + address: authNonces.address, + expiresAt: authNonces.expiresAt, + usedAt: authNonces.usedAt, + }) + .from(authNonces) + .where(eq(authNonces.nonce, nonce)) + .limit(1); + const row = rows[0]; + if (!row) return { ok: false, reason: "not_found" }; + if (row.address !== address) return { ok: false, reason: "mismatch" }; + if (row.usedAt) return { ok: false, reason: "used" }; + if (row.expiresAt.getTime() < Date.now()) + return { ok: false, reason: "expired" }; + + await db + .update(authNonces) + .set({ usedAt: new Date() }) + .where(and(eq(authNonces.nonce, nonce), isNull(authNonces.usedAt))); + return { ok: true }; + }, + }; +} diff --git a/functions/_lib/pages.d.ts b/functions/_lib/pages.d.ts new file mode 100644 index 0000000..da0e375 --- /dev/null +++ b/functions/_lib/pages.d.ts @@ -0,0 +1,9 @@ +// Minimal Cloudflare Pages Functions types for editor/typecheck support. +// We keep this local (instead of depending on @cloudflare/workers-types) to +// avoid adding heavy dependencies while still getting basic safety. + +type PagesFunction> = (context: { + request: Request; + env: Env; + params?: Record; +}) => Response | Promise; diff --git a/functions/_lib/poolQuorum.ts b/functions/_lib/poolQuorum.ts new file mode 100644 index 0000000..ff802a6 --- /dev/null +++ b/functions/_lib/poolQuorum.ts @@ -0,0 +1,42 @@ +export type PoolQuorumInputs = { + attentionQuorum: number; // fraction, e.g. 0.2 + activeGovernors: number; // denominator + upvoteFloor: number; // absolute number of upvotes required +}; + +export type PoolCounts = { upvotes: number; downvotes: number }; + +export type PoolQuorumResult = { + engaged: number; + engagedNeeded: number; + attentionMet: boolean; + upvoteMet: boolean; + shouldAdvance: boolean; +}; + +export function evaluatePoolQuorum( + inputs: PoolQuorumInputs, + counts: PoolCounts, +): PoolQuorumResult { + const active = Math.max(0, Math.floor(inputs.activeGovernors)); + const engaged = Math.max(0, counts.upvotes) + Math.max(0, counts.downvotes); + const quorum = Math.max(0, Math.min(1, inputs.attentionQuorum)); + + // With very small active sets, ceil(active * quorum) can become 1 and cause + // "single-vote advances"; require at least 2 engaged governors whenever there + // is more than 1 active governor. + const minEngaged = active > 1 ? 2 : 1; + const engagedNeeded = + active > 0 ? Math.max(minEngaged, Math.ceil(active * quorum)) : 0; + const attentionMet = active > 0 ? engaged >= engagedNeeded : false; + const upvoteMet = + Math.max(0, counts.upvotes) >= Math.max(0, inputs.upvoteFloor); + + return { + engaged, + engagedNeeded, + attentionMet, + upvoteMet, + shouldAdvance: attentionMet && upvoteMet, + }; +} diff --git a/functions/_lib/poolVotesStore.ts b/functions/_lib/poolVotesStore.ts new file mode 100644 index 0000000..e287182 --- /dev/null +++ b/functions/_lib/poolVotesStore.ts @@ -0,0 +1,118 @@ +import { and, eq, sql } from "drizzle-orm"; + +import { poolVotes } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +type Direction = 1 | -1; + +type Counts = { upvotes: number; downvotes: number }; + +const memoryVotes = new Map>(); + +export async function hasPoolVote( + env: Env, + input: { proposalId: string; voterAddress: string }, +): Promise { + const voterAddress = input.voterAddress.trim(); + if (!env.DATABASE_URL) { + const byVoter = memoryVotes.get(input.proposalId); + if (!byVoter) return false; + return byVoter.has(voterAddress); + } + const db = createDb(env); + const existing = await db + .select({ direction: poolVotes.direction }) + .from(poolVotes) + .where( + and( + eq(poolVotes.proposalId, input.proposalId), + eq(poolVotes.voterAddress, voterAddress), + ), + ) + .limit(1); + return existing.length > 0; +} + +export async function castPoolVote( + env: Env, + input: { proposalId: string; voterAddress: string; direction: Direction }, +): Promise<{ counts: Counts; created: boolean }> { + if (!env.DATABASE_URL) { + const byVoter = + memoryVotes.get(input.proposalId) ?? new Map(); + const key = input.voterAddress.trim(); + const created = !byVoter.has(key); + byVoter.set(key, input.direction); + memoryVotes.set(input.proposalId, byVoter); + return { counts: countMemory(input.proposalId), created }; + } + + const db = createDb(env); + const voterAddress = input.voterAddress.trim(); + const existing = await db + .select({ direction: poolVotes.direction }) + .from(poolVotes) + .where( + and( + eq(poolVotes.proposalId, input.proposalId), + eq(poolVotes.voterAddress, voterAddress), + ), + ) + .limit(1); + const created = existing.length === 0; + const now = new Date(); + await db + .insert(poolVotes) + .values({ + proposalId: input.proposalId, + voterAddress, + direction: input.direction, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [poolVotes.proposalId, poolVotes.voterAddress], + set: { direction: input.direction, updatedAt: now }, + }); + + return { counts: await getPoolVoteCounts(env, input.proposalId), created }; +} + +export async function getPoolVoteCounts( + env: Env, + proposalId: string, +): Promise { + if (!env.DATABASE_URL) return countMemory(proposalId); + const db = createDb(env); + const rows = await db + .select({ + upvotes: sql`sum(case when ${poolVotes.direction} = 1 then 1 else 0 end)`, + downvotes: sql`sum(case when ${poolVotes.direction} = -1 then 1 else 0 end)`, + }) + .from(poolVotes) + .where(eq(poolVotes.proposalId, proposalId)); + + const row = rows[0]; + return { + upvotes: Number(row?.upvotes ?? 0), + downvotes: Number(row?.downvotes ?? 0), + }; +} + +export async function clearPoolVotesForTests() { + memoryVotes.clear(); +} + +function countMemory(proposalId: string): Counts { + const byVoter = memoryVotes.get(proposalId); + if (!byVoter) return { upvotes: 0, downvotes: 0 }; + let upvotes = 0; + let downvotes = 0; + for (const direction of byVoter.values()) { + if (direction === 1) upvotes += 1; + if (direction === -1) downvotes += 1; + } + return { upvotes, downvotes }; +} diff --git a/functions/_lib/proposalDraftsStore.ts b/functions/_lib/proposalDraftsStore.ts new file mode 100644 index 0000000..0c1bacb --- /dev/null +++ b/functions/_lib/proposalDraftsStore.ts @@ -0,0 +1,542 @@ +import { and, desc, eq, isNull } from "drizzle-orm"; +import { z } from "zod"; + +import { proposalDrafts } from "../../db/schema.ts"; +import { randomHex } from "./random.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +const timelineItemSchema = z.object({ + id: z.string(), + title: z.string(), + timeframe: z.string(), +}); + +const outputItemSchema = z.object({ + id: z.string(), + label: z.string(), + url: z.string(), +}); + +const budgetItemSchema = z.object({ + id: z.string(), + description: z.string(), + amount: z.string(), +}); + +const attachmentItemSchema = z.object({ + id: z.string(), + label: z.string(), + url: z.string(), +}); + +const metaGovernanceSchema = z.object({ + action: z.enum(["chamber.create", "chamber.dissolve"]), + chamberId: z.string(), + title: z.string().optional(), + multiplier: z.number().optional(), + genesisMembers: z.array(z.string()).optional(), +}); + +const optionalString = z.string().optional().default(""); +const optionalTimeline = z.array(timelineItemSchema).optional().default([]); +const optionalOutputs = z.array(outputItemSchema).optional().default([]); +const optionalBudgetItems = z.array(budgetItemSchema).optional().default([]); +const optionalAttachments = z + .array(attachmentItemSchema) + .optional() + .default([]); + +const projectDraftSchema = z.object({ + templateId: z.literal("project"), + title: z.string(), + chamberId: z.string(), + summary: z.string(), + what: z.string(), + why: z.string(), + how: z.string(), + metaGovernance: z.undefined().optional(), + timeline: z.array(timelineItemSchema), + outputs: z.array(outputItemSchema), + budgetItems: z.array(budgetItemSchema), + aboutMe: z.string(), + attachments: z.array(attachmentItemSchema), + agreeRules: z.boolean(), + confirmBudget: z.boolean(), +}); + +const systemDraftSchema = z.object({ + templateId: z.literal("system"), + title: z.string(), + chamberId: z.string(), + summary: optionalString, + what: optionalString, + why: optionalString, + how: optionalString, + metaGovernance: metaGovernanceSchema, + timeline: optionalTimeline, + outputs: optionalOutputs, + budgetItems: optionalBudgetItems, + aboutMe: optionalString, + attachments: optionalAttachments, + agreeRules: z.boolean(), + confirmBudget: z.boolean(), +}); + +export const proposalDraftFormSchema = z.preprocess( + (input) => { + if (!input || typeof input !== "object" || Array.isArray(input)) + return input; + const record = { ...(input as Record) }; + if (!("templateId" in record)) { + record.templateId = record.metaGovernance ? "system" : "project"; + } + return record; + }, + z.discriminatedUnion("templateId", [projectDraftSchema, systemDraftSchema]), +); + +export type ProposalDraftForm = z.infer; + +export type ProposalDraftRecord = { + id: string; + authorAddress: string; + title: string; + chamberId: string | null; + summary: string; + payload: ProposalDraftForm; + createdAt: Date; + updatedAt: Date; + submittedAt: Date | null; + submittedProposalId: string | null; +}; + +const memoryDraftsByAuthor = new Map< + string, + Map +>(); + +export function clearProposalDraftsForTests() { + memoryDraftsByAuthor.clear(); +} + +export function seedLegacyDraftForTests(input: { + authorAddress: string; + draftId: string; + title: string; + chamberId?: string | null; + summary?: string; + payload: unknown; + createdAt?: Date; + updatedAt?: Date; + submittedAt?: Date | null; + submittedProposalId?: string | null; +}) { + const address = input.authorAddress.trim(); + const now = new Date(); + const byId = + memoryDraftsByAuthor.get(address) ?? new Map(); + const record: ProposalDraftRecord = { + id: input.draftId, + authorAddress: address, + title: input.title, + chamberId: input.chamberId ?? null, + summary: input.summary ?? "", + payload: input.payload as ProposalDraftForm, + createdAt: input.createdAt ?? now, + updatedAt: input.updatedAt ?? now, + submittedAt: input.submittedAt ?? null, + submittedProposalId: input.submittedProposalId ?? null, + }; + byId.set(record.id, record); + memoryDraftsByAuthor.set(address, byId); +} + +function hasTemplateId(payload: unknown): boolean { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return false; + return typeof (payload as { templateId?: unknown }).templateId === "string"; +} + +function normalizeDraftPayload(payload: unknown): ProposalDraftForm { + return proposalDraftFormSchema.parse(payload); +} + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); +} + +function computeBudgetTotalHmnd(form: ProposalDraftForm): number { + return (form.budgetItems ?? []).reduce((sum, item) => { + const n = Number(item.amount); + if (!Number.isFinite(n) || n <= 0) return sum; + return sum + n; + }, 0); +} + +function resolveTemplateId(form: ProposalDraftForm): "project" | "system" { + return form.templateId; +} + +export function draftIsSubmittable(form: ProposalDraftForm): boolean { + const templateId = resolveTemplateId(form); + const isSystem = templateId === "system"; + const budgetTotal = computeBudgetTotalHmnd(form); + const title = (form.title ?? "").trim(); + const what = (form.what ?? "").trim(); + const why = (form.why ?? "").trim(); + const how = (form.how ?? "").trim(); + const essentialsValid = + title.length > 0 && (isSystem ? true : what.length > 0 && why.length > 0); + const planValid = isSystem ? true : how.length > 0; + const budgetItems = form.budgetItems ?? []; + const budgetValid = isSystem + ? true + : budgetItems.some( + (item) => + item.description.trim().length > 0 && + Number.isFinite(Number(item.amount)) && + Number(item.amount) > 0, + ) && budgetTotal > 0; + const meta = form.metaGovernance; + const systemValid = isSystem + ? Boolean( + meta && + (form.chamberId ?? "").trim().toLowerCase() === "general" && + meta.chamberId.trim().length > 0 && + (meta.action === "chamber.dissolve" + ? true + : (meta.title ?? "").trim().length > 0), + ) + : true; + const rulesValid = form.agreeRules && form.confirmBudget; + return ( + essentialsValid && planValid && budgetValid && systemValid && rulesValid + ); +} + +export function formatChamberLabel(chamberId: string | null): string { + const id = (chamberId ?? "").trim(); + if (!id) return "General chamber"; + const title = id + .split(/[-_\s]+/g) + .filter(Boolean) + .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) + .join(" "); + return `${title} chamber`; +} + +export function formatDraftId(input: { title: string }): string { + const slug = slugify(input.title); + const suffix = randomHex(2); + return `draft-${slug || "untitled"}-${suffix}`; +} + +export async function upsertDraft( + env: Env, + input: { authorAddress: string; draftId?: string; form: ProposalDraftForm }, +): Promise { + const address = input.authorAddress.trim(); + const now = new Date(); + const form = proposalDraftFormSchema.parse(input.form); + + const id = + typeof input.draftId === "string" && input.draftId.trim().length > 0 + ? input.draftId.trim() + : formatDraftId({ title: form.title }); + + if (!env.DATABASE_URL) { + const byId = + memoryDraftsByAuthor.get(address) ?? + new Map(); + const existing = byId.get(id); + const record: ProposalDraftRecord = { + id, + authorAddress: address, + title: form.title, + chamberId: form.chamberId || null, + summary: form.summary, + payload: form, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + submittedAt: existing?.submittedAt ?? null, + submittedProposalId: existing?.submittedProposalId ?? null, + }; + byId.set(id, record); + memoryDraftsByAuthor.set(address, byId); + return record; + } + + const db = createDb(env); + const existing = await db + .select({ + id: proposalDrafts.id, + createdAt: proposalDrafts.createdAt, + submittedAt: proposalDrafts.submittedAt, + submittedProposalId: proposalDrafts.submittedProposalId, + }) + .from(proposalDrafts) + .where( + and(eq(proposalDrafts.id, id), eq(proposalDrafts.authorAddress, address)), + ) + .limit(1); + + const createdAt = existing[0]?.createdAt ?? now; + const submittedAt = existing[0]?.submittedAt ?? null; + const submittedProposalId = existing[0]?.submittedProposalId ?? null; + + await db + .insert(proposalDrafts) + .values({ + id, + authorAddress: address, + title: form.title, + chamberId: form.chamberId || null, + summary: form.summary, + payload: form, + submittedAt, + submittedProposalId, + createdAt, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: proposalDrafts.id, + set: { + title: form.title, + chamberId: form.chamberId || null, + summary: form.summary, + payload: form, + updatedAt: now, + }, + }); + + return { + id, + authorAddress: address, + title: form.title, + chamberId: form.chamberId || null, + summary: form.summary, + payload: form, + createdAt, + updatedAt: now, + submittedAt, + submittedProposalId, + }; +} + +export async function deleteDraft( + env: Env, + input: { authorAddress: string; draftId: string }, +): Promise { + const address = input.authorAddress.trim(); + const id = input.draftId.trim(); + if (!env.DATABASE_URL) { + const byId = memoryDraftsByAuthor.get(address); + if (!byId) return false; + return byId.delete(id); + } + + const db = createDb(env); + const res = await db + .delete(proposalDrafts) + .where( + and(eq(proposalDrafts.id, id), eq(proposalDrafts.authorAddress, address)), + ); + return res.rowCount > 0; +} + +export async function listDrafts( + env: Env, + input: { authorAddress: string; includeSubmitted?: boolean }, +): Promise { + const address = input.authorAddress.trim(); + const includeSubmitted = Boolean(input.includeSubmitted); + + if (!env.DATABASE_URL) { + const byId = memoryDraftsByAuthor.get(address); + const list = byId ? Array.from(byId.values()) : []; + const normalized = list + .filter((d) => includeSubmitted || !d.submittedAt) + .map((draft) => { + if (!hasTemplateId(draft.payload)) { + const payload = normalizeDraftPayload(draft.payload); + const next = { ...draft, payload }; + byId?.set(draft.id, next); + return next; + } + return draft; + }) + .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + return normalized; + } + + const db = createDb(env); + const where = includeSubmitted + ? and(eq(proposalDrafts.authorAddress, address)) + : and( + eq(proposalDrafts.authorAddress, address), + isNull(proposalDrafts.submittedAt), + ); + + const rows = await db + .select({ + id: proposalDrafts.id, + authorAddress: proposalDrafts.authorAddress, + title: proposalDrafts.title, + chamberId: proposalDrafts.chamberId, + summary: proposalDrafts.summary, + payload: proposalDrafts.payload, + createdAt: proposalDrafts.createdAt, + updatedAt: proposalDrafts.updatedAt, + submittedAt: proposalDrafts.submittedAt, + submittedProposalId: proposalDrafts.submittedProposalId, + }) + .from(proposalDrafts) + .where(where) + .orderBy(desc(proposalDrafts.updatedAt)); + + const migrations: Promise[] = []; + const result = rows.map((row) => { + const needsMigration = !hasTemplateId(row.payload); + const payload = normalizeDraftPayload(row.payload); + if (needsMigration) { + migrations.push( + db + .update(proposalDrafts) + .set({ payload, updatedAt: row.updatedAt }) + .where( + and( + eq(proposalDrafts.id, row.id), + eq(proposalDrafts.authorAddress, row.authorAddress), + ), + ), + ); + } + return { + id: row.id, + authorAddress: row.authorAddress, + title: row.title, + chamberId: row.chamberId ?? null, + summary: row.summary, + payload, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + submittedAt: row.submittedAt ?? null, + submittedProposalId: row.submittedProposalId ?? null, + }; + }); + if (migrations.length > 0) { + await Promise.all(migrations); + } + return result; +} + +export async function getDraft( + env: Env, + input: { authorAddress: string; draftId: string }, +): Promise { + const address = input.authorAddress.trim(); + const id = input.draftId.trim(); + if (!env.DATABASE_URL) { + const byId = memoryDraftsByAuthor.get(address); + const record = byId?.get(id) ?? null; + if (!record) return null; + if (!hasTemplateId(record.payload)) { + const payload = normalizeDraftPayload(record.payload); + const next = { ...record, payload }; + byId?.set(id, next); + return next; + } + return record; + } + + const db = createDb(env); + const rows = await db + .select({ + id: proposalDrafts.id, + authorAddress: proposalDrafts.authorAddress, + title: proposalDrafts.title, + chamberId: proposalDrafts.chamberId, + summary: proposalDrafts.summary, + payload: proposalDrafts.payload, + createdAt: proposalDrafts.createdAt, + updatedAt: proposalDrafts.updatedAt, + submittedAt: proposalDrafts.submittedAt, + submittedProposalId: proposalDrafts.submittedProposalId, + }) + .from(proposalDrafts) + .where( + and(eq(proposalDrafts.id, id), eq(proposalDrafts.authorAddress, address)), + ) + .limit(1); + const row = rows[0]; + if (!row) return null; + const needsMigration = !hasTemplateId(row.payload); + const payload = normalizeDraftPayload(row.payload); + if (needsMigration) { + await db + .update(proposalDrafts) + .set({ payload, updatedAt: row.updatedAt }) + .where( + and( + eq(proposalDrafts.id, row.id), + eq(proposalDrafts.authorAddress, row.authorAddress), + ), + ); + } + return { + id: row.id, + authorAddress: row.authorAddress, + title: row.title, + chamberId: row.chamberId ?? null, + summary: row.summary, + payload, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + submittedAt: row.submittedAt ?? null, + submittedProposalId: row.submittedProposalId ?? null, + }; +} + +export async function markDraftSubmitted( + env: Env, + input: { authorAddress: string; draftId: string; proposalId: string }, +): Promise { + const address = input.authorAddress.trim(); + const draftId = input.draftId.trim(); + const now = new Date(); + + if (!env.DATABASE_URL) { + const byId = memoryDraftsByAuthor.get(address); + const existing = byId?.get(draftId); + if (!existing) throw new Error("draft_missing"); + byId?.set(draftId, { + ...existing, + submittedAt: existing.submittedAt ?? now, + submittedProposalId: existing.submittedProposalId ?? input.proposalId, + updatedAt: now, + }); + return; + } + + const db = createDb(env); + await db + .update(proposalDrafts) + .set({ + submittedAt: now, + submittedProposalId: input.proposalId, + updatedAt: now, + }) + .where( + and( + eq(proposalDrafts.id, draftId), + eq(proposalDrafts.authorAddress, address), + ), + ); +} diff --git a/functions/_lib/proposalFinalizer.ts b/functions/_lib/proposalFinalizer.ts new file mode 100644 index 0000000..19627a2 --- /dev/null +++ b/functions/_lib/proposalFinalizer.ts @@ -0,0 +1,195 @@ +import { getChamberYesScoreAverage } from "./chamberVotesStore.ts"; +import { awardCmOnce } from "./cmAwardsStore.ts"; +import { + createChamberFromAcceptedGeneralProposal, + dissolveChamberFromAcceptedGeneralProposal, + getChamberMultiplierTimes10 as getCanonicalChamberMultiplierTimes10, + parseChamberGovernanceFromPayload, +} from "./chambersStore.ts"; +import { + buildV1FormationSeedFromProposalPayload, + ensureFormationSeedFromInput, +} from "./formationStore.ts"; +import { + grantVotingEligibilityForAcceptedProposal, + ensureChamberMembership, +} from "./chamberMembershipsStore.ts"; +import { appendProposalTimelineItem } from "./proposalTimelineStore.ts"; +import { + clearProposalVotePendingVeto, + getProposal, + transitionProposalStage, +} from "./proposalsStore.ts"; +import { randomHex } from "./random.ts"; + +type Env = Record; + +function getFormationEligibleFromProposalPayload(payload: unknown): boolean { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return true; + const record = payload as Record; + if (record.templateId === "system") return false; + if ( + typeof record.metaGovernance === "object" && + record.metaGovernance !== null && + !Array.isArray(record.metaGovernance) + ) + return false; + if (typeof record.formationEligible === "boolean") + return record.formationEligible; + if (typeof record.formation === "boolean") return record.formation; + return true; +} + +export async function finalizeAcceptedProposalFromVote( + env: Env, + input: { proposalId: string; requestUrl: string }, +): Promise< + | { + ok: true; + formationEligible: boolean; + avgScore: number | null; + proposalChamberId: string; + } + | { ok: false; reason: string } +> { + const proposal = await getProposal(env, input.proposalId); + if (!proposal) return { ok: false, reason: "missing_proposal" }; + if (proposal.stage !== "vote") return { ok: false, reason: "stage_invalid" }; + + const transitioned = await transitionProposalStage(env, { + proposalId: input.proposalId, + from: "vote", + to: "build", + }); + if (!transitioned) return { ok: false, reason: "transition_failed" }; + + await clearProposalVotePendingVeto(env, { proposalId: proposal.id }).catch( + () => {}, + ); + + await grantVotingEligibilityForAcceptedProposal(env, { + address: proposal.authorAddress, + chamberId: proposal.chamberId ?? null, + proposalId: proposal.id, + }); + + const proposalChamberId = (() => { + const raw = (proposal.chamberId ?? "general").trim(); + return raw ? raw.toLowerCase() : "general"; + })(); + const meta = parseChamberGovernanceFromPayload(proposal.payload); + const effectiveChamberId = meta ? "general" : proposalChamberId; + + if (effectiveChamberId === "general" && meta) { + if (meta.action === "chamber.create" && meta.title) { + await createChamberFromAcceptedGeneralProposal(env, input.requestUrl, { + id: meta.id, + title: meta.title, + multiplier: meta.multiplier, + proposalId: proposal.id, + }); + + await appendProposalTimelineItem(env, { + proposalId: proposal.id, + stage: "build", + actorAddress: null, + item: { + id: `timeline:chamber-created:${proposal.id}:${randomHex(4)}`, + type: "chamber.created", + title: "Chamber created", + detail: `${meta.id} (${meta.title})`, + actor: "system", + timestamp: new Date().toISOString(), + }, + }); + + const genesisMembers = (() => { + if (!proposal.payload || typeof proposal.payload !== "object") + return []; + const record = proposal.payload as Record; + const mg = record.metaGovernance; + if (!mg || typeof mg !== "object" || Array.isArray(mg)) return []; + const metaRecord = mg as Record; + const raw = metaRecord.genesisMembers; + if (!Array.isArray(raw)) return []; + return raw + .filter((v): v is string => typeof v === "string") + .map((v) => v.trim()) + .filter(Boolean); + })(); + + const memberSet = new Set(genesisMembers); + memberSet.add(proposal.authorAddress.trim()); + for (const address of memberSet) { + await ensureChamberMembership(env, { + address, + chamberId: meta.id, + grantedByProposalId: proposal.id, + source: "chamber_genesis", + }); + } + } + if (meta.action === "chamber.dissolve") { + await dissolveChamberFromAcceptedGeneralProposal(env, input.requestUrl, { + id: meta.id, + proposalId: proposal.id, + }); + + await appendProposalTimelineItem(env, { + proposalId: proposal.id, + stage: "build", + actorAddress: null, + item: { + id: `timeline:chamber-dissolved:${proposal.id}:${randomHex(4)}`, + type: "chamber.dissolved", + title: "Chamber dissolved", + detail: meta.id, + actor: "system", + timestamp: new Date().toISOString(), + }, + }); + } + } + + const formationEligible = getFormationEligibleFromProposalPayload( + proposal.payload, + ); + if (formationEligible) { + const seed = buildV1FormationSeedFromProposalPayload(proposal.payload); + await ensureFormationSeedFromInput(env, { + proposalId: input.proposalId, + seed, + }); + } + + const avgScore = + (await getChamberYesScoreAverage(env, input.proposalId)) ?? null; + const multiplierTimes10 = + (await getCanonicalChamberMultiplierTimes10( + env, + input.requestUrl, + proposalChamberId, + )) ?? 10; + + if (avgScore !== null) { + const lcmPoints = Math.round(avgScore * 10); + const mcmPoints = Math.round((lcmPoints * multiplierTimes10) / 10); + await awardCmOnce(env, { + proposalId: input.proposalId, + proposerId: proposal.authorAddress, + chamberId: proposalChamberId, + avgScore, + lcmPoints, + chamberMultiplierTimes10: multiplierTimes10, + mcmPoints, + }); + } + + return { + ok: true, + formationEligible, + avgScore, + proposalChamberId, + }; +} diff --git a/functions/_lib/proposalProjector.ts b/functions/_lib/proposalProjector.ts new file mode 100644 index 0000000..5eadb3b --- /dev/null +++ b/functions/_lib/proposalProjector.ts @@ -0,0 +1,561 @@ +import { evaluateChamberQuorum } from "./chamberQuorum.ts"; +import { evaluatePoolQuorum } from "./poolQuorum.ts"; +import type { + ProposalListItemDto, + ChamberProposalPageDto, + FormationProposalPageDto, + PoolProposalPageDto, + ProposalStageDatumDto, +} from "../../src/types/api.ts"; +import type { ProposalDraftForm } from "./proposalDraftsStore.ts"; +import { formatChamberLabel } from "./proposalDraftsStore.ts"; +import type { ProposalRecord, ProposalStage } from "./proposalsStore.ts"; +import { + formatTimeLeftDaysHours, + getStageRemainingSeconds, +} from "./stageWindows.ts"; +import { + V1_ACTIVE_GOVERNORS_FALLBACK, + V1_CHAMBER_PASSING_FRACTION, + V1_CHAMBER_QUORUM_FRACTION, + V1_POOL_ATTENTION_QUORUM_FRACTION, + V1_POOL_UPVOTE_FLOOR_FRACTION, +} from "./v1Constants.ts"; +import type { HumanTier } from "./userTier.ts"; + +export function projectProposalListItem( + proposal: ProposalRecord, + input: { + activeGovernors: number; + tier?: HumanTier; + now?: Date; + voteWindowSeconds?: number; + poolCounts?: { upvotes: number; downvotes: number }; + chamberCounts?: { yes: number; no: number; abstain: number }; + formationSummary?: { + teamFilled: number; + teamTotal: number; + milestonesCompleted: number; + milestonesTotal: number; + }; + }, +): ProposalListItemDto { + const chamber = formatChamberLabel(proposal.chamberId); + const date = proposal.createdAt.toISOString().slice(0, 10); + const now = input.now ?? new Date(); + const formationEligible = getFormationEligibleFromPayload(proposal.payload); + const tier: HumanTier = input.tier ?? "Nominee"; + + const stageData = + proposal.stage === "pool" + ? projectPoolListStageData({ + activeGovernors: input.activeGovernors, + counts: input.poolCounts ?? { upvotes: 0, downvotes: 0 }, + }) + : proposal.stage === "vote" + ? projectVoteListStageData({ + activeGovernors: input.activeGovernors, + counts: input.chamberCounts ?? { yes: 0, no: 0, abstain: 0 }, + timeLeft: (() => { + if ( + !( + typeof input.voteWindowSeconds === "number" && + input.voteWindowSeconds > 0 + ) + ) + return "3d 00h"; + const remaining = getStageRemainingSeconds({ + now, + stageStartedAt: proposal.updatedAt, + windowSeconds: input.voteWindowSeconds, + }); + return remaining === 0 + ? "Ended" + : formatTimeLeftDaysHours(remaining); + })(), + }) + : formationEligible + ? projectBuildListStageData({ + summary: input.formationSummary ?? { + teamFilled: 0, + teamTotal: 0, + milestonesCompleted: 0, + milestonesTotal: 0, + }, + }) + : projectPassedListStageData(); + + const budget = formatBudget(getDraftForm(proposal.payload)); + const milestonesCount = getDraftForm(proposal.payload)?.timeline.length ?? 0; + + const ctaPrimary = + proposal.stage === "pool" + ? "Open proposal" + : proposal.stage === "vote" + ? "Open proposal" + : formationEligible + ? "Open project" + : "Open proposal"; + + const ctaSecondary = + proposal.stage === "build" && formationEligible ? "Ping team" : ""; + + const summaryPill = + proposal.stage === "pool" + ? `${milestonesCount} milestones` + : proposal.stage === "vote" + ? "Chamber vote" + : formationEligible + ? "Formation" + : "Passed"; + + return { + id: proposal.id, + title: proposal.title, + meta: `${chamber} · ${tier} tier`, + stage: proposal.stage, + summaryPill, + summary: proposal.summary, + stageData, + stats: [ + { label: "Budget ask", value: budget }, + { label: "Formation", value: formationEligible ? "Yes" : "No" }, + ], + proposer: proposal.authorAddress, + proposerId: proposal.authorAddress, + chamber, + tier, + proofFocus: "pot", + tags: [], + keywords: [], + date, + votes: + proposal.stage === "pool" + ? (input.poolCounts?.upvotes ?? 0) + (input.poolCounts?.downvotes ?? 0) + : proposal.stage === "vote" + ? (input.chamberCounts?.yes ?? 0) + + (input.chamberCounts?.no ?? 0) + + (input.chamberCounts?.abstain ?? 0) + : 0, + activityScore: 0, + ctaPrimary, + ctaSecondary, + }; +} + +export function projectPoolProposalPage( + proposal: ProposalRecord, + input: { + counts: { upvotes: number; downvotes: number }; + activeGovernors: number; + tier?: HumanTier; + }, +): PoolProposalPageDto { + const form = getDraftForm(proposal.payload); + const chamber = formatChamberLabel(proposal.chamberId); + const budget = formatBudget(form); + const formationEligible = getFormationEligibleFromPayload(proposal.payload); + const tier: HumanTier = input.tier ?? "Nominee"; + + const activeGovernors = Math.max( + 0, + Math.floor(input.activeGovernors ?? V1_ACTIVE_GOVERNORS_FALLBACK), + ); + const attentionQuorum = V1_POOL_ATTENTION_QUORUM_FRACTION; + const upvoteFloor = Math.max( + 1, + Math.ceil(activeGovernors * V1_POOL_UPVOTE_FLOOR_FRACTION), + ); + + const rules = [ + `${Math.round(attentionQuorum * 100)}% attention from active governors required.`, + `At least ${Math.round((upvoteFloor / Math.max(1, activeGovernors)) * 100)}% upvotes to move to chamber vote.`, + ]; + + return { + title: proposal.title, + proposer: proposal.authorAddress, + proposerId: proposal.authorAddress, + chamber, + focus: "—", + tier, + budget, + cooldown: "Withdraw cooldown: 12h", + formationEligible, + teamSlots: "1 / 3", + milestones: String(form?.timeline.length ?? 0), + upvotes: input.counts.upvotes, + downvotes: input.counts.downvotes, + attentionQuorum, + activeGovernors, + upvoteFloor, + rules, + attachments: + form?.attachments + .filter((a) => a.label.trim().length > 0) + .map((a) => ({ id: a.id, title: a.label })) ?? [], + teamLocked: [{ name: proposal.authorAddress, role: "Proposer" }], + openSlotNeeds: [], + milestonesDetail: + form?.timeline.map((m, idx) => ({ + title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, + desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", + })) ?? [], + summary: form?.summary ?? proposal.summary, + overview: form?.what ?? "", + executionPlan: + form?.how + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) ?? [], + budgetScope: + form?.budgetItems + .filter((b) => b.description.trim().length > 0) + .map((b) => `${b.description}: ${b.amount} HMND`) + .join("\n") ?? "", + invisionInsight: { + role: "Draft author", + bullets: [ + "Submitted via the simulation backend proposal wizard.", + "This is an off-chain governance simulation (not mainnet).", + ], + }, + }; +} + +export function projectChamberProposalPage( + proposal: ProposalRecord, + input: { + counts: { yes: number; no: number; abstain: number }; + activeGovernors: number; + now?: Date; + voteWindowSeconds?: number; + }, +): ChamberProposalPageDto { + const form = getDraftForm(proposal.payload); + const chamber = formatChamberLabel(proposal.chamberId); + const budget = formatBudget(form); + const formationEligible = getFormationEligibleFromPayload(proposal.payload); + + const activeGovernors = Math.max( + 0, + Math.floor(input.activeGovernors ?? V1_ACTIVE_GOVERNORS_FALLBACK), + ); + const engagedGovernors = + input.counts.yes + input.counts.no + input.counts.abstain; + const now = input.now ?? new Date(); + const timeLeft = (() => { + if ( + !( + typeof input.voteWindowSeconds === "number" && + input.voteWindowSeconds > 0 + ) + ) + return "3d 00h"; + const remaining = getStageRemainingSeconds({ + now, + stageStartedAt: proposal.updatedAt, + windowSeconds: input.voteWindowSeconds, + }); + return remaining === 0 ? "Ended" : formatTimeLeftDaysHours(remaining); + })(); + + return { + title: proposal.title, + proposer: proposal.authorAddress, + proposerId: proposal.authorAddress, + chamber, + budget, + formationEligible, + teamSlots: "1 / 3", + milestones: `${form?.timeline.length ?? 0}`, + timeLeft, + votes: input.counts, + attentionQuorum: V1_CHAMBER_QUORUM_FRACTION, + passingRule: `≥${(V1_CHAMBER_PASSING_FRACTION * 100).toFixed(1)}% + 1 yes within quorum`, + engagedGovernors, + activeGovernors, + attachments: + form?.attachments + .filter((a) => a.label.trim().length > 0) + .map((a) => ({ id: a.id, title: a.label })) ?? [], + teamLocked: [{ name: proposal.authorAddress, role: "Proposer" }], + openSlotNeeds: [], + milestonesDetail: + form?.timeline.map((m, idx) => ({ + title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, + desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", + })) ?? [], + summary: form?.summary ?? proposal.summary, + overview: form?.what ?? "", + executionPlan: + form?.how + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) ?? [], + budgetScope: + form?.budgetItems + .filter((b) => b.description.trim().length > 0) + .map((b) => `${b.description}: ${b.amount} HMND`) + .join("\n") ?? "", + invisionInsight: { + role: "Draft author", + bullets: [ + "Submitted via the simulation backend proposal wizard.", + "This is an off-chain governance simulation (not mainnet).", + ], + }, + }; +} + +export function projectFormationProposalPage( + proposal: ProposalRecord, + input: { + summary: { + teamFilled: number; + teamTotal: number; + milestonesCompleted: number; + milestonesTotal: number; + }; + joiners: { address: string; role?: string | null }[]; + }, +): FormationProposalPageDto { + const form = getDraftForm(proposal.payload); + const chamber = formatChamberLabel(proposal.chamberId); + const budget = formatBudget(form); + + const teamSlots = `${input.summary.teamFilled} / ${input.summary.teamTotal}`; + const milestones = `${input.summary.milestonesCompleted} / ${input.summary.milestonesTotal}`; + const progress = + input.summary.milestonesTotal > 0 + ? `${Math.round((input.summary.milestonesCompleted / input.summary.milestonesTotal) * 100)}%` + : "0%"; + + return { + title: proposal.title, + chamber, + proposer: proposal.authorAddress, + proposerId: proposal.authorAddress, + budget, + timeLeft: "12w", + teamSlots, + milestones, + progress, + stageData: [ + { title: "Budget allocated", description: "HMND", value: "0 / —" }, + { title: "Team slots", description: "Filled / Total", value: teamSlots }, + { + title: "Milestones", + description: "Completed / Total", + value: milestones, + }, + ], + stats: [{ label: "Lead chamber", value: chamber }], + lockedTeam: [ + { name: shortenAddress(proposal.authorAddress), role: "Proposer" }, + ...input.joiners.map((entry) => ({ + name: shortenAddress(entry.address), + role: entry.role ?? "Contributor", + })), + ], + openSlots: [], + milestonesDetail: + form?.timeline.map((m, idx) => ({ + title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, + desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", + })) ?? [], + attachments: + form?.attachments + .filter((a) => a.label.trim().length > 0) + .map((a) => ({ id: a.id, title: a.label })) ?? [], + summary: form?.summary ?? proposal.summary, + overview: form?.what ?? "", + executionPlan: + form?.how + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) ?? [], + budgetScope: + form?.budgetItems + .filter((b) => b.description.trim().length > 0) + .map((b) => `${b.description}: ${b.amount} HMND`) + .join("\n") ?? "", + invisionInsight: { + role: "Draft author", + bullets: [ + "Submitted via the simulation backend proposal wizard.", + "This is an off-chain governance simulation (not mainnet).", + ], + }, + }; +} + +function projectPoolListStageData(input: { + activeGovernors: number; + counts: { upvotes: number; downvotes: number }; +}): ProposalStageDatumDto[] { + const attentionQuorum = V1_POOL_ATTENTION_QUORUM_FRACTION; + const upvoteFloor = Math.max( + 1, + Math.ceil( + Math.max(0, input.activeGovernors) * V1_POOL_UPVOTE_FLOOR_FRACTION, + ), + ); + const quorum = evaluatePoolQuorum( + { attentionQuorum, activeGovernors: input.activeGovernors, upvoteFloor }, + input.counts, + ); + const engagedPct = + input.activeGovernors > 0 + ? (quorum.engaged / input.activeGovernors) * 100 + : 0; + return [ + { + title: "Pool momentum", + description: "Upvotes / Downvotes", + value: `${input.counts.upvotes} / ${input.counts.downvotes}`, + }, + { + title: "Attention quorum", + description: `${Math.round(attentionQuorum * 100)}% active or ≥10% upvotes`, + value: `${quorum.shouldAdvance ? "Met" : "Needs"} · ${Math.round(engagedPct)}% engaged`, + tone: quorum.shouldAdvance ? "ok" : "warn", + }, + { + title: "Upvote floor", + description: `${upvoteFloor} needed`, + value: `${input.counts.upvotes} / ${upvoteFloor}`, + tone: input.counts.upvotes >= upvoteFloor ? "ok" : "warn", + }, + ]; +} + +function projectVoteListStageData(input: { + activeGovernors: number; + counts: { yes: number; no: number; abstain: number }; + timeLeft: string; +}): ProposalStageDatumDto[] { + const quorumFraction = V1_CHAMBER_QUORUM_FRACTION; + const passingFraction = V1_CHAMBER_PASSING_FRACTION; + const result = evaluateChamberQuorum( + { quorumFraction, activeGovernors: input.activeGovernors, passingFraction }, + input.counts, + ); + const quorumPct = + input.activeGovernors > 0 + ? (result.engaged / input.activeGovernors) * 100 + : 0; + return [ + { + title: "Voting quorum", + description: `Strict ${Math.round(quorumFraction * 100)}% active governors`, + value: `${result.quorumMet ? "Met" : "Needs"} · ${Math.round(quorumPct)}%`, + tone: result.quorumMet ? "ok" : "warn", + }, + { + title: "Passing", + description: "≥66.6% yes", + value: `${Math.round(result.yesFraction * 1000) / 10}% yes`, + tone: result.passMet ? "ok" : "warn", + }, + { title: "Time left", description: "Voting window", value: input.timeLeft }, + ]; +} + +function projectBuildListStageData(input: { + summary: { + teamFilled: number; + teamTotal: number; + milestonesCompleted: number; + milestonesTotal: number; + }; +}): ProposalStageDatumDto[] { + const teamValue = `${input.summary.teamFilled} / ${input.summary.teamTotal}`; + const milestonesValue = `${input.summary.milestonesCompleted} / ${input.summary.milestonesTotal}`; + const pct = + input.summary.milestonesTotal > 0 + ? (input.summary.milestonesCompleted / input.summary.milestonesTotal) * + 100 + : 0; + return [ + { title: "Team slots", description: "Filled / Total", value: teamValue }, + { + title: "Milestones", + description: "Completed / Total", + value: milestonesValue, + }, + { + title: "Progress", + description: "Milestones", + value: `${Math.round(pct)}%`, + tone: pct >= 50 ? "ok" : "warn", + }, + ]; +} + +function projectPassedListStageData(): ProposalStageDatumDto[] { + return [ + { + title: "Accepted", + description: "Passed chamber vote", + value: "Yes", + tone: "ok", + }, + { + title: "Formation", + description: "Execution stage", + value: "Not required", + }, + ]; +} + +function getDraftForm(payload: unknown): ProposalDraftForm | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return null; + const record = payload as Partial; + if (typeof record.title !== "string") return null; + if (!Array.isArray(record.timeline) || !Array.isArray(record.budgetItems)) + return null; + return record as ProposalDraftForm; +} + +function getFormationEligibleFromPayload(payload: unknown): boolean { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return true; + const record = payload as Record; + if (record.templateId === "system") return false; + if ( + typeof record.metaGovernance === "object" && + record.metaGovernance !== null && + !Array.isArray(record.metaGovernance) + ) + return false; + if (typeof record.formationEligible === "boolean") + return record.formationEligible; + if (typeof record.formation === "boolean") return record.formation; + return true; +} + +function formatBudget(form: ProposalDraftForm | null): string { + if (!form) return "—"; + const total = form.budgetItems.reduce((sum, item) => { + const n = Number(item.amount); + if (!Number.isFinite(n) || n <= 0) return sum; + return sum + n; + }, 0); + return total > 0 ? `${total.toLocaleString()} HMND` : "—"; +} + +export function parseProposalStageQuery( + value: string | null, +): ProposalStage | null { + if (!value) return null; + if (value === "pool" || value === "vote" || value === "build") return value; + return null; +} + +function shortenAddress(address: string): string { + const normalized = address.trim(); + if (normalized.length <= 12) return normalized; + return `${normalized.slice(0, 6)}…${normalized.slice(-4)}`; +} diff --git a/functions/_lib/proposalStageDenominatorsStore.ts b/functions/_lib/proposalStageDenominatorsStore.ts new file mode 100644 index 0000000..0487f71 --- /dev/null +++ b/functions/_lib/proposalStageDenominatorsStore.ts @@ -0,0 +1,158 @@ +import { and, eq, inArray } from "drizzle-orm"; + +import { proposalStageDenominators } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; +import { createClockStore } from "./clockStore.ts"; + +type Env = Record; + +export type ProposalDenominatorStage = "pool" | "vote"; + +export type ProposalStageDenominator = { + proposalId: string; + stage: ProposalDenominatorStage; + era: number; + activeGovernors: number; + capturedAt: string; +}; + +const memory = new Map(); // key: `${proposalId}:${stage}` + +export async function captureProposalStageDenominator( + env: Env, + input: { + proposalId: string; + stage: ProposalDenominatorStage; + activeGovernors: number; + }, +): Promise { + const proposalId = input.proposalId.trim(); + const stage = input.stage; + const activeGovernors = Math.max(0, Math.floor(input.activeGovernors)); + + const clock = createClockStore(env); + const { currentEra } = await clock.get(); + + const capturedAt = new Date().toISOString(); + const row: ProposalStageDenominator = { + proposalId, + stage, + era: currentEra, + activeGovernors, + capturedAt, + }; + + if (!env.DATABASE_URL) { + const key = `${proposalId}:${stage}`; + if (memory.has(key)) return; + memory.set(key, row); + return; + } + + const db = createDb(env); + await db + .insert(proposalStageDenominators) + .values({ + proposalId, + stage, + era: currentEra, + activeGovernors, + capturedAt: new Date(capturedAt), + }) + .onConflictDoNothing({ + target: [ + proposalStageDenominators.proposalId, + proposalStageDenominators.stage, + ], + }); +} + +export async function getProposalStageDenominator( + env: Env, + input: { proposalId: string; stage: ProposalDenominatorStage }, +): Promise { + const proposalId = input.proposalId.trim(); + const stage = input.stage; + + if (!env.DATABASE_URL) { + return memory.get(`${proposalId}:${stage}`) ?? null; + } + + const db = createDb(env); + const rows = await db + .select({ + proposalId: proposalStageDenominators.proposalId, + stage: proposalStageDenominators.stage, + era: proposalStageDenominators.era, + activeGovernors: proposalStageDenominators.activeGovernors, + capturedAt: proposalStageDenominators.capturedAt, + }) + .from(proposalStageDenominators) + .where( + and( + eq(proposalStageDenominators.proposalId, proposalId), + eq(proposalStageDenominators.stage, stage), + ), + ) + .limit(1); + const row = rows[0]; + if (!row) return null; + return { + proposalId: row.proposalId, + stage: row.stage as ProposalDenominatorStage, + era: row.era, + activeGovernors: row.activeGovernors, + capturedAt: row.capturedAt.toISOString(), + }; +} + +export async function getProposalStageDenominatorMap( + env: Env, + input: { stage: ProposalDenominatorStage; proposalIds: string[] }, +): Promise> { + const stage = input.stage; + const proposalIds = input.proposalIds.map((id) => id.trim()).filter(Boolean); + const map = new Map(); + + if (proposalIds.length === 0) return map; + + if (!env.DATABASE_URL) { + for (const proposalId of proposalIds) { + const row = memory.get(`${proposalId}:${stage}`); + if (row) map.set(proposalId, row); + } + return map; + } + + const db = createDb(env); + const rows = await db + .select({ + proposalId: proposalStageDenominators.proposalId, + stage: proposalStageDenominators.stage, + era: proposalStageDenominators.era, + activeGovernors: proposalStageDenominators.activeGovernors, + capturedAt: proposalStageDenominators.capturedAt, + }) + .from(proposalStageDenominators) + .where( + and( + eq(proposalStageDenominators.stage, stage), + inArray(proposalStageDenominators.proposalId, proposalIds), + ), + ); + + for (const row of rows) { + map.set(row.proposalId, { + proposalId: row.proposalId, + stage: row.stage as ProposalDenominatorStage, + era: row.era, + activeGovernors: row.activeGovernors, + capturedAt: row.capturedAt.toISOString(), + }); + } + return map; +} + +export function clearProposalStageDenominatorsForTests(): void { + memory.clear(); +} diff --git a/functions/_lib/proposalStateMachine.ts b/functions/_lib/proposalStateMachine.ts new file mode 100644 index 0000000..460a79a --- /dev/null +++ b/functions/_lib/proposalStateMachine.ts @@ -0,0 +1,64 @@ +import { evaluateChamberQuorum } from "./chamberQuorum.ts"; +import { evaluatePoolQuorum } from "./poolQuorum.ts"; +import type { ProposalStage } from "./proposalsStore.ts"; +import { + V1_CHAMBER_PASSING_FRACTION, + V1_CHAMBER_QUORUM_FRACTION, + V1_POOL_ATTENTION_QUORUM_FRACTION, + V1_POOL_UPVOTE_FLOOR_FRACTION, +} from "./v1Constants.ts"; + +export type ProposalStageTransition = { + from: ProposalStage; + to: ProposalStage; +}; + +export const PROPOSAL_TRANSITIONS: ProposalStageTransition[] = [ + { from: "pool", to: "vote" }, + { from: "vote", to: "build" }, +]; + +export function canTransitionStage( + from: ProposalStage, + to: ProposalStage, +): boolean { + return PROPOSAL_TRANSITIONS.some((t) => t.from === from && t.to === to); +} + +export function computePoolUpvoteFloor(activeGovernors: number): number { + const active = Math.max(0, Math.floor(activeGovernors)); + return Math.max(1, Math.ceil(active * V1_POOL_UPVOTE_FLOOR_FRACTION)); +} + +export function shouldAdvancePoolToVote(input: { + activeGovernors: number; + counts: { upvotes: number; downvotes: number }; +}): boolean { + const upvoteFloor = computePoolUpvoteFloor(input.activeGovernors); + const quorum = evaluatePoolQuorum( + { + attentionQuorum: V1_POOL_ATTENTION_QUORUM_FRACTION, + activeGovernors: input.activeGovernors, + upvoteFloor, + }, + input.counts, + ); + return quorum.shouldAdvance; +} + +export function shouldAdvanceVoteToBuild(input: { + activeGovernors: number; + counts: { yes: number; no: number; abstain: number }; + minQuorum?: number; +}): boolean { + const result = evaluateChamberQuorum( + { + quorumFraction: V1_CHAMBER_QUORUM_FRACTION, + activeGovernors: input.activeGovernors, + passingFraction: V1_CHAMBER_PASSING_FRACTION, + minQuorum: input.minQuorum, + }, + input.counts, + ); + return result.shouldAdvance; +} diff --git a/functions/_lib/proposalTimelineStore.ts b/functions/_lib/proposalTimelineStore.ts new file mode 100644 index 0000000..ae2f432 --- /dev/null +++ b/functions/_lib/proposalTimelineStore.ts @@ -0,0 +1,71 @@ +import { and, asc, eq } from "drizzle-orm"; + +import { events } from "../../db/schema.ts"; +import type { ProposalTimelineItemDto } from "../../src/types/api.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +const EVENT_TYPE = "proposal.timeline.v1"; +const ENTITY_TYPE = "proposal"; + +const memory = new Map(); + +export async function appendProposalTimelineItem( + env: Env, + input: { + proposalId: string; + stage?: string | null; + actorAddress?: string | null; + item: ProposalTimelineItemDto; + }, +): Promise { + if (!env.DATABASE_URL) { + const items = memory.get(input.proposalId) ?? []; + memory.set(input.proposalId, [...items, input.item]); + return; + } + + const db = createDb(env); + await db.insert(events).values({ + type: EVENT_TYPE, + stage: input.stage ?? null, + actorAddress: input.actorAddress ?? null, + entityType: ENTITY_TYPE, + entityId: input.proposalId, + payload: input.item, + createdAt: new Date(input.item.timestamp), + }); +} + +export async function listProposalTimelineItems( + env: Env, + input: { proposalId: string; limit: number }, +): Promise { + if (!env.DATABASE_URL) { + const items = memory.get(input.proposalId) ?? []; + return [...items] + .sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + .slice(-input.limit); + } + + const db = createDb(env); + const rows = await db + .select({ seq: events.seq, payload: events.payload }) + .from(events) + .where( + and( + eq(events.type, EVENT_TYPE), + eq(events.entityType, ENTITY_TYPE), + eq(events.entityId, input.proposalId), + ), + ) + .orderBy(asc(events.seq)) + .limit(Math.max(1, input.limit)); + + return rows.map((row) => row.payload as ProposalTimelineItemDto); +} + +export function clearProposalTimelineForTests(): void { + memory.clear(); +} diff --git a/functions/_lib/proposalsStore.ts b/functions/_lib/proposalsStore.ts new file mode 100644 index 0000000..c071145 --- /dev/null +++ b/functions/_lib/proposalsStore.ts @@ -0,0 +1,363 @@ +import { and, eq, sql } from "drizzle-orm"; + +import { proposals } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; +type Env = Record; + +export type ProposalStage = "pool" | "vote" | "build"; + +export type ProposalRecord = { + id: string; + stage: ProposalStage; + authorAddress: string; + title: string; + chamberId: string | null; + summary: string; + payload: unknown; + vetoCount: number; + votePassedAt: Date | null; + voteFinalizesAt: Date | null; + vetoCouncil: string[] | null; + vetoThreshold: number | null; + createdAt: Date; + updatedAt: Date; +}; + +const memory = new Map(); + +function normalizeVetoCouncil(value: unknown): string[] | null { + if (value === null || value === undefined) return null; + if (!Array.isArray(value)) return null; + const members = value + .filter((v): v is string => typeof v === "string") + .map((v) => v.trim()) + .filter(Boolean); + return members.length > 0 ? members : null; +} + +export async function createProposal( + env: Env, + input: { + id: string; + stage: ProposalStage; + authorAddress: string; + title: string; + chamberId: string | null; + summary: string; + payload: unknown; + vetoCount?: number; + votePassedAt?: Date | null; + voteFinalizesAt?: Date | null; + vetoCouncil?: string[] | null; + vetoThreshold?: number | null; + }, +): Promise { + const now = new Date(); + const record: ProposalRecord = { + id: input.id, + stage: input.stage, + authorAddress: input.authorAddress, + title: input.title, + chamberId: input.chamberId ?? null, + summary: input.summary, + payload: input.payload, + vetoCount: input.vetoCount ?? 0, + votePassedAt: input.votePassedAt ?? null, + voteFinalizesAt: input.voteFinalizesAt ?? null, + vetoCouncil: input.vetoCouncil ?? null, + vetoThreshold: input.vetoThreshold ?? null, + createdAt: now, + updatedAt: now, + }; + + if (env.DATABASE_URL) { + const db = createDb(env); + await db.insert(proposals).values({ + id: record.id, + stage: record.stage, + authorAddress: record.authorAddress, + title: record.title, + chamberId: record.chamberId, + summary: record.summary, + payload: record.payload, + vetoCount: record.vetoCount, + votePassedAt: record.votePassedAt, + voteFinalizesAt: record.voteFinalizesAt, + vetoCouncil: record.vetoCouncil, + vetoThreshold: record.vetoThreshold, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }); + return record; + } + + memory.set(record.id, record); + return record; +} + +export async function updateProposalStage( + env: Env, + input: { proposalId: string; stage: ProposalStage }, +): Promise { + const now = new Date(); + if (env.DATABASE_URL) { + const db = createDb(env); + await db + .update(proposals) + .set({ stage: input.stage, updatedAt: now }) + .where(eq(proposals.id, input.proposalId)); + return; + } + + const existing = memory.get(input.proposalId); + if (!existing) return; + memory.set(input.proposalId, { + ...existing, + stage: input.stage, + updatedAt: now, + }); +} + +export async function setProposalVotePendingVeto( + env: Env, + input: { + proposalId: string; + passedAt: Date; + finalizesAt: Date; + vetoCouncil: string[]; + vetoThreshold: number; + }, +): Promise { + if (env.DATABASE_URL) { + const db = createDb(env); + await db + .update(proposals) + .set({ + votePassedAt: input.passedAt, + voteFinalizesAt: input.finalizesAt, + vetoCouncil: input.vetoCouncil, + vetoThreshold: input.vetoThreshold, + }) + .where(eq(proposals.id, input.proposalId)); + return; + } + + const existing = memory.get(input.proposalId); + if (!existing) return; + memory.set(input.proposalId, { + ...existing, + votePassedAt: input.passedAt, + voteFinalizesAt: input.finalizesAt, + vetoCouncil: input.vetoCouncil, + vetoThreshold: input.vetoThreshold, + }); +} + +export async function clearProposalVotePendingVeto( + env: Env, + input: { proposalId: string }, +): Promise { + if (env.DATABASE_URL) { + const db = createDb(env); + await db + .update(proposals) + .set({ + votePassedAt: null, + voteFinalizesAt: null, + vetoCouncil: null, + vetoThreshold: null, + }) + .where(eq(proposals.id, input.proposalId)); + return; + } + + const existing = memory.get(input.proposalId); + if (!existing) return; + memory.set(input.proposalId, { + ...existing, + votePassedAt: null, + voteFinalizesAt: null, + vetoCouncil: null, + vetoThreshold: null, + }); +} + +export async function applyProposalVeto( + env: Env, + input: { proposalId: string; nextVoteStartsAt: Date }, +): Promise { + if (env.DATABASE_URL) { + const db = createDb(env); + await db + .update(proposals) + .set({ + vetoCount: sql`${proposals.vetoCount} + 1`, + votePassedAt: null, + voteFinalizesAt: null, + vetoCouncil: null, + vetoThreshold: null, + updatedAt: input.nextVoteStartsAt, + }) + .where(eq(proposals.id, input.proposalId)); + return; + } + + const existing = memory.get(input.proposalId); + if (!existing) return; + memory.set(input.proposalId, { + ...existing, + vetoCount: existing.vetoCount + 1, + votePassedAt: null, + voteFinalizesAt: null, + vetoCouncil: null, + vetoThreshold: null, + updatedAt: input.nextVoteStartsAt, + }); +} + +export async function transitionProposalStage( + env: Env, + input: { proposalId: string; from: ProposalStage; to: ProposalStage }, +): Promise { + if ( + !( + (input.from === "pool" && input.to === "vote") || + (input.from === "vote" && input.to === "build") + ) + ) { + throw new Error("invalid_transition"); + } + + const now = new Date(); + if (env.DATABASE_URL) { + const db = createDb(env); + const res = await db + .update(proposals) + .set({ stage: input.to, updatedAt: now }) + .where( + and( + eq(proposals.id, input.proposalId), + eq(proposals.stage, input.from), + ), + ); + return res.rowCount > 0; + } + + const existing = memory.get(input.proposalId); + if (!existing) return false; + if (existing.stage !== input.from) return false; + memory.set(input.proposalId, { + ...existing, + stage: input.to, + updatedAt: now, + }); + return true; +} + +export async function getProposal( + env: Env, + proposalId: string, +): Promise { + if (env.DATABASE_URL) { + const db = createDb(env); + const rows = await db + .select({ + id: proposals.id, + stage: proposals.stage, + authorAddress: proposals.authorAddress, + title: proposals.title, + chamberId: proposals.chamberId, + summary: proposals.summary, + payload: proposals.payload, + vetoCount: proposals.vetoCount, + votePassedAt: proposals.votePassedAt, + voteFinalizesAt: proposals.voteFinalizesAt, + vetoCouncil: proposals.vetoCouncil, + vetoThreshold: proposals.vetoThreshold, + createdAt: proposals.createdAt, + updatedAt: proposals.updatedAt, + }) + .from(proposals) + .where(eq(proposals.id, proposalId)) + .limit(1); + const row = rows[0]; + if (!row) return null; + return { + id: row.id, + stage: row.stage as ProposalStage, + authorAddress: row.authorAddress, + title: row.title, + chamberId: row.chamberId ?? null, + summary: row.summary, + payload: row.payload, + vetoCount: row.vetoCount ?? 0, + votePassedAt: row.votePassedAt ?? null, + voteFinalizesAt: row.voteFinalizesAt ?? null, + vetoCouncil: normalizeVetoCouncil(row.vetoCouncil), + vetoThreshold: + typeof row.vetoThreshold === "number" ? row.vetoThreshold : null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + return memory.get(proposalId) ?? null; +} + +export async function listProposals( + env: Env, + input?: { stage?: ProposalStage | null }, +): Promise { + const stage = input?.stage ?? null; + if (env.DATABASE_URL) { + const db = createDb(env); + const base = db + .select({ + id: proposals.id, + stage: proposals.stage, + authorAddress: proposals.authorAddress, + title: proposals.title, + chamberId: proposals.chamberId, + summary: proposals.summary, + payload: proposals.payload, + vetoCount: proposals.vetoCount, + votePassedAt: proposals.votePassedAt, + voteFinalizesAt: proposals.voteFinalizesAt, + vetoCouncil: proposals.vetoCouncil, + vetoThreshold: proposals.vetoThreshold, + createdAt: proposals.createdAt, + updatedAt: proposals.updatedAt, + }) + .from(proposals); + const rows = stage + ? await base.where(eq(proposals.stage, stage)) + : await base; + return rows + .map((row) => ({ + id: row.id, + stage: row.stage as ProposalStage, + authorAddress: row.authorAddress, + title: row.title, + chamberId: row.chamberId ?? null, + summary: row.summary, + payload: row.payload, + vetoCount: row.vetoCount ?? 0, + votePassedAt: row.votePassedAt ?? null, + voteFinalizesAt: row.voteFinalizesAt ?? null, + vetoCouncil: normalizeVetoCouncil(row.vetoCouncil), + vetoThreshold: + typeof row.vetoThreshold === "number" ? row.vetoThreshold : null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + } + + const items = Array.from(memory.values()); + const filtered = stage ? items.filter((p) => p.stage === stage) : items; + return filtered.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); +} + +export function clearProposalsForTests(): void { + memory.clear(); +} diff --git a/functions/_lib/readModelsStore.ts b/functions/_lib/readModelsStore.ts index 6a2162f..e0fab3e 100644 --- a/functions/_lib/readModelsStore.ts +++ b/functions/_lib/readModelsStore.ts @@ -7,18 +7,28 @@ type Env = Record; export type ReadModelsStore = { get: (key: string) => Promise; + set?: (key: string, payload: unknown) => Promise; }; export async function createReadModelsStore( env: Env, ): Promise { + if (env.READ_MODELS_INLINE_EMPTY === "true") { + const map = await getEmptyReadModelsMap(); + return { + get: async (key) => map.get(key) ?? null, + set: async (key, payload) => { + map.set(key, payload); + }, + }; + } if (env.READ_MODELS_INLINE === "true") { - const { buildReadModelSeed } = await import("../../db/seed/readModels.ts"); - const map = new Map( - buildReadModelSeed().map((entry) => [entry.key, entry.payload]), - ); + const map = await getInlineReadModelsMap(); return { get: async (key) => map.get(key) ?? null, + set: async (key, payload) => { + map.set(key, payload); + }, }; } @@ -32,5 +42,38 @@ export async function createReadModelsStore( .limit(1); return rows[0]?.payload ?? null; }, + set: async (key, payload) => { + const now = new Date(); + await db + .insert(readModels) + .values({ key, payload, updatedAt: now }) + .onConflictDoUpdate({ + target: readModels.key, + set: { payload, updatedAt: now }, + }); + }, }; } + +let inlineReadModelsMap: Map | null = null; +let emptyReadModelsMap: Map | null = null; + +export function clearInlineReadModelsForTests() { + inlineReadModelsMap = null; + emptyReadModelsMap = null; +} + +async function getInlineReadModelsMap(): Promise> { + if (inlineReadModelsMap) return inlineReadModelsMap; + const { buildReadModelSeed } = await import("../../db/seed/readModels.ts"); + inlineReadModelsMap = new Map( + buildReadModelSeed().map((entry) => [entry.key, entry.payload]), + ); + return inlineReadModelsMap; +} + +async function getEmptyReadModelsMap(): Promise> { + if (emptyReadModelsMap) return emptyReadModelsMap; + emptyReadModelsMap = new Map(); + return emptyReadModelsMap; +} diff --git a/functions/_lib/requestIp.ts b/functions/_lib/requestIp.ts new file mode 100644 index 0000000..38e2f19 --- /dev/null +++ b/functions/_lib/requestIp.ts @@ -0,0 +1,7 @@ +export function getRequestIp(request: Request): string | undefined { + const cf = request.headers.get("cf-connecting-ip"); + if (cf) return cf; + const xff = request.headers.get("x-forwarded-for"); + if (!xff) return undefined; + return xff.split(",")[0]?.trim() || undefined; +} diff --git a/functions/_lib/signatures.ts b/functions/_lib/signatures.ts new file mode 100644 index 0000000..3901492 --- /dev/null +++ b/functions/_lib/signatures.ts @@ -0,0 +1,19 @@ +import { cryptoWaitReady, signatureVerify } from "@polkadot/util-crypto"; + +export async function verifySubstrateSignature(input: { + address: string; + message: string; + signature: string; +}): Promise { + await cryptoWaitReady(); + try { + const result = signatureVerify( + input.message, + input.signature, + input.address, + ); + return result.isValid; + } catch { + return false; + } +} diff --git a/functions/_lib/simConfig.ts b/functions/_lib/simConfig.ts new file mode 100644 index 0000000..7b1389b --- /dev/null +++ b/functions/_lib/simConfig.ts @@ -0,0 +1,137 @@ +type SimConfig = { + humanodeRpcUrl?: string; + genesisChamberMembers?: Record; + genesisChambers?: { id: string; title: string; multiplier: number }[]; + genesisUserTiers?: Record< + string, + "Nominee" | "Ecclesiast" | "Legate" | "Consul" | "Citizen" + >; +}; + +let cached: + | { + value: SimConfig | null; + expiresAtMs: number; + } + | undefined; + +function asUserTiers( + value: unknown, +): SimConfig["genesisUserTiers"] | undefined { + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + const out: NonNullable = {}; + for (const [rawKey, rawValue] of Object.entries(record)) { + const address = rawKey.trim(); + if (!address) continue; + if (typeof rawValue !== "string") continue; + const tier = rawValue.trim(); + if ( + tier !== "Nominee" && + tier !== "Ecclesiast" && + tier !== "Legate" && + tier !== "Consul" && + tier !== "Citizen" + ) + continue; + out[address] = tier; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function asGenesisMembers( + value: unknown, +): Record | undefined { + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + + const out: Record = {}; + for (const [key, raw] of Object.entries(record)) { + if (!Array.isArray(raw)) continue; + const list = raw + .filter((v): v is string => typeof v === "string") + .map((v) => v.trim()) + .filter(Boolean); + if (list.length > 0) out[key.trim().toLowerCase()] = list; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function parseSimConfig(json: unknown): SimConfig | null { + if (!json || typeof json !== "object") return null; + const value = json as Record; + const genesisChambersRaw = value.genesisChambers; + const genesisChambers = Array.isArray(genesisChambersRaw) + ? genesisChambersRaw + .filter((row): row is Record => { + return Boolean(row && typeof row === "object" && !Array.isArray(row)); + }) + .map((row) => ({ + id: typeof row.id === "string" ? row.id.trim().toLowerCase() : "", + title: + typeof row.title === "string" + ? row.title.trim() + : typeof row.name === "string" + ? row.name.trim() + : "", + multiplier: + typeof row.multiplier === "number" + ? row.multiplier + : typeof row.multiplierTimes10 === "number" + ? row.multiplierTimes10 / 10 + : 1, + })) + .filter((row) => row.id && row.title) + : undefined; + return { + humanodeRpcUrl: + typeof value.humanodeRpcUrl === "string" + ? value.humanodeRpcUrl + : undefined, + genesisChamberMembers: asGenesisMembers(value.genesisChamberMembers), + genesisUserTiers: asUserTiers(value.genesisUserTiers), + genesisChambers: + genesisChambers && genesisChambers.length > 0 + ? genesisChambers + : undefined, + }; +} + +export async function getSimConfig( + env: Record, + requestUrl: string, +): Promise { + const rawOverride = (env.SIM_CONFIG_JSON ?? "").trim(); + if (rawOverride) { + try { + const json = JSON.parse(rawOverride) as unknown; + return parseSimConfig(json); + } catch { + return null; + } + } + return getSimConfigFromOrigin(requestUrl); +} + +export async function getSimConfigFromOrigin( + requestUrl: string, +): Promise { + const now = Date.now(); + if (cached && cached.expiresAtMs > now) return cached.value; + + const origin = new URL(requestUrl).origin; + try { + const res = await fetch(`${origin}/sim-config.json`, { method: "GET" }); + if (!res.ok) { + cached = { value: null, expiresAtMs: now + 60_000 }; + return null; + } + const json = (await res.json()) as unknown; + const value = parseSimConfig(json); + cached = { value, expiresAtMs: now + 60_000 }; + return value; + } catch { + cached = { value: null, expiresAtMs: now + 10_000 }; + return null; + } +} diff --git a/functions/_lib/stageWindows.ts b/functions/_lib/stageWindows.ts new file mode 100644 index 0000000..31cd40d --- /dev/null +++ b/functions/_lib/stageWindows.ts @@ -0,0 +1,77 @@ +import type { ProposalStage } from "./proposalsStore.ts"; +import { + V1_POOL_STAGE_SECONDS_DEFAULT, + V1_VOTE_STAGE_SECONDS_DEFAULT, +} from "./v1Constants.ts"; + +type Env = Record; + +export function getSimNow(env: Env): Date { + const raw = env.SIM_NOW_ISO ?? ""; + if (raw.trim().length > 0) { + const parsed = new Date(raw); + if (!Number.isNaN(parsed.getTime())) return parsed; + } + return new Date(); +} + +export function stageWindowsEnabled(env: Env): boolean { + return env.SIM_ENABLE_STAGE_WINDOWS === "true"; +} + +export function getStageWindowSeconds(env: Env, stage: ProposalStage): number { + const raw = + stage === "pool" + ? env.SIM_POOL_WINDOW_SECONDS + : stage === "vote" + ? env.SIM_VOTE_WINDOW_SECONDS + : null; + const parsed = raw ? Number(raw) : NaN; + if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed); + + return stage === "pool" + ? V1_POOL_STAGE_SECONDS_DEFAULT + : stage === "vote" + ? V1_VOTE_STAGE_SECONDS_DEFAULT + : 0; +} + +export function getStageDeadlineIso(input: { + stageStartedAt: Date; + windowSeconds: number; +}): string { + const ms = + input.stageStartedAt.getTime() + Math.max(0, input.windowSeconds) * 1000; + return new Date(ms).toISOString(); +} + +export function getStageRemainingSeconds(input: { + now: Date; + stageStartedAt: Date; + windowSeconds: number; +}): number { + const msRemaining = + input.stageStartedAt.getTime() + + Math.max(0, input.windowSeconds) * 1000 - + input.now.getTime(); + return Math.max(0, Math.floor(msRemaining / 1000)); +} + +export function formatTimeLeftDaysHours(remainingSeconds: number): string { + const seconds = Math.max(0, Math.floor(remainingSeconds)); + const days = Math.floor(seconds / (24 * 60 * 60)); + const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60)); + return `${days}d ${String(hours).padStart(2, "0")}h`; +} + +export function isStageOpen(input: { + now: Date; + stageStartedAt: Date; + windowSeconds: number; +}): boolean { + return ( + input.now.getTime() >= input.stageStartedAt.getTime() && + input.now.getTime() < + input.stageStartedAt.getTime() + Math.max(0, input.windowSeconds) * 1000 + ); +} diff --git a/functions/_lib/userStore.ts b/functions/_lib/userStore.ts new file mode 100644 index 0000000..3976284 --- /dev/null +++ b/functions/_lib/userStore.ts @@ -0,0 +1,13 @@ +import { users } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +export async function upsertUser(env: Env, input: { address: string }) { + if (!env.DATABASE_URL) return; + const db = createDb(env); + await db + .insert(users) + .values({ address: input.address }) + .onConflictDoNothing(); +} diff --git a/functions/_lib/userTier.ts b/functions/_lib/userTier.ts new file mode 100644 index 0000000..bff0d21 --- /dev/null +++ b/functions/_lib/userTier.ts @@ -0,0 +1,34 @@ +import { getSimConfig } from "./simConfig.ts"; +import { addressesReferToSameKey } from "./address.ts"; + +export type HumanTier = + | "Nominee" + | "Ecclesiast" + | "Legate" + | "Consul" + | "Citizen"; + +export async function resolveUserTierFromSimConfig( + simConfig: Awaited> | null, + address: string, +): Promise { + const tiers = simConfig?.genesisUserTiers; + if (tiers) { + const key = address.trim(); + const exact = tiers[key]; + if (exact) return exact; + for (const [candidate, tier] of Object.entries(tiers)) { + if (await addressesReferToSameKey(candidate, key)) return tier; + } + } + return "Nominee"; +} + +export async function getUserTier( + env: Record, + requestUrl: string, + address: string, +): Promise { + const simConfig = await getSimConfig(env, requestUrl).catch(() => null); + return await resolveUserTierFromSimConfig(simConfig, address); +} diff --git a/functions/_lib/v1Constants.ts b/functions/_lib/v1Constants.ts new file mode 100644 index 0000000..f6829c3 --- /dev/null +++ b/functions/_lib/v1Constants.ts @@ -0,0 +1,22 @@ +export const V1_ACTIVE_GOVERNORS_FALLBACK = 150; + +// The simulation's default era length (used by Phase 16 automation). +// This does not come from chain state; it's an off-chain simulation constant. +export const V1_ERA_SECONDS_DEFAULT = 7 * 24 * 60 * 60; // 7 days + +export const V1_POOL_STAGE_SECONDS_DEFAULT = 7 * 24 * 60 * 60; // 7 days +// Paper: "Any proposal that is pulled out of the proposal pool gets a week to be voted upon". +export const V1_VOTE_STAGE_SECONDS_DEFAULT = 7 * 24 * 60 * 60; // 7 days + +// Paper: 22% of active governors engaged (upvote + downvote) in the proposal pool. +export const V1_POOL_ATTENTION_QUORUM_FRACTION = 0.22; +export const V1_POOL_UPVOTE_FLOOR_FRACTION = 0.1; + +export const V1_CHAMBER_QUORUM_FRACTION = 0.33; +export const V1_CHAMBER_PASSING_FRACTION = 2 / 3; // 66.6% + +// Veto (temporary slow-down) (Phase 30). +// If the veto threshold is met, the proposal returns to chamber voting after a delay. +export const V1_VETO_PASSING_FRACTION = 2 / 3; // 66.6% + 1 (rounded per council size) +export const V1_VETO_DELAY_SECONDS_DEFAULT = 14 * 24 * 60 * 60; // 2 weeks +export const V1_VETO_MAX_APPLIES = 2; diff --git a/functions/_lib/vetoCouncilStore.ts b/functions/_lib/vetoCouncilStore.ts new file mode 100644 index 0000000..4ba1f23 --- /dev/null +++ b/functions/_lib/vetoCouncilStore.ts @@ -0,0 +1,79 @@ +import { desc, eq, sql } from "drizzle-orm"; + +import { cmAwards } from "../../db/schema.ts"; +import { listChambers } from "./chambersStore.ts"; +import { createDb } from "./db.ts"; +import { listCmAwards } from "./cmAwardsStore.ts"; +import { V1_VETO_PASSING_FRACTION } from "./v1Constants.ts"; + +type Env = Record; + +export type VetoCouncilSnapshot = { + members: string[]; + threshold: number; +}; + +function computeThreshold(memberCount: number): number { + const n = Math.max(0, Math.floor(memberCount)); + if (n === 0) return 0; + return Math.floor(n * V1_VETO_PASSING_FRACTION) + 1; +} + +async function getTopLcmHolderForChamber( + env: Env, + chamberId: string, +): Promise { + const id = chamberId.trim().toLowerCase(); + if (!id) return null; + + if (!env.DATABASE_URL) { + const awards = await listCmAwards(env, { chamberId: id }); + const totals = new Map(); + for (const award of awards) { + const proposer = award.proposerId.trim(); + if (!proposer) continue; + totals.set(proposer, (totals.get(proposer) ?? 0) + award.lcmPoints); + } + let best: string | null = null; + let bestPoints = -1; + for (const [proposer, points] of totals.entries()) { + if (points > bestPoints) { + best = proposer; + bestPoints = points; + } + } + return best; + } + + const db = createDb(env); + const rows = await db + .select({ + proposerId: cmAwards.proposerId, + lcm: sql`sum(${cmAwards.lcmPoints})`, + }) + .from(cmAwards) + .where(eq(cmAwards.chamberId, id)) + .groupBy(cmAwards.proposerId) + .orderBy(desc(sql`sum(${cmAwards.lcmPoints})`)) + .limit(1); + const top = rows[0]?.proposerId?.trim(); + return top ? top : null; +} + +export async function computeVetoCouncilSnapshot( + env: Env, + requestUrl: string, +): Promise { + const chambers = await listChambers(env, requestUrl, { + includeDissolved: false, + }); + + const members = new Set(); + for (const chamber of chambers) { + const top = await getTopLcmHolderForChamber(env, chamber.id); + if (top) members.add(top); + } + + const list = Array.from(members); + return { members: list, threshold: computeThreshold(list.length) }; +} diff --git a/functions/_lib/vetoVotesStore.ts b/functions/_lib/vetoVotesStore.ts new file mode 100644 index 0000000..a193015 --- /dev/null +++ b/functions/_lib/vetoVotesStore.ts @@ -0,0 +1,137 @@ +import { and, eq, sql } from "drizzle-orm"; + +import { vetoVotes } from "../../db/schema.ts"; +import { createDb } from "./db.ts"; + +type Env = Record; + +export type VetoVoteChoice = "veto" | "keep"; + +export type VetoVoteCounts = { + veto: number; + keep: number; +}; + +type StoredVetoVote = { choice: VetoVoteChoice }; +const memoryVotes = new Map>(); + +export async function hasVetoVote( + env: Env, + input: { proposalId: string; voterAddress: string }, +): Promise { + const voter = input.voterAddress.trim(); + if (!env.DATABASE_URL) { + const byVoter = memoryVotes.get(input.proposalId); + if (!byVoter) return false; + return byVoter.has(voter); + } + + const db = createDb(env); + const existing = await db + .select({ choice: vetoVotes.choice }) + .from(vetoVotes) + .where( + and( + eq(vetoVotes.proposalId, input.proposalId), + eq(vetoVotes.voterAddress, voter), + ), + ) + .limit(1); + return existing.length > 0; +} + +export async function castVetoVote( + env: Env, + input: { + proposalId: string; + voterAddress: string; + choice: VetoVoteChoice; + }, +): Promise<{ counts: VetoVoteCounts; created: boolean }> { + const voter = input.voterAddress.trim(); + const now = new Date(); + + if (!env.DATABASE_URL) { + const byVoter = + memoryVotes.get(input.proposalId) ?? new Map(); + const created = !byVoter.has(voter); + byVoter.set(voter, { choice: input.choice }); + memoryVotes.set(input.proposalId, byVoter); + return { counts: countMemory(input.proposalId), created }; + } + + const db = createDb(env); + const existing = await db + .select({ choice: vetoVotes.choice }) + .from(vetoVotes) + .where( + and( + eq(vetoVotes.proposalId, input.proposalId), + eq(vetoVotes.voterAddress, voter), + ), + ) + .limit(1); + const created = existing.length === 0; + await db + .insert(vetoVotes) + .values({ + proposalId: input.proposalId, + voterAddress: voter, + choice: input.choice, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [vetoVotes.proposalId, vetoVotes.voterAddress], + set: { choice: input.choice, updatedAt: now }, + }); + + return { counts: await getVetoVoteCounts(env, input.proposalId), created }; +} + +export async function getVetoVoteCounts( + env: Env, + proposalId: string, +): Promise { + if (!env.DATABASE_URL) return countMemory(proposalId); + + const db = createDb(env); + const rows = await db + .select({ + veto: sql`sum(case when ${vetoVotes.choice} = 'veto' then 1 else 0 end)`, + keep: sql`sum(case when ${vetoVotes.choice} = 'keep' then 1 else 0 end)`, + }) + .from(vetoVotes) + .where(eq(vetoVotes.proposalId, proposalId)); + const row = rows[0]; + return { veto: Number(row?.veto ?? 0), keep: Number(row?.keep ?? 0) }; +} + +export async function clearVetoVotesForProposal( + env: Env, + proposalId: string, +): Promise { + if (!env.DATABASE_URL) { + memoryVotes.delete(proposalId); + return; + } + + const db = createDb(env); + await db.delete(vetoVotes).where(eq(vetoVotes.proposalId, proposalId)); +} + +export function clearVetoVotesForTests(): void { + memoryVotes.clear(); +} + +function countMemory(proposalId: string): VetoVoteCounts { + const byVoter = memoryVotes.get(proposalId); + if (!byVoter) return { veto: 0, keep: 0 }; + let veto = 0; + let keep = 0; + for (const v of byVoter.values()) { + if (v.choice === "veto") veto += 1; + if (v.choice === "keep") keep += 1; + } + return { veto, keep }; +} diff --git a/functions/api/admin/audit/index.ts b/functions/api/admin/audit/index.ts new file mode 100644 index 0000000..b293208 --- /dev/null +++ b/functions/api/admin/audit/index.ts @@ -0,0 +1,35 @@ +import { assertAdmin } from "../../../_lib/clockStore.ts"; +import { listAdminAudit } from "../../../_lib/adminAuditStore.ts"; +import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; + +const DEFAULT_LIMIT = 50; + +export const onRequestGet: PagesFunction = async (context) => { + try { + assertAdmin(context); + } catch (error) { + const status = (error as Error & { status?: number }).status ?? 500; + return errorResponse(status, (error as Error).message); + } + + const url = new URL(context.request.url); + const cursor = url.searchParams.get("cursor"); + let beforeSeq: number | undefined; + if (cursor !== null) { + const parsed = Number.parseInt(cursor, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return errorResponse(400, "Invalid cursor"); + } + beforeSeq = parsed; + } + + const page = await listAdminAudit(context.env, { + beforeSeq, + limit: DEFAULT_LIMIT, + }); + const response = + page.nextSeq !== undefined + ? { items: page.items, nextCursor: String(page.nextSeq) } + : { items: page.items }; + return jsonResponse(response); +}; diff --git a/functions/api/admin/stats.ts b/functions/api/admin/stats.ts new file mode 100644 index 0000000..a53cc7c --- /dev/null +++ b/functions/api/admin/stats.ts @@ -0,0 +1,199 @@ +import { eq, sql } from "drizzle-orm"; + +import { + adminState, + apiRateLimits, + chamberVotes, + cmAwards, + courtCases, + courtReports, + courtVerdicts, + eraRollups, + eraUserActivity, + events, + formationMilestoneEvents, + formationTeam, + poolVotes, + userActionLocks, + users, +} from "../../../db/schema.ts"; +import { createDb } from "../../_lib/db.ts"; +import { getCommandRateLimitConfig } from "../../_lib/apiRateLimitStore.ts"; +import { assertAdmin, createClockStore } from "../../_lib/clockStore.ts"; +import { getEraQuotaConfig } from "../../_lib/eraQuotas.ts"; +import { listEraUserActivity } from "../../_lib/eraStore.ts"; +import { errorResponse, jsonResponse } from "../../_lib/http.ts"; + +type Env = Record; + +function sum( + rows: Array<{ + poolVotes: number; + chamberVotes: number; + courtActions: number; + formationActions: number; + }>, +) { + return rows.reduce( + (acc, r) => ({ + poolVotes: acc.poolVotes + r.poolVotes, + chamberVotes: acc.chamberVotes + r.chamberVotes, + courtActions: acc.courtActions + r.courtActions, + formationActions: acc.formationActions + r.formationActions, + }), + { poolVotes: 0, chamberVotes: 0, courtActions: 0, formationActions: 0 }, + ); +} + +async function getWritesFrozen(env: Env): Promise { + if (env.SIM_WRITE_FREEZE === "true") return true; + if (env.READ_MODELS_INLINE === "true") return false; + if (!env.DATABASE_URL) return false; + const db = createDb(env); + await db.insert(adminState).values({ id: 1 }).onConflictDoNothing(); + const rows = await db + .select({ writesFrozen: adminState.writesFrozen }) + .from(adminState) + .where(eq(adminState.id, 1)) + .limit(1); + return rows[0]?.writesFrozen ?? false; +} + +export const onRequestGet: PagesFunction = async (context) => { + try { + assertAdmin(context); + } catch (error) { + const status = (error as Error & { status?: number }).status ?? 500; + return errorResponse(status, (error as Error).message); + } + + const clock = createClockStore(context.env); + const { currentEra } = await clock.get(); + const rate = getCommandRateLimitConfig(context.env); + const quotas = getEraQuotaConfig(context.env); + const writesFrozen = await getWritesFrozen(context.env); + + if (!context.env.DATABASE_URL) { + const rows = await listEraUserActivity(context.env, { era: currentEra }); + const totals = sum(rows); + return jsonResponse({ + currentEra, + writesFrozen, + config: { + rateLimitsPerMinute: rate, + eraQuotas: quotas, + }, + currentEraActivity: { + rows: rows.length, + totals, + }, + db: null, + }); + } + + const db = createDb(context.env); + const now = new Date(); + + const [ + usersCount, + eventsCount, + adminAuditCount, + feedEventCount, + poolVotesCount, + chamberVotesCount, + cmAwardsCount, + formationTeamCount, + formationMilestoneEventsCount, + courtCasesCount, + courtReportsCount, + courtVerdictsCount, + rateLimitBucketsCount, + activeLocksCount, + currentEraActivityRowsCount, + currentEraActivityTotals, + rollupsCount, + ] = await Promise.all([ + db.select({ n: sql`count(*)` }).from(users), + db.select({ n: sql`count(*)` }).from(events), + db + .select({ n: sql`count(*)` }) + .from(events) + .where(sql`${events.type} = 'admin.action.v1'`), + db + .select({ n: sql`count(*)` }) + .from(events) + .where(sql`${events.type} = 'feed.item.v1'`), + db.select({ n: sql`count(*)` }).from(poolVotes), + db.select({ n: sql`count(*)` }).from(chamberVotes), + db.select({ n: sql`count(*)` }).from(cmAwards), + db.select({ n: sql`count(*)` }).from(formationTeam), + db.select({ n: sql`count(*)` }).from(formationMilestoneEvents), + db.select({ n: sql`count(*)` }).from(courtCases), + db.select({ n: sql`count(*)` }).from(courtReports), + db.select({ n: sql`count(*)` }).from(courtVerdicts), + db.select({ n: sql`count(*)` }).from(apiRateLimits), + db + .select({ n: sql`count(*)` }) + .from(userActionLocks) + .where(sql`${userActionLocks.lockedUntil} > ${now}`), + db + .select({ n: sql`count(*)` }) + .from(eraUserActivity) + .where(sql`${eraUserActivity.era} = ${currentEra}`), + db + .select({ + poolVotes: sql`sum(${eraUserActivity.poolVotes})`, + chamberVotes: sql`sum(${eraUserActivity.chamberVotes})`, + courtActions: sql`sum(${eraUserActivity.courtActions})`, + formationActions: sql`sum(${eraUserActivity.formationActions})`, + }) + .from(eraUserActivity) + .where(sql`${eraUserActivity.era} = ${currentEra}`), + db.select({ n: sql`count(*)` }).from(eraRollups), + ]); + + return jsonResponse({ + currentEra, + writesFrozen, + config: { + rateLimitsPerMinute: rate, + eraQuotas: quotas, + }, + db: { + users: Number(usersCount[0]?.n ?? 0), + events: { + total: Number(eventsCount[0]?.n ?? 0), + feedItems: Number(feedEventCount[0]?.n ?? 0), + adminAudit: Number(adminAuditCount[0]?.n ?? 0), + }, + actions: { + poolVotes: Number(poolVotesCount[0]?.n ?? 0), + chamberVotes: Number(chamberVotesCount[0]?.n ?? 0), + cmAwards: Number(cmAwardsCount[0]?.n ?? 0), + formationTeam: Number(formationTeamCount[0]?.n ?? 0), + formationMilestoneEvents: Number( + formationMilestoneEventsCount[0]?.n ?? 0, + ), + courtCases: Number(courtCasesCount[0]?.n ?? 0), + courtReports: Number(courtReportsCount[0]?.n ?? 0), + courtVerdicts: Number(courtVerdictsCount[0]?.n ?? 0), + }, + hardening: { + rateLimitBuckets: Number(rateLimitBucketsCount[0]?.n ?? 0), + activeLocks: Number(activeLocksCount[0]?.n ?? 0), + }, + eras: { + rollups: Number(rollupsCount[0]?.n ?? 0), + currentEraActivityRows: Number(currentEraActivityRowsCount[0]?.n ?? 0), + currentEraTotals: { + poolVotes: Number(currentEraActivityTotals[0]?.poolVotes ?? 0), + chamberVotes: Number(currentEraActivityTotals[0]?.chamberVotes ?? 0), + courtActions: Number(currentEraActivityTotals[0]?.courtActions ?? 0), + formationActions: Number( + currentEraActivityTotals[0]?.formationActions ?? 0, + ), + }, + }, + }, + }); +}; diff --git a/functions/api/admin/users/[address].ts b/functions/api/admin/users/[address].ts new file mode 100644 index 0000000..739082d --- /dev/null +++ b/functions/api/admin/users/[address].ts @@ -0,0 +1,54 @@ +import { assertAdmin } from "../../../_lib/clockStore.ts"; +import { createActionLocksStore } from "../../../_lib/actionLocksStore.ts"; +import { getEraQuotaConfig } from "../../../_lib/eraQuotas.ts"; +import { getUserEraActivity } from "../../../_lib/eraStore.ts"; +import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; + +export const onRequestGet: PagesFunction<{ address: string }> = async ( + context, +) => { + try { + assertAdmin(context); + } catch (error) { + const status = (error as Error & { status?: number }).status ?? 500; + return errorResponse(status, (error as Error).message); + } + + const address = (context.params.address ?? "").trim(); + if (!address) return errorResponse(400, "Missing address"); + + const activity = await getUserEraActivity(context.env, { address }); + const quotas = getEraQuotaConfig(context.env); + const lock = await createActionLocksStore(context.env).getActiveLock(address); + + const remaining = { + poolVotes: + quotas.maxPoolVotes === null + ? null + : Math.max(0, quotas.maxPoolVotes - activity.counts.poolVotes), + chamberVotes: + quotas.maxChamberVotes === null + ? null + : Math.max(0, quotas.maxChamberVotes - activity.counts.chamberVotes), + courtActions: + quotas.maxCourtActions === null + ? null + : Math.max(0, quotas.maxCourtActions - activity.counts.courtActions), + formationActions: + quotas.maxFormationActions === null + ? null + : Math.max( + 0, + quotas.maxFormationActions - activity.counts.formationActions, + ), + }; + + return jsonResponse({ + address, + era: activity.era, + counts: activity.counts, + quotas, + remaining, + lock, + }); +}; diff --git a/functions/api/admin/users/lock.ts b/functions/api/admin/users/lock.ts new file mode 100644 index 0000000..ebc8bd8 --- /dev/null +++ b/functions/api/admin/users/lock.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +import { assertAdmin } from "../../../_lib/clockStore.ts"; +import { createActionLocksStore } from "../../../_lib/actionLocksStore.ts"; +import { appendAdminAudit } from "../../../_lib/adminAuditStore.ts"; +import { errorResponse, jsonResponse, readJson } from "../../../_lib/http.ts"; + +const schema = z.object({ + address: z.string().min(1), + lockedUntil: z.string().min(1), + reason: z.string().min(1).optional(), +}); + +export const onRequestPost: PagesFunction = async (context) => { + try { + assertAdmin(context); + } catch (error) { + const status = (error as Error & { status?: number }).status ?? 500; + return errorResponse(status, (error as Error).message); + } + + let body: unknown; + try { + body = await readJson(context.request); + } catch (error) { + return errorResponse(400, (error as Error).message); + } + + const parsed = schema.safeParse(body); + if (!parsed.success) { + return errorResponse(400, "Invalid body", { issues: parsed.error.issues }); + } + + const lockedUntil = new Date(parsed.data.lockedUntil); + if (Number.isNaN(lockedUntil.getTime())) { + return errorResponse(400, "Invalid lockedUntil"); + } + + await createActionLocksStore(context.env).setLock({ + address: parsed.data.address, + lockedUntil, + reason: parsed.data.reason ?? null, + }); + + await appendAdminAudit(context.env, { + action: "user.lock", + targetAddress: parsed.data.address, + lockedUntil: lockedUntil.toISOString(), + reason: parsed.data.reason ?? null, + }); + + return jsonResponse({ ok: true }); +}; diff --git a/functions/api/admin/users/locks.ts b/functions/api/admin/users/locks.ts new file mode 100644 index 0000000..88b206e --- /dev/null +++ b/functions/api/admin/users/locks.ts @@ -0,0 +1,15 @@ +import { assertAdmin } from "../../../_lib/clockStore.ts"; +import { createActionLocksStore } from "../../../_lib/actionLocksStore.ts"; +import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; + +export const onRequestGet: PagesFunction = async (context) => { + try { + assertAdmin(context); + } catch (error) { + const status = (error as Error & { status?: number }).status ?? 500; + return errorResponse(status, (error as Error).message); + } + + const locks = await createActionLocksStore(context.env).listActiveLocks(); + return jsonResponse({ items: locks }); +}; diff --git a/functions/api/admin/users/unlock.ts b/functions/api/admin/users/unlock.ts new file mode 100644 index 0000000..2d8e3d8 --- /dev/null +++ b/functions/api/admin/users/unlock.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { assertAdmin } from "../../../_lib/clockStore.ts"; +import { createActionLocksStore } from "../../../_lib/actionLocksStore.ts"; +import { appendAdminAudit } from "../../../_lib/adminAuditStore.ts"; +import { errorResponse, jsonResponse, readJson } from "../../../_lib/http.ts"; + +const schema = z.object({ + address: z.string().min(1), +}); + +export const onRequestPost: PagesFunction = async (context) => { + try { + assertAdmin(context); + } catch (error) { + const status = (error as Error & { status?: number }).status ?? 500; + return errorResponse(status, (error as Error).message); + } + + let body: unknown; + try { + body = await readJson(context.request); + } catch (error) { + return errorResponse(400, (error as Error).message); + } + + const parsed = schema.safeParse(body); + if (!parsed.success) { + return errorResponse(400, "Invalid body", { issues: parsed.error.issues }); + } + + await createActionLocksStore(context.env).clearLock(parsed.data.address); + await appendAdminAudit(context.env, { + action: "user.unlock", + targetAddress: parsed.data.address, + }); + return jsonResponse({ ok: true }); +}; diff --git a/functions/api/admin/writes/freeze.ts b/functions/api/admin/writes/freeze.ts new file mode 100644 index 0000000..25aacd9 --- /dev/null +++ b/functions/api/admin/writes/freeze.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { assertAdmin } from "../../../_lib/clockStore.ts"; +import { appendAdminAudit } from "../../../_lib/adminAuditStore.ts"; +import { createAdminStateStore } from "../../../_lib/adminStateStore.ts"; +import { errorResponse, jsonResponse, readJson } from "../../../_lib/http.ts"; + +const schema = z.object({ + enabled: z.boolean(), +}); + +export const onRequestPost: PagesFunction = async (context) => { + try { + assertAdmin(context); + } catch (error) { + const status = (error as Error & { status?: number }).status ?? 500; + return errorResponse(status, (error as Error).message); + } + + let body: unknown; + try { + body = await readJson(context.request); + } catch (error) { + return errorResponse(400, (error as Error).message); + } + + const parsed = schema.safeParse(body); + if (!parsed.success) { + return errorResponse(400, "Invalid body", { issues: parsed.error.issues }); + } + + const enabled = parsed.data.enabled; + await createAdminStateStore(context.env).setWritesFrozen(enabled); + + await appendAdminAudit(context.env, { + action: enabled ? "writes.freeze" : "writes.unfreeze", + targetAddress: "global", + }); + + return jsonResponse({ ok: true, writesFrozen: enabled }); +}; diff --git a/functions/api/auth/nonce.ts b/functions/api/auth/nonce.ts index 268da2c..f5a46e4 100644 --- a/functions/api/auth/nonce.ts +++ b/functions/api/auth/nonce.ts @@ -1,5 +1,8 @@ import { issueNonce } from "../../_lib/auth.ts"; +import { createNonceStore } from "../../_lib/nonceStore.ts"; import { errorResponse, jsonResponse, readJson } from "../../_lib/http.ts"; +import { getRequestIp } from "../../_lib/requestIp.ts"; +import { canonicalizeHmndAddress } from "../../_lib/address.ts"; type Body = { address?: string }; @@ -13,15 +16,31 @@ export const onRequestPost: PagesFunction = async (context) => { const address = (body.address ?? "").trim(); if (!address) return errorResponse(400, "Missing address"); + const canonical = (await canonicalizeHmndAddress(address)) ?? address; const headers = new Headers(); try { - const { nonce } = await issueNonce( + const nonceStore = createNonceStore(context.env); + const requestIp = getRequestIp(context.request); + const rate = await nonceStore.canIssue({ address: canonical, requestIp }); + if (!rate.ok) + return errorResponse(429, "Rate limited", { + retryAfterSeconds: rate.retryAfterSeconds, + }); + + const { nonce, expiresAt } = await issueNonce( headers, context.env, context.request.url, - address, + canonical, ); + + await nonceStore.create({ + address: canonical, + nonce, + requestIp, + expiresAt: new Date(expiresAt), + }); return jsonResponse({ nonce }, { headers }); } catch (error) { return errorResponse(500, (error as Error).message); diff --git a/functions/api/auth/verify.ts b/functions/api/auth/verify.ts index ba53ed8..d05f593 100644 --- a/functions/api/auth/verify.ts +++ b/functions/api/auth/verify.ts @@ -1,6 +1,13 @@ import { issueSession, verifyNonceCookie } from "../../_lib/auth.ts"; import { envBoolean } from "../../_lib/env.ts"; +import { createNonceStore } from "../../_lib/nonceStore.ts"; +import { verifySubstrateSignature } from "../../_lib/signatures.ts"; import { errorResponse, jsonResponse, readJson } from "../../_lib/http.ts"; +import { upsertUser } from "../../_lib/userStore.ts"; +import { + canonicalizeHmndAddress, + addressesReferToSameKey, +} from "../../_lib/address.ts"; type Body = { address?: string; @@ -22,6 +29,7 @@ export const onRequestPost: PagesFunction = async (context) => { if (!address) return errorResponse(400, "Missing address"); if (!nonce) return errorResponse(400, "Missing nonce"); if (!signature) return errorResponse(400, "Missing signature"); + const canonical = (await canonicalizeHmndAddress(address)) ?? address; const nonceToken = await verifyNonceCookie(context.request, context.env); if (!nonceToken) @@ -29,18 +37,33 @@ export const onRequestPost: PagesFunction = async (context) => { 401, "Nonce expired or missing; call /api/auth/nonce again", ); - if (nonceToken.address !== address) + if (!(await addressesReferToSameKey(nonceToken.address, canonical))) return errorResponse(401, "Nonce was issued for a different address"); if (nonceToken.nonce !== nonce) return errorResponse(401, "Nonce mismatch"); + const nonceStore = createNonceStore(context.env); + const consume = await nonceStore.consume({ address: canonical, nonce }); + if (!consume.ok) { + const message = + consume.reason === "expired" + ? "Nonce expired; call /api/auth/nonce again" + : consume.reason === "used" + ? "Nonce already used; call /api/auth/nonce again" + : "Nonce invalid; call /api/auth/nonce again"; + return errorResponse(401, message, { reason: consume.reason }); + } + if (!envBoolean(context.env, "DEV_BYPASS_SIGNATURE")) { - return errorResponse( - 501, - "Signature verification is not implemented yet; set DEV_BYPASS_SIGNATURE=true to continue locally.", - ); + const ok = await verifySubstrateSignature({ + address: canonical, + message: nonce, + signature, + }); + if (!ok) return errorResponse(401, "Invalid signature"); } const headers = new Headers(); - await issueSession(headers, context.env, context.request.url, address); - return jsonResponse({ ok: true, address }, { headers }); + await issueSession(headers, context.env, context.request.url, canonical); + await upsertUser(context.env, { address: canonical }); + return jsonResponse({ ok: true, address: canonical }, { headers }); }; diff --git a/functions/api/chambers/[id].ts b/functions/api/chambers/[id].ts index 3e9f8b4..e58b146 100644 --- a/functions/api/chambers/[id].ts +++ b/functions/api/chambers/[id].ts @@ -1,15 +1,186 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; import { errorResponse, jsonResponse } from "../../_lib/http.ts"; +import { getChamber } from "../../_lib/chambersStore.ts"; +import { listProposals } from "../../_lib/proposalsStore.ts"; +import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; +import { + listAllChamberMembers, + listChamberMembers, +} from "../../_lib/chamberMembershipsStore.ts"; +import { getSimConfig } from "../../_lib/simConfig.ts"; +import { resolveUserTierFromSimConfig } from "../../_lib/userTier.ts"; export const onRequestGet: PagesFunction = async (context) => { try { const id = context.params?.id; if (!id) return errorResponse(400, "Missing chamber id"); - const store = await createReadModelsStore(context.env); - const payload = await store.get(`chambers:${id}`); - if (!payload) - return errorResponse(404, `Missing read model: chambers:${id}`); - return jsonResponse(payload); + if (context.env.READ_MODELS_INLINE_EMPTY === "true") { + const store = await createReadModelsStore(context.env).catch(() => null); + const fallback = store ? await store.get(`chambers:${id}`) : null; + if (!fallback) return errorResponse(404, "Chamber not found"); + } + + let chamber = await getChamber(context.env, context.request.url, id); + if (!chamber) { + const store = await createReadModelsStore(context.env).catch(() => null); + const listPayload = store ? await store.get("chambers:list") : null; + const items = + listPayload && + typeof listPayload === "object" && + !Array.isArray(listPayload) && + Array.isArray((listPayload as { items?: unknown[] }).items) + ? (listPayload as { items: unknown[] }).items + : []; + const entry = items.find( + (item) => + item && + typeof item === "object" && + !Array.isArray(item) && + String((item as { id?: string }).id ?? "").toLowerCase() === + id.toLowerCase(), + ) as + | { + id?: string; + name?: string; + multiplier?: number; + status?: string; + } + | undefined; + if (entry) { + const multiplier = + typeof entry.multiplier === "number" ? entry.multiplier : 1; + const now = new Date(); + chamber = { + id: String(entry.id ?? id).toLowerCase(), + title: entry.name ?? entry.id ?? id, + status: + entry.status === "dissolved" ? "dissolved" : ("active" as const), + multiplierTimes10: Math.round(multiplier * 10), + createdAt: now, + updatedAt: now, + dissolvedAt: null, + }; + } + } + if (!chamber) return errorResponse(404, "Chamber not found"); + + const stageOptions = [ + { value: "upcoming", label: "Upcoming" }, + { value: "live", label: "Live" }, + { value: "ended", label: "Ended" }, + ] as const; + + const proposalsList: Array<{ + id: string; + title: string; + meta: string; + summary: string; + lead: string; + nextStep: string; + timing: string; + stage: "upcoming" | "live" | "ended"; + }> = []; + + const proposalRows = await listProposals(context.env); + for (const proposal of proposalRows) { + if ((proposal.chamberId ?? "general").toLowerCase() !== id.toLowerCase()) + continue; + const stage = + proposal.stage === "pool" + ? "upcoming" + : proposal.stage === "vote" + ? "live" + : "ended"; + const formationEligible = (() => { + const payload = proposal.payload as Record | null; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return true; + if (payload.templateId === "system") return false; + if ( + typeof payload.metaGovernance === "object" && + payload.metaGovernance !== null && + !Array.isArray(payload.metaGovernance) + ) + return false; + if (typeof payload.formationEligible === "boolean") + return payload.formationEligible; + if (typeof payload.formation === "boolean") return payload.formation; + return true; + })(); + + const meta = + stage === "upcoming" + ? "Proposal pool" + : stage === "live" + ? "Chamber vote" + : formationEligible + ? "Formation" + : "Passed"; + + proposalsList.push({ + id: proposal.id, + title: proposal.title, + meta, + summary: proposal.summary, + lead: chamber.title, + nextStep: + stage === "upcoming" + ? "Cast attention vote" + : stage === "live" + ? "Cast chamber vote" + : formationEligible + ? "Open Formation" + : "Read outcome", + timing: proposal.createdAt.toISOString().slice(0, 10), + stage, + }); + } + + const cfg = await getSimConfig(context.env, context.request.url); + const genesisMembers = cfg?.genesisChamberMembers ?? undefined; + const memberAddresses = new Set(); + const normalizeAddress = (value: string) => value.trim(); + const chamberId = id.toLowerCase(); + + if (chamberId === "general") { + if (genesisMembers) { + for (const list of Object.values(genesisMembers)) { + for (const addr of list) memberAddresses.add(normalizeAddress(addr)); + } + } + // In v1, the roster for General is the set of anyone with any membership. + // This will be refined once canonical human profiles and era activity are in place. + const seeded = await listAllChamberMembers(context.env); + for (const addr of seeded) memberAddresses.add(normalizeAddress(addr)); + } else { + if (genesisMembers) { + for (const addr of genesisMembers[chamberId] ?? []) + memberAddresses.add(normalizeAddress(addr)); + } + const seeded = await listChamberMembers(context.env, id); + for (const addr of seeded) memberAddresses.add(normalizeAddress(addr)); + } + + const governors = await Promise.all( + Array.from(memberAddresses) + .sort() + .map(async (address) => ({ + id: address, + name: + address.length > 12 + ? `${address.slice(0, 6)}…${address.slice(-4)}` + : address, + tier: await resolveUserTierFromSimConfig(cfg, address), + focus: chamber.title, + })), + ); + + return jsonResponse({ + proposals: proposalsList.sort((a, b) => a.title.localeCompare(b.title)), + governors, + threads: [], + chatLog: [], + stageOptions, + }); } catch (error) { return errorResponse(500, (error as Error).message); } diff --git a/functions/api/chambers/index.ts b/functions/api/chambers/index.ts index 7035e08..de8812a 100644 --- a/functions/api/chambers/index.ts +++ b/functions/api/chambers/index.ts @@ -1,13 +1,46 @@ -import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; import { errorResponse, jsonResponse } from "../../_lib/http.ts"; +import { + listChambers, + projectChamberPipeline, + projectChamberStats, +} from "../../_lib/chambersStore.ts"; export const onRequestGet: PagesFunction = async (context) => { try { - const store = await createReadModelsStore(context.env); - const payload = await store.get("chambers:list"); - if (!payload) - return errorResponse(404, "Missing read model: chambers:list"); - return jsonResponse(payload); + if (context.env.READ_MODELS_INLINE_EMPTY === "true") { + return jsonResponse({ items: [] }); + } + const url = new URL(context.request.url); + const includeDissolved = + url.searchParams.get("includeDissolved") === "true"; + const chambers = await listChambers(context.env, context.request.url, { + includeDissolved, + }); + const items = await Promise.all( + chambers.map(async (chamber) => { + const pipeline = await projectChamberPipeline(context.env, { + chamberId: chamber.id, + }); + const stats = await projectChamberStats( + context.env, + context.request.url, + { chamberId: chamber.id }, + ); + return { + id: chamber.id, + name: chamber.title, + multiplier: Math.round((chamber.multiplierTimes10 / 10) * 10) / 10, + stats: { + governors: stats.governors.toLocaleString(), + acm: stats.acm.toLocaleString(), + lcm: stats.lcm.toLocaleString(), + mcm: stats.mcm.toLocaleString(), + }, + pipeline, + }; + }), + ); + return jsonResponse({ items }); } catch (error) { return errorResponse(500, (error as Error).message); } diff --git a/functions/api/clock/advance-era.ts b/functions/api/clock/advance-era.ts index a0dd237..8dd8c7e 100644 --- a/functions/api/clock/advance-era.ts +++ b/functions/api/clock/advance-era.ts @@ -1,11 +1,14 @@ import { assertAdmin, createClockStore } from "../../_lib/clockStore.ts"; +import { ensureEraSnapshot } from "../../_lib/eraStore.ts"; import { errorResponse, jsonResponse } from "../../_lib/http.ts"; export const onRequestPost: PagesFunction = async (context) => { try { assertAdmin(context); const clock = createClockStore(context.env); - return jsonResponse(await clock.advanceEra()); + const next = await clock.advanceEra(); + await ensureEraSnapshot(context.env, next.currentEra).catch(() => {}); + return jsonResponse(next); } catch (error) { const err = error as Error & { status?: number }; if (err.status) return errorResponse(err.status, err.message); diff --git a/functions/api/clock/index.ts b/functions/api/clock/index.ts index d0f8d08..4888bac 100644 --- a/functions/api/clock/index.ts +++ b/functions/api/clock/index.ts @@ -1,10 +1,21 @@ import { createClockStore } from "../../_lib/clockStore.ts"; +import { getActiveGovernorsForCurrentEra } from "../../_lib/eraStore.ts"; +import { getEraRollupMeta } from "../../_lib/eraRollupStore.ts"; import { errorResponse, jsonResponse } from "../../_lib/http.ts"; export const onRequestGet: PagesFunction = async (context) => { try { const clock = createClockStore(context.env); - return jsonResponse(await clock.get()); + const snapshot = await clock.get(); + const activeGovernors = await getActiveGovernorsForCurrentEra(context.env); + const rollup = await getEraRollupMeta(context.env, { + era: snapshot.currentEra, + }).catch(() => null); + return jsonResponse({ + ...snapshot, + activeGovernors, + ...(rollup ? { currentEraRollup: rollup } : {}), + }); } catch (error) { return errorResponse(500, (error as Error).message); } diff --git a/functions/api/clock/rollup-era.ts b/functions/api/clock/rollup-era.ts new file mode 100644 index 0000000..b8e2c7a --- /dev/null +++ b/functions/api/clock/rollup-era.ts @@ -0,0 +1,38 @@ +import { assertAdmin, createClockStore } from "../../_lib/clockStore.ts"; +import { rollupEra } from "../../_lib/eraRollupStore.ts"; +import { setEraSnapshotActiveGovernors } from "../../_lib/eraStore.ts"; +import { errorResponse, jsonResponse } from "../../_lib/http.ts"; + +export const onRequestPost: PagesFunction = async (context) => { + try { + assertAdmin(context); + + const clock = createClockStore(context.env); + const { currentEra } = await clock.get(); + + let era = currentEra; + const contentType = context.request.headers.get("content-type") ?? ""; + if (contentType.toLowerCase().includes("application/json")) { + const body = (await context.request.json().catch(() => null)) as { + era?: number; + } | null; + if (body && typeof body.era === "number") era = Math.floor(body.era); + } + + const result = await rollupEra(context.env, { + era, + requestUrl: context.request.url, + }); + + await setEraSnapshotActiveGovernors(context.env, { + era: era + 1, + activeGovernors: result.activeGovernorsNextEra, + }).catch(() => {}); + + return jsonResponse({ ok: true as const, ...result }); + } catch (error) { + const err = error as Error & { status?: number }; + if (err.status) return errorResponse(err.status, err.message); + return errorResponse(500, err.message); + } +}; diff --git a/functions/api/clock/tick.ts b/functions/api/clock/tick.ts new file mode 100644 index 0000000..51a57f6 --- /dev/null +++ b/functions/api/clock/tick.ts @@ -0,0 +1,226 @@ +import { assertAdmin, createClockStore } from "../../_lib/clockStore.ts"; +import { errorResponse, jsonResponse } from "../../_lib/http.ts"; +import { appendFeedItemEventOnce } from "../../_lib/appendEvents.ts"; +import { rollupEra } from "../../_lib/eraRollupStore.ts"; +import { + ensureEraSnapshot, + setEraSnapshotActiveGovernors, +} from "../../_lib/eraStore.ts"; +import { formatChamberLabel } from "../../_lib/proposalDraftsStore.ts"; +import { listProposals } from "../../_lib/proposalsStore.ts"; +import { + getSimNow, + getStageDeadlineIso, + getStageWindowSeconds, + isStageOpen, + stageWindowsEnabled, +} from "../../_lib/stageWindows.ts"; +import { V1_ERA_SECONDS_DEFAULT } from "../../_lib/v1Constants.ts"; +import { finalizeAcceptedProposalFromVote } from "../../_lib/proposalFinalizer.ts"; +import { appendProposalTimelineItem } from "../../_lib/proposalTimelineStore.ts"; +import { randomHex } from "../../_lib/random.ts"; + +type Env = Record; + +function getEraSeconds(env: Env): number { + const raw = env.SIM_ERA_SECONDS ?? ""; + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed); + return V1_ERA_SECONDS_DEFAULT; +} + +export const onRequestPost: PagesFunction = async (context) => { + try { + assertAdmin(context); + + const contentType = context.request.headers.get("content-type") ?? ""; + const body = contentType.toLowerCase().includes("application/json") + ? ((await context.request.json().catch(() => null)) as { + forceAdvance?: boolean; + rollup?: boolean; + } | null) + : null; + + const forceAdvance = body?.forceAdvance === true; + const shouldRollup = body?.rollup !== false; + + const clock = createClockStore(context.env); + const snapshot = await clock.get(); + await ensureEraSnapshot(context.env, snapshot.currentEra).catch(() => {}); + + const now = getSimNow(context.env); + const eraSeconds = getEraSeconds(context.env); + const updatedAt = new Date(snapshot.updatedAt); + const dueByTime = + Number.isFinite(updatedAt.getTime()) && + now.getTime() - updatedAt.getTime() >= eraSeconds * 1000; + + const due = forceAdvance || dueByTime; + + const rollup = shouldRollup + ? await rollupEra(context.env, { + era: snapshot.currentEra, + requestUrl: context.request.url, + }) + : null; + + if (rollup) { + await setEraSnapshotActiveGovernors(context.env, { + era: snapshot.currentEra + 1, + activeGovernors: rollup.activeGovernorsNextEra, + }).catch(() => {}); + } + + let advancedTo = snapshot.currentEra; + let advanced = false; + if (due) { + const next = await clock.advanceEra(); + advancedTo = next.currentEra; + advanced = advancedTo !== snapshot.currentEra; + await ensureEraSnapshot(context.env, next.currentEra).catch(() => {}); + } + + const endedWindows: Array<{ + proposalId: string; + stage: "pool" | "vote"; + endedAt: string; + emitted: boolean; + }> = []; + + if (stageWindowsEnabled(context.env)) { + const proposals = await listProposals(context.env).catch(() => []); + for (const proposal of proposals) { + if (proposal.stage !== "pool" && proposal.stage !== "vote") continue; + if (proposal.stage === "vote" && proposal.votePassedAt) continue; + const windowSeconds = getStageWindowSeconds( + context.env, + proposal.stage, + ); + if (windowSeconds <= 0) continue; + + const stageStartedAt = proposal.updatedAt; + if (now.getTime() < stageStartedAt.getTime()) continue; + const endedAt = getStageDeadlineIso({ stageStartedAt, windowSeconds }); + const open = isStageOpen({ now, stageStartedAt, windowSeconds }); + if (open) continue; + + const entityType = "proposal.stage_window_ended.v1"; + const entityId = `${proposal.id}:${proposal.stage}:${endedAt}`; + + const chamberLabel = proposal.chamberId + ? formatChamberLabel(proposal.chamberId) + : "General chamber"; + + const emitted = await appendFeedItemEventOnce(context.env, { + stage: proposal.stage, + entityType, + entityId, + payload: { + id: `proposal-window-ended:${proposal.id}:${proposal.stage}:${endedAt}`, + title: + proposal.stage === "pool" + ? "Proposal pool window ended" + : "Chamber voting window ended", + meta: `${chamberLabel} · System`, + stage: proposal.stage, + summaryPill: + proposal.stage === "pool" ? "Proposal pool" : "Chamber vote", + summary: + proposal.stage === "pool" + ? "Pool voting is now closed for this proposal." + : "Voting is now closed for this proposal.", + ctaPrimary: "Open proposal", + href: + proposal.stage === "pool" + ? `/app/proposals/${proposal.id}/pp` + : `/app/proposals/${proposal.id}/chamber`, + timestamp: endedAt, + }, + }); + + endedWindows.push({ + proposalId: proposal.id, + stage: proposal.stage, + endedAt, + emitted, + }); + } + } + + const finalized: Array<{ proposalId: string; ok: boolean }> = []; + { + const proposals = await listProposals(context.env, { + stage: "vote", + }).catch(() => []); + for (const proposal of proposals) { + const finalizesAt = proposal.voteFinalizesAt; + if (!finalizesAt) continue; + if (now.getTime() < finalizesAt.getTime()) continue; + if (!proposal.votePassedAt) continue; + + const result = await finalizeAcceptedProposalFromVote(context.env, { + proposalId: proposal.id, + requestUrl: context.request.url, + }); + finalized.push({ proposalId: proposal.id, ok: result.ok }); + if (!result.ok) continue; + + await appendFeedItemEventOnce(context.env, { + stage: "build", + entityType: "proposal", + entityId: `vote-finalized:${proposal.id}:${finalizesAt.toISOString()}`, + payload: { + id: `vote-finalized:${proposal.id}:${finalizesAt.toISOString()}`, + title: "Proposal accepted", + meta: "Chamber vote", + stage: "build", + summaryPill: "Accepted", + summary: + "Veto window ended; chamber vote is finalized and the proposal is now accepted.", + stats: [ + ...(result.avgScore !== null + ? [{ label: "Avg CM", value: result.avgScore.toFixed(1) }] + : []), + ], + ctaPrimary: "Open proposal", + href: result.formationEligible + ? `/app/proposals/${proposal.id}/formation` + : `/app/proposals/${proposal.id}/chamber`, + timestamp: now.toISOString(), + }, + }); + + await appendProposalTimelineItem(context.env, { + proposalId: proposal.id, + stage: "build", + actorAddress: null, + item: { + id: `timeline:vote-finalized:${proposal.id}:${randomHex(4)}`, + type: "proposal.vote.finalized", + title: "Proposal accepted", + detail: "Veto window ended", + actor: "system", + timestamp: now.toISOString(), + }, + }); + } + } + + return jsonResponse({ + ok: true as const, + now: now.toISOString(), + eraSeconds, + due, + advanced, + fromEra: snapshot.currentEra, + toEra: advancedTo, + ...(endedWindows.length > 0 ? { endedWindows } : {}), + ...(finalized.length > 0 ? { finalized } : {}), + ...(rollup ? { rollup } : {}), + }); + } catch (error) { + const err = error as Error & { status?: number }; + if (err.status) return errorResponse(err.status, err.message); + return errorResponse(500, err.message); + } +}; diff --git a/functions/api/command.ts b/functions/api/command.ts new file mode 100644 index 0000000..a41551c --- /dev/null +++ b/functions/api/command.ts @@ -0,0 +1,3159 @@ +import { z } from "zod"; + +import { readSession } from "../_lib/auth.ts"; +import { checkEligibility } from "../_lib/gate.ts"; +import { errorResponse, jsonResponse, readJson } from "../_lib/http.ts"; +import { + getIdempotencyResponse, + storeIdempotencyResponse, +} from "../_lib/idempotencyStore.ts"; +import { castPoolVote } from "../_lib/poolVotesStore.ts"; +import { appendFeedItemEvent } from "../_lib/appendEvents.ts"; +import { appendProposalTimelineItem } from "../_lib/proposalTimelineStore.ts"; +import { createReadModelsStore } from "../_lib/readModelsStore.ts"; +import { evaluatePoolQuorum } from "../_lib/poolQuorum.ts"; +import { + castChamberVote, + getChamberYesScoreAverage, + clearChamberVotesForProposal, +} from "../_lib/chamberVotesStore.ts"; +import { evaluateChamberQuorum } from "../_lib/chamberQuorum.ts"; +import { awardCmOnce, hasLcmHistoryInChamber } from "../_lib/cmAwardsStore.ts"; +import { + joinFormationProject, + ensureFormationSeed, + getFormationMilestoneStatus, + isFormationTeamMember, + requestFormationMilestoneUnlock, + submitFormationMilestone, +} from "../_lib/formationStore.ts"; +import { + castCourtVerdict, + hasCourtReport, + hasCourtVerdict, + reportCourtCase, +} from "../_lib/courtsStore.ts"; +import { + getActiveGovernorsForCurrentEra, + incrementEraUserActivity, + getUserEraActivity, +} from "../_lib/eraStore.ts"; +import { + createApiRateLimitStore, + getCommandRateLimitConfig, +} from "../_lib/apiRateLimitStore.ts"; +import { getRequestIp } from "../_lib/requestIp.ts"; +import { createActionLocksStore } from "../_lib/actionLocksStore.ts"; +import { getEraQuotaConfig } from "../_lib/eraQuotas.ts"; +import { hasPoolVote } from "../_lib/poolVotesStore.ts"; +import { hasChamberVote } from "../_lib/chamberVotesStore.ts"; +import { createAdminStateStore } from "../_lib/adminStateStore.ts"; +import { + deleteDraft, + draftIsSubmittable, + formatChamberLabel, + getDraft, + markDraftSubmitted, + proposalDraftFormSchema, + upsertDraft, +} from "../_lib/proposalDraftsStore.ts"; +import { + createProposal, + setProposalVotePendingVeto, + getProposal, + transitionProposalStage, + applyProposalVeto, +} from "../_lib/proposalsStore.ts"; +import { + captureProposalStageDenominator, + getProposalStageDenominator, +} from "../_lib/proposalStageDenominatorsStore.ts"; +import { clearDelegation, setDelegation } from "../_lib/delegationsStore.ts"; +import { + ensureChamberMembership, + hasAnyChamberMembership, + hasChamberMembership, +} from "../_lib/chamberMembershipsStore.ts"; +import { getActiveGovernorsDenominatorForChamberCurrentEra } from "../_lib/chamberActiveDenominators.ts"; +import { randomHex } from "../_lib/random.ts"; +import { + computePoolUpvoteFloor, + shouldAdvancePoolToVote, + shouldAdvanceVoteToBuild, +} from "../_lib/proposalStateMachine.ts"; +import { + V1_ACTIVE_GOVERNORS_FALLBACK, + V1_CHAMBER_PASSING_FRACTION, + V1_CHAMBER_QUORUM_FRACTION, + V1_POOL_ATTENTION_QUORUM_FRACTION, + V1_VETO_DELAY_SECONDS_DEFAULT, + V1_VETO_MAX_APPLIES, +} from "../_lib/v1Constants.ts"; +import { computeVetoCouncilSnapshot } from "../_lib/vetoCouncilStore.ts"; +import { + castVetoVote, + clearVetoVotesForProposal, +} from "../_lib/vetoVotesStore.ts"; +import { finalizeAcceptedProposalFromVote } from "../_lib/proposalFinalizer.ts"; +import { + formatTimeLeftDaysHours, + getSimNow, + getStageDeadlineIso, + getStageRemainingSeconds, + getStageWindowSeconds, + isStageOpen, + stageWindowsEnabled, +} from "../_lib/stageWindows.ts"; +import { envBoolean } from "../_lib/env.ts"; +import { getSimConfig } from "../_lib/simConfig.ts"; +import { resolveUserTierFromSimConfig } from "../_lib/userTier.ts"; +import { addressesReferToSameKey } from "../_lib/address.ts"; +import { + createChamberFromAcceptedGeneralProposal, + dissolveChamberFromAcceptedGeneralProposal, + getChamber, + parseChamberGovernanceFromPayload, + setChamberMultiplierTimes10, +} from "../_lib/chambersStore.ts"; +import { + getChamberMultiplierAggregate, + upsertChamberMultiplierSubmission, +} from "../_lib/chamberMultiplierSubmissionsStore.ts"; + +function getGenesisMembersForDenominators( + simConfig: Awaited> | null, + chamberId: string, +): string[] | null { + const genesis = simConfig?.genesisChamberMembers; + if (!genesis) return null; + const normalized = chamberId.trim().toLowerCase(); + if (normalized === "general") return Object.values(genesis).flat(); + return genesis[normalized] ?? null; +} + +const poolVoteSchema = z.object({ + type: z.literal("pool.vote"), + payload: z.object({ + proposalId: z.string().min(1), + direction: z.enum(["up", "down"]), + }), + idempotencyKey: z.string().min(8).optional(), +}); + +const chamberVoteSchema = z.object({ + type: z.literal("chamber.vote"), + payload: z.object({ + proposalId: z.string().min(1), + choice: z.enum(["yes", "no", "abstain"]), + score: z.number().int().min(1).max(10).optional(), + }), + idempotencyKey: z.string().min(8).optional(), +}); + +const formationJoinSchema = z.object({ + type: z.literal("formation.join"), + payload: z.object({ + proposalId: z.string().min(1), + role: z.string().min(1).optional(), + }), + idempotencyKey: z.string().min(8).optional(), +}); + +const formationMilestoneSubmitSchema = z.object({ + type: z.literal("formation.milestone.submit"), + payload: z.object({ + proposalId: z.string().min(1), + milestoneIndex: z.number().int().min(1), + note: z.string().min(1).optional(), + }), + idempotencyKey: z.string().min(8).optional(), +}); + +const formationMilestoneUnlockSchema = z.object({ + type: z.literal("formation.milestone.requestUnlock"), + payload: z.object({ + proposalId: z.string().min(1), + milestoneIndex: z.number().int().min(1), + }), + idempotencyKey: z.string().min(8).optional(), +}); + +const courtReportSchema = z.object({ + type: z.literal("court.case.report"), + payload: z.object({ + caseId: z.string().min(1), + }), + idempotencyKey: z.string().min(8).optional(), +}); + +const courtVerdictSchema = z.object({ + type: z.literal("court.case.verdict"), + payload: z.object({ + caseId: z.string().min(1), + verdict: z.enum(["guilty", "not_guilty"]), + }), + idempotencyKey: z.string().min(8).optional(), +}); + +const proposalDraftSaveSchema = z.object({ + type: z.literal("proposal.draft.save"), + payload: z.object({ + draftId: z.string().min(1).optional(), + form: proposalDraftFormSchema, + }), + idempotencyKey: z.string().min(8).optional(), +}); + +const proposalDraftDeleteSchema = z.object({ + type: z.literal("proposal.draft.delete"), + payload: z.object({ + draftId: z.string().min(1), + }), + idempotencyKey: z.string().min(8).optional(), +}); + +const proposalSubmitToPoolSchema = z.object({ + type: z.literal("proposal.submitToPool"), + payload: z.object({ + draftId: z.string().min(1), + }), + idempotencyKey: z.string().min(8).optional(), +}); + +const delegationSetSchema = z.object({ + type: z.literal("delegation.set"), + payload: z.object({ + chamberId: z.string().min(1), + delegateeAddress: z.string().min(1), + }), + idempotencyKey: z.string().min(8).optional(), +}); + +const delegationClearSchema = z.object({ + type: z.literal("delegation.clear"), + payload: z.object({ + chamberId: z.string().min(1), + }), + idempotencyKey: z.string().min(8).optional(), +}); + +const vetoVoteSchema = z.object({ + type: z.literal("veto.vote"), + payload: z.object({ + proposalId: z.string().min(1), + choice: z.enum(["veto", "keep"]), + }), + idempotencyKey: z.string().min(8).optional(), +}); + +const chamberMultiplierSubmitSchema = z.object({ + type: z.literal("chamber.multiplier.submit"), + payload: z.object({ + chamberId: z.string().min(1), + multiplierTimes10: z.number().int().min(1).max(100), + }), + idempotencyKey: z.string().min(8).optional(), +}); + +const commandSchema = z.discriminatedUnion("type", [ + poolVoteSchema, + chamberVoteSchema, + formationJoinSchema, + formationMilestoneSubmitSchema, + formationMilestoneUnlockSchema, + courtReportSchema, + courtVerdictSchema, + proposalDraftSaveSchema, + proposalDraftDeleteSchema, + proposalSubmitToPoolSchema, + delegationSetSchema, + delegationClearSchema, + vetoVoteSchema, + chamberMultiplierSubmitSchema, +]); + +type CommandInput = z.infer; + +export const onRequestPost: PagesFunction = async (context) => { + let body: unknown; + try { + body = await readJson(context.request); + } catch (error) { + return errorResponse(400, (error as Error).message); + } + + const parsed = commandSchema.safeParse(body); + if (!parsed.success) { + return errorResponse(400, "Invalid command", { + issues: parsed.error.issues, + }); + } + + const session = await readSession(context.request, context.env); + if (!session) return errorResponse(401, "Not authenticated"); + const sessionAddress = session.address; + + const gate = await checkEligibility( + context.env, + sessionAddress, + context.request.url, + ); + if (!gate.eligible) { + return errorResponse(403, gate.reason ?? "not_eligible", { gate }); + } + + if (context.env.SIM_WRITE_FREEZE === "true") { + return errorResponse(503, "Writes are temporarily disabled", { + code: "writes_frozen", + }); + } + const adminState = await createAdminStateStore(context.env) + .get() + .catch(() => ({ writesFrozen: false })); + if (adminState.writesFrozen) { + return errorResponse(503, "Writes are temporarily disabled", { + code: "writes_frozen", + }); + } + + const locks = createActionLocksStore(context.env); + const activeLock = await locks.getActiveLock(sessionAddress); + if (activeLock) { + return errorResponse(403, "Action locked", { + code: "action_locked", + lock: activeLock, + }); + } + + const rateLimits = createApiRateLimitStore(context.env); + const rateConfig = getCommandRateLimitConfig(context.env); + const requestIp = getRequestIp(context.request); + + if (requestIp) { + const ipLimit = await rateLimits.consume({ + bucket: `command:ip:${requestIp}`, + limit: rateConfig.perIpPerMinute, + windowSeconds: 60, + }); + if (!ipLimit.ok) { + return errorResponse(429, "Rate limited", { + scope: "ip", + retryAfterSeconds: ipLimit.retryAfterSeconds, + resetAt: ipLimit.resetAt, + }); + } + } + + const addressLimit = await rateLimits.consume({ + bucket: `command:address:${session.address}`, + limit: rateConfig.perAddressPerMinute, + windowSeconds: 60, + }); + if (!addressLimit.ok) { + return errorResponse(429, "Rate limited", { + scope: "address", + retryAfterSeconds: addressLimit.retryAfterSeconds, + resetAt: addressLimit.resetAt, + }); + } + + const input: CommandInput = parsed.data; + const headerKey = + context.request.headers.get("idempotency-key") ?? + context.request.headers.get("x-idempotency-key") ?? + undefined; + const idempotencyKey = headerKey ?? input.idempotencyKey; + const requestForIdem = { type: input.type, payload: input.payload }; + + if (idempotencyKey) { + const hit = await getIdempotencyResponse(context.env, { + key: idempotencyKey, + address: session.address, + request: requestForIdem, + }); + if ("conflict" in hit && hit.conflict) { + return errorResponse(409, "Idempotency key conflict"); + } + if (hit.hit) return jsonResponse(hit.response); + } + + const readModels = await createReadModelsStore(context.env).catch(() => null); + const activeGovernorsBaseline = await getActiveGovernorsForCurrentEra( + context.env, + ).catch(() => null); + + const quotas = getEraQuotaConfig(context.env); + + async function enforceEraQuota(input: { + kind: "poolVotes" | "chamberVotes" | "courtActions" | "formationActions"; + wouldCount: boolean; + }): Promise { + if (!input.wouldCount) return null; + const limit = + input.kind === "poolVotes" + ? quotas.maxPoolVotes + : input.kind === "chamberVotes" + ? quotas.maxChamberVotes + : input.kind === "courtActions" + ? quotas.maxCourtActions + : quotas.maxFormationActions; + if (limit === null) return null; + + const activity = await getUserEraActivity(context.env, { + address: sessionAddress, + }); + const used = activity.counts[input.kind] ?? 0; + if (used >= limit) { + return errorResponse(429, "Era quota exceeded", { + code: "era_quota_exceeded", + era: activity.era, + kind: input.kind, + limit, + used, + }); + } + return null; + } + if ( + input.type === "pool.vote" || + input.type === "chamber.vote" || + input.type === "formation.join" || + input.type === "formation.milestone.submit" || + input.type === "formation.milestone.requestUnlock" + ) { + const requiredStage = + input.type === "pool.vote" + ? "pool" + : input.type === "chamber.vote" + ? "vote" + : "build"; + const stage = + (await getProposal(context.env, input.payload.proposalId))?.stage ?? + (readModels + ? await getProposalStage(readModels, input.payload.proposalId) + : null); + if (!stage) return errorResponse(404, "Unknown proposal"); + if (stage !== requiredStage) { + return errorResponse(409, "Proposal is not in the required stage", { + stage, + requiredStage, + }); + } + } + + if (input.type === "proposal.draft.save") { + const record = await upsertDraft(context.env, { + authorAddress: sessionAddress, + draftId: input.payload.draftId, + form: input.payload.form, + }); + + const response = { + ok: true as const, + type: input.type, + draftId: record.id, + updatedAt: record.updatedAt.toISOString(), + }; + + if (idempotencyKey) { + await storeIdempotencyResponse(context.env, { + key: idempotencyKey, + address: sessionAddress, + request: requestForIdem, + response, + }); + } + + return jsonResponse(response); + } + + if (input.type === "proposal.draft.delete") { + const deleted = await deleteDraft(context.env, { + authorAddress: sessionAddress, + draftId: input.payload.draftId, + }); + + const response = { + ok: true as const, + type: input.type, + draftId: input.payload.draftId, + deleted, + }; + + if (idempotencyKey) { + await storeIdempotencyResponse(context.env, { + key: idempotencyKey, + address: sessionAddress, + request: requestForIdem, + response, + }); + } + + return jsonResponse(response); + } + + if (input.type === "proposal.submitToPool") { + const draft = await getDraft(context.env, { + authorAddress: sessionAddress, + draftId: input.payload.draftId, + }); + if (!draft) return errorResponse(404, "Draft not found"); + if (draft.submittedAt || draft.submittedProposalId) { + return errorResponse(409, "Draft already submitted"); + } + if (!draftIsSubmittable(draft.payload)) { + return errorResponse(400, "Draft is not ready for submission", { + code: "draft_not_submittable", + }); + } + + const now = new Date(); + const baseSlug = draft.title + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); + const proposalId = `${baseSlug || "proposal"}-${randomHex(2)}`; + + const chamberId = (draft.chamberId ?? "").trim().toLowerCase(); + if (chamberId && chamberId !== "general") { + const chamber = await getChamber( + context.env, + context.request.url, + chamberId, + ); + if (!chamber) { + return errorResponse(400, "Unknown chamber", { + code: "invalid_chamber", + chamberId, + }); + } + if (chamber.status !== "active") { + return errorResponse(409, "Chamber is dissolved", { + code: "chamber_dissolved", + chamberId, + status: chamber.status, + dissolvedAt: chamber.dissolvedAt?.toISOString() ?? null, + }); + } + } + + const meta = (() => { + const payload = draft.payload as Record | null; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return null; + const mg = payload.metaGovernance; + if (!mg || typeof mg !== "object" || Array.isArray(mg)) return null; + const record = mg as Record; + const action = typeof record.action === "string" ? record.action : ""; + if (action !== "chamber.create" && action !== "chamber.dissolve") + return { invalid: true as const }; + + const id = typeof record.chamberId === "string" ? record.chamberId : ""; + const title = typeof record.title === "string" ? record.title : ""; + const multiplier = + typeof record.multiplier === "number" ? record.multiplier : null; + const genesisMembersRaw = record.genesisMembers; + const genesisMembers = Array.isArray(genesisMembersRaw) + ? genesisMembersRaw + .filter((v): v is string => typeof v === "string") + .map((v) => v.trim()) + .filter(Boolean) + : []; + return { + action, + id, + title, + multiplier, + genesisMembers, + } as const; + })(); + + if (meta?.invalid) { + return errorResponse(400, "Invalid meta-governance payload", { + code: "invalid_meta_governance", + }); + } + + if (meta && meta.action) { + if (chamberId !== "general") { + return errorResponse( + 400, + "Meta-governance proposals must use General chamber", + { + code: "meta_governance_requires_general", + }, + ); + } + + const targetId = meta.id.trim().toLowerCase(); + if (!targetId || targetId === "general") { + return errorResponse(400, "Invalid target chamber", { + code: "invalid_meta_chamber", + }); + } + + const existing = await getChamber( + context.env, + context.request.url, + targetId, + ); + + if (meta.action === "chamber.create") { + if (existing) { + return errorResponse(409, "Chamber already exists", { + code: "chamber_exists", + chamberId: targetId, + status: existing.status, + }); + } + if (!meta.title.trim()) { + return errorResponse(400, "Chamber title is required", { + code: "invalid_meta_chamber", + }); + } + if (meta.multiplier !== null && !(meta.multiplier > 0)) { + return errorResponse(400, "Multiplier must be > 0", { + code: "invalid_meta_chamber", + }); + } + } else { + if (!existing) { + return errorResponse(400, "Unknown chamber", { + code: "invalid_chamber", + chamberId: targetId, + }); + } + if (existing.status !== "active") { + return errorResponse(409, "Chamber is already dissolved", { + code: "chamber_dissolved", + chamberId: targetId, + status: existing.status, + dissolvedAt: existing.dissolvedAt?.toISOString() ?? null, + }); + } + } + } + + const normalizedChamberId = meta ? "general" : draft.chamberId; + + await createProposal(context.env, { + id: proposalId, + stage: "pool", + authorAddress: sessionAddress, + title: draft.title, + chamberId: normalizedChamberId ?? null, + summary: draft.summary, + payload: draft.payload, + }); + + const chamber = formatChamberLabel(normalizedChamberId ?? null); + const budgetTotal = draft.payload.budgetItems.reduce((sum, item) => { + const n = Number(item.amount); + if (!Number.isFinite(n) || n <= 0) return sum; + return sum + n; + }, 0); + const budget = + budgetTotal > 0 ? `${budgetTotal.toLocaleString()} HMND` : "—"; + + const poolChamberId = (normalizedChamberId ?? "general") + .trim() + .toLowerCase(); + const simConfig = await getSimConfig( + context.env, + context.request.url, + ).catch(() => null); + const authorTier = await resolveUserTierFromSimConfig( + simConfig, + sessionAddress, + ); + const genesisMembers = getGenesisMembersForDenominators( + simConfig, + poolChamberId, + ); + const poolActiveGovernors = + await getActiveGovernorsDenominatorForChamberCurrentEra(context.env, { + chamberId: poolChamberId || "general", + fallbackActiveGovernors: + typeof activeGovernorsBaseline === "number" + ? activeGovernorsBaseline + : V1_ACTIVE_GOVERNORS_FALLBACK, + genesisMembers, + }); + const attentionQuorum = V1_POOL_ATTENTION_QUORUM_FRACTION; + const upvoteFloor = computePoolUpvoteFloor(poolActiveGovernors); + + const formationEligible = getFormationEligibleFromProposalPayload( + draft.payload, + ); + + const poolPagePayload = { + title: draft.title, + proposer: sessionAddress, + proposerId: sessionAddress, + chamber, + focus: "—", + tier: authorTier, + budget, + cooldown: "Withdraw cooldown: 12h", + formationEligible, + templateId: isRecord(draft.payload) + ? draft.payload.templateId + : undefined, + metaGovernance: isRecord(draft.payload) + ? draft.payload.metaGovernance + : undefined, + teamSlots: "1 / 3", + milestones: String(draft.payload.timeline.length), + upvotes: 0, + downvotes: 0, + attentionQuorum, + activeGovernors: poolActiveGovernors, + upvoteFloor, + rules: [ + `${Math.round(attentionQuorum * 100)}% attention from active governors required.`, + poolActiveGovernors > 0 + ? `At least ${Math.round((upvoteFloor / poolActiveGovernors) * 100)}% upvotes to move to chamber vote.` + : "At least 0% upvotes to move to chamber vote.", + ], + attachments: draft.payload.attachments + .filter((a) => a.label.trim().length > 0) + .map((a) => ({ id: a.id, title: a.label })), + teamLocked: [{ name: sessionAddress, role: "Proposer" }], + openSlotNeeds: [], + milestonesDetail: draft.payload.timeline.map((m, idx) => ({ + title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, + desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", + })), + summary: draft.payload.summary, + overview: draft.payload.what, + executionPlan: draft.payload.how + .split("\n") + .map((line) => line.trim()) + .filter(Boolean), + budgetScope: draft.payload.budgetItems + .filter((b) => b.description.trim().length > 0) + .map((b) => `${b.description}: ${b.amount} HMND`) + .join("\n"), + invisionInsight: { + role: "Draft author", + bullets: [ + "Submitted via the simulation backend proposal wizard.", + "This is an off-chain governance simulation (not mainnet).", + ], + }, + }; + + const listPayload = readModels?.set + ? await readModels.get("proposals:list") + : null; + const existingItems = + readModels?.set && + isRecord(listPayload) && + Array.isArray(listPayload.items) + ? listPayload.items + : []; + + const listItem = { + id: proposalId, + title: draft.title, + meta: `${chamber} · ${authorTier} tier`, + stage: "pool", + summaryPill: `${draft.payload.timeline.length} milestones`, + summary: draft.payload.summary, + stageData: [ + { + title: "Pool momentum", + description: "Upvotes / Downvotes", + value: "0 / 0", + }, + { + title: "Attention quorum", + description: "20% active or ≥10% upvotes", + value: "Needs · 0% engaged", + tone: "warn", + }, + { title: "Votes casted", description: "Backing seats", value: "0" }, + ], + stats: [ + { label: "Budget ask", value: budget }, + { label: "Formation", value: formationEligible ? "Yes" : "No" }, + ], + proposer: sessionAddress, + proposerId: sessionAddress, + chamber, + tier: authorTier, + proofFocus: "pot", + tags: [], + keywords: [], + date: now.toISOString().slice(0, 10), + votes: 0, + activityScore: 0, + ctaPrimary: "Open proposal", + ctaSecondary: "", + }; + + if (readModels?.set) { + await readModels.set("proposals:list", { + ...(isRecord(listPayload) ? listPayload : {}), + items: [...existingItems, listItem], + }); + await readModels.set(`proposals:${proposalId}:pool`, poolPagePayload); + } + + await captureProposalStageDenominator(context.env, { + proposalId, + stage: "pool", + activeGovernors: poolActiveGovernors, + }).catch(() => {}); + + await markDraftSubmitted(context.env, { + authorAddress: sessionAddress, + draftId: input.payload.draftId, + proposalId, + }); + + const response = { + ok: true as const, + type: input.type, + draftId: input.payload.draftId, + proposalId, + }; + + if (idempotencyKey) { + await storeIdempotencyResponse(context.env, { + key: idempotencyKey, + address: sessionAddress, + request: requestForIdem, + response, + }); + } + + await appendFeedItemEvent(context.env, { + stage: "pool", + actorAddress: sessionAddress, + entityType: "proposal", + entityId: proposalId, + payload: { + id: `proposal-submitted:${proposalId}:${Date.now()}`, + title: "Proposal submitted", + meta: "Proposal pool · New", + stage: "pool", + summaryPill: "Submitted", + summary: `Submitted "${draft.title}" to the proposal pool.`, + stats: [{ label: "Budget ask", value: budget }], + ctaPrimary: "Open proposal", + href: `/app/proposals/${proposalId}/pp`, + timestamp: new Date().toISOString(), + }, + }); + + await appendProposalTimelineItem(context.env, { + proposalId, + stage: "pool", + actorAddress: sessionAddress, + item: { + id: `timeline:proposal-submitted:${proposalId}:${randomHex(4)}`, + type: "proposal.submitted", + title: "Proposal submitted", + detail: `Submitted to ${chamber}`, + actor: sessionAddress, + timestamp: new Date().toISOString(), + }, + }); + + return jsonResponse(response); + } + + if (input.type === "delegation.set") { + const chamberId = input.payload.chamberId.trim().toLowerCase(); + const delegateeAddress = input.payload.delegateeAddress.trim(); + + const isDelegatorEligible = + chamberId === "general" + ? await hasAnyChamberMembership(context.env, sessionAddress) + : await hasChamberMembership(context.env, { + address: sessionAddress, + chamberId, + }); + if (!isDelegatorEligible) { + return errorResponse(400, "Delegator is not eligible for delegation", { + code: "delegator_not_eligible", + chamberId, + }); + } + + const isDelegateeEligible = + chamberId === "general" + ? await hasAnyChamberMembership(context.env, delegateeAddress) + : await hasChamberMembership(context.env, { + address: delegateeAddress, + chamberId, + }); + if (!isDelegateeEligible) { + return errorResponse(400, "Delegatee is not eligible for delegation", { + code: "delegatee_not_eligible", + chamberId, + }); + } + + let record; + try { + record = await setDelegation(context.env, { + chamberId, + delegatorAddress: sessionAddress, + delegateeAddress, + }); + } catch (error) { + const code = (error as Error).message; + return errorResponse(400, "Unable to set delegation", { code }); + } + + const response = { + ok: true as const, + type: input.type, + chamberId: record.chamberId, + delegatorAddress: record.delegatorAddress, + delegateeAddress: record.delegateeAddress, + updatedAt: record.updatedAt, + }; + + if (idempotencyKey) { + await storeIdempotencyResponse(context.env, { + key: idempotencyKey, + address: session.address, + request: requestForIdem, + response, + }); + } + + await appendFeedItemEvent(context.env, { + stage: "vote", + actorAddress: session.address, + entityType: "delegation", + entityId: `${record.chamberId}:${record.delegatorAddress}`, + payload: { + id: `delegation-set:${record.chamberId}:${record.delegatorAddress}:${Date.now()}`, + title: "Delegation set", + meta: "Delegation", + stage: "vote", + summaryPill: "Delegated", + summary: `Delegated voting power in ${record.chamberId} chamber.`, + stats: [{ label: "Delegatee", value: record.delegateeAddress }], + ctaPrimary: "Open My Governance", + href: "/app/my-governance", + timestamp: new Date().toISOString(), + }, + }); + + return jsonResponse(response); + } + + if (input.type === "delegation.clear") { + const chamberId = input.payload.chamberId.trim().toLowerCase(); + + let cleared; + try { + cleared = await clearDelegation(context.env, { + chamberId, + delegatorAddress: sessionAddress, + }); + } catch (error) { + const code = (error as Error).message; + return errorResponse(400, "Unable to clear delegation", { code }); + } + + const response = { + ok: true as const, + type: input.type, + chamberId, + delegatorAddress: sessionAddress, + cleared: cleared.cleared, + }; + + if (idempotencyKey) { + await storeIdempotencyResponse(context.env, { + key: idempotencyKey, + address: session.address, + request: requestForIdem, + response, + }); + } + + return jsonResponse(response); + } + + if (input.type === "chamber.multiplier.submit") { + const chamberId = input.payload.chamberId.trim().toLowerCase(); + const multiplierTimes10 = input.payload.multiplierTimes10; + + const chamber = await getChamber( + context.env, + context.request.url, + chamberId, + ); + if (!chamber) { + return errorResponse(400, "Unknown chamber", { + code: "invalid_chamber", + chamberId, + }); + } + if (chamber.status !== "active") { + return errorResponse(409, "Chamber is dissolved", { + code: "chamber_dissolved", + chamberId, + }); + } + + const simConfig = await getSimConfig(context.env, context.request.url); + const genesis = simConfig?.genesisChamberMembers ?? null; + const hasGenesisMembership = (() => { + if (!genesis) return false; + for (const list of Object.values(genesis)) { + if (list.some((addr) => addr.trim() === sessionAddress)) return true; + } + return false; + })(); + + const isGovernor = + hasGenesisMembership || + (await hasAnyChamberMembership(context.env, sessionAddress)); + if (!isGovernor) { + return errorResponse(403, "Only governors can set chamber multipliers", { + code: "not_governor", + }); + } + + const hasLcmHere = await hasLcmHistoryInChamber(context.env, { + proposerId: sessionAddress, + chamberId, + }); + if (hasLcmHere) { + return errorResponse(400, "Multiplier voting is outsiders-only", { + code: "multiplier_outsider_required", + chamberId, + }); + } + + const { submission } = await upsertChamberMultiplierSubmission( + context.env, + { + chamberId, + voterAddress: sessionAddress, + multiplierTimes10, + }, + ); + + const aggregate = await getChamberMultiplierAggregate(context.env, { + chamberId, + }); + + const applied = + typeof aggregate.avgTimes10 === "number" + ? await setChamberMultiplierTimes10(context.env, context.request.url, { + id: chamberId, + multiplierTimes10: aggregate.avgTimes10, + }) + : null; + + const response = { + ok: true as const, + type: input.type, + chamberId, + submission: { + multiplierTimes10: submission.multiplierTimes10, + }, + aggregate, + applied: applied + ? { + updated: applied.updated, + prevMultiplierTimes10: applied.prevTimes10, + nextMultiplierTimes10: applied.nextTimes10, + } + : null, + }; + + if (idempotencyKey) { + await storeIdempotencyResponse(context.env, { + key: idempotencyKey, + address: session.address, + request: requestForIdem, + response, + }); + } + + await appendFeedItemEvent(context.env, { + stage: "vote", + actorAddress: sessionAddress, + entityType: "chamber", + entityId: `multiplier:${chamberId}`, + payload: { + id: `chamber-multiplier-submit:${chamberId}:${sessionAddress}:${Date.now()}`, + title: "Multiplier submitted", + meta: "Chambers · CM", + stage: "vote", + summaryPill: "Multiplier", + summary: `Submitted a chamber multiplier for ${chamberId}.`, + stats: [ + { label: "Submitted", value: String(submission.multiplierTimes10) }, + ...(typeof aggregate.avgTimes10 === "number" + ? [{ label: "Avg", value: String(aggregate.avgTimes10) }] + : []), + ], + ctaPrimary: "Open chambers", + href: "/app/chambers", + timestamp: new Date().toISOString(), + }, + }); + + return jsonResponse(response); + } + + if (input.type === "veto.vote") { + const proposalId = input.payload.proposalId; + const proposal = await getProposal(context.env, proposalId); + if (!proposal) return errorResponse(404, "Unknown proposal"); + if (proposal.stage !== "vote") { + return errorResponse(409, "Proposal is not in chamber vote stage", { + code: "stage_invalid", + stage: proposal.stage, + }); + } + + const now = getSimNow(context.env); + if (!proposal.votePassedAt || !proposal.voteFinalizesAt) { + return errorResponse(409, "No veto window is open for this proposal", { + code: "veto_not_open", + }); + } + if (now.getTime() >= proposal.voteFinalizesAt.getTime()) { + return errorResponse(409, "Veto window ended", { + code: "veto_window_ended", + finalizesAt: proposal.voteFinalizesAt.toISOString(), + }); + } + + const council = proposal.vetoCouncil ?? []; + const threshold = proposal.vetoThreshold ?? 0; + if (council.length === 0 || threshold <= 0) { + return errorResponse(409, "Veto is not enabled for this proposal", { + code: "veto_disabled", + }); + } + if (!council.includes(sessionAddress)) { + return errorResponse(403, "Not eligible to cast a veto vote", { + code: "not_veto_holder", + }); + } + + const { counts, created } = await castVetoVote(context.env, { + proposalId, + voterAddress: sessionAddress, + choice: input.payload.choice, + }); + + const response = { + ok: true as const, + type: input.type, + proposalId, + choice: input.payload.choice, + counts, + threshold, + }; + + if (idempotencyKey) { + await storeIdempotencyResponse(context.env, { + key: idempotencyKey, + address: session.address, + request: requestForIdem, + response, + }); + } + + await appendProposalTimelineItem(context.env, { + proposalId, + stage: "vote", + actorAddress: sessionAddress, + item: { + id: `timeline:veto-vote:${proposalId}:${sessionAddress}:${randomHex(4)}`, + type: "veto.vote", + title: "Veto vote cast", + detail: input.payload.choice === "veto" ? "Veto" : "Keep", + actor: sessionAddress, + timestamp: now.toISOString(), + }, + }); + + if (counts.veto >= threshold) { + await clearVetoVotesForProposal(context.env, proposalId).catch(() => {}); + await clearChamberVotesForProposal(context.env, proposalId).catch( + () => {}, + ); + + const nextVoteStartsAt = new Date( + now.getTime() + V1_VETO_DELAY_SECONDS_DEFAULT * 1000, + ); + await applyProposalVeto(context.env, { proposalId, nextVoteStartsAt }); + + await appendFeedItemEvent(context.env, { + stage: "vote", + actorAddress: session.address, + entityType: "proposal", + entityId: proposalId, + payload: { + id: `veto-applied:${proposalId}:${Date.now()}`, + title: "Veto applied", + meta: "Veto", + stage: "vote", + summaryPill: "Vetoed", + summary: + "Veto threshold met; chamber vote is reset and voting is paused.", + stats: [ + { label: "Veto votes", value: `${counts.veto} / ${threshold}` }, + ], + ctaPrimary: "Open proposal", + href: `/app/proposals/${proposalId}/chamber`, + timestamp: now.toISOString(), + }, + }); + + await appendProposalTimelineItem(context.env, { + proposalId, + stage: "vote", + actorAddress: null, + item: { + id: `timeline:veto-applied:${proposalId}:${randomHex(4)}`, + type: "veto.applied", + title: "Veto applied", + detail: `Voting resumes at ${nextVoteStartsAt.toISOString()}`, + actor: "system", + timestamp: now.toISOString(), + }, + }); + } + + if (created) { + await incrementEraUserActivity(context.env, { + address: session.address, + delta: { chamberVotes: 1 }, + }).catch(() => {}); + } + + return jsonResponse(response); + } + + if (input.type === "pool.vote") { + const poolEligibilityError = await enforcePoolVoteEligibility( + context.env, + readModels, + { + proposalId: input.payload.proposalId, + voterAddress: sessionAddress, + }, + context.request.url, + ); + if (poolEligibilityError) return poolEligibilityError; + + const proposal = await getProposal(context.env, input.payload.proposalId); + if ( + proposal && + stageWindowsEnabled(context.env) && + proposal.stage === "pool" + ) { + const now = getSimNow(context.env); + const windowSeconds = getStageWindowSeconds(context.env, "pool"); + if ( + !isStageOpen({ + now, + stageStartedAt: proposal.updatedAt, + windowSeconds, + }) + ) { + return errorResponse(409, "Pool window ended", { + code: "stage_closed", + stage: "pool", + endedAt: getStageDeadlineIso({ + stageStartedAt: proposal.updatedAt, + windowSeconds, + }), + timeLeft: (() => { + const remaining = getStageRemainingSeconds({ + now, + stageStartedAt: proposal.updatedAt, + windowSeconds, + }); + return remaining === 0 + ? "Ended" + : formatTimeLeftDaysHours(remaining); + })(), + }); + } + } + + const wouldCount = !(await hasPoolVote(context.env, { + proposalId: input.payload.proposalId, + voterAddress: sessionAddress, + })); + const quotaError = await enforceEraQuota({ + kind: "poolVotes", + wouldCount, + }); + if (quotaError) return quotaError; + + const direction = input.payload.direction === "up" ? 1 : -1; + const { counts, created } = await castPoolVote(context.env, { + proposalId: input.payload.proposalId, + voterAddress: session.address, + direction, + }); + + const response = { + ok: true as const, + type: input.type, + proposalId: input.payload.proposalId, + direction: input.payload.direction, + counts, + }; + + if (idempotencyKey) { + await storeIdempotencyResponse(context.env, { + key: idempotencyKey, + address: session.address, + request: requestForIdem, + response, + }); + } + + await appendFeedItemEvent(context.env, { + stage: "pool", + actorAddress: session.address, + entityType: "proposal", + entityId: input.payload.proposalId, + payload: { + id: `pool-vote:${input.payload.proposalId}:${session.address}:${Date.now()}`, + title: "Pool vote cast", + meta: "Proposal pool · Vote", + stage: "pool", + summaryPill: input.payload.direction === "up" ? "Upvote" : "Downvote", + summary: `Recorded a ${input.payload.direction}vote in the proposal pool.`, + stats: [ + { label: "Upvotes", value: String(counts.upvotes) }, + { label: "Downvotes", value: String(counts.downvotes) }, + ], + ctaPrimary: "Open proposal", + href: `/app/proposals/${input.payload.proposalId}/pp`, + timestamp: new Date().toISOString(), + }, + }); + + await appendProposalTimelineItem(context.env, { + proposalId: input.payload.proposalId, + stage: "pool", + actorAddress: session.address, + item: { + id: `timeline:pool-vote:${input.payload.proposalId}:${session.address}:${randomHex(4)}`, + type: "pool.vote", + title: "Pool vote cast", + detail: input.payload.direction === "up" ? "Upvote" : "Downvote", + actor: session.address, + timestamp: new Date().toISOString(), + }, + }); + + const storedPoolDenominator = await getProposalStageDenominator( + context.env, + { + proposalId: input.payload.proposalId, + stage: "pool", + }, + ).catch(() => null); + const poolChamberId = await getProposalChamberIdForPool( + context.env, + readModels, + { proposalId: input.payload.proposalId }, + ); + const simConfig = await getSimConfig( + context.env, + context.request.url, + ).catch(() => null); + const genesisMembers = getGenesisMembersForDenominators( + simConfig, + poolChamberId, + ); + const poolDenominator = + storedPoolDenominator?.activeGovernors ?? + (await getActiveGovernorsDenominatorForChamberCurrentEra(context.env, { + chamberId: poolChamberId, + fallbackActiveGovernors: + typeof activeGovernorsBaseline === "number" + ? activeGovernorsBaseline + : V1_ACTIVE_GOVERNORS_FALLBACK, + genesisMembers, + })); + if (!storedPoolDenominator) { + await captureProposalStageDenominator(context.env, { + proposalId: input.payload.proposalId, + stage: "pool", + activeGovernors: poolDenominator, + }).catch(() => {}); + } + + const canonicalAdvanced = await maybeAdvancePoolProposalToVoteCanonical( + context.env, + { + proposalId: input.payload.proposalId, + counts, + activeGovernors: poolDenominator, + }, + ); + const readModelAdvanced = + !canonicalAdvanced && + readModels && + (await maybeAdvancePoolProposalToVote(readModels, { + proposalId: input.payload.proposalId, + counts, + activeGovernors: poolDenominator, + })); + const advanced = canonicalAdvanced || Boolean(readModelAdvanced); + + if (advanced) { + const voteDenominator = + await getActiveGovernorsDenominatorForChamberCurrentEra(context.env, { + chamberId: poolChamberId, + fallbackActiveGovernors: + typeof activeGovernorsBaseline === "number" + ? activeGovernorsBaseline + : V1_ACTIVE_GOVERNORS_FALLBACK, + genesisMembers, + }); + await captureProposalStageDenominator(context.env, { + proposalId: input.payload.proposalId, + stage: "vote", + activeGovernors: voteDenominator, + }).catch(() => {}); + + await appendFeedItemEvent(context.env, { + stage: "vote", + actorAddress: session.address, + entityType: "proposal", + entityId: input.payload.proposalId, + payload: { + id: `pool-advance:${input.payload.proposalId}:${Date.now()}`, + title: "Proposal advanced", + meta: "Chamber vote", + stage: "vote", + summaryPill: "Advanced", + summary: "Attention quorum met; proposal moved to chamber vote.", + stats: [ + { label: "Upvotes", value: String(counts.upvotes) }, + { + label: "Engaged", + value: String(counts.upvotes + counts.downvotes), + }, + ], + ctaPrimary: "Open proposal", + href: `/app/proposals/${input.payload.proposalId}/chamber`, + timestamp: new Date().toISOString(), + }, + }); + + await appendProposalTimelineItem(context.env, { + proposalId: input.payload.proposalId, + stage: "vote", + actorAddress: session.address, + item: { + id: `timeline:pool-advance:${input.payload.proposalId}:${randomHex(4)}`, + type: "proposal.stage.advanced", + title: "Advanced to chamber vote", + detail: "Attention quorum met", + actor: "system", + timestamp: new Date().toISOString(), + }, + }); + } + + if (created) { + await incrementEraUserActivity(context.env, { + address: session.address, + delta: { poolVotes: 1 }, + }).catch(() => {}); + } + + return jsonResponse(response); + } + + if (input.type === "formation.join") { + if (!readModels) return errorResponse(500, "Read models store unavailable"); + const formationGate = await requireFormationEnabled(context.env, { + proposalId: input.payload.proposalId, + }); + if (!formationGate.ok) return formationGate.error; + const wouldCount = !(await isFormationTeamMember(context.env, { + proposalId: input.payload.proposalId, + memberAddress: session.address, + })); + const quotaError = await enforceEraQuota({ + kind: "formationActions", + wouldCount, + }); + if (quotaError) return quotaError; + + let summary; + let created = false; + try { + const result = await joinFormationProject(context.env, readModels, { + proposalId: input.payload.proposalId, + memberAddress: session.address, + role: input.payload.role ?? null, + }); + summary = result.summary; + created = result.created; + } catch (error) { + const message = (error as Error).message; + if (message === "team_full") + return errorResponse(409, "Formation team is full"); + return errorResponse(400, "Unable to join formation project", { + code: message, + }); + } + + const response = { + ok: true as const, + type: input.type, + proposalId: input.payload.proposalId, + teamSlots: { filled: summary.teamFilled, total: summary.teamTotal }, + }; + + if (idempotencyKey) { + await storeIdempotencyResponse(context.env, { + key: idempotencyKey, + address: session.address, + request: requestForIdem, + response, + }); + } + + await appendFeedItemEvent(context.env, { + stage: "build", + actorAddress: session.address, + entityType: "proposal", + entityId: input.payload.proposalId, + payload: { + id: `formation-join:${input.payload.proposalId}:${session.address}:${Date.now()}`, + title: "Joined formation project", + meta: "Formation", + stage: "build", + summaryPill: "Joined", + summary: "Joined the formation project team (mock).", + stats: [ + { + label: "Team slots", + value: `${summary.teamFilled} / ${summary.teamTotal}`, + }, + ], + ctaPrimary: "Open proposal", + href: `/app/proposals/${input.payload.proposalId}/formation`, + timestamp: new Date().toISOString(), + }, + }); + + await appendProposalTimelineItem(context.env, { + proposalId: input.payload.proposalId, + stage: "build", + actorAddress: session.address, + item: { + id: `timeline:formation-join:${input.payload.proposalId}:${session.address}:${randomHex(4)}`, + type: "formation.join", + title: "Joined formation project", + detail: input.payload.role + ? `Role: ${input.payload.role}` + : "Joined as contributor", + actor: session.address, + timestamp: new Date().toISOString(), + }, + }); + + if (created) { + await incrementEraUserActivity(context.env, { + address: session.address, + delta: { formationActions: 1 }, + }).catch(() => {}); + } + + return jsonResponse(response); + } + + if (input.type === "formation.milestone.submit") { + if (!readModels) return errorResponse(500, "Read models store unavailable"); + const formationGate = await requireFormationEnabled(context.env, { + proposalId: input.payload.proposalId, + }); + if (!formationGate.ok) return formationGate.error; + const status = await getFormationMilestoneStatus(context.env, readModels, { + proposalId: input.payload.proposalId, + milestoneIndex: input.payload.milestoneIndex, + }).catch(() => null); + const wouldCount = + status !== null && status !== "submitted" && status !== "unlocked"; + const quotaError = await enforceEraQuota({ + kind: "formationActions", + wouldCount, + }); + if (quotaError) return quotaError; + + let summary; + let created = false; + try { + const result = await submitFormationMilestone(context.env, readModels, { + proposalId: input.payload.proposalId, + milestoneIndex: input.payload.milestoneIndex, + actorAddress: session.address, + note: input.payload.note ?? null, + }); + summary = result.summary; + created = result.created; + } catch (error) { + const message = (error as Error).message; + if (message === "milestone_out_of_range") + return errorResponse(400, "Milestone index is out of range"); + if (message === "milestone_already_unlocked") + return errorResponse(409, "Milestone is already unlocked"); + return errorResponse(400, "Unable to submit milestone", { + code: message, + }); + } + + const response = { + ok: true as const, + type: input.type, + proposalId: input.payload.proposalId, + milestoneIndex: input.payload.milestoneIndex, + milestones: { + completed: summary.milestonesCompleted, + total: summary.milestonesTotal, + }, + }; + + if (idempotencyKey) { + await storeIdempotencyResponse(context.env, { + key: idempotencyKey, + address: session.address, + request: requestForIdem, + response, + }); + } + + await appendFeedItemEvent(context.env, { + stage: "build", + actorAddress: session.address, + entityType: "proposal", + entityId: input.payload.proposalId, + payload: { + id: `formation-milestone-submit:${input.payload.proposalId}:${input.payload.milestoneIndex}:${Date.now()}`, + title: "Milestone submitted", + meta: "Formation", + stage: "build", + summaryPill: `M${input.payload.milestoneIndex}`, + summary: "Submitted a milestone deliverable for review (mock).", + stats: [ + { + label: "Milestones", + value: `${summary.milestonesCompleted} / ${summary.milestonesTotal}`, + }, + ], + ctaPrimary: "Open proposal", + href: `/app/proposals/${input.payload.proposalId}/formation`, + timestamp: new Date().toISOString(), + }, + }); + + await appendProposalTimelineItem(context.env, { + proposalId: input.payload.proposalId, + stage: "build", + actorAddress: session.address, + item: { + id: `timeline:formation-milestone-unlock:${input.payload.proposalId}:${input.payload.milestoneIndex}:${randomHex(4)}`, + type: "formation.milestone.unlockRequested", + title: `Unlock requested (M${input.payload.milestoneIndex})`, + detail: "Requested unlock for milestone payout (mock)", + actor: session.address, + timestamp: new Date().toISOString(), + }, + }); + + if (created) { + await incrementEraUserActivity(context.env, { + address: session.address, + delta: { formationActions: 1 }, + }).catch(() => {}); + } + + return jsonResponse(response); + } + + if (input.type === "formation.milestone.requestUnlock") { + if (!readModels) return errorResponse(500, "Read models store unavailable"); + const formationGate = await requireFormationEnabled(context.env, { + proposalId: input.payload.proposalId, + }); + if (!formationGate.ok) return formationGate.error; + const quotaError = await enforceEraQuota({ + kind: "formationActions", + wouldCount: true, + }); + if (quotaError) return quotaError; + + let summary; + let created = false; + try { + const result = await requestFormationMilestoneUnlock( + context.env, + readModels, + { + proposalId: input.payload.proposalId, + milestoneIndex: input.payload.milestoneIndex, + actorAddress: session.address, + }, + ); + summary = result.summary; + created = result.created; + } catch (error) { + const message = (error as Error).message; + if (message === "milestone_out_of_range") + return errorResponse(400, "Milestone index is out of range"); + if (message === "milestone_not_submitted") + return errorResponse(409, "Milestone must be submitted first"); + if (message === "milestone_already_unlocked") + return errorResponse(409, "Milestone is already unlocked"); + return errorResponse(400, "Unable to request unlock", { code: message }); + } + + const response = { + ok: true as const, + type: input.type, + proposalId: input.payload.proposalId, + milestoneIndex: input.payload.milestoneIndex, + milestones: { + completed: summary.milestonesCompleted, + total: summary.milestonesTotal, + }, + }; + + if (idempotencyKey) { + await storeIdempotencyResponse(context.env, { + key: idempotencyKey, + address: session.address, + request: requestForIdem, + response, + }); + } + + await appendFeedItemEvent(context.env, { + stage: "build", + actorAddress: session.address, + entityType: "proposal", + entityId: input.payload.proposalId, + payload: { + id: `formation-milestone-unlock:${input.payload.proposalId}:${input.payload.milestoneIndex}:${Date.now()}`, + title: "Milestone unlocked", + meta: "Formation", + stage: "build", + summaryPill: `M${input.payload.milestoneIndex}`, + summary: "Milestone marked as unlocked (mock).", + stats: [ + { + label: "Milestones", + value: `${summary.milestonesCompleted} / ${summary.milestonesTotal}`, + }, + ], + ctaPrimary: "Open proposal", + href: `/app/proposals/${input.payload.proposalId}/formation`, + timestamp: new Date().toISOString(), + }, + }); + + if (created) { + await incrementEraUserActivity(context.env, { + address: session.address, + delta: { formationActions: 1 }, + }).catch(() => {}); + } + + return jsonResponse(response); + } + + if (input.type === "court.case.report") { + if (!readModels) return errorResponse(500, "Read models store unavailable"); + const wouldCount = !(await hasCourtReport(context.env, { + caseId: input.payload.caseId, + reporterAddress: session.address, + })); + const quotaError = await enforceEraQuota({ + kind: "courtActions", + wouldCount, + }); + if (quotaError) return quotaError; + + let overlay; + let created = false; + try { + const result = await reportCourtCase(context.env, readModels, { + caseId: input.payload.caseId, + reporterAddress: session.address, + }); + overlay = result.overlay; + created = result.created; + } catch (error) { + const code = (error as Error).message; + if (code === "court_case_missing") + return errorResponse(404, "Unknown case"); + return errorResponse(400, "Unable to report case", { code }); + } + + const response = { + ok: true as const, + type: input.type, + caseId: input.payload.caseId, + reports: overlay.reports, + status: overlay.status, + }; + + if (idempotencyKey) { + await storeIdempotencyResponse(context.env, { + key: idempotencyKey, + address: session.address, + request: requestForIdem, + response, + }); + } + + await appendFeedItemEvent(context.env, { + stage: "courts", + actorAddress: session.address, + entityType: "court_case", + entityId: input.payload.caseId, + payload: { + id: `court-report:${input.payload.caseId}:${session.address}:${Date.now()}`, + title: "Court case reported", + meta: "Courts", + stage: "courts", + summaryPill: "Report", + summary: "Filed a report for a court case (mock).", + stats: [{ label: "Reports", value: String(overlay.reports) }], + ctaPrimary: "Open courtroom", + href: `/app/courts/${input.payload.caseId}`, + timestamp: new Date().toISOString(), + }, + }); + + if (created) { + await incrementEraUserActivity(context.env, { + address: session.address, + delta: { courtActions: 1 }, + }).catch(() => {}); + } + + return jsonResponse(response); + } + + if (input.type === "court.case.verdict") { + if (!readModels) return errorResponse(500, "Read models store unavailable"); + const wouldCount = !(await hasCourtVerdict(context.env, { + caseId: input.payload.caseId, + voterAddress: session.address, + })); + const quotaError = await enforceEraQuota({ + kind: "courtActions", + wouldCount, + }); + if (quotaError) return quotaError; + + let overlay; + let created = false; + try { + const result = await castCourtVerdict(context.env, readModels, { + caseId: input.payload.caseId, + voterAddress: session.address, + verdict: input.payload.verdict, + }); + overlay = result.overlay; + created = result.created; + } catch (error) { + const code = (error as Error).message; + if (code === "court_case_missing") + return errorResponse(404, "Unknown case"); + if (code === "case_not_live") + return errorResponse(409, "Case is not live"); + return errorResponse(400, "Unable to cast verdict", { code }); + } + + const response = { + ok: true as const, + type: input.type, + caseId: input.payload.caseId, + verdict: input.payload.verdict, + status: overlay.status, + totals: { + guilty: overlay.verdicts.guilty, + notGuilty: overlay.verdicts.notGuilty, + }, + }; + + if (idempotencyKey) { + await storeIdempotencyResponse(context.env, { + key: idempotencyKey, + address: session.address, + request: requestForIdem, + response, + }); + } + + await appendFeedItemEvent(context.env, { + stage: "courts", + actorAddress: session.address, + entityType: "court_case", + entityId: input.payload.caseId, + payload: { + id: `court-verdict:${input.payload.caseId}:${session.address}:${Date.now()}`, + title: "Verdict cast", + meta: "Courtroom", + stage: "courts", + summaryPill: + input.payload.verdict === "guilty" ? "Guilty" : "Not guilty", + summary: "Cast a verdict in a courtroom session (mock).", + stats: [ + { label: "Guilty", value: String(overlay.verdicts.guilty) }, + { label: "Not guilty", value: String(overlay.verdicts.notGuilty) }, + ], + ctaPrimary: "Open courtroom", + href: `/app/courts/${input.payload.caseId}`, + timestamp: new Date().toISOString(), + }, + }); + + if (created) { + await incrementEraUserActivity(context.env, { + address: session.address, + delta: { courtActions: 1 }, + }).catch(() => {}); + } + + return jsonResponse(response); + } + + if (input.type !== "chamber.vote") { + return errorResponse(400, "Unsupported command"); + } + + const proposal = await getProposal(context.env, input.payload.proposalId); + if (proposal && proposal.stage !== "vote") { + return errorResponse(409, "Proposal is not in chamber vote stage", { + code: "stage_invalid", + stage: proposal.stage, + }); + } + + if (proposal && proposal.stage === "vote") { + const now = getSimNow(context.env); + if (proposal.votePassedAt && proposal.voteFinalizesAt) { + if (now.getTime() < proposal.voteFinalizesAt.getTime()) { + return errorResponse(409, "Vote already passed (pending veto)", { + code: "vote_pending_veto", + finalizesAt: proposal.voteFinalizesAt.toISOString(), + }); + } + } + if (now.getTime() < proposal.updatedAt.getTime()) { + return errorResponse(409, "Voting is paused", { + code: "vote_paused", + resumesAt: proposal.updatedAt.toISOString(), + }); + } + } + + if ( + proposal && + stageWindowsEnabled(context.env) && + proposal.stage === "vote" + ) { + const now = getSimNow(context.env); + const windowSeconds = getStageWindowSeconds(context.env, "vote"); + if ( + !isStageOpen({ + now, + stageStartedAt: proposal.updatedAt, + windowSeconds, + }) + ) { + return errorResponse(409, "Voting window ended", { + code: "stage_closed", + stage: "vote", + endedAt: getStageDeadlineIso({ + stageStartedAt: proposal.updatedAt, + windowSeconds, + }), + timeLeft: (() => { + const remaining = getStageRemainingSeconds({ + now, + stageStartedAt: proposal.updatedAt, + windowSeconds, + }); + return remaining === 0 ? "Ended" : formatTimeLeftDaysHours(remaining); + })(), + }); + } + } + + if (proposal) { + const chamberId = (proposal.chamberId ?? "general").toLowerCase(); + if (chamberId !== "general") { + const chamber = await getChamber( + context.env, + context.request.url, + chamberId, + ); + if (chamber?.status === "dissolved" && chamber.dissolvedAt) { + const proposalCreatedAt = proposal.createdAt.getTime(); + const dissolvedAt = chamber.dissolvedAt.getTime(); + if (proposalCreatedAt > dissolvedAt) { + return errorResponse(409, "Chamber is dissolved", { + code: "chamber_dissolved", + chamberId, + dissolvedAt: chamber.dissolvedAt.toISOString(), + }); + } + } + } + } + + const eligibilityError = await enforceChamberVoteEligibility( + context.env, + readModels, + { + proposalId: input.payload.proposalId, + voterAddress: session.address, + }, + context.request.url, + ); + if (eligibilityError) return eligibilityError; + + if (input.payload.choice !== "yes" && input.payload.score !== undefined) { + return errorResponse(400, "Score is only allowed for yes votes"); + } + + const wouldCount = !(await hasChamberVote(context.env, { + proposalId: input.payload.proposalId, + voterAddress: session.address, + })); + const quotaError = await enforceEraQuota({ + kind: "chamberVotes", + wouldCount, + }); + if (quotaError) return quotaError; + + const chamberIdForVote = await getProposalChamberIdForVote( + context.env, + readModels, + { proposalId: input.payload.proposalId }, + ); + const choice = + input.payload.choice === "yes" ? 1 : input.payload.choice === "no" ? -1 : 0; + const { counts, created } = await castChamberVote(context.env, { + proposalId: input.payload.proposalId, + voterAddress: session.address, + choice, + score: + input.payload.choice === "yes" ? (input.payload.score ?? null) : null, + chamberId: chamberIdForVote, + }); + + const response = { + ok: true as const, + type: input.type, + proposalId: input.payload.proposalId, + choice: input.payload.choice, + counts, + }; + + if (idempotencyKey) { + await storeIdempotencyResponse(context.env, { + key: idempotencyKey, + address: session.address, + request: requestForIdem, + response, + }); + } + + await appendFeedItemEvent(context.env, { + stage: "vote", + actorAddress: session.address, + entityType: "proposal", + entityId: input.payload.proposalId, + payload: { + id: `chamber-vote:${input.payload.proposalId}:${session.address}:${Date.now()}`, + title: "Chamber vote cast", + meta: "Chamber vote", + stage: "vote", + summaryPill: + input.payload.choice === "yes" + ? "Yes" + : input.payload.choice === "no" + ? "No" + : "Abstain", + summary: "Recorded a vote in chamber stage.", + stats: [ + { label: "Yes", value: String(counts.yes) }, + { label: "No", value: String(counts.no) }, + { label: "Abstain", value: String(counts.abstain) }, + ], + ctaPrimary: "Open proposal", + href: `/app/proposals/${input.payload.proposalId}/chamber`, + timestamp: new Date().toISOString(), + }, + }); + + await appendProposalTimelineItem(context.env, { + proposalId: input.payload.proposalId, + stage: "vote", + actorAddress: session.address, + item: { + id: `timeline:chamber-vote:${input.payload.proposalId}:${session.address}:${randomHex(4)}`, + type: "chamber.vote", + title: "Chamber vote cast", + detail: + input.payload.choice === "yes" + ? `Yes${input.payload.score ? ` (score ${input.payload.score})` : ""}` + : input.payload.choice === "no" + ? "No" + : "Abstain", + actor: session.address, + timestamp: new Date().toISOString(), + }, + }); + + const storedVoteDenominator = await getProposalStageDenominator(context.env, { + proposalId: input.payload.proposalId, + stage: "vote", + }).catch(() => null); + const simConfig = await getSimConfig(context.env, context.request.url).catch( + () => null, + ); + const genesisMembers = getGenesisMembersForDenominators( + simConfig, + chamberIdForVote, + ); + const voteDenominator = + storedVoteDenominator?.activeGovernors ?? + (await getActiveGovernorsDenominatorForChamberCurrentEra(context.env, { + chamberId: chamberIdForVote, + fallbackActiveGovernors: + typeof activeGovernorsBaseline === "number" + ? activeGovernorsBaseline + : V1_ACTIVE_GOVERNORS_FALLBACK, + genesisMembers, + })); + if (!storedVoteDenominator) { + await captureProposalStageDenominator(context.env, { + proposalId: input.payload.proposalId, + stage: "vote", + activeGovernors: voteDenominator, + }).catch(() => {}); + } + + const canonicalOutcome = await maybeAdvanceVoteProposalToBuildCanonical( + context.env, + { + proposalId: input.payload.proposalId, + counts, + activeGovernors: voteDenominator, + }, + context.request.url, + ); + + const readModelAdvanced = + canonicalOutcome.status === "none" && + readModels && + (await maybeAdvanceVoteProposalToBuild(context.env, readModels, { + proposalId: input.payload.proposalId, + counts, + activeGovernors: voteDenominator, + requestUrl: context.request.url, + })); + + const advanced = + canonicalOutcome.status === "advanced" || Boolean(readModelAdvanced); + + if (canonicalOutcome.status === "pending_veto") { + await appendFeedItemEvent(context.env, { + stage: "vote", + actorAddress: session.address, + entityType: "proposal", + entityId: input.payload.proposalId, + payload: { + id: `vote-pass-pending-veto:${input.payload.proposalId}:${Date.now()}`, + title: "Proposal passed (pending veto)", + meta: "Chamber vote", + stage: "vote", + summaryPill: "Passed", + summary: + "Chamber vote passed; the proposal is in the veto window before acceptance is finalized.", + stats: [ + { label: "Yes", value: String(counts.yes) }, + { + label: "Engaged", + value: String(counts.yes + counts.no + counts.abstain), + }, + { + label: "Veto", + value: `${canonicalOutcome.vetoCouncilSize} holders · ${canonicalOutcome.vetoThreshold} needed`, + }, + ], + ctaPrimary: "Open proposal", + href: `/app/proposals/${input.payload.proposalId}/chamber`, + timestamp: new Date().toISOString(), + }, + }); + + await appendProposalTimelineItem(context.env, { + proposalId: input.payload.proposalId, + stage: "vote", + actorAddress: session.address, + item: { + id: `timeline:vote-pass-pending-veto:${input.payload.proposalId}:${randomHex(4)}`, + type: "proposal.vote.passed", + title: "Chamber vote passed", + detail: `Pending veto until ${canonicalOutcome.finalizesAt}`, + actor: "system", + timestamp: new Date().toISOString(), + }, + }); + } + + if (advanced) { + const avgScore = + canonicalOutcome.status === "advanced" + ? canonicalOutcome.avgScore + : ((await getChamberYesScoreAverage( + context.env, + input.payload.proposalId, + )) ?? null); + const formationEligible = + canonicalOutcome.status === "advanced" + ? canonicalOutcome.formationEligible + : await (async () => { + if (!readModels) return true; + const chamberPayload = await readModels.get( + `proposals:${input.payload.proposalId}:chamber`, + ); + if (isRecord(chamberPayload)) { + const meta = parseChamberGovernanceFromPayload(chamberPayload); + if (meta) return false; + if (typeof chamberPayload.formationEligible === "boolean") { + return chamberPayload.formationEligible; + } + } + const poolPayload = await readModels.get( + `proposals:${input.payload.proposalId}:pool`, + ); + if (isRecord(poolPayload)) { + const meta = parseChamberGovernanceFromPayload(poolPayload); + if (meta) return false; + if (typeof poolPayload.formationEligible === "boolean") { + return poolPayload.formationEligible; + } + } + return true; + })(); + + await appendFeedItemEvent(context.env, { + stage: "build", + actorAddress: session.address, + entityType: "proposal", + entityId: input.payload.proposalId, + payload: { + id: `vote-pass:${input.payload.proposalId}:${Date.now()}`, + title: "Proposal accepted", + meta: "Chamber vote", + stage: "build", + summaryPill: "Accepted", + summary: "Chamber vote finalized; proposal is now accepted.", + stats: [ + ...(avgScore !== null + ? [{ label: "Avg CM", value: avgScore.toFixed(1) }] + : []), + { label: "Yes", value: String(counts.yes) }, + { + label: "Engaged", + value: String(counts.yes + counts.no + counts.abstain), + }, + ], + ctaPrimary: "Open proposal", + href: formationEligible + ? `/app/proposals/${input.payload.proposalId}/formation` + : `/app/proposals/${input.payload.proposalId}/chamber`, + timestamp: new Date().toISOString(), + }, + }); + + await appendProposalTimelineItem(context.env, { + proposalId: input.payload.proposalId, + stage: "build", + actorAddress: session.address, + item: { + id: `timeline:vote-pass:${input.payload.proposalId}:${randomHex(4)}`, + type: "proposal.stage.advanced", + title: "Advanced to accepted", + detail: "Chamber vote finalized", + actor: "system", + timestamp: new Date().toISOString(), + }, + }); + } + + if (created) { + await incrementEraUserActivity(context.env, { + address: session.address, + delta: { chamberVotes: 1 }, + }).catch(() => {}); + } + + return jsonResponse(response); +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +async function getProposalStage( + store: Awaited>, + proposalId: string, +): Promise { + const listPayload = await store.get("proposals:list"); + if (!isRecord(listPayload)) return null; + const items = listPayload.items; + if (!Array.isArray(items)) return null; + const item = items.find( + (entry) => isRecord(entry) && entry.id === proposalId, + ); + if (!item || !isRecord(item)) return null; + return typeof item.stage === "string" ? item.stage : null; +} + +async function maybeAdvancePoolProposalToVote( + store: Awaited>, + input: { + proposalId: string; + counts: { upvotes: number; downvotes: number }; + activeGovernors: number; + }, +): Promise { + if (!store.set) return false; + + const poolPayload = await store.get(`proposals:${input.proposalId}:pool`); + if (!isRecord(poolPayload)) return false; + const attentionQuorum = poolPayload.attentionQuorum; + const activeGovernors = input.activeGovernors; + const upvoteFloor = computePoolUpvoteFloor(activeGovernors); + if ( + typeof attentionQuorum !== "number" || + typeof activeGovernors !== "number" || + typeof upvoteFloor !== "number" + ) { + return false; + } + + const quorum = evaluatePoolQuorum( + { attentionQuorum, activeGovernors, upvoteFloor }, + input.counts, + ); + if (!quorum.shouldAdvance) return false; + + const listPayload = await store.get("proposals:list"); + if (!isRecord(listPayload)) return false; + const items = listPayload.items; + if (!Array.isArray(items)) return false; + + const chamberPayload = await ensureChamberProposalPage( + store, + input.proposalId, + poolPayload, + { + activeGovernors, + }, + ); + const voteStageData = buildVoteStageData(chamberPayload); + + let changed = false; + const nextItems = items.map((item) => { + if (!isRecord(item) || item.id !== input.proposalId) return item; + if (item.stage !== "pool") return item; + changed = true; + return { + ...item, + stage: "vote", + summaryPill: "Chamber vote", + stageData: voteStageData ?? item.stageData, + }; + }); + if (!changed) return false; + + await store.set("proposals:list", { ...listPayload, items: nextItems }); + return true; +} + +async function maybeAdvancePoolProposalToVoteCanonical( + env: Record, + input: { + proposalId: string; + counts: { upvotes: number; downvotes: number }; + activeGovernors: number; + }, +): Promise { + const proposal = await getProposal(env, input.proposalId); + if (!proposal) return false; + if (proposal.stage !== "pool") return false; + + const shouldAdvance = shouldAdvancePoolToVote({ + activeGovernors: input.activeGovernors, + counts: input.counts, + }); + if (!shouldAdvance) return false; + + return transitionProposalStage(env, { + proposalId: input.proposalId, + from: "pool", + to: "vote", + }); +} + +async function ensureChamberProposalPage( + store: Awaited>, + proposalId: string, + poolPayload: Record, + input: { activeGovernors: number }, +): Promise { + const existing = await store.get(`proposals:${proposalId}:chamber`); + if (existing) return existing; + if (!store.set) return existing; + + const generated = buildChamberProposalPageFromPool(poolPayload); + (generated as Record).activeGovernors = + input.activeGovernors; + await store.set(`proposals:${proposalId}:chamber`, generated); + return generated; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + const n = typeof value === "number" ? value : Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function asBoolean(value: unknown, fallback = false): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : []; +} + +function buildChamberProposalPageFromPool( + poolPayload: Record, +): Record { + const activeGovernors = asNumber(poolPayload.activeGovernors, 0); + return { + title: asString(poolPayload.title, "Proposal"), + proposer: asString(poolPayload.proposer, "Unknown"), + proposerId: asString(poolPayload.proposerId, "unknown"), + chamber: asString(poolPayload.chamber, "General chamber"), + budget: asString(poolPayload.budget, "—"), + formationEligible: asBoolean(poolPayload.formationEligible, false), + templateId: poolPayload.templateId, + metaGovernance: poolPayload.metaGovernance, + teamSlots: asString(poolPayload.teamSlots, "—"), + milestones: asString(poolPayload.milestones, "—"), + timeLeft: "3d 00h", + votes: { yes: 0, no: 0, abstain: 0 }, + attentionQuorum: V1_CHAMBER_QUORUM_FRACTION, + passingRule: `≥${(V1_CHAMBER_PASSING_FRACTION * 100).toFixed(1)}% + 1 yes within quorum`, + engagedGovernors: 0, + activeGovernors, + attachments: asArray(poolPayload.attachments), + teamLocked: asArray(poolPayload.teamLocked), + openSlotNeeds: asArray(poolPayload.openSlotNeeds), + milestonesDetail: asArray(poolPayload.milestonesDetail), + summary: asString(poolPayload.summary, ""), + overview: asString(poolPayload.overview, ""), + executionPlan: asArray(poolPayload.executionPlan), + budgetScope: asString(poolPayload.budgetScope, ""), + invisionInsight: isRecord(poolPayload.invisionInsight) + ? poolPayload.invisionInsight + : { role: "—", bullets: [] }, + }; +} + +function buildVoteStageData(payload: unknown): Array<{ + title: string; + description: string; + value: string; + tone?: "ok" | "warn"; +}> | null { + if (!isRecord(payload)) return null; + const attentionQuorum = payload.attentionQuorum; + const activeGovernors = payload.activeGovernors; + const engagedGovernors = payload.engagedGovernors; + const passingRule = payload.passingRule; + const timeLeft = payload.timeLeft; + const votes = payload.votes; + if ( + typeof attentionQuorum !== "number" || + typeof activeGovernors !== "number" || + typeof engagedGovernors !== "number" || + typeof passingRule !== "string" || + typeof timeLeft !== "string" || + !isRecord(votes) + ) { + return null; + } + + const yes = Number(votes.yes ?? 0); + const no = Number(votes.no ?? 0); + const abstain = Number(votes.abstain ?? 0); + const total = Math.max(0, yes) + Math.max(0, no) + Math.max(0, abstain); + const yesPct = total > 0 ? (yes / total) * 100 : 0; + + const quorumNeeded = Math.ceil( + Math.max(0, activeGovernors) * attentionQuorum, + ); + const quorumPct = + activeGovernors > 0 ? (engagedGovernors / activeGovernors) * 100 : 0; + const quorumMet = engagedGovernors >= quorumNeeded; + + return [ + { + title: "Voting quorum", + description: `Strict ${Math.round(attentionQuorum * 100)}% active governors`, + value: `${quorumMet ? "Met" : "Needs"} · ${Math.round(quorumPct)}%`, + tone: quorumMet ? "ok" : "warn", + }, + { + title: "Passing rule", + description: passingRule, + value: `Current ${Math.round(yesPct)}%`, + tone: yesPct >= V1_CHAMBER_PASSING_FRACTION * 100 ? "ok" : "warn", + }, + { title: "Time left", description: "Voting window", value: timeLeft }, + ]; +} + +async function upsertChamberReadModel( + store: Awaited>, + input: { + action: "create" | "dissolve"; + id: string; + title?: string; + multiplier?: number; + }, +): Promise { + if (!store.set) return; + const listPayload = await store.get("chambers:list"); + const existing = + isRecord(listPayload) && Array.isArray(listPayload.items) + ? listPayload.items + : []; + + const normalizedId = input.id.trim().toLowerCase(); + const nextItems = existing.filter( + (item) => !isRecord(item) || String(item.id).toLowerCase() !== normalizedId, + ); + + if (input.action === "create") { + const multiplier = + typeof input.multiplier === "number" && Number.isFinite(input.multiplier) + ? input.multiplier + : 1; + nextItems.push({ + id: normalizedId, + name: input.title?.trim() || normalizedId, + multiplier, + stats: { governors: "0", acm: "0", mcm: "0", lcm: "0" }, + pipeline: { pool: 0, vote: 0, build: 0 }, + status: "active", + }); + + await store.set(`chambers:${normalizedId}`, { + proposals: [], + governors: [], + threads: [], + chatLog: [], + stageOptions: [ + { value: "upcoming", label: "Upcoming" }, + { value: "live", label: "Live" }, + { value: "ended", label: "Ended" }, + ], + }); + } + + await store.set("chambers:list", { + ...(isRecord(listPayload) ? listPayload : {}), + items: nextItems, + }); +} + +async function maybeAdvanceVoteProposalToBuild( + env: Record, + store: Awaited>, + input: { + proposalId: string; + counts: { yes: number; no: number; abstain: number }; + activeGovernors: number; + requestUrl: string; + }, +): Promise { + if (!store.set) return false; + + const chamberPayload = await store.get( + `proposals:${input.proposalId}:chamber`, + ); + if (!isRecord(chamberPayload)) return false; + + const attentionQuorum = chamberPayload.attentionQuorum; + const activeGovernors = input.activeGovernors; + let meta = parseChamberGovernanceFromPayload(chamberPayload); + let poolPayload: Record | null = null; + if (!meta) { + const candidate = await store.get(`proposals:${input.proposalId}:pool`); + if (isRecord(candidate)) { + poolPayload = candidate; + meta = parseChamberGovernanceFromPayload(candidate); + } + } + const formationEligible = meta + ? false + : typeof chamberPayload.formationEligible === "boolean" + ? chamberPayload.formationEligible + : poolPayload && typeof poolPayload.formationEligible === "boolean" + ? poolPayload.formationEligible + : true; + if ( + typeof attentionQuorum !== "number" || + typeof activeGovernors !== "number" || + typeof formationEligible !== "boolean" + ) { + return false; + } + + const minQuorum = + env.SIM_ACTIVE_GOVERNORS || env.VORTEX_ACTIVE_GOVERNORS + ? undefined + : activeGovernors > 1 + ? 2 + : undefined; + + const quorum = evaluateChamberQuorum( + { + quorumFraction: attentionQuorum, + activeGovernors, + passingFraction: V1_CHAMBER_PASSING_FRACTION, + minQuorum, + }, + input.counts, + ); + if (!quorum.shouldAdvance) return false; + + const listPayload = await store.get("proposals:list"); + if (!isRecord(listPayload)) return false; + const items = listPayload.items; + if (!Array.isArray(items)) return false; + + if (meta?.action === "chamber.create" && meta.title && meta.id) { + await createChamberFromAcceptedGeneralProposal(env, input.requestUrl, { + id: meta.id, + title: meta.title, + multiplier: meta.multiplier, + proposalId: input.proposalId, + }); + + await upsertChamberReadModel(store, { + action: "create", + id: meta.id, + title: meta.title, + multiplier: meta.multiplier, + }); + + const genesisMembers = (() => { + const source = + (chamberPayload.metaGovernance as { genesisMembers?: unknown }) ?? + (poolPayload?.metaGovernance as { genesisMembers?: unknown }); + const raw = source?.genesisMembers; + if (!Array.isArray(raw)) return []; + return raw + .filter((v): v is string => typeof v === "string") + .map((v) => v.trim()) + .filter(Boolean); + })(); + + const proposerId = asString(chamberPayload.proposerId, "").trim(); + const memberSet = new Set(genesisMembers); + if (proposerId) memberSet.add(proposerId); + for (const address of memberSet) { + await ensureChamberMembership(env, { + address, + chamberId: meta.id, + grantedByProposalId: input.proposalId, + source: "chamber_genesis", + }); + } + } + + if (meta?.action === "chamber.dissolve" && meta.id) { + await dissolveChamberFromAcceptedGeneralProposal(env, input.requestUrl, { + id: meta.id, + proposalId: input.proposalId, + }); + + await upsertChamberReadModel(store, { + action: "dissolve", + id: meta.id, + }); + } + + if (formationEligible) { + await ensureFormationProposalPage(store, input.proposalId, chamberPayload); + await ensureFormationSeed(env, store, input.proposalId); + } + + let changed = false; + const nextItems = items.map((item) => { + if (!isRecord(item) || item.id !== input.proposalId) return item; + if (item.stage !== "vote") return item; + changed = true; + return { + ...item, + stage: "build", + summaryPill: formationEligible ? "Formation" : "Passed", + }; + }); + if (!changed) return false; + + await store.set("proposals:list", { ...listPayload, items: nextItems }); + + const proposerId = asString(chamberPayload.proposerId, ""); + const chamberLabel = asString(chamberPayload.chamber, ""); + const chamberId = normalizeChamberId(chamberLabel); + const multiplierTimes10 = await getChamberMultiplierTimes10(store, chamberId); + const avgScore = + (await getChamberYesScoreAverage(env, input.proposalId)) ?? null; + + if (proposerId && avgScore !== null) { + const lcmPoints = Math.round(avgScore * 10); + const mcmPoints = Math.round((lcmPoints * multiplierTimes10) / 10); + await awardCmOnce(env, { + proposalId: input.proposalId, + proposerId, + chamberId, + avgScore, + lcmPoints, + chamberMultiplierTimes10: multiplierTimes10, + mcmPoints, + }); + } + + return true; +} + +async function maybeAdvanceVoteProposalToBuildCanonical( + env: Record, + input: { + proposalId: string; + counts: { yes: number; no: number; abstain: number }; + activeGovernors: number; + }, + requestUrl: string, +): Promise< + | { status: "none" } + | { + status: "pending_veto"; + finalizesAt: string; + vetoCouncilSize: number; + vetoThreshold: number; + } + | { status: "advanced"; formationEligible: boolean; avgScore: number | null } +> { + const proposal = await getProposal(env, input.proposalId); + if (!proposal) return { status: "none" }; + if (proposal.stage !== "vote") return { status: "none" }; + if (proposal.votePassedAt && proposal.voteFinalizesAt) { + const now = getSimNow(env); + if (now.getTime() < proposal.voteFinalizesAt.getTime()) { + return { status: "none" }; + } + } + + const minQuorum = + env.SIM_ACTIVE_GOVERNORS || env.VORTEX_ACTIVE_GOVERNORS + ? undefined + : input.activeGovernors > 1 + ? 2 + : undefined; + + const shouldAdvance = shouldAdvanceVoteToBuild({ + activeGovernors: input.activeGovernors, + counts: input.counts, + minQuorum, + }); + if (!shouldAdvance) return { status: "none" }; + + const vetoCount = proposal.vetoCount ?? 0; + if (vetoCount < V1_VETO_MAX_APPLIES) { + const snapshot = await computeVetoCouncilSnapshot(env, requestUrl); + if (snapshot.members.length > 0 && snapshot.threshold > 0) { + const now = getSimNow(env); + const finalizesAt = new Date( + now.getTime() + V1_VETO_DELAY_SECONDS_DEFAULT * 1000, + ); + await clearVetoVotesForProposal(env, proposal.id).catch(() => {}); + await setProposalVotePendingVeto(env, { + proposalId: proposal.id, + passedAt: now, + finalizesAt, + vetoCouncil: snapshot.members, + vetoThreshold: snapshot.threshold, + }); + return { + status: "pending_veto", + finalizesAt: finalizesAt.toISOString(), + vetoCouncilSize: snapshot.members.length, + vetoThreshold: snapshot.threshold, + }; + } + } + + const finalized = await finalizeAcceptedProposalFromVote(env, { + proposalId: proposal.id, + requestUrl, + }); + if (!finalized.ok) return { status: "none" }; + + return { + status: "advanced", + formationEligible: finalized.formationEligible, + avgScore: finalized.avgScore, + }; +} + +function getFormationEligibleFromProposalPayload(payload: unknown): boolean { + if (!isRecord(payload)) return true; + if (payload.templateId === "system") return false; + if ( + typeof payload.metaGovernance === "object" && + payload.metaGovernance !== null && + !Array.isArray(payload.metaGovernance) + ) + return false; + if (typeof payload.formationEligible === "boolean") + return payload.formationEligible; + if (typeof payload.formation === "boolean") return payload.formation; + return true; +} + +async function requireFormationEnabled( + env: Record, + input: { proposalId: string }, +): Promise< + | { ok: true } + | { + ok: false; + error: Response; + } +> { + const proposal = await getProposal(env, input.proposalId); + if (!proposal) return { ok: true }; + if (proposal.stage !== "build") { + return { + ok: false, + error: errorResponse(409, "Proposal is not in formation stage", { + code: "stage_invalid", + stage: proposal.stage, + }), + }; + } + if (!getFormationEligibleFromProposalPayload(proposal.payload)) { + return { + ok: false, + error: errorResponse(409, "Formation is not required for this proposal", { + code: "formation_not_required", + }), + }; + } + return { ok: true }; +} + +async function ensureFormationProposalPage( + store: Awaited>, + proposalId: string, + chamberPayload: Record, +): Promise { + const existing = await store.get(`proposals:${proposalId}:formation`); + if (existing) return; + if (!store.set) return; + await store.set( + `proposals:${proposalId}:formation`, + buildFormationProposalPageFromChamber(chamberPayload), + ); +} + +function buildFormationProposalPageFromChamber( + chamberPayload: Record, +): Record { + return { + title: asString(chamberPayload.title, "Proposal"), + chamber: asString(chamberPayload.chamber, "General chamber"), + proposer: asString(chamberPayload.proposer, "Unknown"), + proposerId: asString(chamberPayload.proposerId, "unknown"), + budget: asString(chamberPayload.budget, "—"), + timeLeft: "12w", + teamSlots: asString(chamberPayload.teamSlots, "0 / 0"), + milestones: asString(chamberPayload.milestones, "0 / 0"), + progress: "0%", + stageData: [ + { title: "Budget allocated", description: "HMND", value: "0 / —" }, + { title: "Team slots", description: "Filled / Total", value: "0 / —" }, + { title: "Milestones", description: "Completed / Total", value: "0 / —" }, + ], + stats: [{ label: "Lead chamber", value: asString(chamberPayload.chamber) }], + lockedTeam: asArray(chamberPayload.teamLocked), + openSlots: asArray(chamberPayload.openSlotNeeds), + milestonesDetail: asArray(chamberPayload.milestonesDetail), + attachments: asArray(chamberPayload.attachments), + summary: asString(chamberPayload.summary, ""), + overview: asString(chamberPayload.overview, ""), + executionPlan: asArray(chamberPayload.executionPlan), + budgetScope: asString(chamberPayload.budgetScope, ""), + invisionInsight: isRecord(chamberPayload.invisionInsight) + ? chamberPayload.invisionInsight + : { role: "—", bullets: [] }, + }; +} + +function normalizeChamberId(chamberLabel: string): string { + const match = chamberLabel.trim().match(/^([A-Za-z]+)/); + return (match?.[1] ?? chamberLabel).toLowerCase(); +} + +async function enforceChamberVoteEligibility( + env: Record, + readModels: Awaited> | null, + input: { proposalId: string; voterAddress: string }, + requestUrl: string, +): Promise { + if (envBoolean(env, "DEV_BYPASS_CHAMBER_ELIGIBILITY")) return null; + + const simConfig = await getSimConfig(env, requestUrl); + const genesis = simConfig?.genesisChamberMembers; + + const chamberId = await getProposalChamberIdForVote(env, readModels, { + proposalId: input.proposalId, + }); + const voterAddress = input.voterAddress.trim(); + + const hasGenesisMembership = async ( + targetChamberId: string, + ): Promise => { + if (!genesis) return false; + const members = genesis[targetChamberId.toLowerCase()] ?? []; + for (const member of members) { + if (await addressesReferToSameKey(member, voterAddress)) return true; + } + return false; + }; + const hasAnyGenesisMembership = async (): Promise => { + if (!genesis) return false; + for (const members of Object.values(genesis)) { + for (const member of members) { + if (await addressesReferToSameKey(member, voterAddress)) return true; + } + } + return false; + }; + + if (chamberId === "general") { + // Bootstrap: if the user is explicitly configured with a tier, treat them as eligible in General. + const tier = await resolveUserTierFromSimConfig(simConfig, voterAddress); + if (tier !== "Nominee") return null; + + return null; + } + + const eligible = await hasChamberMembership(env, { + address: voterAddress, + chamberId, + }); + if (!eligible && !(await hasGenesisMembership(chamberId))) { + return errorResponse(403, "Not eligible to vote in this chamber", { + code: "chamber_vote_ineligible", + chamberId, + }); + } + return null; +} + +async function enforcePoolVoteEligibility( + env: Record, + readModels: Awaited> | null, + input: { proposalId: string; voterAddress: string }, + requestUrl: string, +): Promise { + if (envBoolean(env, "DEV_BYPASS_CHAMBER_ELIGIBILITY")) return null; + + const simConfig = await getSimConfig(env, requestUrl); + const genesis = simConfig?.genesisChamberMembers; + + const chamberId = await getProposalChamberIdForPool(env, readModels, { + proposalId: input.proposalId, + }); + const voterAddress = input.voterAddress.trim(); + + const hasAnyGenesisMembership = async (): Promise => { + if (!genesis) return false; + for (const members of Object.values(genesis)) { + for (const member of members) { + if (await addressesReferToSameKey(member, voterAddress)) return true; + } + } + return false; + }; + + const hasGenesisMembership = async (target: string): Promise => { + const members = genesis?.[target]?.map((m) => m.trim()) ?? []; + for (const member of members) { + if (await addressesReferToSameKey(member, voterAddress)) return true; + } + return false; + }; + + if (chamberId === "general") { + // Bootstrap: if the user is explicitly configured with a tier, treat them as eligible in General. + const tier = await resolveUserTierFromSimConfig(simConfig, voterAddress); + if (tier !== "Nominee") return null; + + const eligible = + (await hasAnyChamberMembership(env, voterAddress)) || + (await hasChamberMembership(env, { + address: voterAddress, + chamberId: "general", + })) || + (await hasAnyGenesisMembership()); + if (!eligible) { + return errorResponse(403, "Not eligible to vote in the proposal pool", { + code: "pool_vote_ineligible", + chamberId, + }); + } + return null; + } + + const eligible = + (await hasChamberMembership(env, { address: voterAddress, chamberId })) || + (await hasGenesisMembership(chamberId)); + if (!eligible) { + return errorResponse(403, "Not eligible to vote in the proposal pool", { + code: "pool_vote_ineligible", + chamberId, + }); + } + return null; +} + +async function getProposalChamberIdForVote( + env: Record, + readModels: Awaited> | null, + input: { proposalId: string }, +): Promise { + const proposal = await getProposal(env, input.proposalId); + if (proposal) return (proposal.chamberId ?? "general").toLowerCase(); + + if (!readModels) return "general"; + + const chamberPayload = await readModels.get( + `proposals:${input.proposalId}:chamber`, + ); + if (isRecord(chamberPayload)) { + const label = asString(chamberPayload.chamber, ""); + const normalized = normalizeChamberId(label); + return normalized || "general"; + } + + const listPayload = await readModels.get("proposals:list"); + if (isRecord(listPayload) && Array.isArray(listPayload.items)) { + const entry = listPayload.items.find( + (item) => isRecord(item) && item.id === input.proposalId, + ); + if (isRecord(entry)) { + const label = asString(entry.chamber, asString(entry.meta, "")); + const normalized = normalizeChamberId(label); + return normalized || "general"; + } + } + + return "general"; +} + +async function getProposalChamberIdForPool( + env: Record, + readModels: Awaited> | null, + input: { proposalId: string }, +): Promise { + const proposal = await getProposal(env, input.proposalId); + if (proposal) return (proposal.chamberId ?? "general").toLowerCase(); + + if (!readModels) return "general"; + + const poolPayload = await readModels.get( + `proposals:${input.proposalId}:pool`, + ); + if (isRecord(poolPayload)) { + const label = asString(poolPayload.chamber, ""); + const normalized = normalizeChamberId(label); + return normalized || "general"; + } + + const listPayload = await readModels.get("proposals:list"); + if (isRecord(listPayload) && Array.isArray(listPayload.items)) { + const entry = listPayload.items.find( + (item) => isRecord(item) && item.id === input.proposalId, + ); + if (isRecord(entry)) { + const label = asString(entry.chamber, asString(entry.meta, "")); + const normalized = normalizeChamberId(label); + return normalized || "general"; + } + } + + return "general"; +} + +async function getChamberMultiplierTimes10( + store: Awaited>, + chamberId: string, +): Promise { + const payload = await store.get("chambers:list"); + if (!isRecord(payload)) return 10; + const items = payload.items; + if (!Array.isArray(items)) return 10; + const entry = items.find( + (item) => + isRecord(item) && + (item.id === chamberId || + (typeof item.name === "string" && + item.name.toLowerCase() === chamberId)), + ); + if (!isRecord(entry)) return 10; + const mult = entry.multiplier; + if (typeof mult !== "number") return 10; + return Math.round(mult * 10); +} diff --git a/functions/api/courts/[id].ts b/functions/api/courts/[id].ts index f981a51..13b4fd5 100644 --- a/functions/api/courts/[id].ts +++ b/functions/api/courts/[id].ts @@ -1,5 +1,6 @@ import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; import { errorResponse, jsonResponse } from "../../_lib/http.ts"; +import { getCourtOverlay } from "../../_lib/courtsStore.ts"; export const onRequestGet: PagesFunction = async (context) => { try { @@ -8,7 +9,23 @@ export const onRequestGet: PagesFunction = async (context) => { const store = await createReadModelsStore(context.env); const payload = await store.get(`courts:${id}`); if (!payload) return errorResponse(404, `Missing read model: courts:${id}`); - return jsonResponse(payload); + + let overlay; + try { + overlay = await getCourtOverlay(context.env, store, id); + } catch { + overlay = null; + } + + if (!overlay) return jsonResponse(payload); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return jsonResponse(payload); + const record = payload as Record; + return jsonResponse({ + ...record, + status: overlay.status, + reports: overlay.reports, + }); } catch (error) { return errorResponse(500, (error as Error).message); } diff --git a/functions/api/courts/index.ts b/functions/api/courts/index.ts index d670168..1c0f0c3 100644 --- a/functions/api/courts/index.ts +++ b/functions/api/courts/index.ts @@ -1,12 +1,39 @@ import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; import { errorResponse, jsonResponse } from "../../_lib/http.ts"; +import { getCourtOverlay } from "../../_lib/courtsStore.ts"; export const onRequestGet: PagesFunction = async (context) => { try { const store = await createReadModelsStore(context.env); const payload = await store.get("courts:list"); - if (!payload) return errorResponse(404, "Missing read model: courts:list"); - return jsonResponse(payload); + if (!payload) return jsonResponse({ items: [] }); + if ( + typeof payload !== "object" || + payload === null || + Array.isArray(payload) + ) + return jsonResponse({ items: [] }); + + const record = payload as Record; + const items = Array.isArray(record.items) ? record.items : []; + + const nextItems = await Promise.all( + items.map(async (item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) + return item; + const row = item as Record; + const id = typeof row.id === "string" ? row.id : null; + if (!id) return item; + try { + const overlay = await getCourtOverlay(context.env, store, id); + return { ...row, status: overlay.status, reports: overlay.reports }; + } catch { + return item; + } + }), + ); + + return jsonResponse({ ...record, items: nextItems }); } catch (error) { return errorResponse(500, (error as Error).message); } diff --git a/functions/api/factions/[id].ts b/functions/api/factions/[id].ts new file mode 100644 index 0000000..daa73aa --- /dev/null +++ b/functions/api/factions/[id].ts @@ -0,0 +1,16 @@ +import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; +import { errorResponse, jsonResponse } from "../../_lib/http.ts"; + +export const onRequestGet: PagesFunction = async (context) => { + try { + const id = context.params?.id; + if (!id) return errorResponse(400, "Missing faction id"); + const store = await createReadModelsStore(context.env); + const payload = await store.get(`factions:${id}`); + if (!payload) + return errorResponse(404, `Missing read model: factions:${id}`); + return jsonResponse(payload); + } catch (error) { + return errorResponse(500, (error as Error).message); + } +}; diff --git a/functions/api/factions/index.ts b/functions/api/factions/index.ts new file mode 100644 index 0000000..1faf077 --- /dev/null +++ b/functions/api/factions/index.ts @@ -0,0 +1,12 @@ +import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; +import { errorResponse, jsonResponse } from "../../_lib/http.ts"; + +export const onRequestGet: PagesFunction = async (context) => { + try { + const store = await createReadModelsStore(context.env); + const payload = await store.get("factions:list"); + return jsonResponse(payload ?? { items: [] }); + } catch (error) { + return errorResponse(500, (error as Error).message); + } +}; diff --git a/functions/api/feed/index.ts b/functions/api/feed/index.ts new file mode 100644 index 0000000..fa68d12 --- /dev/null +++ b/functions/api/feed/index.ts @@ -0,0 +1,109 @@ +import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; +import { errorResponse, jsonResponse } from "../../_lib/http.ts"; +import { base64UrlDecode, base64UrlEncode } from "../../_lib/base64url.ts"; +import { listFeedEventsPage } from "../../_lib/eventsStore.ts"; + +const DEFAULT_PAGE_SIZE = 25; + +type Cursor = + | { kind: "read_models"; ts: string; id: string } + | { kind: "events"; seq: number }; + +function decodeCursor(input: string): Cursor | null { + try { + const bytes = base64UrlDecode(input); + const raw = new TextDecoder().decode(bytes); + const parsed = JSON.parse(raw) as { + ts?: unknown; + id?: unknown; + seq?: unknown; + }; + if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) { + return { kind: "events", seq: parsed.seq }; + } + if (typeof parsed.ts === "string" && typeof parsed.id === "string") { + return { kind: "read_models", ts: parsed.ts, id: parsed.id }; + } + return null; + } catch { + return null; + } +} + +function encodeCursor(input: { ts: string; id: string } | { seq: number }) { + const raw = JSON.stringify(input); + const bytes = new TextEncoder().encode(raw); + return base64UrlEncode(bytes); +} + +export const onRequestGet: PagesFunction = async (context) => { + try { + const url = new URL(context.request.url); + const stage = url.searchParams.get("stage"); + const cursor = url.searchParams.get("cursor"); + const decoded = cursor ? decodeCursor(cursor) : null; + if (cursor && !decoded) return errorResponse(400, "Invalid cursor"); + + const wantsInlineReadModels = context.env.READ_MODELS_INLINE === "true"; + const hasDatabase = Boolean(context.env.DATABASE_URL); + + if (hasDatabase && !wantsInlineReadModels) { + if (decoded && decoded.kind !== "events") { + return errorResponse(400, "Invalid cursor"); + } + const beforeSeq = decoded?.seq ?? null; + const page = await listFeedEventsPage(context.env, { + stage, + beforeSeq, + limit: DEFAULT_PAGE_SIZE, + }); + const nextCursor = + page.nextSeq !== undefined + ? encodeCursor({ seq: page.nextSeq }) + : undefined; + return jsonResponse( + nextCursor ? { items: page.items, nextCursor } : { items: page.items }, + ); + } + + const store = await createReadModelsStore(context.env); + const payload = await store.get("feed:list"); + if (!payload) return jsonResponse({ items: [] }); + if (decoded && decoded.kind !== "read_models") { + return errorResponse(400, "Invalid cursor"); + } + + const typed = payload as { + items?: { id: string; stage: string; timestamp: string }[]; + }; + let items = [...(typed.items ?? [])]; + + if (stage) items = items.filter((item) => item.stage === stage); + + items.sort( + (a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ); + + if (decoded?.kind === "read_models") { + const idx = items.findIndex( + (item) => item.timestamp === decoded.ts && item.id === decoded.id, + ); + if (idx >= 0) items = items.slice(idx + 1); + } + + const page = items.slice(0, DEFAULT_PAGE_SIZE); + const next = + items.length > DEFAULT_PAGE_SIZE + ? encodeCursor({ + ts: page[page.length - 1]?.timestamp ?? "", + id: page[page.length - 1]?.id ?? "", + }) + : undefined; + + const response = next ? { items: page, nextCursor: next } : { items: page }; + return jsonResponse(response); + } catch (error) { + return errorResponse(500, (error as Error).message); + } +}; diff --git a/functions/api/formation/index.ts b/functions/api/formation/index.ts new file mode 100644 index 0000000..f775e0a --- /dev/null +++ b/functions/api/formation/index.ts @@ -0,0 +1,12 @@ +import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; +import { errorResponse, jsonResponse } from "../../_lib/http.ts"; + +export const onRequestGet: PagesFunction = async (context) => { + try { + const store = await createReadModelsStore(context.env); + const payload = await store.get("formation:directory"); + return jsonResponse(payload ?? { metrics: [], projects: [] }); + } catch (error) { + return errorResponse(500, (error as Error).message); + } +}; diff --git a/functions/api/gate/status.ts b/functions/api/gate/status.ts index 7c24340..a741919 100644 --- a/functions/api/gate/status.ts +++ b/functions/api/gate/status.ts @@ -1,4 +1,5 @@ -import { checkEligibility, readSession } from "../../_lib/auth.ts"; +import { readSession } from "../../_lib/auth.ts"; +import { checkEligibility } from "../../_lib/gate.ts"; import { jsonResponse } from "../../_lib/http.ts"; export const onRequestGet: PagesFunction = async (context) => { @@ -10,5 +11,7 @@ export const onRequestGet: PagesFunction = async (context) => { expiresAt: new Date().toISOString(), }); } - return jsonResponse(await checkEligibility(context.env, session.address)); + return jsonResponse( + await checkEligibility(context.env, session.address, context.request.url), + ); }; diff --git a/functions/api/humans/[id].ts b/functions/api/humans/[id].ts index a67fb5d..83e1103 100644 --- a/functions/api/humans/[id].ts +++ b/functions/api/humans/[id].ts @@ -1,5 +1,6 @@ import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; import { errorResponse, jsonResponse } from "../../_lib/http.ts"; +import { getAcmDelta } from "../../_lib/cmAwardsStore.ts"; export const onRequestGet: PagesFunction = async (context) => { try { @@ -8,7 +9,22 @@ export const onRequestGet: PagesFunction = async (context) => { const store = await createReadModelsStore(context.env); const payload = await store.get(`humans:${id}`); if (!payload) return errorResponse(404, `Missing read model: humans:${id}`); - return jsonResponse(payload); + + const delta = await getAcmDelta(context.env, id); + if (!delta) return jsonResponse(payload); + + const typed = payload as Record; + const heroStats = Array.isArray(typed.heroStats) + ? (typed.heroStats as Array>) + : []; + const nextHeroStats = heroStats.map((stat) => { + if (stat.label !== "ACM") return stat; + const raw = typeof stat.value === "string" ? stat.value : "0"; + const base = Number(raw.replace(/,/g, "")) || 0; + return { ...stat, value: String(base + delta) }; + }); + + return jsonResponse({ ...typed, heroStats: nextHeroStats }); } catch (error) { return errorResponse(500, (error as Error).message); } diff --git a/functions/api/humans/index.ts b/functions/api/humans/index.ts index e1d120f..23adcd8 100644 --- a/functions/api/humans/index.ts +++ b/functions/api/humans/index.ts @@ -1,12 +1,26 @@ import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; import { errorResponse, jsonResponse } from "../../_lib/http.ts"; +import { getAcmDelta } from "../../_lib/cmAwardsStore.ts"; export const onRequestGet: PagesFunction = async (context) => { try { const store = await createReadModelsStore(context.env); const payload = await store.get("humans:list"); - if (!payload) return errorResponse(404, "Missing read model: humans:list"); - return jsonResponse(payload); + if (!payload) return jsonResponse({ items: [] }); + const typed = payload as { items?: Array> }; + const items = Array.isArray(typed.items) ? typed.items : []; + + const nextItems = await Promise.all( + items.map(async (item) => { + const id = typeof item.id === "string" ? item.id : null; + if (!id) return item; + const delta = await getAcmDelta(context.env, id); + const base = typeof item.acm === "number" ? item.acm : 0; + return { ...item, acm: base + delta }; + }), + ); + + return jsonResponse({ ...typed, items: nextItems }); } catch (error) { return errorResponse(500, (error as Error).message); } diff --git a/functions/api/invision/index.ts b/functions/api/invision/index.ts new file mode 100644 index 0000000..15cb24c --- /dev/null +++ b/functions/api/invision/index.ts @@ -0,0 +1,19 @@ +import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; +import { errorResponse, jsonResponse } from "../../_lib/http.ts"; + +export const onRequestGet: PagesFunction = async (context) => { + try { + const store = await createReadModelsStore(context.env); + const payload = await store.get("invision:dashboard"); + return jsonResponse( + payload ?? { + governanceState: { label: "—", metrics: [] }, + economicIndicators: [], + riskSignals: [], + chamberProposals: [], + }, + ); + } catch (error) { + return errorResponse(500, (error as Error).message); + } +}; diff --git a/functions/api/me.ts b/functions/api/me.ts index e9e671e..cae2510 100644 --- a/functions/api/me.ts +++ b/functions/api/me.ts @@ -1,10 +1,15 @@ -import { checkEligibility, readSession } from "../_lib/auth.ts"; +import { readSession } from "../_lib/auth.ts"; +import { checkEligibility } from "../_lib/gate.ts"; import { jsonResponse } from "../_lib/http.ts"; export const onRequestGet: PagesFunction = async (context) => { const session = await readSession(context.request, context.env); if (!session) return jsonResponse({ authenticated: false }); - const gate = await checkEligibility(context.env, session.address); + const gate = await checkEligibility( + context.env, + session.address, + context.request.url, + ); return jsonResponse({ authenticated: true, address: session.address, diff --git a/functions/api/my-governance/index.ts b/functions/api/my-governance/index.ts new file mode 100644 index 0000000..88cfc33 --- /dev/null +++ b/functions/api/my-governance/index.ts @@ -0,0 +1,181 @@ +import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; +import { readSession } from "../../_lib/auth.ts"; +import { getUserEraActivity } from "../../_lib/eraStore.ts"; +import { + getEraRollupMeta, + getEraUserStatus, +} from "../../_lib/eraRollupStore.ts"; +import { errorResponse, jsonResponse } from "../../_lib/http.ts"; + +function envInt( + env: Record, + key: string, + fallback: number, +): number { + const raw = env[key]; + if (!raw) return fallback; + const n = Number(raw); + if (!Number.isFinite(n)) return fallback; + if (n < 0) return fallback; + return Math.floor(n); +} + +export const onRequestGet: PagesFunction = async (context) => { + try { + const store = await createReadModelsStore(context.env); + const payload = await store.get("my-governance:summary"); + const base = + payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : { + eraActivity: { + era: "Era 0", + required: 0, + completed: 0, + actions: [], + timeLeft: "—", + }, + myChamberIds: [], + }; + + const requiredByLabel: Record = { + "Pool votes": envInt(context.env, "SIM_REQUIRED_POOL_VOTES", 1), + "Chamber votes": envInt(context.env, "SIM_REQUIRED_CHAMBER_VOTES", 1), + "Court actions": envInt(context.env, "SIM_REQUIRED_COURT_ACTIONS", 0), + "Formation actions": envInt( + context.env, + "SIM_REQUIRED_FORMATION_ACTIONS", + 0, + ), + }; + + const session = await readSession(context.request, context.env); + if (!session) { + // Normalize the base model to the configured requirements (even for anon users). + const baseEraActivity = + base && typeof base === "object" && !Array.isArray(base) + ? (base as Record).eraActivity + : null; + const actions = + baseEraActivity && + typeof baseEraActivity === "object" && + baseEraActivity !== null && + !Array.isArray(baseEraActivity) && + Array.isArray((baseEraActivity as Record).actions) + ? ((baseEraActivity as Record).actions as Array< + Record + >) + : []; + + const normalizedActions = actions + .map((action) => { + const label = String(action.label ?? ""); + if (!(label in requiredByLabel)) return null; + return { + ...action, + label, + required: requiredByLabel[label], + }; + }) + .filter(Boolean) as Array>; + + const requiredTotal = normalizedActions.reduce((sum, action) => { + return ( + sum + (typeof action.required === "number" ? action.required : 0) + ); + }, 0); + + return jsonResponse({ + ...base, + eraActivity: { + ...(baseEraActivity as Record), + required: requiredTotal, + actions: normalizedActions, + }, + }); + } + + const era = await getUserEraActivity(context.env, { + address: session.address, + }).catch(() => null); + if (!era) return jsonResponse(base); + + const baseEraActivity = + base && typeof base === "object" && !Array.isArray(base) + ? (base as Record).eraActivity + : null; + const actions = + baseEraActivity && + typeof baseEraActivity === "object" && + baseEraActivity !== null && + !Array.isArray(baseEraActivity) && + Array.isArray((baseEraActivity as Record).actions) + ? ((baseEraActivity as Record).actions as Array< + Record + >) + : []; + + const nextActions = actions + .map((action) => { + const label = String(action.label ?? ""); + if (!(label in requiredByLabel)) return null; + const done = + label === "Pool votes" + ? era.counts.poolVotes + : label === "Chamber votes" + ? era.counts.chamberVotes + : label === "Court actions" + ? era.counts.courtActions + : label === "Formation actions" + ? era.counts.formationActions + : 0; + return { ...action, label, required: requiredByLabel[label], done }; + }) + .filter(Boolean) as Array>; + + const requiredTotal = nextActions.reduce((sum, action) => { + return sum + (typeof action.required === "number" ? action.required : 0); + }, 0); + const completedTotal = nextActions.reduce( + (sum, action) => + sum + (typeof action.done === "number" ? action.done : 0), + 0, + ); + + const rollupMeta = await getEraRollupMeta(context.env, { + era: era.era, + }).catch(() => null); + const rollupUser = rollupMeta + ? await getEraUserStatus(context.env, { + era: rollupMeta.era, + address: session.address, + }).catch(() => null) + : null; + + return jsonResponse({ + ...base, + eraActivity: { + ...(baseEraActivity as Record), + era: String(era.era), + required: requiredTotal, + completed: completedTotal, + actions: nextActions, + }, + ...(rollupMeta + ? { + rollup: { + era: rollupMeta.era, + rolledAt: rollupMeta.rolledAt, + status: rollupUser?.status ?? "Losing status", + requiredTotal: rollupMeta.requiredTotal, + completedTotal: rollupUser?.completedTotal ?? 0, + isActiveNextEra: rollupUser?.isActiveNextEra ?? false, + activeGovernorsNextEra: rollupMeta.activeGovernorsNextEra, + }, + } + : {}), + }); + } catch (error) { + return errorResponse(500, (error as Error).message); + } +}; diff --git a/functions/api/proposals/[id]/chamber.ts b/functions/api/proposals/[id]/chamber.ts index 4d1741d..0f3f974 100644 --- a/functions/api/proposals/[id]/chamber.ts +++ b/functions/api/proposals/[id]/chamber.ts @@ -1,15 +1,72 @@ import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; +import { getChamberVoteCounts } from "../../../_lib/chamberVotesStore.ts"; +import { getActiveGovernorsForCurrentEra } from "../../../_lib/eraStore.ts"; +import { getProposal } from "../../../_lib/proposalsStore.ts"; +import { projectChamberProposalPage } from "../../../_lib/proposalProjector.ts"; +import { getProposalStageDenominator } from "../../../_lib/proposalStageDenominatorsStore.ts"; +import { V1_ACTIVE_GOVERNORS_FALLBACK } from "../../../_lib/v1Constants.ts"; +import { + getSimNow, + getStageWindowSeconds, + stageWindowsEnabled, +} from "../../../_lib/stageWindows.ts"; + +function normalizeChamberId(chamberLabel: string): string { + const match = chamberLabel.trim().match(/^([A-Za-z]+)/); + return (match?.[1] ?? chamberLabel).toLowerCase(); +} export const onRequestGet: PagesFunction = async (context) => { try { const id = context.params?.id; if (!id) return errorResponse(400, "Missing proposal id"); + + const baseline = + (await getActiveGovernorsForCurrentEra(context.env).catch(() => null)) ?? + V1_ACTIVE_GOVERNORS_FALLBACK; + const activeGovernors = + ( + await getProposalStageDenominator(context.env, { + proposalId: id, + stage: "vote", + }).catch(() => null) + )?.activeGovernors ?? baseline; + + const proposal = await getProposal(context.env, id); + if (proposal) { + const counts = await getChamberVoteCounts(context.env, id, { + chamberId: (proposal.chamberId ?? "general").toLowerCase(), + }); + const now = getSimNow(context.env); + return jsonResponse( + projectChamberProposalPage(proposal, { + counts, + activeGovernors, + now, + voteWindowSeconds: stageWindowsEnabled(context.env) + ? getStageWindowSeconds(context.env, "vote") + : undefined, + }), + ); + } + const store = await createReadModelsStore(context.env); const payload = await store.get(`proposals:${id}:chamber`); if (!payload) return errorResponse(404, `Missing read model: proposals:${id}:chamber`); - return jsonResponse(payload); + + const typed = payload as Record; + const chamberId = + normalizeChamberId(String(typed.chamber ?? "general")) || "general"; + const counts = await getChamberVoteCounts(context.env, id, { chamberId }); + const engagedGovernors = counts.yes + counts.no + counts.abstain; + return jsonResponse({ + ...typed, + votes: counts, + engagedGovernors, + activeGovernors, + }); } catch (error) { return errorResponse(500, (error as Error).message); } diff --git a/functions/api/proposals/[id]/formation.ts b/functions/api/proposals/[id]/formation.ts index c988240..2e260f5 100644 --- a/functions/api/proposals/[id]/formation.ts +++ b/functions/api/proposals/[id]/formation.ts @@ -1,19 +1,149 @@ import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; +import { + getFormationSummary, + listFormationJoiners, + ensureFormationSeedFromInput, + buildV1FormationSeedFromProposalPayload, +} from "../../../_lib/formationStore.ts"; +import { getProposal } from "../../../_lib/proposalsStore.ts"; +import type { ReadModelsStore } from "../../../_lib/readModelsStore.ts"; +import { projectFormationProposalPage } from "../../../_lib/proposalProjector.ts"; export const onRequestGet: PagesFunction = async (context) => { try { const id = context.params?.id; if (!id) return errorResponse(400, "Missing proposal id"); + + const proposal = await getProposal(context.env, id); + if (proposal) { + const store: ReadModelsStore = (await createReadModelsStore( + context.env, + ).catch(() => null)) ?? { + get: async () => null, + }; + + const formationEligible = (() => { + const payload = proposal.payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return true; + const record = payload as Record; + if (record.templateId === "system") return false; + if ( + typeof record.metaGovernance === "object" && + record.metaGovernance !== null && + !Array.isArray(record.metaGovernance) + ) + return false; + if (typeof record.formationEligible === "boolean") + return record.formationEligible; + if (typeof record.formation === "boolean") return record.formation; + return true; + })(); + + if (!formationEligible) { + return jsonResponse( + projectFormationProposalPage(proposal, { + summary: { + teamFilled: 0, + teamTotal: 0, + milestonesCompleted: 0, + milestonesTotal: 0, + }, + joiners: [], + }), + ); + } + + if (formationEligible) { + const seed = buildV1FormationSeedFromProposalPayload(proposal.payload); + await ensureFormationSeedFromInput(context.env, { + proposalId: id, + seed, + }); + } + + const summary = await getFormationSummary(context.env, store, id); + const joiners = await listFormationJoiners(context.env, id); + return jsonResponse( + projectFormationProposalPage(proposal, { summary, joiners }), + ); + } + const store = await createReadModelsStore(context.env); - const payload = await store.get(`proposals:${id}:formation`); + const readModelKey = `proposals:${id}:formation`; + const payload = await store.get(readModelKey); if (!payload) - return errorResponse( - 404, - `Missing read model: proposals:${id}:formation`, - ); - return jsonResponse(payload); + return errorResponse(404, `Missing read model: ${readModelKey}`); + + const summary = await getFormationSummary(context.env, store, id); + const joiners = await listFormationJoiners(context.env, id); + + const next = patchFormationReadModel(payload, { + teamFilled: summary.teamFilled, + teamTotal: summary.teamTotal, + milestonesCompleted: summary.milestonesCompleted, + milestonesTotal: summary.milestonesTotal, + joiners, + }); + + return jsonResponse(next); } catch (error) { return errorResponse(500, (error as Error).message); } }; + +function patchFormationReadModel( + payload: unknown, + input: { + teamFilled: number; + teamTotal: number; + milestonesCompleted: number; + milestonesTotal: number; + joiners: { address: string; role?: string | null }[]; + }, +): unknown { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return payload; + } + + const record = payload as Record; + const teamSlots = `${input.teamFilled} / ${input.teamTotal}`; + const milestones = `${input.milestonesCompleted} / ${input.milestonesTotal}`; + const progress = + input.milestonesTotal > 0 + ? `${Math.round((input.milestonesCompleted / input.milestonesTotal) * 100)}%` + : "0%"; + + const baseTeam = Array.isArray(record.lockedTeam) ? record.lockedTeam : []; + const joinerItems = input.joiners.map((entry) => ({ + name: shortenAddress(entry.address), + role: entry.role ?? "Contributor", + })); + + const stageData = Array.isArray(record.stageData) ? record.stageData : []; + const nextStageData = stageData.map((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) + return entry; + const row = entry as Record; + const title = String(row.title ?? "").toLowerCase(); + if (title.includes("team slots")) return { ...row, value: teamSlots }; + if (title.includes("milestones")) return { ...row, value: milestones }; + return entry; + }); + + return { + ...record, + teamSlots, + milestones, + progress, + stageData: nextStageData, + lockedTeam: [...baseTeam, ...joinerItems], + }; +} + +function shortenAddress(address: string): string { + const normalized = address.trim(); + if (normalized.length <= 12) return normalized; + return `${normalized.slice(0, 6)}…${normalized.slice(-4)}`; +} diff --git a/functions/api/proposals/[id]/pool.ts b/functions/api/proposals/[id]/pool.ts index db229f2..bdc6a60 100644 --- a/functions/api/proposals/[id]/pool.ts +++ b/functions/api/proposals/[id]/pool.ts @@ -1,15 +1,60 @@ import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; +import { getPoolVoteCounts } from "../../../_lib/poolVotesStore.ts"; +import { getActiveGovernorsForCurrentEra } from "../../../_lib/eraStore.ts"; +import { getProposal } from "../../../_lib/proposalsStore.ts"; +import { projectPoolProposalPage } from "../../../_lib/proposalProjector.ts"; +import { getProposalStageDenominator } from "../../../_lib/proposalStageDenominatorsStore.ts"; +import { V1_ACTIVE_GOVERNORS_FALLBACK } from "../../../_lib/v1Constants.ts"; +import { getSimConfig } from "../../../_lib/simConfig.ts"; +import { resolveUserTierFromSimConfig } from "../../../_lib/userTier.ts"; export const onRequestGet: PagesFunction = async (context) => { try { const id = context.params?.id; if (!id) return errorResponse(400, "Missing proposal id"); + + const counts = await getPoolVoteCounts(context.env, id); + const baseline = + (await getActiveGovernorsForCurrentEra(context.env).catch(() => null)) ?? + V1_ACTIVE_GOVERNORS_FALLBACK; + const activeGovernors = + ( + await getProposalStageDenominator(context.env, { + proposalId: id, + stage: "pool", + }).catch(() => null) + )?.activeGovernors ?? baseline; + + const proposal = await getProposal(context.env, id); + if (proposal) { + const simConfig = await getSimConfig( + context.env, + context.request.url, + ).catch(() => null); + return jsonResponse( + projectPoolProposalPage(proposal, { + counts, + activeGovernors, + tier: await resolveUserTierFromSimConfig( + simConfig, + proposal.authorAddress, + ), + }), + ); + } + const store = await createReadModelsStore(context.env); const payload = await store.get(`proposals:${id}:pool`); if (!payload) return errorResponse(404, `Missing read model: proposals:${id}:pool`); - return jsonResponse(payload); + const patched = { + ...(payload as Record), + upvotes: counts.upvotes, + downvotes: counts.downvotes, + activeGovernors, + }; + return jsonResponse(patched); } catch (error) { return errorResponse(500, (error as Error).message); } diff --git a/functions/api/proposals/[id]/timeline.ts b/functions/api/proposals/[id]/timeline.ts new file mode 100644 index 0000000..be96a8e --- /dev/null +++ b/functions/api/proposals/[id]/timeline.ts @@ -0,0 +1,24 @@ +import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; +import { listProposalTimelineItems } from "../../../_lib/proposalTimelineStore.ts"; + +export const onRequestGet: PagesFunction = async (context) => { + try { + const id = context.params?.id; + if (!id) return errorResponse(400, "Missing proposal id"); + + const url = new URL(context.request.url); + const limitParam = url.searchParams.get("limit"); + const limitRaw = limitParam ? Number.parseInt(limitParam, 10) : 100; + const limit = Number.isFinite(limitRaw) + ? Math.max(1, Math.min(500, limitRaw)) + : 100; + + const items = await listProposalTimelineItems(context.env, { + proposalId: id, + limit, + }); + return jsonResponse({ items }); + } catch (error) { + return errorResponse(500, (error as Error).message); + } +}; diff --git a/functions/api/proposals/drafts/[id].ts b/functions/api/proposals/drafts/[id].ts new file mode 100644 index 0000000..d649133 --- /dev/null +++ b/functions/api/proposals/drafts/[id].ts @@ -0,0 +1,170 @@ +import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; +import { readSession } from "../../../_lib/auth.ts"; +import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; +import { + getDraft, + formatChamberLabel, +} from "../../../_lib/proposalDraftsStore.ts"; +import { getUserTier } from "../../../_lib/userTier.ts"; + +export const onRequestGet: PagesFunction = async (context) => { + try { + const id = context.params?.id; + if (!id) return errorResponse(400, "Missing draft id"); + const session = await readSession(context.request, context.env); + + if (!context.env.DATABASE_URL) { + if (session) { + const tier = await getUserTier( + context.env, + context.request.url, + session.address, + ); + const draft = await getDraft(context.env, { + authorAddress: session.address, + draftId: id, + }); + if (draft) { + const budgetTotal = draft.payload.budgetItems.reduce((sum, item) => { + const n = Number(item.amount); + if (!Number.isFinite(n) || n <= 0) return sum; + return sum + n; + }, 0); + + return jsonResponse({ + title: draft.title, + proposer: session.address, + chamber: formatChamberLabel(draft.chamberId), + focus: draft.payload.chamberId + ? "Chamber-scoped proposal" + : "General proposal", + tier, + budget: + budgetTotal > 0 ? `${budgetTotal.toLocaleString()} HMND` : "—", + formationEligible: !draft.payload.metaGovernance, + teamSlots: "1 / 3", + milestonesPlanned: `${draft.payload.timeline.length} milestones`, + summary: draft.payload.summary, + rationale: draft.payload.why, + budgetScope: draft.payload.budgetItems + .filter((b) => b.description.trim().length > 0) + .map((b) => `${b.description}: ${b.amount} HMND`) + .join("\n"), + invisionInsight: { + role: "Draft author", + bullets: [ + "This is a saved draft in the off-chain simulation backend.", + "Submission to the pool is gated by active Humanode status.", + ], + }, + checklist: draft.payload.how + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 12), + milestones: draft.payload.timeline + .map((m) => m.title) + .filter(Boolean), + teamLocked: [ + { + name: session.address, + role: "Proposer", + }, + ], + openSlotNeeds: [ + { + title: "Contributor (open slot)", + desc: "Join the taskforce if the proposal reaches Formation.", + }, + ], + milestonesDetail: draft.payload.timeline.map((m, idx) => ({ + title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, + desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", + })), + attachments: draft.payload.attachments + .filter((a) => a.label.trim().length > 0) + .map((a) => ({ title: a.label, href: a.url || "#" })), + }); + } + } + + const store = await createReadModelsStore(context.env); + const payload = await store.get(`proposals:drafts:${id}`); + if (!payload) + return errorResponse(404, `Missing read model: proposals:drafts:${id}`); + return jsonResponse(payload); + } + + if (!session) return errorResponse(404, "Draft not found"); + const tier = await getUserTier( + context.env, + context.request.url, + session.address, + ); + const draft = await getDraft(context.env, { + authorAddress: session.address, + draftId: id, + }); + if (!draft) return errorResponse(404, "Draft not found"); + + const budgetTotal = draft.payload.budgetItems.reduce((sum, item) => { + const n = Number(item.amount); + if (!Number.isFinite(n) || n <= 0) return sum; + return sum + n; + }, 0); + + return jsonResponse({ + title: draft.title, + proposer: session.address, + chamber: formatChamberLabel(draft.chamberId), + focus: draft.payload.chamberId + ? "Chamber-scoped proposal" + : "General proposal", + tier, + budget: budgetTotal > 0 ? `${budgetTotal.toLocaleString()} HMND` : "—", + formationEligible: !draft.payload.metaGovernance, + teamSlots: "1 / 3", + milestonesPlanned: `${draft.payload.timeline.length} milestones`, + summary: draft.payload.summary, + rationale: draft.payload.why, + budgetScope: draft.payload.budgetItems + .filter((b) => b.description.trim().length > 0) + .map((b) => `${b.description}: ${b.amount} HMND`) + .join("\n"), + invisionInsight: { + role: "Draft author", + bullets: [ + "This is a saved draft in the off-chain simulation backend.", + "Submission to the pool is gated by active Humanode status.", + ], + }, + checklist: draft.payload.how + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 12), + milestones: draft.payload.timeline.map((m) => m.title).filter(Boolean), + teamLocked: [ + { + name: session.address, + role: "Proposer", + }, + ], + openSlotNeeds: [ + { + title: "Contributor (open slot)", + desc: "Join the taskforce if the proposal reaches Formation.", + }, + ], + milestonesDetail: draft.payload.timeline.map((m, idx) => ({ + title: m.title.trim().length ? m.title : `Milestone ${idx + 1}`, + desc: m.timeframe.trim().length ? m.timeframe : "Timeline TBD", + })), + attachments: draft.payload.attachments + .filter((a) => a.label.trim().length > 0) + .map((a) => ({ title: a.label, href: a.url || "#" })), + }); + } catch (error) { + return errorResponse(500, (error as Error).message); + } +}; diff --git a/functions/api/proposals/drafts/index.ts b/functions/api/proposals/drafts/index.ts new file mode 100644 index 0000000..47f00bd --- /dev/null +++ b/functions/api/proposals/drafts/index.ts @@ -0,0 +1,63 @@ +import { createReadModelsStore } from "../../../_lib/readModelsStore.ts"; +import { readSession } from "../../../_lib/auth.ts"; +import { errorResponse, jsonResponse } from "../../../_lib/http.ts"; +import { + listDrafts, + formatChamberLabel, +} from "../../../_lib/proposalDraftsStore.ts"; +import { getUserTier } from "../../../_lib/userTier.ts"; + +export const onRequestGet: PagesFunction = async (context) => { + try { + const session = await readSession(context.request, context.env); + + if (!context.env.DATABASE_URL) { + if (session) { + const tier = await getUserTier( + context.env, + context.request.url, + session.address, + ); + const drafts = await listDrafts(context.env, { + authorAddress: session.address, + }); + return jsonResponse({ + items: drafts.map((d) => ({ + id: d.id, + title: d.title, + chamber: formatChamberLabel(d.chamberId), + tier, + summary: d.summary, + updated: d.updatedAt.toISOString().slice(0, 10), + })), + }); + } + + const store = await createReadModelsStore(context.env); + const payload = await store.get("proposals:drafts:list"); + return jsonResponse(payload ?? { items: [] }); + } + + if (!session) return jsonResponse({ items: [] }); + const tier = await getUserTier( + context.env, + context.request.url, + session.address, + ); + const drafts = await listDrafts(context.env, { + authorAddress: session.address, + }); + return jsonResponse({ + items: drafts.map((d) => ({ + id: d.id, + title: d.title, + chamber: formatChamberLabel(d.chamberId), + tier, + summary: d.summary, + updated: d.updatedAt.toISOString().slice(0, 10), + })), + }); + } catch (error) { + return errorResponse(500, (error as Error).message); + } +}; diff --git a/functions/api/proposals/index.ts b/functions/api/proposals/index.ts index b991fc0..84014d9 100644 --- a/functions/api/proposals/index.ts +++ b/functions/api/proposals/index.ts @@ -1,20 +1,138 @@ import { createReadModelsStore } from "../../_lib/readModelsStore.ts"; import { errorResponse, jsonResponse } from "../../_lib/http.ts"; +import { listProposals } from "../../_lib/proposalsStore.ts"; +import { getActiveGovernorsForCurrentEra } from "../../_lib/eraStore.ts"; +import { getPoolVoteCounts } from "../../_lib/poolVotesStore.ts"; +import { getChamberVoteCounts } from "../../_lib/chamberVotesStore.ts"; +import { getFormationSummary } from "../../_lib/formationStore.ts"; +import { getProposalStageDenominatorMap } from "../../_lib/proposalStageDenominatorsStore.ts"; +import { + parseProposalStageQuery, + projectProposalListItem, +} from "../../_lib/proposalProjector.ts"; +import { V1_ACTIVE_GOVERNORS_FALLBACK } from "../../_lib/v1Constants.ts"; +import { getSimConfig } from "../../_lib/simConfig.ts"; +import { resolveUserTierFromSimConfig } from "../../_lib/userTier.ts"; +import { + getSimNow, + getStageWindowSeconds, + stageWindowsEnabled, +} from "../../_lib/stageWindows.ts"; export const onRequestGet: PagesFunction = async (context) => { try { const store = await createReadModelsStore(context.env); - const payload = await store.get("proposals:list"); - if (!payload) - return errorResponse(404, "Missing read model: proposals:list"); - + const now = getSimNow(context.env); + const voteWindowSeconds = stageWindowsEnabled(context.env) + ? getStageWindowSeconds(context.env, "vote") + : undefined; const url = new URL(context.request.url); const stage = url.searchParams.get("stage"); - if (!stage) return jsonResponse(payload); - const typed = payload as { items?: { stage?: string }[] }; - const items = (typed.items ?? []).filter((p) => p.stage === stage); - return jsonResponse({ items }); + const listPayload = await store.get("proposals:list"); + const readModelItems = + listPayload && + typeof listPayload === "object" && + !Array.isArray(listPayload) && + Array.isArray((listPayload as { items?: unknown[] }).items) + ? ((listPayload as { items: unknown[] }).items.filter( + (entry) => + entry && typeof entry === "object" && !Array.isArray(entry), + ) as Array>) + : []; + + const activeGovernors = + (await getActiveGovernorsForCurrentEra(context.env).catch(() => null)) ?? + V1_ACTIVE_GOVERNORS_FALLBACK; + + const stageQuery = + stage === "draft" ? null : parseProposalStageQuery(stage ?? null); + const proposals = + stage === "draft" + ? [] + : await listProposals(context.env, { stage: stageQuery }); + const simConfig = await getSimConfig( + context.env, + context.request.url, + ).catch(() => null); + + const poolDenominators = await getProposalStageDenominatorMap(context.env, { + stage: "pool", + proposalIds: proposals.filter((p) => p.stage === "pool").map((p) => p.id), + }); + const voteDenominators = await getProposalStageDenominatorMap(context.env, { + stage: "vote", + proposalIds: proposals.filter((p) => p.stage === "vote").map((p) => p.id), + }); + + const projected = await Promise.all( + proposals.map(async (proposal) => { + const formationEligible = (() => { + const payload = proposal.payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return true; + const record = payload as Record; + if (record.templateId === "system") return false; + if ( + typeof record.metaGovernance === "object" && + record.metaGovernance !== null && + !Array.isArray(record.metaGovernance) + ) + return false; + if (typeof record.formationEligible === "boolean") + return record.formationEligible; + if (typeof record.formation === "boolean") return record.formation; + return true; + })(); + + const poolCounts = + proposal.stage === "pool" + ? await getPoolVoteCounts(context.env, proposal.id) + : undefined; + const chamberCounts = + proposal.stage === "vote" + ? await getChamberVoteCounts(context.env, proposal.id, { + chamberId: (proposal.chamberId ?? "general").toLowerCase(), + }) + : undefined; + const formationSummary = + proposal.stage === "build" && formationEligible + ? await getFormationSummary(context.env, store, proposal.id).catch( + () => null, + ) + : null; + const stageDenominator = + proposal.stage === "pool" + ? poolDenominators.get(proposal.id)?.activeGovernors + : proposal.stage === "vote" + ? voteDenominators.get(proposal.id)?.activeGovernors + : undefined; + return projectProposalListItem(proposal, { + activeGovernors: stageDenominator ?? activeGovernors, + tier: await resolveUserTierFromSimConfig( + simConfig, + proposal.authorAddress, + ), + now, + voteWindowSeconds, + poolCounts, + chamberCounts, + formationSummary: formationSummary ?? undefined, + }); + }), + ); + + const projectedIds = new Set(projected.map((item) => item.id)); + const merged = [ + ...readModelItems.filter((item) => !projectedIds.has(String(item.id))), + ...projected, + ]; + + const filtered = stage + ? merged.filter((item) => String(item.stage) === stage) + : merged; + + return jsonResponse({ items: filtered }); } catch (error) { return errorResponse(500, (error as Error).message); } diff --git a/functions/cloudflare.d.ts b/functions/cloudflare.d.ts new file mode 100644 index 0000000..1a682b7 --- /dev/null +++ b/functions/cloudflare.d.ts @@ -0,0 +1,11 @@ +type PagesEnv = Record; + +type PagesContext> = { + request: Request; + env: PagesEnv; + params: Params; +}; + +type PagesFunction> = ( + context: PagesContext, +) => Response | Promise; diff --git a/functions/tsconfig.json b/functions/tsconfig.json new file mode 100644 index 0000000..6080cef --- /dev/null +++ b/functions/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true, + "lib": ["DOM", "ES2020"] + }, + "include": ["**/*.ts", "**/*.d.ts"], + "exclude": ["../node_modules", "../dist"] +} diff --git a/package.json b/package.json index a2101e1..f928aae 100644 --- a/package.json +++ b/package.json @@ -5,17 +5,24 @@ "packageManager": "yarn@4.11.0", "scripts": { "dev": "rsbuild dev --open", + "dev:api": "node --experimental-transform-types scripts/dev-api-node.mjs", + "dev:api:wrangler": "yarn build && wrangler pages dev ./dist --compatibility-date=2024-11-01 --port 8788 --binding SESSION_SECRET=dev-secret --binding DEV_BYPASS_SIGNATURE=true --binding DEV_BYPASS_GATE=true --binding DEV_INSECURE_COOKIES=true --binding READ_MODELS_INLINE_EMPTY=true", + "dev:full": "node scripts/dev-full.mjs", "build": "rsbuild build", "preview": "rsbuild preview", "test": "node --test --experimental-transform-types tests/**/*.test.js", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", + "db:clear": "node --experimental-transform-types scripts/db-clear.ts", "db:seed": "node --experimental-transform-types scripts/db-seed.ts", "prettier:check": "prettier --list-different .", "prettier:fix": "prettier --write ." }, "dependencies": { "@neondatabase/serverless": "^1.0.2", + "@polkadot/keyring": "^14.0.1", + "@polkadot/util": "^14.0.1", + "@polkadot/util-crypto": "^14.0.1", "@radix-ui/react-slot": "^1.2.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/public/sim-config.json b/public/sim-config.json new file mode 100644 index 0000000..2c01f72 --- /dev/null +++ b/public/sim-config.json @@ -0,0 +1,22 @@ +{ + "humanodeRpcUrl": "https://explorer-rpc.mainnet.stages.humanode.io", + "genesisChambers": [ + { "id": "general", "title": "General", "multiplier": 1.2 }, + { "id": "design", "title": "Design", "multiplier": 1.4 }, + { "id": "engineering", "title": "Engineering", "multiplier": 1.5 }, + { "id": "economics", "title": "Economics", "multiplier": 1.3 }, + { "id": "marketing", "title": "Marketing", "multiplier": 1.1 }, + { "id": "product", "title": "Product", "multiplier": 1.2 } + ], + "genesisChamberMembers": { + "general": ["hmpt3fxBvpWrkZxq5H5uWjZ2BgHRMJs2hKHiWJDoqD7am1xPs"], + "design": [], + "engineering": [], + "economics": [], + "marketing": [], + "product": [] + }, + "genesisUserTiers": { + "hmpt3fxBvpWrkZxq5H5uWjZ2BgHRMJs2hKHiWJDoqD7am1xPs": "Citizen" + } +} diff --git a/rsbuild.config.ts b/rsbuild.config.ts index a6bef9d..85939d8 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -3,4 +3,12 @@ import { pluginReact } from "@rsbuild/plugin-react"; export default defineConfig({ plugins: [pluginReact()], + server: { + proxy: { + "/api": { + target: process.env.API_PROXY_TARGET ?? "http://127.0.0.1:8788", + changeOrigin: true, + }, + }, + }, }); diff --git a/scripts/db-clear.ts b/scripts/db-clear.ts new file mode 100644 index 0000000..bffbadd --- /dev/null +++ b/scripts/db-clear.ts @@ -0,0 +1,30 @@ +import { neon } from "@neondatabase/serverless"; +import { drizzle } from "drizzle-orm/neon-http"; +import { sql } from "drizzle-orm"; +import { pathToFileURL } from "node:url"; + +function requireEnv(key: string): string { + const value = process.env[key]; + if (!value) throw new Error(`Missing ${key}`); + return value; +} + +async function main() { + const databaseUrl = requireEnv("DATABASE_URL"); + const client = neon(databaseUrl); + const db = drizzle(client); + + await db.execute( + sql`TRUNCATE TABLE auth_nonces, eligibility_cache, users, clock_state, read_models, proposal_drafts, proposals, proposal_stage_denominators, veto_votes, chamber_multiplier_submissions, chamber_memberships, chambers, delegations, delegation_events, events, pool_votes, chamber_votes, cm_awards, idempotency_keys, formation_projects, formation_team, formation_milestones, formation_milestone_events, court_cases, court_reports, court_verdicts, era_snapshots, era_user_activity, era_rollups, era_user_status, api_rate_limits, user_action_locks, admin_state RESTART IDENTITY`, + ); + + console.log("Cleared simulation tables (data removed, schema preserved)."); +} + +const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? "").href; +if (isMain) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/db-seed.ts b/scripts/db-seed.ts index 76477e6..27e14f8 100644 --- a/scripts/db-seed.ts +++ b/scripts/db-seed.ts @@ -1,13 +1,16 @@ import { neon } from "@neondatabase/serverless"; import { drizzle } from "drizzle-orm/neon-http"; +import { sql } from "drizzle-orm"; import { pathToFileURL } from "node:url"; -import { readModels } from "../db/schema.ts"; +import { chambers as chambersTable, events, readModels } from "../db/schema.ts"; import { buildReadModelSeed, type ReadModelSeedEntry, } from "../db/seed/readModels.ts"; +import { buildEventSeed } from "../db/seed/events.ts"; +import { chambers as chamberFixtures } from "../db/seed/fixtures/chambers.ts"; function requireEnv(key: string): string { const value = process.env[key]; @@ -30,18 +33,51 @@ async function upsertReadModel( }); } -export { buildReadModelSeed }; - async function main() { const databaseUrl = requireEnv("DATABASE_URL"); - const sql = neon(databaseUrl); - const db = drizzle(sql); + const client = neon(databaseUrl); + const db = drizzle(client); for (const entry of buildReadModelSeed()) { await upsertReadModel(db, entry.key, entry.payload); } - console.log("Seeded read models into Postgres."); + const eventSeed = buildEventSeed(); + await db.execute(sql`TRUNCATE TABLE events RESTART IDENTITY`); + await db.execute( + sql`TRUNCATE TABLE chambers, chamber_memberships, pool_votes, chamber_votes, cm_awards, idempotency_keys, formation_projects, formation_team, formation_milestones, formation_milestone_events, court_cases, court_reports, court_verdicts, era_snapshots, era_user_activity, era_rollups, era_user_status RESTART IDENTITY`, + ); + + await db.insert(chambersTable).values( + chamberFixtures.map((chamber) => ({ + id: chamber.id, + title: chamber.name, + status: "active", + multiplierTimes10: Math.round(chamber.multiplier * 10), + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + dissolvedAt: null, + createdByProposalId: null, + dissolvedByProposalId: null, + })), + ); + + if (eventSeed.length > 0) { + await db.insert(events).values( + eventSeed.map((event) => ({ + type: event.type, + stage: event.stage, + actorAddress: event.actorAddress, + entityType: event.entityType, + entityId: event.entityId, + payload: event.payload, + createdAt: event.createdAt, + })), + ); + } + + console.log("Seeded read models and events into Postgres."); } const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? "").href; diff --git a/scripts/dev-api-node.mjs b/scripts/dev-api-node.mjs new file mode 100644 index 0000000..0a819bc --- /dev/null +++ b/scripts/dev-api-node.mjs @@ -0,0 +1,291 @@ +import http from "node:http"; +import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { URL } from "node:url"; + +function setDefaultEnv() { + process.env.SESSION_SECRET ??= "dev-secret"; + process.env.DEV_BYPASS_SIGNATURE ??= "false"; + process.env.DEV_BYPASS_GATE ??= "false"; + process.env.DEV_INSECURE_COOKIES ??= "true"; + + // Ensure the backend always has access to sim config (RPC URL, genesis members) + // even when requests come through a proxy and `request.url` origin isn't the API server. + // `functions/_lib/simConfig.ts` prefers `SIM_CONFIG_JSON` over fetching `/sim-config.json`. + if (!process.env.SIM_CONFIG_JSON) { + try { + const filepath = resolve(process.cwd(), "public", "sim-config.json"); + process.env.SIM_CONFIG_JSON = readFileSync(filepath, "utf8"); + } catch { + // ignore + } + } + + const hasDb = Boolean(process.env.DATABASE_URL); + + if (hasDb) { + process.env.READ_MODELS_INLINE ??= "false"; + process.env.READ_MODELS_INLINE_EMPTY ??= "false"; + return; + } + + process.env.READ_MODELS_INLINE ??= "false"; + if (process.env.READ_MODELS_INLINE === "true") { + process.env.READ_MODELS_INLINE_EMPTY ??= "false"; + } else { + process.env.READ_MODELS_INLINE_EMPTY ??= "true"; + } +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +function resolveRoute(pathname) { + const patterns = [ + ["GET", /^\/api\/health$/, () => import("../functions/api/health.ts")], + ["GET", /^\/api\/me$/, () => import("../functions/api/me.ts")], + [ + "GET", + /^\/api\/gate\/status$/, + () => import("../functions/api/gate/status.ts"), + ], + [ + "POST", + /^\/api\/auth\/nonce$/, + () => import("../functions/api/auth/nonce.ts"), + ], + [ + "POST", + /^\/api\/auth\/verify$/, + () => import("../functions/api/auth/verify.ts"), + ], + [ + "POST", + /^\/api\/auth\/logout$/, + () => import("../functions/api/auth/logout.ts"), + ], + ["POST", /^\/api\/command$/, () => import("../functions/api/command.ts")], + ["GET", /^\/api\/clock$/, () => import("../functions/api/clock/index.ts")], + [ + "POST", + /^\/api\/clock\/advance-era$/, + () => import("../functions/api/clock/advance-era.ts"), + ], + [ + "POST", + /^\/api\/clock\/rollup-era$/, + () => import("../functions/api/clock/rollup-era.ts"), + ], + [ + "GET", + /^\/api\/chambers$/, + () => import("../functions/api/chambers/index.ts"), + ], + [ + "GET", + /^\/api\/chambers\/([^/]+)$/, + () => import("../functions/api/chambers/[id].ts"), + ], + [ + "GET", + /^\/api\/proposals$/, + () => import("../functions/api/proposals/index.ts"), + ], + [ + "GET", + /^\/api\/proposals\/drafts$/, + () => import("../functions/api/proposals/drafts/index.ts"), + ], + [ + "GET", + /^\/api\/proposals\/drafts\/([^/]+)$/, + () => import("../functions/api/proposals/drafts/[id].ts"), + ], + ["GET", /^\/api\/feed$/, () => import("../functions/api/feed/index.ts")], + [ + "GET", + /^\/api\/proposals\/([^/]+)\/pool$/, + () => import("../functions/api/proposals/[id]/pool.ts"), + ], + [ + "GET", + /^\/api\/proposals\/([^/]+)\/chamber$/, + () => import("../functions/api/proposals/[id]/chamber.ts"), + ], + [ + "GET", + /^\/api\/proposals\/([^/]+)\/formation$/, + () => import("../functions/api/proposals/[id]/formation.ts"), + ], + [ + "GET", + /^\/api\/courts$/, + () => import("../functions/api/courts/index.ts"), + ], + [ + "GET", + /^\/api\/courts\/([^/]+)$/, + () => import("../functions/api/courts/[id].ts"), + ], + [ + "GET", + /^\/api\/humans$/, + () => import("../functions/api/humans/index.ts"), + ], + [ + "GET", + /^\/api\/humans\/([^/]+)$/, + () => import("../functions/api/humans/[id].ts"), + ], + [ + "GET", + /^\/api\/factions$/, + () => import("../functions/api/factions/index.ts"), + ], + [ + "GET", + /^\/api\/factions\/([^/]+)$/, + () => import("../functions/api/factions/[id].ts"), + ], + [ + "GET", + /^\/api\/formation$/, + () => import("../functions/api/formation/index.ts"), + ], + [ + "GET", + /^\/api\/invision$/, + () => import("../functions/api/invision/index.ts"), + ], + [ + "GET", + /^\/api\/my-governance$/, + () => import("../functions/api/my-governance/index.ts"), + ], + ]; + + for (const [method, re, load] of patterns) { + const match = pathname.match(re); + if (!match) continue; + return { + method, + load, + params: match[1] ? { id: match[1] } : {}, + }; + } + return null; +} + +function getSetCookieHeaders(headers) { + const getSetCookie = headers?.getSetCookie?.bind(headers); + if (getSetCookie) return getSetCookie(); + const v = headers?.get?.("set-cookie"); + return v ? [v] : []; +} + +async function handleSimConfig(_nodeReq, nodeRes) { + try { + const filepath = resolve(process.cwd(), "public", "sim-config.json"); + const raw = await readFile(filepath, "utf8"); + nodeRes.statusCode = 200; + nodeRes.setHeader("content-type", "application/json; charset=utf-8"); + nodeRes.setHeader("cache-control", "no-store"); + nodeRes.end(raw); + } catch { + nodeRes.statusCode = 404; + nodeRes.setHeader("content-type", "application/json; charset=utf-8"); + nodeRes.end( + JSON.stringify({ + error: { message: "Missing public/sim-config.json for local dev" }, + }), + ); + } +} + +async function handleRequest(nodeReq, nodeRes) { + const origin = `http://${nodeReq.headers.host ?? "127.0.0.1"}`; + const url = new URL(nodeReq.url ?? "/", origin); + + if (nodeReq.method === "GET" && url.pathname === "/sim-config.json") { + await handleSimConfig(nodeReq, nodeRes); + return; + } + + const route = resolveRoute(url.pathname); + if (!route) { + nodeRes.statusCode = 404; + nodeRes.setHeader("content-type", "application/json"); + nodeRes.end(JSON.stringify({ error: { message: "Not found" } })); + return; + } + + if (nodeReq.method !== route.method) { + nodeRes.statusCode = 405; + nodeRes.setHeader("content-type", "application/json"); + nodeRes.end(JSON.stringify({ error: { message: "Method not allowed" } })); + return; + } + + const body = await readBody(nodeReq); + const request = new Request(url.toString(), { + method: nodeReq.method, + headers: nodeReq.headers, + body: body.length ? body : undefined, + }); + + const mod = await route.load(); + const handler = + nodeReq.method === "POST" ? mod.onRequestPost : mod.onRequestGet; + + if (typeof handler !== "function") { + nodeRes.statusCode = 500; + nodeRes.setHeader("content-type", "application/json"); + nodeRes.end( + JSON.stringify({ error: { message: "Handler not implemented" } }), + ); + return; + } + + const env = { ...process.env }; + const response = await handler({ request, env, params: route.params }); + + nodeRes.statusCode = response.status; + + const setCookies = getSetCookieHeaders(response.headers); + for (const cookie of setCookies) { + nodeRes.appendHeader?.("set-cookie", cookie); + } + for (const [key, value] of response.headers.entries()) { + if (key.toLowerCase() === "set-cookie") continue; + nodeRes.setHeader(key, value); + } + + const arrayBuffer = await response.arrayBuffer(); + nodeRes.end(Buffer.from(arrayBuffer)); +} + +setDefaultEnv(); + +const port = Number(process.env.API_PORT ?? "8788"); +const host = process.env.API_HOST ?? "127.0.0.1"; + +const server = http.createServer((req, res) => { + void handleRequest(req, res).catch((err) => { + res.statusCode = 500; + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ error: { message: err?.message ?? String(err) } }), + ); + }); +}); + +server.listen(port, host, () => { + console.log(`[api] listening on http://${host}:${port}`); +}); diff --git a/scripts/dev-full.mjs b/scripts/dev-full.mjs new file mode 100644 index 0000000..713d28d --- /dev/null +++ b/scripts/dev-full.mjs @@ -0,0 +1,26 @@ +import { spawn } from "node:child_process"; + +function spawnProc(command, args, name) { + const child = spawn(command, args, { stdio: "inherit" }); + child.on("exit", (code) => { + if (code && code !== 0) { + console.error(`[${name}] exited with code ${code}`); + } + }); + return child; +} + +const api = spawnProc( + "node", + ["--experimental-transform-types", "scripts/dev-api-node.mjs"], + "api", +); +const app = spawnProc("yarn", ["dev"], "app"); + +function shutdown() { + api.kill("SIGTERM"); + app.kill("SIGTERM"); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/src/app/App.tsx b/src/app/App.tsx index 87ea916..8f366fa 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,10 +1,13 @@ import { BrowserRouter } from "react-router"; +import { AuthProvider } from "@/app/auth/AuthContext"; import AppRoutes from "./AppRoutes"; const App: React.FC = () => { return ( - + + + ); }; diff --git a/src/app/AppSidebar.css b/src/app/AppSidebar.css index d33a0a6..e98c02f 100644 --- a/src/app/AppSidebar.css +++ b/src/app/AppSidebar.css @@ -21,6 +21,97 @@ width: 100%; } +.sidebar__auth { + margin-top: -0.75rem; + padding: 0.75rem; + border-radius: 14px; + border: 1px solid var(--sidebar-border); + background: var(--sidebar-active-bg); + box-shadow: var(--shadow-control); +} + +.sidebar__authRow { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.4rem; +} + +.sidebar__authKicker { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--sidebar-muted); +} + +.sidebar__authValue { + font-size: 12px; + font-weight: 600; + color: var(--sidebar-text); +} + +.sidebar__authValue--ok { + color: var(--sidebar-primary); +} + +.sidebar__authValue--warn { + color: var(--sidebar-muted); +} + +.sidebar__authButtons { + display: flex; + gap: 0.4rem; + margin-top: 0.55rem; +} + +.sidebar__authBtn { + flex: 1 1 0; + padding: 0.45rem 0.55rem; + border-radius: 12px; + border: 1px solid var(--sidebar-border); + background: transparent; + color: var(--sidebar-text); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: + background 160ms ease, + border-color 160ms ease; +} + +.sidebar__authBtn:hover { + background: rgba(255, 255, 255, 0.06); + border-color: var(--sidebar-primary-dim); +} + +.sidebar__authBtn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.sidebar__authBtn--ghost { + opacity: 0.9; +} + +.sidebar__authSelect { + margin-top: 0.55rem; + width: 100%; + padding: 0.45rem 0.55rem; + border-radius: 12px; + border: 1px solid var(--sidebar-border); + background: rgba(0, 0, 0, 0.12); + color: var(--sidebar-text); + font-size: 12px; +} + +.sidebar__authError { + margin-top: 0.5rem; + font-size: 11px; + color: var(--sidebar-muted); + line-height: 1.35; +} + .sidebar__logo { width: 48px; height: 48px; diff --git a/src/app/AppSidebar.tsx b/src/app/AppSidebar.tsx index d5cdc26..5cd15aa 100644 --- a/src/app/AppSidebar.tsx +++ b/src/app/AppSidebar.tsx @@ -18,6 +18,7 @@ import { Users, FileText, } from "lucide-react"; +import { AuthSidebarPanel } from "@/app/auth/AuthContext"; const navClass = ({ isActive }: { isActive: boolean }) => clsx("sidebar__link", isActive && "sidebar__link--active"); @@ -56,6 +57,7 @@ const AppSidebar: React.FC = ({ children }) => { Vortex +