diff --git a/README.md b/README.md index c11c2195..e6b2d8ad 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Corgi

- A community-governed Bluesky feed where subscribers vote on the ranking algorithm. + Community-shaped Bluesky ranking with inspectable policy and receipts.

[![Deploy](https://github.com/andrewnordstrom-eng/bluesky-community-feed/actions/workflows/deploy.yml/badge.svg)](https://github.com/andrewnordstrom-eng/bluesky-community-feed/actions/workflows/deploy.yml) @@ -16,41 +16,39 @@ [![Node >= 20](https://img.shields.io/badge/Node-%3E%3D20-339933)](https://nodejs.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-6-3178C6)](https://www.typescriptlang.org/) -Corgi is a production Bluesky custom feed where subscribers democratically vote on ranking weights and content rules. The backend applies those decisions in a transparent, auditable scoring pipeline. Every ranking decision is decomposed, stored, and explainable. +Corgi Commons is a production Bluesky custom feed shaped through a limited governance pilot. Approved participants can vote on five global ranking signals, topic priorities that shape relevance, and content rules that shape eligibility. Closed results are reviewed and approved before the complete policy is applied and the feed is rescored. Bluesky shows the ordered posts; Corgi exposes the policy and available ranking receipts. > **Research question:** Can communities meaningfully govern their own recommendation algorithms? ![Governance dashboard screenshot](docs/screenshots/dashboard.png) -**Live production snapshot:** on 2026-07-07 at 03:00 UTC, `GET /api/transparency/stats` reported epoch 2 active with 3,348 scored posts, 3,007 unique authors, and 0 votes in the current epoch. See [the dated anonymized metrics packet](docs/lab/2026-07-07-recsys-live-metrics-packet.md) for commands, caveats, and counterfactual receipts. - --- ## Try It -**No coding required** — just a Bluesky account. +**No coding required.** Anyone can use the anonymous demo; a Bluesky account is needed only to save or subscribe to the feed. -1. **Add Corgi's feed** — Search "Community Governed" in Bluesky's Feeds tab, or open: - [View feed on Bluesky](https://bsky.app/profile/corgi-network.bsky.social/feed/community-gov) +1. **Open Corgi Commons** — view or subscribe to the public feed without a Corgi account: + [Open Corgi Commons on Bluesky](https://bsky.app/profile/corgi-network.bsky.social/feed/community-gov) -2. **Vote on the algorithm** — Sign in with your Bluesky handle + [app password](https://bsky.app/settings/app-passwords): - [feed.corgi.network/vote](https://feed.corgi.network/vote) +2. **Try isolated shadow governance** — propose a policy, combine it with 24 scripted deterministic voter archetypes, and inspect how a frozen comparison corpus reranks without changing production: + [feed.corgi.network/demo](https://feed.corgi.network/demo) -3. **See the transparency dashboard** — View current weights, vote history, and score breakdowns: - [feed.corgi.network/dashboard](https://feed.corgi.network/dashboard) +3. **Request pilot access** — production governance participation is currently available through an approved waitlist: + [feed.corgi.network/start](https://feed.corgi.network/start) --- ## Features **Governance** -- Community voting on ranking components: starts with recency, engagement, bridging, source diversity, relevance — the registry accepts arbitrary additions (see [Contributing a scoring component](docs/contributing-scoring-components.md)) -- Content-rule voting with include/exclude keywords -- Epoch-based governance lifecycle with trimmed-mean aggregation +- Ballots can cover five global ranking signals, topic priorities, and include/exclude content rules +- Include rules act as an allowlist, excludes take precedence, and rule adoption requires at least 30% support among content-rule ballots +- Scheduled or manual rounds with configurable voting windows, arithmetic mean below 10 ballots, 10% trimmed mean at 10 or more, results review, operator approval, and post-approval rescoring - Append-only audit log (DB-enforced, no edits or deletes) **Scoring & Transparency** -- Full score decomposition persisted per post per epoch — raw, weight, weighted for every registered component, in a normalized long table +- Score decomposition persisted per post per epoch — raw, weight, and weighted contribution for every registered component, in a normalized long table - Transparency endpoints: per-post explanations, counterfactual analysis, feed-level statistics - Redis-backed feed serving with snapshot cursors (<50ms response time) @@ -61,7 +59,7 @@ Corgi is a production Bluesky custom feed where subscribers democratically vote - Interaction analytics: scroll depth, engagement attribution, keyword performance **Research Mode** -- Private feed gating: approved participant list for controlled studies +- Approved governance-participant list for the limited pilot; viewing Corgi Commons and using the shadow demo stay public - Research consent flow with IRB-ready architecture - Anonymized data export (deterministic hashing, correlatable across tables) - Legal framework: Terms of Service, Privacy Policy, research consent separation @@ -75,7 +73,7 @@ Corgi is a production Bluesky custom feed where subscribers democratically vote - Research data export: votes, scores, engagement, epochs, audit log (CSV/JSON) **Engineering** -- 500+ automated tests (unit, integration, stress) with PR-gated CI +- Automated unit, integration, contract, and stress tests with PR-gated CI - Genuinely pluggable scoring components: implement the `ScoringComponent` interface from `@corgi/feed-sdk`, register, ship. No schema migration required. See the [contribution guide](docs/contributing-scoring-components.md) and the [civility example](examples/civility-component/) for an end-to-end walk-through. ADR-0001 covers the design. - Pre-commit hooks (husky + lint-staged + tsc) - Dependabot for automated dependency updates @@ -212,7 +210,7 @@ Five components, each normalized to 0.0–1.0: | Source Diversity | Author variety | Diminishing returns per author (1.0 → 0.7 → 0.5 → 0.3) | | Relevance | Topic match × community preference | Weighted dot product of topic vectors and governance weights | -**Final score:** `total = Σ(component_raw × governance_weight)` where weights sum to 1.0 and are set by community vote. +**Component score:** `total = Σ(component_raw × approved_signal_weight)` where the five global signal weights sum to 1.0. Topic preferences affect the relevance component only; eligibility rules and publication adjustments are applied separately. All 15 numeric values (5× raw, weight, weighted) are persisted per post per epoch for full auditability. diff --git a/docs/PRD.md b/docs/PRD.md index 292a3f00..c1f5aac3 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -3,13 +3,13 @@ Status: canonical product brief Owner: bluesky-feed Service class: production_service -Last updated: 2026-04-05 +Last updated: 2026-07-13 ## Mission -Prove that a real social feed can be community-governed without becoming opaque, -slow, or unserious. Subscribers should be able to influence ranking behavior and -see exactly how those choices affect the feed. +Prove that a real social feed can be community-shaped without becoming opaque, +slow, or unserious. Approved pilot participants can propose ranking policy while +anyone can inspect what changed and why. ## Problem @@ -19,8 +19,9 @@ algorithm, and cannot audit the consequences of policy changes. ## Outcomes -- Subscribers can vote on ranking weights and content rules through a usable - governance surface. +- Approved pilot participants can vote on five global signal weights, topic + priorities, and content rules through a usable governance surface. Anyone can + inspect the public feed or use the isolated shadow demo without joining the pilot. - The ranking pipeline persists full score decomposition so every result is explainable — in a normalized long table that supports any-N components, not a 5-column fossilized schema. diff --git a/docs/SYSTEM_OVERVIEW.md b/docs/SYSTEM_OVERVIEW.md index 613c833c..81a7a3e4 100644 --- a/docs/SYSTEM_OVERVIEW.md +++ b/docs/SYSTEM_OVERVIEW.md @@ -1,15 +1,13 @@ -# Community-Governed Bluesky Feed Generator +# Corgi Commons Feed Generator ## What This Is -A Bluesky custom feed where **subscribers democratically vote on algorithm parameters**. Instead of one person deciding how posts are ranked, the community does through Polis-style deliberation. +A production Bluesky custom feed with inspectable, community-shaped ranking. Corgi Commons is public to view and subscribe to; production governance participation is currently an approved waitlist pilot. -- Users subscribe to the feed in their Bluesky app -- Subscribers vote on how much weight each scoring component should have -- Votes are aggregated using trimmed mean (removes outliers) -- The feed algorithm uses the community-chosen weights - -**No one has built this before.** This is the first governance-weighted custom feed on AT Protocol. +- Anyone can view Corgi Commons in Bluesky and use the isolated shadow demo +- Approved pilot participants can submit signal weights, topic priorities, and content rules +- Fewer than 10 ballots use an arithmetic mean; 10 or more use a 10% trimmed mean +- Closed results require review and operator approval before the complete policy is applied and the feed is rescored --- @@ -49,7 +47,7 @@ A Bluesky custom feed where **subscribers democratically vote on algorithm param 2. **Scoring Pipeline** (every 5 minutes) → Calculates 5 component scores per post, applies governance weights, stores full decomposition 3. **Top 1000 posts** → Pushed to Redis sorted set (`feed:current`) 4. **getFeedSkeleton** → Bluesky app requests feed, we read from Redis and return post URIs -5. **Users vote** → Weights aggregated via trimmed mean → Next epoch uses new weights +5. **Approved participants vote** → Ballots aggregate after the configurable window → Results review and operator approval → Complete policy application → Rescore --- @@ -86,49 +84,42 @@ Example with default weights: ### How Voting Works -1. **Eligibility**: Only feed subscribers can vote (tracked when they request the feed) -2. **Weight Distribution**: Voters set their preferred weight for each component (must sum to 100%) -3. **One Vote Per Epoch**: Each subscriber gets one vote per governance epoch (can update) -4. **Audit Trail**: All votes logged to append-only audit table +1. **Eligibility**: Production voting is limited to approved waitlist participants during the pilot +2. **Three ballot channels**: Five global signal weights, topic priorities that shape relevance, and include/exclude content rules +3. **One ballot per epoch**: An approved participant can update their ballot while voting is open +4. **Audit trail**: Governance and operator actions are recorded in the append-only audit table ### Trimmed Mean Aggregation To prevent manipulation by outliers: -1. Collect all votes for the epoch +1. Collect all ballots for the epoch 2. For each component independently: - - Sort values - - Remove top 10% and bottom 10% - - Calculate mean of remaining values + - With fewer than 10 ballots, calculate the arithmetic mean + - With 10 or more, sort values and remove the top and bottom 10% + - Calculate the mean of the remaining values 3. Normalize result to sum to exactly 1.0 -Minimum 10 votes required before trimming kicks in. +Content-rule keywords use a separate threshold: at least 30% support among ballots that submit content rules. Include rules act as an allowlist and excludes take precedence. ### Epoch Lifecycle -``` -┌──────────────┐ votes ≥ MIN_VOTES ┌──────────────┐ -│ ACTIVE │ ─────────────────────▶ │ VOTING │ -│ (current) │ │ (closing) │ -└──────────────┘ └──────┬───────┘ - │ - ┌────────────────────────────────────────┘ - │ aggregate votes, create new epoch - ▼ -┌──────────────┐ ┌──────────────┐ -│ ACTIVE │ ◀──────────────────────│ CLOSED │ -│ (new epoch) │ │ (archived) │ -└──────────────┘ └──────────────┘ -``` +1. A vote is scheduled or opened manually with a configurable voting window. +2. The window closes and ballots are aggregated into proposed signals, topics, and content rules. +3. Results enter review; an operator approves or rejects the complete proposal. +4. Approval applies all three policy channels to the active epoch, returns it to the running phase, and queues a durable rescore. +5. Bluesky receives the resulting ordered post URIs; Corgi exposes policy and receipt metadata. --- ## Database Schema (Key Tables) ### posts + Stores all ingested Bluesky posts with `deleted` flag for soft deletes. ### post_scores (GOLDEN RULE) + Stores **decomposed scores** - 15 columns per post per epoch: - 5 raw scores (recency_score, engagement_score, etc.) - 5 weights from governance epoch @@ -137,18 +128,23 @@ Stores **decomposed scores** - 15 columns per post per epoch: - epoch_id (critical for measuring governance impact) ### governance_epochs + Current and historical governance periods with weight distribution. ### governance_votes -Individual subscriber votes (one per voter per epoch). + +Individual approved-participant ballots (one per voter per epoch, updatable while open). ### governance_audit_log + **Append-only** record of all governance actions (trust anchor). ### subscribers -Tracks who uses the feed and is eligible to vote. + +Tracks feed subscriptions and participant state; pilot voting eligibility is separately allowlisted. ### jetstream_cursor + Persists cursor every 1000 events to resume without gaps. --- @@ -160,38 +156,23 @@ The React frontend provides: - **Current Weights**: Radar chart showing active epoch's weight distribution - **Feed Statistics**: Posts scored, unique authors, average bridging - **Epoch History**: Timeline of all governance epochs with weight changes -- **Post Explanations**: Why any post has its current rank (score breakdown) +- **Post Explanations**: Score and ranking provenance when a Corgi receipt is available - **Audit Log**: Complete history of governance actions --- ## Current Limitations -1. **All of Bluesky**: Currently ingests everything, not scoped to topic/community -2. **No Moderation Layer**: Relies on Bluesky's own moderation -3. **Relevance**: Uses keyword + embedding classification at ingestion time; governance weights determine per-topic relevance at scoring time -4. **Single Feed**: One governance epoch affects one feed +1. **Pilot governance access**: Viewing and the shadow demo are public; production voting is allowlisted +2. **Single public governed feed**: Corgi Commons is the production feed; multi-community governance is not yet public +3. **Relevance model**: Curated keyword topic classification is primary; sentence-embedding similarity is optional behind configuration +4. **Receipt coverage**: A receipt requires persisted score provenance for the post and epoch --- -## Future: Collection Governance - -Currently users vote on **ranking** (how to order posts). Future extension: vote on **collection** (what to include). - -### Potential Collection Rules - -- **Topic Keywords**: Include posts mentioning specific terms -- **Source Allowlists/Blocklists**: Include/exclude specific accounts -- **Content Type Filters**: Text only, images allowed, no reposts -- **Language Filters**: English only, multilingual, etc. -- **Engagement Thresholds**: Minimum likes to be considered - -### Implementation Path +## Content-Rule Governance -1. Add `collection_rules` JSONB column to `governance_epochs` -2. Create voting UI for rule proposals -3. Jetstream handlers check rules before inserting posts -4. Separate epochs for collection rules vs. ranking weights +Content rules are part of the current ballot. Include-keyword rules form an allowlist, exclude-keyword rules take precedence, and adopted rules are applied with the approved signal and topic policy before rescoring. The public shadow demo exposes only bounded exclusion-rule mechanics for this release. --- diff --git a/docs/agent/REPO_CONTRACT.md b/docs/agent/REPO_CONTRACT.md index 554b3262..9b1c5c3d 100644 --- a/docs/agent/REPO_CONTRACT.md +++ b/docs/agent/REPO_CONTRACT.md @@ -4,8 +4,8 @@ Status: canonical repo contract Owner: bluesky-feed Service class: production_service Contract version: 2 -Last updated: 2026-04-06 -Last verified: 2026-04-06 +Last updated: 2026-07-13 +Last verified: 2026-07-13 > Canonical reference for any human or tooling operating in this repo. @@ -13,12 +13,12 @@ Last verified: 2026-04-06 ## 1. What This Repo Is -A production Bluesky custom feed generator where subscribers democratically vote -on ranking weights and content rules. The backend applies those decisions in a -transparent, auditable scoring pipeline. Every ranking decision is decomposed, -stored, and explainable. - -This is the first governance-weighted custom feed on AT Protocol. +A production Bluesky custom feed generator for Corgi Commons, with inspectable, +community-shaped ranking. Anyone can view the public feed or use the isolated +shadow demo. Production governance is an approved waitlist pilot: participants +can vote on five global signal weights, topic priorities, and content rules. +Closed results require review and operator approval before the complete policy is +applied and the feed is rescored. **Canonical URL:** `https://feed.corgi.network` **API docs:** `https://docs.corgi.network` @@ -34,12 +34,11 @@ Before this project, Bluesky custom feeds were opaque ranking systems controlled by a single operator. There was no mechanism for subscribers to influence how posts are ranked. This feed exists to: -- Let subscribers vote on algorithm weights through Polis-style governance - epochs. Five scoring components (recency, engagement, bridging, source - diversity, relevance) are weighted by community vote. -- Provide full transparency: every score is decomposed into raw, weight, and - weighted values, persisted per post per epoch, and exposed through public - transparency endpoints. +- Let approved pilot participants submit structured ballots across signal, + topic, and content-rule policy channels while preserving operator review. +- Provide inspectable ranking: score decompositions are persisted as raw, + weight, and weighted values per post and epoch, then exposed when receipt + provenance is available. - Serve as a research instrument for studying algorithmic governance on decentralized social networks, with IRB-ready consent flows, research gating, and anonymized export. @@ -77,17 +76,18 @@ posts are ranked. This feed exists to: | audit) | | | +------------+ +-----------+ +--------------+ +------------+ + | Next.js 15 | | React 19 | - | Vite 7 | - | Dashboard | - | Voting UI | + | Static web | + | + app UI | +------------+ ``` **Runtime:** Node.js 20, TypeScript 5, Fastify 5 **Data layer:** PostgreSQL 16 (posts, scores, governance, audit), Redis 7 (feed cache, sessions) -**Frontend:** React 19, Vite 7 (transparency dashboard, voting UI) +**Frontend:** Next.js 15 static export, React 19, Tailwind (public pages, +transparency dashboard, voting UI) **Protocol:** `@atproto/api`, `@atproto/xrpc-server` **NLP:** winkNLP (topic classification at ingestion), HuggingFace Transformers (embedding-based classification) @@ -118,7 +118,8 @@ cache, sessions) | `src/lib/` | Shared utilities (logging, metrics, rate limiting) | | `src/maintenance/` | Operational maintenance routines | | `src/legal/` | Terms of service and privacy policy content | -| `web/` | React + Vite frontend (dashboard, voting UI, transparency views) | +| `web-next/` | Canonical Next.js frontend (public pages, demo, dashboard, voting, transparency) | +| `web/` | Legacy React + Vite frontend retained for compatibility during migration | | `cli/` | CLI tool (`feed-cli`) for admin operations from any terminal | | `scripts/` | Setup, migration, seed, publish-feed, report generation scripts | | `tests/` | Unit, integration, and stress tests (Vitest) | @@ -134,9 +135,10 @@ cache, sessions) ## 5. Build / Test / Run Commands ```bash -# Install dependencies (backend + frontend) +# Install dependencies (backend + both frontend packages) npm install -cd web && npm install && cd .. +cd web-next && npm install && cd .. +cd web && npm install && cd .. # legacy compatibility frontend # Build (TypeScript -> dist/) npm run build @@ -148,8 +150,8 @@ npm run stress # stress tests # Development server (tsx watch, auto-reload) npm run dev -# Frontend dev server (separate terminal) -cd web && npm run dev +# Canonical frontend dev server (separate terminal) +cd web-next && npm run dev # Production start (requires prior build) npm start @@ -163,7 +165,7 @@ npx tsx scripts/seed-governance.ts # Publish feed record to Bluesky npm run publish-feed -# Full verify (build + test + cli + mcp + web lint + web build) +# Full verify (backend, CLI, MCP, SDK, legacy web, and web-next) npm run verify # Docs verification @@ -314,9 +316,11 @@ See `docs/OPERABILITY.md`, `docs/runbooks/operator-quickstart.md`, and must contain a Linear key. Enforcement happens in org-policy / CI checks and any local hook configuration that may be installed by the workspace. -3. **Two install targets.** Backend and frontend have separate `node_modules`. - Run `npm install` at repo root AND `cd web && npm install` for the frontend. - The `npm run verify` command covers both. +3. **Separate install targets.** Backend and frontends have separate + `node_modules`. Run `npm install` at repo root and `cd web-next && npm + install` for the canonical frontend. Install `web/` dependencies only when + working on the legacy compatibility frontend. The `npm run verify` command + covers both frontend packages. 4. **PostgreSQL port offset in production.** Docker Compose binds PostgreSQL to `127.0.0.1:5433` (not standard 5432) and Redis to `127.0.0.1:6380` (not @@ -328,10 +332,11 @@ See `docs/OPERABILITY.md`, `docs/runbooks/operator-quickstart.md`, and feed looks stale after deploying, wait for the next scoring cycle or trigger a manual rescore via `npm run cli -- feed rescore`. -6. **Governance epoch transitions.** Epochs require a minimum vote threshold - before trimmed-mean aggregation kicks in (10 votes). Below that threshold, - weights remain at their prior values. New epochs must be seeded via - `npx tsx scripts/seed-governance.ts` on a fresh database. +6. **Governance epoch transitions.** Fewer than 10 ballots use an arithmetic + mean; 10 or more use a 10% trimmed mean. Closing a voting window enters + results review. Operator approval applies signal weights, topic priorities, + and adopted content rules together before rescoring. New databases still + require `npx tsx scripts/seed-governance.ts`. 7. **Append-only audit log.** The `governance_audit_log` table is DB-enforced append-only (no edits, no deletes). Do not attempt to modify it in diff --git a/docs/docs-site/openapi.json b/docs/docs-site/openapi.json index 469c12c9..29c7e066 100644 --- a/docs/docs-site/openapi.json +++ b/docs/docs-site/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "Community Feed API", - "description": "Community-governed Bluesky feed generator where subscribers democratically vote on ranking algorithm weights. Built on AT Protocol.\n\n## Authentication\n- **Governance endpoints** require a session cookie or bearer token from `POST /api/governance/auth/login`.\n- **Admin endpoints** additionally require the caller's DID to be in the `BOT_ADMIN_DIDS` allowlist.\n- **Feed endpoints** are public (called by the Bluesky app). Auth is optional for subscriber tracking.\n- **Transparency endpoints** are public and unauthenticated.", + "description": "Corgi Commons is a Bluesky feed with inspectable, community-shaped ranking. Approved pilot participants can vote on ranking signals, topic priorities, and content rules; reviewed policy changes are applied before rescoring. Built on AT Protocol.\n\n## Authentication\n- **Governance endpoints** require a session cookie or bearer token from `POST /api/governance/auth/login`.\n- **Admin endpoints** additionally require the caller's DID to be in the `BOT_ADMIN_DIDS` allowlist.\n- **Feed endpoints** are public (called by the Bluesky app). Viewing the feed and using the shadow demo do not require governance access.\n- **Transparency endpoints** are public and unauthenticated.", "version": "1.2.0", "contact": { "name": "Community Feed", @@ -84,7 +84,7 @@ "tags": [ "Feed" ], - "description": "Returns the DID document for did:web resolution. Used as a fallback \u2014 production uses did:plc.", + "description": "Returns the DID document for did:web resolution. Used as a fallback — production uses did:plc.", "responses": { "200": { "description": "Default Response", @@ -525,6 +525,36 @@ } } }, + "403": { + "description": "Valid credentials, but the account is not approved for the pilot.", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Valid credentials, but the account is not approved for the pilot.", + "properties": { + "error": { + "type": "string", + "example": "NotApproved" + }, + "message": { + "type": "string" + }, + "waitlist": { + "type": "boolean", + "example": true, + "description": "Discriminator: the client should offer the waitlist." + } + }, + "required": [ + "error", + "message", + "waitlist" + ] + } + } + } + }, "429": { "description": "Default Response", "content": { @@ -775,7 +805,7 @@ "tags": [ "Governance" ], - "description": "Cast or update your vote for the current epoch. You can vote on algorithm weights, content keywords, topic weights, or any combination. Weights must sum to 1.0. Requires authentication and active subscription.", + "description": "Cast or update your vote for the current epoch. You can vote on algorithm weights, content keywords, topic weights, or any combination. Weights must sum to 1.0. Requires authentication, an active feed subscription, and approved pilot participation when allowlisting is enabled.", "requestBody": { "required": true, "content": { @@ -783,31 +813,31 @@ "schema": { "type": "object", "properties": { - "recency": { + "recency_weight": { "type": "number", "minimum": 0, "maximum": 1, "description": "Weight for recency component" }, - "engagement": { + "engagement_weight": { "type": "number", "minimum": 0, "maximum": 1, "description": "Weight for engagement component" }, - "bridging": { + "bridging_weight": { "type": "number", "minimum": 0, "maximum": 1, "description": "Weight for bridging component" }, - "source_diversity": { + "source_diversity_weight": { "type": "number", "minimum": 0, "maximum": 1, "description": "Weight for source diversity component" }, - "relevance": { + "relevance_weight": { "type": "number", "minimum": 0, "maximum": 1, @@ -838,7 +868,7 @@ "minimum": 0, "maximum": 1 }, - "description": "Topic slug \u2192 weight mapping" + "description": "Topic slug → weight mapping" } } } @@ -1255,7 +1285,7 @@ "tags": [ "Topics" ], - "description": "Returns all active topics from the topic catalog with their current community-voted weights. Public endpoint \u2014 no authentication required.", + "description": "Returns all active topics from the topic catalog with their current community-voted weights. Public endpoint — no authentication required.", "responses": { "200": { "description": "Default Response", @@ -1660,7 +1690,7 @@ }, "difference": { "type": "object", - "description": "Weight differences (epoch2 \u2212 epoch1)", + "description": "Weight differences (epoch2 − epoch1)", "properties": { "recency": { "type": "number" @@ -1835,6 +1865,11 @@ "format": "date-time", "nullable": true }, + "results_approved_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, "description": { "type": "string", "nullable": true @@ -1951,7 +1986,7 @@ }, "subscriber_count": { "type": "integer", - "description": "Active subscribers (potential voters)" + "description": "Approved active governance participants" }, "created_at": { "type": "string", @@ -2414,14 +2449,14 @@ "additionalProperties": { "type": "integer" }, - "description": "Keyword \u2192 vote count map for include keywords" + "description": "Keyword → vote count map for include keywords" }, "exclude_keyword_votes": { "type": "object", "additionalProperties": { "type": "integer" }, - "description": "Keyword \u2192 vote count map for exclude keywords" + "description": "Keyword → vote count map for exclude keywords" }, "total_voters": { "type": "integer", @@ -2480,7 +2515,7 @@ "tags": [ "Governance" ], - "description": "Returns the authenticated user's current research consent status. Research consent is separate from TOS \u2014 declining does not restrict governance access.", + "description": "Returns the authenticated user's current research consent status. Research consent is separate from TOS — declining does not restrict governance access.", "security": [ { "cookieAuth": [] @@ -2685,6 +2720,151 @@ } } }, + "/api/governance/waitlist": { + "post": { + "summary": "Join the voting waitlist", + "tags": [ + "Governance" + ], + "description": "Request voting access during the pilot by submitting a Bluesky handle and optional note. The response does not reveal whether the handle was already on the list or approved.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "handle": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "note": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "handle" + ], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "429": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "TooManyRequests" + }, + "message": { + "type": "string" + }, + "retryAfterSeconds": { + "type": "integer", + "description": "Seconds to wait before retrying" + }, + "correlationId": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, "/api/transparency/post/{uri}": { "get": { "summary": "Explain post ranking", @@ -2905,7 +3085,7 @@ "tags": [ "Transparency" ], - "description": "Returns aggregate statistics for the current feed including epoch weights, scoring metrics (posts scored, unique authors, avg/median bridging), governance info, and optional Gini coefficient.", + "description": "Returns bounded aggregate statistics for the current feed including epoch weights, materialized feed-snapshot metrics, governance info, provenance, and optional Gini coefficient.", "responses": { "200": { "description": "Default Response", @@ -2953,10 +3133,12 @@ "type": "object", "properties": { "total_posts_scored": { - "type": "integer" + "type": "integer", + "description": "Rows in the current materialized feed snapshot." }, "unique_authors": { - "type": "integer" + "type": "integer", + "description": "Unique authors in the materialized feed snapshot." }, "avg_bridging_score": { "type": "number" @@ -2999,12 +3181,46 @@ "nullable": true } } + }, + "stats_status": { + "type": "object", + "description": "Provenance for the bounded stats response.", + "properties": { + "source": { + "type": "string", + "enum": [ + "scoring_run", + "fallback" + ] + }, + "degraded": { + "type": "boolean" + }, + "computed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "run_id": { + "type": "string", + "nullable": true + }, + "message": { + "type": "string", + "nullable": true + } + }, + "required": [ + "source", + "degraded" + ] } }, "required": [ "epoch", "feed_stats", - "governance" + "governance", + "stats_status" ] } } @@ -3462,6 +3678,294 @@ } } }, + "/api/demo/sessions": { + "post": { + "tags": [ + "Demo" + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}/votes": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}/agents/run": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}/epochs/advance": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}/feed": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}/receipts": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions": { + "post": { + "tags": [ + "Demo" + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/votes": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/agents/run": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/epochs/advance": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/feed": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/receipts": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, "/api/legal/tos": { "get": { "summary": "Terms of Service", @@ -3805,6 +4309,10 @@ "name": "Transparency", "description": "Score explanations, feed stats, and audit logs" }, + { + "name": "Demo", + "description": "Public, isolated shadow-governance sessions for the reviewer walkthrough" + }, { "name": "Admin", "description": "Admin-only endpoints (requires BOT_ADMIN_DIDS)" diff --git a/docs/lab/demo-shadow-governance-contract.md b/docs/lab/demo-shadow-governance-contract.md index 1b488753..bf8ea24e 100644 --- a/docs/lab/demo-shadow-governance-contract.md +++ b/docs/lab/demo-shadow-governance-contract.md @@ -86,7 +86,7 @@ npm run demo:capture-community-gov -- \ --review-sheet /tmp/community-gov-review.html ``` -It emits the ordered URI manifest and digest, reviewed record CIDs, score completeness, public/withheld counts, language and media distributions, unique-author count, top-author concentration, gate failures, and a local review sheet. A release snapshot must have: +It always emits a machine-readable quality report. It emits the ordered URI manifest, digest, reviewed record CIDs, and local review sheet only when the live production cohort passes every objective gate. A failed capture deletes any stale manifest/review sheet at the requested paths and exits nonzero; it never substitutes fixture posts into a live review artifact. A release snapshot must have: - at least 40 eligible and 12 displayable public posts; - 100 percent score decomposition for eligible posts; @@ -96,7 +96,16 @@ It emits the ordered URI manifest and digest, reviewed record CIDs, score comple - zero public-view policy violations; - a completed manual review with no unexplained unsafe result under built-in presets. -Failure returns the explicitly labeled mechanics fixture. The system never weakens thresholds silently or calls a fixture live. +The runtime demo may fall back to its explicitly labeled mechanics fixture. The capture and approval commands never do: approval requires the matching passing report as well as acknowledgement that every review-sheet row was checked. + +```bash +npm run demo:approve-community-gov -- \ + --manifest /tmp/community-gov-manifest.json \ + --report /tmp/community-gov-report.json \ + --output src/demo/community-gov-release-snapshot.json \ + --reviewed-at 2026-07-13T20:00:00Z \ + --review-acknowledged +``` ### Approved 2026-07-11 Snapshot Receipt diff --git a/docs/openapi-public.json b/docs/openapi-public.json index 86aa3010..29c7e066 100644 --- a/docs/openapi-public.json +++ b/docs/openapi-public.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "Community Feed API", - "description": "Community-governed Bluesky feed generator where subscribers democratically vote on ranking algorithm weights. Built on AT Protocol.\n\n## Authentication\n- **Governance endpoints** require a session cookie or bearer token from `POST /api/governance/auth/login`.\n- **Admin endpoints** additionally require the caller's DID to be in the `BOT_ADMIN_DIDS` allowlist.\n- **Feed endpoints** are public (called by the Bluesky app). Auth is optional for subscriber tracking.\n- **Transparency endpoints** are public and unauthenticated.", + "description": "Corgi Commons is a Bluesky feed with inspectable, community-shaped ranking. Approved pilot participants can vote on ranking signals, topic priorities, and content rules; reviewed policy changes are applied before rescoring. Built on AT Protocol.\n\n## Authentication\n- **Governance endpoints** require a session cookie or bearer token from `POST /api/governance/auth/login`.\n- **Admin endpoints** additionally require the caller's DID to be in the `BOT_ADMIN_DIDS` allowlist.\n- **Feed endpoints** are public (called by the Bluesky app). Viewing the feed and using the shadow demo do not require governance access.\n- **Transparency endpoints** are public and unauthenticated.", "version": "1.2.0", "contact": { "name": "Community Feed", @@ -84,7 +84,7 @@ "tags": [ "Feed" ], - "description": "Returns the DID document for did:web resolution. Used as a fallback \u2014 production uses did:plc.", + "description": "Returns the DID document for did:web resolution. Used as a fallback — production uses did:plc.", "responses": { "200": { "description": "Default Response", @@ -525,6 +525,36 @@ } } }, + "403": { + "description": "Valid credentials, but the account is not approved for the pilot.", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Valid credentials, but the account is not approved for the pilot.", + "properties": { + "error": { + "type": "string", + "example": "NotApproved" + }, + "message": { + "type": "string" + }, + "waitlist": { + "type": "boolean", + "example": true, + "description": "Discriminator: the client should offer the waitlist." + } + }, + "required": [ + "error", + "message", + "waitlist" + ] + } + } + } + }, "429": { "description": "Default Response", "content": { @@ -775,7 +805,7 @@ "tags": [ "Governance" ], - "description": "Cast or update your vote for the current epoch. You can vote on algorithm weights, content keywords, topic weights, or any combination. Weights must sum to 1.0. Requires authentication and active subscription.", + "description": "Cast or update your vote for the current epoch. You can vote on algorithm weights, content keywords, topic weights, or any combination. Weights must sum to 1.0. Requires authentication, an active feed subscription, and approved pilot participation when allowlisting is enabled.", "requestBody": { "required": true, "content": { @@ -783,31 +813,31 @@ "schema": { "type": "object", "properties": { - "recency": { + "recency_weight": { "type": "number", "minimum": 0, "maximum": 1, "description": "Weight for recency component" }, - "engagement": { + "engagement_weight": { "type": "number", "minimum": 0, "maximum": 1, "description": "Weight for engagement component" }, - "bridging": { + "bridging_weight": { "type": "number", "minimum": 0, "maximum": 1, "description": "Weight for bridging component" }, - "source_diversity": { + "source_diversity_weight": { "type": "number", "minimum": 0, "maximum": 1, "description": "Weight for source diversity component" }, - "relevance": { + "relevance_weight": { "type": "number", "minimum": 0, "maximum": 1, @@ -838,7 +868,7 @@ "minimum": 0, "maximum": 1 }, - "description": "Topic slug \u2192 weight mapping" + "description": "Topic slug → weight mapping" } } } @@ -1255,7 +1285,7 @@ "tags": [ "Topics" ], - "description": "Returns all active topics from the topic catalog with their current community-voted weights. Public endpoint \u2014 no authentication required.", + "description": "Returns all active topics from the topic catalog with their current community-voted weights. Public endpoint — no authentication required.", "responses": { "200": { "description": "Default Response", @@ -1660,7 +1690,7 @@ }, "difference": { "type": "object", - "description": "Weight differences (epoch2 \u2212 epoch1)", + "description": "Weight differences (epoch2 − epoch1)", "properties": { "recency": { "type": "number" @@ -1835,6 +1865,11 @@ "format": "date-time", "nullable": true }, + "results_approved_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, "description": { "type": "string", "nullable": true @@ -1951,7 +1986,7 @@ }, "subscriber_count": { "type": "integer", - "description": "Active subscribers (potential voters)" + "description": "Approved active governance participants" }, "created_at": { "type": "string", @@ -2414,14 +2449,14 @@ "additionalProperties": { "type": "integer" }, - "description": "Keyword \u2192 vote count map for include keywords" + "description": "Keyword → vote count map for include keywords" }, "exclude_keyword_votes": { "type": "object", "additionalProperties": { "type": "integer" }, - "description": "Keyword \u2192 vote count map for exclude keywords" + "description": "Keyword → vote count map for exclude keywords" }, "total_voters": { "type": "integer", @@ -2480,7 +2515,7 @@ "tags": [ "Governance" ], - "description": "Returns the authenticated user's current research consent status. Research consent is separate from TOS \u2014 declining does not restrict governance access.", + "description": "Returns the authenticated user's current research consent status. Research consent is separate from TOS — declining does not restrict governance access.", "security": [ { "cookieAuth": [] @@ -2685,6 +2720,151 @@ } } }, + "/api/governance/waitlist": { + "post": { + "summary": "Join the voting waitlist", + "tags": [ + "Governance" + ], + "description": "Request voting access during the pilot by submitting a Bluesky handle and optional note. The response does not reveal whether the handle was already on the list or approved.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "handle": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "note": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "handle" + ], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "429": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "TooManyRequests" + }, + "message": { + "type": "string" + }, + "retryAfterSeconds": { + "type": "integer", + "description": "Seconds to wait before retrying" + }, + "correlationId": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, "/api/transparency/post/{uri}": { "get": { "summary": "Explain post ranking", @@ -3498,6 +3678,294 @@ } } }, + "/api/demo/sessions": { + "post": { + "tags": [ + "Demo" + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}/votes": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}/agents/run": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}/epochs/advance": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}/feed": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}/receipts": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions": { + "post": { + "tags": [ + "Demo" + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/votes": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/agents/run": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/epochs/advance": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/feed": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/receipts": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, "/api/legal/tos": { "get": { "summary": "Terms of Service", @@ -3841,6 +4309,10 @@ "name": "Transparency", "description": "Score explanations, feed stats, and audit logs" }, + { + "name": "Demo", + "description": "Public, isolated shadow-governance sessions for the reviewer walkthrough" + }, { "name": "Admin", "description": "Admin-only endpoints (requires BOT_ADMIN_DIDS)" diff --git a/docs/openapi.json b/docs/openapi.json index 4d606c33..a29392bd 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "Community Feed API", - "description": "Community-governed Bluesky feed generator where subscribers democratically vote on ranking algorithm weights. Built on AT Protocol.\n\n## Authentication\n- **Governance endpoints** require a session cookie or bearer token from `POST /api/governance/auth/login`.\n- **Admin endpoints** additionally require the caller's DID to be in the `BOT_ADMIN_DIDS` allowlist.\n- **Feed endpoints** are public (called by the Bluesky app). Auth is optional for subscriber tracking.\n- **Transparency endpoints** are public and unauthenticated.", + "description": "Corgi Commons is a Bluesky feed with inspectable, community-shaped ranking. Approved pilot participants can vote on ranking signals, topic priorities, and content rules; reviewed policy changes are applied before rescoring. Built on AT Protocol.\n\n## Authentication\n- **Governance endpoints** require a session cookie or bearer token from `POST /api/governance/auth/login`.\n- **Admin endpoints** additionally require the caller's DID to be in the `BOT_ADMIN_DIDS` allowlist.\n- **Feed endpoints** are public (called by the Bluesky app). Viewing the feed and using the shadow demo do not require governance access.\n- **Transparency endpoints** are public and unauthenticated.", "version": "1.2.0", "contact": { "name": "Community Feed", @@ -525,6 +525,36 @@ } } }, + "403": { + "description": "Valid credentials, but the account is not approved for the pilot.", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Valid credentials, but the account is not approved for the pilot.", + "properties": { + "error": { + "type": "string", + "example": "NotApproved" + }, + "message": { + "type": "string" + }, + "waitlist": { + "type": "boolean", + "example": true, + "description": "Discriminator: the client should offer the waitlist." + } + }, + "required": [ + "error", + "message", + "waitlist" + ] + } + } + } + }, "429": { "description": "Default Response", "content": { @@ -775,7 +805,7 @@ "tags": [ "Governance" ], - "description": "Cast or update your vote for the current epoch. You can vote on algorithm weights, content keywords, topic weights, or any combination. Weights must sum to 1.0. Requires authentication and active subscription.", + "description": "Cast or update your vote for the current epoch. You can vote on algorithm weights, content keywords, topic weights, or any combination. Weights must sum to 1.0. Requires authentication, an active feed subscription, and approved pilot participation when allowlisting is enabled.", "requestBody": { "required": true, "content": { @@ -783,31 +813,31 @@ "schema": { "type": "object", "properties": { - "recency": { + "recency_weight": { "type": "number", "minimum": 0, "maximum": 1, "description": "Weight for recency component" }, - "engagement": { + "engagement_weight": { "type": "number", "minimum": 0, "maximum": 1, "description": "Weight for engagement component" }, - "bridging": { + "bridging_weight": { "type": "number", "minimum": 0, "maximum": 1, "description": "Weight for bridging component" }, - "source_diversity": { + "source_diversity_weight": { "type": "number", "minimum": 0, "maximum": 1, "description": "Weight for source diversity component" }, - "relevance": { + "relevance_weight": { "type": "number", "minimum": 0, "maximum": 1, @@ -1835,6 +1865,11 @@ "format": "date-time", "nullable": true }, + "results_approved_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, "description": { "type": "string", "nullable": true @@ -1951,7 +1986,7 @@ }, "subscriber_count": { "type": "integer", - "description": "Active subscribers (potential voters)" + "description": "Approved active governance participants" }, "created_at": { "type": "string", @@ -2281,6 +2316,137 @@ } } }, + "/api/governance/epochs/transition": { + "post": { + "summary": "Direct transition disabled", + "tags": [ + "Admin" + ], + "description": "Direct epoch transitions are disabled. Use the reviewed voting lifecycle to start voting, close voting, inspect the proposed policy, and approve results.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "403": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, "/api/governance/polis": { "get": { "summary": "Polis conversation info", @@ -2685,24 +2851,38 @@ } } }, - "/api/transparency/post/{uri}": { - "get": { - "summary": "Explain post ranking", + "/api/governance/waitlist": { + "post": { + "summary": "Join the voting waitlist", "tags": [ - "Transparency" + "Governance" ], - "description": "Returns a full breakdown of why a post is ranked where it is: all 5 component scores (raw, weight, weighted), current rank, and a counterfactual showing what rank it would have with pure engagement sorting.", - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "uri", - "required": true, - "description": "AT-URI of the post (URL-encoded)" + "description": "Request voting access during the pilot by submitting a Bluesky handle and optional note. The response does not reveal whether the handle was already on the list or approved.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "handle": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "note": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "handle" + ], + "additionalProperties": false + } + } } - ], + }, "responses": { "200": { "description": "Default Response", @@ -2711,23 +2891,154 @@ "schema": { "type": "object", "properties": { - "post_uri": { - "type": "string" + "success": { + "type": "boolean", + "example": true }, - "epoch_id": { - "type": "integer" + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" }, - "epoch_description": { + "message": { "type": "string", - "nullable": true + "description": "Human-readable error message" }, - "total_score": { - "type": "number" + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "429": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "TooManyRequests" }, - "rank": { - "type": "integer" + "message": { + "type": "string" }, - "components": { + "retryAfterSeconds": { + "type": "integer", + "description": "Seconds to wait before retrying" + }, + "correlationId": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/transparency/post/{uri}": { + "get": { + "summary": "Explain post ranking", + "tags": [ + "Transparency" + ], + "description": "Returns a full breakdown of why a post is ranked where it is: all 5 component scores (raw, weight, weighted), current rank, and a counterfactual showing what rank it would have with pure engagement sorting.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "uri", + "required": true, + "description": "AT-URI of the post (URL-encoded)" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "post_uri": { + "type": "string" + }, + "epoch_id": { + "type": "integer" + }, + "epoch_description": { + "type": "string", + "nullable": true + }, + "total_score": { + "type": "number" + }, + "rank": { + "type": "integer" + }, + "components": { "type": "object", "description": "All 5 scoring components with raw, weight, and weighted values", "additionalProperties": { @@ -2905,7 +3216,7 @@ "tags": [ "Transparency" ], - "description": "Returns aggregate statistics for the current feed including epoch weights, scoring metrics (posts scored, unique authors, avg/median bridging), governance info, and optional Gini coefficient.", + "description": "Returns bounded aggregate statistics for the current feed including epoch weights, materialized feed-snapshot metrics, governance info, provenance, and optional Gini coefficient.", "responses": { "200": { "description": "Default Response", @@ -2953,10 +3264,12 @@ "type": "object", "properties": { "total_posts_scored": { - "type": "integer" + "type": "integer", + "description": "Rows in the current materialized feed snapshot." }, "unique_authors": { - "type": "integer" + "type": "integer", + "description": "Unique authors in the materialized feed snapshot." }, "avg_bridging_score": { "type": "number" @@ -2999,12 +3312,46 @@ "nullable": true } } + }, + "stats_status": { + "type": "object", + "description": "Provenance for the bounded stats response.", + "properties": { + "source": { + "type": "string", + "enum": [ + "scoring_run", + "fallback" + ] + }, + "degraded": { + "type": "boolean" + }, + "computed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "run_id": { + "type": "string", + "nullable": true + }, + "message": { + "type": "string", + "nullable": true + } + }, + "required": [ + "source", + "degraded" + ] } }, "required": [ "epoch", "feed_stats", - "governance" + "governance", + "stats_status" ] } } @@ -3462,172 +3809,287 @@ } } }, - "/api/legal/tos": { - "get": { - "summary": "Terms of Service", + "/api/demo/sessions": { + "post": { "tags": [ - "Legal" + "Demo" ], - "description": "Returns the current Terms of Service document in Markdown format with version info.", "responses": { "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "Full document content in Markdown" - }, - "document": { - "type": "string", - "enum": [ - "tos", - "privacy" - ] - }, - "version": { - "type": "string" - }, - "lastUpdated": { - "type": "string", - "format": "date" - } - }, - "required": [ - "content", - "document", - "version", - "lastUpdated" - ] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string", - "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" - }, - "message": { - "type": "string", - "description": "Human-readable error message" - }, - "correlationId": { - "type": "string", - "description": "Request correlation ID for debugging" - } - }, - "required": [ - "error", - "message" - ] - } - } - } + "description": "Default Response" } } } }, - "/api/legal/privacy": { + "/api/demo/sessions/{sessionId}": { "get": { - "summary": "Privacy Policy", "tags": [ - "Legal" + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } ], - "description": "Returns the current Privacy Policy document in Markdown format with version info.", "responses": { "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "Full document content in Markdown" - }, - "document": { - "type": "string", - "enum": [ - "tos", - "privacy" - ] - }, - "version": { - "type": "string" - }, - "lastUpdated": { - "type": "string", - "format": "date" - } - }, - "required": [ - "content", - "document", - "version", - "lastUpdated" - ] - } - } - } - }, - "404": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string", - "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" - }, - "message": { - "type": "string", - "description": "Human-readable error message" - }, - "correlationId": { - "type": "string", - "description": "Request correlation ID for debugging" - } - }, - "required": [ - "error", - "message" - ] - } - } - } + "description": "Default Response" } } } }, - "/mcp": { + "/api/demo/sessions/{sessionId}/votes": { "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], "responses": { "200": { "description": "Default Response" } } - }, + } + }, + "/api/demo/sessions/{sessionId}/agents/run": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}/epochs/advance": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}/feed": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/sessions/{sessionId}/receipts": { "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], "responses": { "200": { "description": "Default Response" } } - }, - "delete": { + } + }, + "/api/demo/v4/sessions": { + "post": { + "tags": [ + "Demo" + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/votes": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/agents/run": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/epochs/advance": { + "post": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/feed": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/demo/v4/sessions/{sessionId}/receipts": { + "get": { + "tags": [ + "Demo" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "sessionId", + "required": true + } + ], "responses": { "200": { "description": "Default Response" @@ -3635,13 +4097,8424 @@ } } }, - "/health": { - "get": { - "summary": "Public health check", + "/api/debug/feed-health": { + "get": { + "summary": "Feed health debug", + "tags": [ + "Admin" + ], + "description": "Comprehensive feed health check: current epoch, active weights, vote count, subscriber count, last scoring run, Redis feed size, and sample top-3 post scores with full decomposition.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "current_epoch": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "active_weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "source_diversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "votes_this_epoch": { + "type": "integer" + }, + "subscriber_count": { + "type": "integer" + }, + "last_scoring_run": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "feed_size": { + "type": "integer" + }, + "sample_post_scores": { + "type": "array", + "items": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "total_score": { + "type": "number" + }, + "scores": { + "type": "object", + "additionalProperties": true + }, + "weights_used": { + "type": "object", + "additionalProperties": true + }, + "scored_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "weights_source": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/debug/scoring-weights": { + "get": { + "summary": "Current scoring weights", + "tags": [ + "Admin" + ], + "description": "Returns the current active governance epoch weights used by the scoring pipeline.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "epoch_id": { + "type": "integer" + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "source_diversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "source": { + "type": "string" + }, + "last_updated": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "epoch_id", + "weights", + "source", + "last_updated" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/debug/content-rules": { + "get": { + "summary": "Active content rules", + "tags": [ + "Admin" + ], + "description": "Returns the currently active keyword include/exclude content rules from cache or DB.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "hasActiveRules": { + "type": "boolean" + } + }, + "required": [ + "rules", + "hasActiveRules" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/debug/test-content-filter": { + "post": { + "summary": "Test content filter", + "tags": [ + "Admin" + ], + "description": "Tests content filtering with provided rules against sample posts or a single text string. Returns which posts pass or are filtered and why.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "additionalProperties": false + }, + "posts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "text": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "uri", + "text" + ], + "additionalProperties": false + } + }, + "text": { + "type": "string" + } + }, + "required": [ + "rules" + ], + "additionalProperties": false + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Result varies by input mode (single text or batch posts)", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Result varies by input mode (single text or batch posts)", + "properties": { + "text": { + "type": "string" + }, + "rules": { + "type": "object", + "additionalProperties": true + }, + "result": { + "type": "object", + "additionalProperties": true + }, + "input_count": { + "type": "integer" + }, + "passed_count": { + "type": "integer" + }, + "filtered_count": { + "type": "integer" + }, + "passed": { + "type": "array" + }, + "filtered": { + "type": "array" + } + } + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/legal/tos": { + "get": { + "summary": "Terms of Service", + "tags": [ + "Legal" + ], + "description": "Returns the current Terms of Service document in Markdown format with version info.", + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Full document content in Markdown" + }, + "document": { + "type": "string", + "enum": [ + "tos", + "privacy" + ] + }, + "version": { + "type": "string" + }, + "lastUpdated": { + "type": "string", + "format": "date" + } + }, + "required": [ + "content", + "document", + "version", + "lastUpdated" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/legal/privacy": { + "get": { + "summary": "Privacy Policy", + "tags": [ + "Legal" + ], + "description": "Returns the current Privacy Policy document in Markdown format with version info.", + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Full document content in Markdown" + }, + "document": { + "type": "string", + "enum": [ + "tos", + "privacy" + ] + }, + "version": { + "type": "string" + }, + "lastUpdated": { + "type": "string", + "format": "date" + } + }, + "required": [ + "content", + "document", + "version", + "lastUpdated" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/mcp": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "delete": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/health": { + "get": { + "summary": "Public health check", + "tags": [ + "Health" + ], + "description": "Returns a redacted health status (ok or degraded). Does not expose component details.", + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "degraded" + ], + "description": "Overall system health" + } + }, + "required": [ + "status" + ] + } + } + } + } + } + } + }, + "/health/live": { + "get": { + "summary": "Liveness probe", + "tags": [ + "Health" + ], + "description": "Returns 200 if the process is running. Used by Kubernetes liveness probes.", + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "live" + ], + "description": "Process is running" + } + }, + "required": [ + "status" + ] + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "not live" + ] + } + }, + "required": [ + "status" + ] + } + } + } + } + } + } + }, + "/health/ready": { + "get": { + "summary": "Readiness probe", + "tags": [ + "Health" + ], + "description": "Returns 200 if database and Redis are healthy. Used by Kubernetes readiness probes.", + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ready" + ], + "description": "All critical dependencies healthy" + } + }, + "required": [ + "status" + ] + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "not ready" + ] + } + }, + "required": [ + "status" + ] + } + } + } + } + } + } + }, + "/api/admin/ping": { + "get": { + "summary": "Ping", + "tags": [ + "Admin" + ], + "description": "Simple admin test endpoint. Returns timestamp.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pong": { + "type": "boolean" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "pong", + "timestamp" + ] + } + } + } + } + } + } + }, + "/api/admin/status": { + "get": { + "summary": "System status", + "tags": [ + "Admin" + ], + "description": "Returns a full system overview for the admin dashboard: epoch state, feed stats, scoring info, content rules, subscriber count, and approved pilot participant count.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "isAdmin": { + "type": "boolean" + }, + "feedPrivateMode": { + "type": "boolean" + }, + "loginAllowlistEnabled": { + "type": "boolean" + }, + "system": { + "type": "object", + "properties": { + "currentEpoch": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "phase": { + "type": "string" + }, + "votingOpen": { + "type": "boolean" + }, + "votingEndsAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "autoTransition": { + "type": "boolean" + }, + "voteCount": { + "type": "integer" + }, + "weights": { + "type": "object", + "additionalProperties": true + }, + "contentRules": { + "type": "object", + "additionalProperties": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "feed": { + "type": "object", + "properties": { + "totalPosts": { + "type": "integer" + }, + "postsLast24h": { + "type": "integer" + }, + "scoredPosts": { + "type": "integer" + }, + "lastScoringRun": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "lastScoringDuration": { + "type": "number", + "nullable": true, + "description": "Duration in seconds" + }, + "subscriberCount": { + "type": "integer" + }, + "approvedParticipantCount": { + "type": "integer", + "description": "Approved active feed subscribers currently eligible for pilot governance" + } + }, + "required": [ + "totalPosts", + "postsLast24h", + "scoredPosts", + "subscriberCount", + "approvedParticipantCount" + ] + }, + "contentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "required": [ + "isAdmin", + "system" + ] + } + } + } + } + } + } + }, + "/api/admin/epochs": { + "get": { + "summary": "List epochs", + "tags": [ + "Admin" + ], + "description": "Lists recent governance epochs with their weights, vote counts, and status.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/admin/epochs/current": { + "patch": { + "summary": "Update current epoch", + "tags": [ + "Admin" + ], + "description": "Updates the active epoch voting window or auto-transition settings.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/admin/epochs/transition": { + "post": { + "summary": "Direct transition disabled", + "tags": [ + "Admin" + ], + "description": "Direct epoch transitions are disabled. Use the voting lifecycle: start voting, close voting, review results, then approve the complete policy.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/admin/announcements": { + "get": { + "summary": "List announcements", + "tags": [ + "Admin" + ], + "description": "Lists the most recent bot announcements posted to the feed.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "post": { + "summary": "Post announcement", + "tags": [ + "Admin" + ], + "description": "Posts a custom announcement to Bluesky via the bot (rate-limited).", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/admin/feed-health": { + "get": { + "summary": "Feed health", + "tags": [ + "Admin" + ], + "description": "Returns detailed feed ingestion and scoring health statistics.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/admin/jetstream/reconnect": { + "post": { + "summary": "Reconnect Jetstream", + "tags": [ + "Admin" + ], + "description": "Triggers a manual reconnect cycle for Jetstream ingestion.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/admin/feed/rescore": { + "post": { + "summary": "Rescore feed", + "tags": [ + "Admin" + ], + "description": "Manually triggers the scoring pipeline to rescore the feed.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/admin/health": { + "get": { + "summary": "Deep health check", + "tags": [ + "Admin" + ], + "description": "Returns detailed health status for all system components (database, Redis, Jetstream, scoring). Requires admin access.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "healthy", + "degraded", + "unhealthy" + ], + "description": "Overall system health" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of the health check" + }, + "components": { + "type": "object", + "properties": { + "database": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy" + ] + }, + "latency_ms": { + "type": "number", + "description": "Check latency in milliseconds" + }, + "error": { + "type": "string", + "description": "Error message if unhealthy" + } + }, + "required": [ + "status" + ] + }, + "redis": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy" + ] + }, + "latency_ms": { + "type": "number", + "description": "Check latency in milliseconds" + }, + "error": { + "type": "string", + "description": "Error message if unhealthy" + } + }, + "required": [ + "status" + ] + }, + "jetstream": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy" + ] + }, + "connected": { + "type": "boolean", + "description": "Whether the WebSocket is connected" + }, + "last_event_age_ms": { + "type": "number", + "description": "Age of the last received event in ms" + }, + "error": { + "type": "string" + } + }, + "required": [ + "status", + "connected" + ] + }, + "scoring": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "healthy", + "unhealthy" + ] + }, + "is_running": { + "type": "boolean", + "description": "Whether a scoring run is in progress" + }, + "last_run_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of last scoring run" + }, + "error": { + "type": "string" + } + }, + "required": [ + "status", + "is_running" + ] + } + } + } + }, + "required": [ + "status", + "timestamp", + "components" + ] + } + } + } + } + } + } + }, + "/api/admin/trigger-cleanup": { + "post": { + "summary": "Trigger retention cleanup", + "tags": [ + "Admin" + ], + "description": "Manually triggers the retention cleanup job: hard-deletes unscored posts past retention, orphaned likes/reposts/post_engagement, stale likes/reposts, and old follows. Requires admin access.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "False if a cleanup run was already in progress" + }, + "result": { + "type": "object", + "nullable": true, + "properties": { + "postsDeleted": { + "type": "integer" + }, + "orphanedLikesDeleted": { + "type": "integer" + }, + "orphanedRepostsDeleted": { + "type": "integer" + }, + "orphanedEngagementDeleted": { + "type": "integer" + }, + "staleLikesDeleted": { + "type": "integer" + }, + "staleRepostsDeleted": { + "type": "integer" + }, + "oldFollowsDeleted": { + "type": "integer" + }, + "vacuumRan": { + "type": "boolean" + }, + "durationMs": { + "type": "integer" + } + } + } + }, + "required": [ + "success", + "result" + ] + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "403": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/audit-log": { + "get": { + "summary": "Admin audit log", + "tags": [ + "Admin" + ], + "description": "View recent admin/system governance actions with optional filters by action type or actor DID. Unlike the transparency audit log, this returns unredacted actor DIDs.", + "parameters": [ + { + "schema": { + "type": "number", + "minimum": 1, + "maximum": 200, + "default": 50 + }, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "action", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "actor", + "required": false + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "action": { + "type": "string" + }, + "actor": { + "type": "string", + "nullable": true + }, + "epochId": { + "type": "integer", + "nullable": true + }, + "details": { + "type": "object", + "additionalProperties": true + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + } + }, + "total": { + "type": "integer" + } + }, + "required": [ + "entries", + "total" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/audit/weight-impact": { + "get": { + "summary": "Weight impact analysis", + "tags": [ + "Admin" + ], + "description": "Performs sensitivity analysis on current governance weights. Shows how the top-N posts are ranked, which scoring component dominates each, and how ±10% weight shifts would affect rankings. Uses live feed from Redis + score decomposition from PostgreSQL.", + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 20 + }, + "in": "query", + "name": "limit", + "required": false + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "currentEpochId": { + "type": "integer" + }, + "currentWeights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "topPosts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "textPreview": { + "type": "string", + "nullable": true + }, + "rank": { + "type": "integer" + }, + "totalScore": { + "type": "number" + }, + "components": { + "type": "object", + "properties": { + "recency": { + "type": "object", + "properties": { + "raw": { + "type": "number" + }, + "weighted": { + "type": "number" + } + } + }, + "engagement": { + "type": "object", + "properties": { + "raw": { + "type": "number" + }, + "weighted": { + "type": "number" + } + } + }, + "bridging": { + "type": "object", + "properties": { + "raw": { + "type": "number" + }, + "weighted": { + "type": "number" + } + } + }, + "sourceDiversity": { + "type": "object", + "properties": { + "raw": { + "type": "number" + }, + "weighted": { + "type": "number" + } + } + }, + "relevance": { + "type": "object", + "properties": { + "raw": { + "type": "number" + }, + "weighted": { + "type": "number" + } + } + } + } + }, + "dominantFactor": { + "type": "string" + }, + "wouldRankWithEqualWeights": { + "type": "integer" + } + } + } + }, + "weightSensitivity": { + "type": "object", + "description": "Per-component sensitivity (±10% shift impact)", + "additionalProperties": { + "type": "object", + "properties": { + "postsAffected": { + "type": "integer" + }, + "avgRankChange": { + "type": "number" + } + } + } + }, + "analyzedPosts": { + "type": "integer" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "currentEpochId", + "currentWeights", + "topPosts", + "weightSensitivity", + "analyzedPosts", + "generatedAt" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/scheduler/status": { + "get": { + "summary": "Scheduler status", + "tags": [ + "Admin" + ], + "description": "Returns the epoch scheduler status and any pending auto-transitions.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scheduler": { + "type": "object", + "properties": { + "running": { + "type": "boolean" + }, + "interval_ms": { + "type": "integer" + }, + "last_check": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "pendingTransitions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "epochId": { + "type": "integer" + }, + "votingEndsAt": { + "type": "string", + "format": "date-time" + }, + "autoTransition": { + "type": "boolean" + }, + "readyForTransition": { + "type": "boolean" + } + } + } + } + }, + "required": [ + "scheduler", + "pendingTransitions" + ] + } + } + } + } + } + } + }, + "/api/admin/scheduler/check": { + "post": { + "summary": "Trigger scheduler check", + "tags": [ + "Admin" + ], + "description": "Manually triggers the epoch scheduler to check for pending auto-transitions. Logged to audit trail.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "transitioned": { + "type": "boolean", + "description": "Whether an epoch was transitioned" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "success" + ] + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "403": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/governance": { + "get": { + "summary": "Governance overview", + "tags": [ + "Admin" + ], + "description": "Returns a full governance overview: the current round, all recent rounds (up to 30), current weights, content keywords, and voting schedule.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "currentRound": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "running", + "voting", + "results" + ] + }, + "voteCount": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "closedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingEndsAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingClosedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedBy": { + "type": "string", + "nullable": true + }, + "proposedWeights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + }, + "nullable": true + }, + "proposedTopicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "nullable": true + }, + "proposedContentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "nullable": true + }, + "autoTransition": { + "type": "boolean" + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "topicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "contentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "nullable": true + }, + "rounds": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "running", + "voting", + "results" + ] + }, + "voteCount": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "closedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingEndsAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingClosedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedBy": { + "type": "string", + "nullable": true + }, + "proposedWeights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + }, + "nullable": true + }, + "proposedTopicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "nullable": true + }, + "proposedContentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "nullable": true + }, + "autoTransition": { + "type": "boolean" + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "topicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "contentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + }, + "nullable": true + }, + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "votingEndsAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "autoTransition": { + "type": "boolean" + } + } + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/governance/start-voting": { + "post": { + "summary": "Start voting period", + "tags": [ + "Admin" + ], + "description": "Opens a voting period on the current round. Optionally announces to Bluesky.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "durationHours": { + "type": "integer", + "minimum": 1, + "maximum": 168, + "default": 72 + }, + "announce": { + "type": "boolean", + "default": true + } + }, + "additionalProperties": false + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "round": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "running", + "voting", + "results" + ] + }, + "voteCount": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "closedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingEndsAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingClosedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedBy": { + "type": "string", + "nullable": true + }, + "proposedWeights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + }, + "nullable": true + }, + "proposedTopicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "nullable": true + }, + "proposedContentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "nullable": true + }, + "autoTransition": { + "type": "boolean" + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "topicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "contentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/governance/end-voting": { + "post": { + "summary": "End voting period", + "tags": [ + "Admin" + ], + "description": "Closes the current voting period, aggregates votes, and moves the round to results phase. Optionally announces to Bluesky.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "announce": { + "type": "boolean", + "default": true + } + }, + "additionalProperties": false + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "voteCount": { + "type": "integer" + }, + "proposedWeights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "proposedTopicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "proposedContentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "round": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "running", + "voting", + "results" + ] + }, + "voteCount": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "closedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingEndsAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingClosedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedBy": { + "type": "string", + "nullable": true + }, + "proposedWeights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + }, + "nullable": true + }, + "proposedTopicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "nullable": true + }, + "proposedContentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "nullable": true + }, + "autoTransition": { + "type": "boolean" + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "topicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "contentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/governance/approve-results": { + "post": { + "summary": "Approve voting results", + "tags": [ + "Admin" + ], + "description": "Approves aggregated voting results, applying the proposed signal weights, topic weights, and content rules to the active epoch before triggering a rescore.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "announce": { + "type": "boolean", + "default": true + } + }, + "additionalProperties": false + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "topicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "contentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "rescoreTriggered": { + "type": "boolean" + }, + "round": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "running", + "voting", + "results" + ] + }, + "voteCount": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "closedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingEndsAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingClosedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedBy": { + "type": "string", + "nullable": true + }, + "proposedWeights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + }, + "nullable": true + }, + "proposedTopicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "nullable": true + }, + "proposedContentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "nullable": true + }, + "autoTransition": { + "type": "boolean" + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "topicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "contentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/governance/reject-results": { + "post": { + "summary": "Reject voting results", + "tags": [ + "Admin" + ], + "description": "Rejects the proposed voting results and returns the round to running phase. Weights remain unchanged.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "round": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "running", + "voting", + "results" + ] + }, + "voteCount": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "closedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingEndsAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingClosedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedBy": { + "type": "string", + "nullable": true + }, + "proposedWeights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + }, + "nullable": true + }, + "proposedTopicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "nullable": true + }, + "proposedContentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "nullable": true + }, + "autoTransition": { + "type": "boolean" + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "topicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "contentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "required": [ + "success" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/governance/schedule-vote": { + "post": { + "summary": "Schedule a future vote", + "tags": [ + "Admin" + ], + "description": "Schedules a voting period to start automatically at a future time. Announces the schedule to Bluesky.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "startsAt": { + "type": "string", + "format": "date-time" + }, + "durationHours": { + "type": "integer", + "minimum": 1, + "maximum": 168, + "default": 72 + } + }, + "required": [ + "startsAt" + ], + "additionalProperties": false + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "scheduledVote": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "startsAt": { + "type": "string", + "format": "date-time" + }, + "durationHours": { + "type": "integer" + }, + "announced": { + "type": "boolean" + }, + "createdBy": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/governance/schedule": { + "get": { + "summary": "List scheduled votes", + "tags": [ + "Admin" + ], + "description": "Returns upcoming and recently past scheduled voting periods.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scheduledVotes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "startsAt": { + "type": "string", + "format": "date-time" + }, + "durationHours": { + "type": "integer" + }, + "announced": { + "type": "boolean" + }, + "createdBy": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + } + } + } + }, + "/api/admin/governance/content-rules": { + "patch": { + "summary": "Override content rules", + "tags": [ + "Admin" + ], + "description": "Admin override of include/exclude keyword lists on the current epoch. Triggers a rescore.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "rules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "rescoreTriggered": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/governance/content-rules/keyword": { + "post": { + "summary": "Add a keyword", + "tags": [ + "Admin" + ], + "description": "Adds a single keyword to either the include or exclude list. Triggers a rescore.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "include", + "exclude" + ] + }, + "keyword": { + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "confirm": { + "type": "boolean" + } + }, + "required": [ + "type", + "keyword" + ], + "additionalProperties": false + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "rules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "rescoreTriggered": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + }, + "delete": { + "summary": "Remove a keyword", + "tags": [ + "Admin" + ], + "description": "Removes a keyword from the include or exclude list. Removing the last include keyword requires confirm=true. Triggers a rescore.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "include", + "exclude" + ] + }, + "keyword": { + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "confirm": { + "type": "boolean" + } + }, + "required": [ + "type", + "keyword" + ], + "additionalProperties": false + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "rules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "rescoreTriggered": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/governance/weights": { + "patch": { + "summary": "Override weights", + "tags": [ + "Admin" + ], + "description": "Admin override of governance weights on the current epoch. Partial updates supported — omitted weights keep their current value. Result is normalized to sum to 1.0. Triggers a rescore.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "recency": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "engagement": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "bridging": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "sourceDiversity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "relevance": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "rescoreTriggered": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/governance/extend-voting": { + "post": { + "summary": "Extend voting period", + "tags": [ + "Admin" + ], + "description": "Extends the current voting period by the specified number of hours. Only works while voting is open.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hours": { + "type": "integer", + "minimum": 1, + "maximum": 168 + } + }, + "required": [ + "hours" + ], + "additionalProperties": false + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "round": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "running", + "voting", + "results" + ] + }, + "voteCount": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "closedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingEndsAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingClosedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedBy": { + "type": "string", + "nullable": true + }, + "proposedWeights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + }, + "nullable": true + }, + "proposedTopicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "nullable": true + }, + "proposedContentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "nullable": true + }, + "autoTransition": { + "type": "boolean" + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "topicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "contentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/governance/apply-results": { + "post": { + "summary": "Approve pending results (legacy alias)", + "tags": [ + "Admin" + ], + "description": "Deprecated compatibility alias for approving an already-reviewed results phase. It cannot bypass voting closure or results review.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "voteCount": { + "type": "integer" + }, + "appliedWeights": { + "type": "boolean", + "description": "Whether aggregated weights were applied (false if no votes)" + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "topicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "contentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "round": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "running", + "voting", + "results" + ] + }, + "voteCount": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "closedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingEndsAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingClosedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedBy": { + "type": "string", + "nullable": true + }, + "proposedWeights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + }, + "nullable": true + }, + "proposedTopicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "nullable": true + }, + "proposedContentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "nullable": true + }, + "autoTransition": { + "type": "boolean" + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "topicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "contentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "rescoreTriggered": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/governance/rounds/{id}": { + "get": { + "summary": "Get round detail", + "tags": [ + "Admin" + ], + "description": "Returns full detail for a specific governance round including starting/ending weights, vote configurations, duration, and audit trail.", + "parameters": [ + { + "schema": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "in": "path", + "name": "id", + "required": true + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "round": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "running", + "voting", + "results" + ] + }, + "voteCount": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "closedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingEndsAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "votingClosedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resultsApprovedBy": { + "type": "string", + "nullable": true + }, + "proposedWeights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + }, + "nullable": true + }, + "proposedTopicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "nullable": true + }, + "proposedContentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "nullable": true + }, + "autoTransition": { + "type": "boolean" + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "topicWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "contentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "startingWeights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "endingWeights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + } + }, + "startingRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "endingRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "voteCount": { + "type": "integer" + }, + "weightConfigurations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "weights": { + "type": "object", + "properties": { + "recency": { + "type": "number" + }, + "engagement": { + "type": "number" + }, + "bridging": { + "type": "number" + }, + "sourceDiversity": { + "type": "number" + }, + "relevance": { + "type": "number" + } + }, + "nullable": true + } + } + } + }, + "duration": { + "type": "object", + "properties": { + "startedAt": { + "type": "string", + "format": "date-time" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "durationMs": { + "type": "integer" + } + } + }, + "auditTrail": { + "type": "array", + "items": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/governance/end-round": { + "post": { + "summary": "Direct transition disabled", + "tags": [ + "Admin" + ], + "description": "Direct round transitions are disabled. Use start voting, end voting, results review, and approval so the complete policy is applied before rescoring.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/interactions/overview": { + "get": { + "summary": "Interaction overview", + "tags": [ + "Admin" + ], + "description": "Today's live stats, yesterday's pre-aggregated stats, and a 7-day trend of total requests, unique viewers, scroll depth, and returning viewers.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "today": { + "type": "object", + "properties": { + "totalRequests": { + "type": "integer" + }, + "uniqueViewers": { + "type": "integer" + }, + "anonymousRequests": { + "type": "integer" + }, + "avgScrollDepth": { + "type": "number" + }, + "avgResponseTimeMs": { + "type": "number" + }, + "returningViewers": { + "type": "integer" + } + } + }, + "yesterday": { + "type": "object", + "nullable": true, + "properties": { + "totalRequests": { + "type": "integer" + }, + "uniqueViewers": { + "type": "integer" + }, + "anonymousRequests": { + "type": "integer" + }, + "avgScrollDepth": { + "type": "number" + }, + "returningViewers": { + "type": "integer" + } + } + }, + "trend": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "totalRequests": { + "type": "integer" + }, + "uniqueViewers": { + "type": "integer" + }, + "anonymousRequests": { + "type": "integer" + }, + "maxScrollDepth": { + "type": "integer" + }, + "avgPagesPerSession": { + "type": "number" + }, + "returningViewers": { + "type": "integer" + } + } + } + } + }, + "required": [ + "today", + "trend" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/interactions/scroll-depth": { + "get": { + "summary": "Scroll depth histogram", + "tags": [ + "Admin" + ], + "description": "Returns a histogram of scroll depth by session for the last 7 days, bucketed by page count.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "histogram": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bucket": { + "type": "string", + "description": "Depth bucket label (e.g. \"1 page\", \"2-3 pages\")" + }, + "sessionCount": { + "type": "integer" + } + } + } + } + }, + "required": [ + "histogram" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/interactions/engagement": { + "get": { + "summary": "Engagement attribution stats", + "tags": [ + "Admin" + ], + "description": "Returns overall engagement attribution stats (served vs engaged, likes, reposts) and a breakdown by feed position bucket for the last 7 days.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "overall": { + "type": "object", + "properties": { + "totalServed": { + "type": "integer" + }, + "totalEngaged": { + "type": "integer" + }, + "engagementRate": { + "type": "number" + }, + "likes": { + "type": "integer" + }, + "reposts": { + "type": "integer" + } + } + }, + "byPosition": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bucket": { + "type": "string", + "description": "Position bucket (e.g. \"Top 10\", \"11-25\")" + }, + "served": { + "type": "integer" + }, + "engaged": { + "type": "integer" + }, + "rate": { + "type": "number" + } + } + } + } + }, + "required": [ + "overall", + "byPosition" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/interactions/epoch-comparison": { + "get": { + "summary": "Epoch engagement comparison", + "tags": [ + "Admin" + ], + "description": "Returns engagement stats across all epochs for chart rendering, including feed loads, viewers, and engagement rates.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "epochs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "epochId": { + "type": "integer" + }, + "totalFeedLoads": { + "type": "integer" + }, + "uniqueViewers": { + "type": "integer" + }, + "avgScrollDepth": { + "type": "number", + "nullable": true + }, + "returningViewerPct": { + "type": "number", + "nullable": true + }, + "engagementRate": { + "type": "number", + "nullable": true + }, + "avgEngagementPosition": { + "type": "number", + "nullable": true + }, + "postsServed": { + "type": "integer" + }, + "postsWithEngagement": { + "type": "integer" + }, + "computedAt": { + "type": "string", + "format": "date-time" + }, + "epochStartedAt": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "required": [ + "epochs" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/interactions/keyword-performance": { + "get": { + "summary": "Keyword engagement performance", + "tags": [ + "Admin" + ], + "description": "Returns per-keyword engagement rates from the current active epoch, along with the current keyword rules for context.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "keywords": { + "type": "array", + "items": { + "type": "object", + "properties": { + "keyword": { + "type": "string" + }, + "served": { + "type": "integer" + }, + "engaged": { + "type": "integer" + }, + "rate": { + "type": "number" + } + } + } + }, + "currentRules": { + "type": "object", + "properties": { + "includeKeywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeKeywords": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "required": [ + "keywords", + "currentRules" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/interactions/feed-signals": { + "get": { + "summary": "Feed signal analytics", + "tags": [ + "Admin" + ], + "description": "Analytics from the sendInteractions API (requestMore, requestLess). Shows totals by type (today, yesterday, 7-day), per-epoch breakdown, and top posts by signal ratio.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "byType": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "today": { + "type": "integer" + }, + "yesterday": { + "type": "integer" + }, + "last7Days": { + "type": "integer" + } + } + } + }, + "byEpoch": { + "type": "array", + "items": { + "type": "object", + "properties": { + "epochId": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "uniqueUsers": { + "type": "integer" + } + } + } + }, + "topPosts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "postUri": { + "type": "string" + }, + "requestMore": { + "type": "integer" + }, + "requestLess": { + "type": "integer" + }, + "totalSignals": { + "type": "integer" + } + } + } + } + }, + "required": [ + "byType", + "byEpoch", + "topPosts" + ] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/participants": { + "get": { + "summary": "List participants", + "tags": [ + "Admin" + ], + "description": "Returns all active (non-removed) approved participants for private feed mode.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "participants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "did": { + "type": "string" + }, + "handle": { + "type": "string", + "nullable": true + }, + "added_by": { + "type": "string" + }, + "notes": { + "type": "string", + "nullable": true + }, + "added_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "total": { + "type": "integer" + } + }, + "required": [ + "participants", + "total" + ] + } + } + } + } + } + }, + "post": { + "summary": "Add participant", + "tags": [ + "Admin" + ], + "description": "Adds an approved participant by DID or Bluesky handle. Handles are resolved to DIDs via AT Protocol.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "did": { + "type": "string", + "description": "DID of participant (provide did or handle)" + }, + "handle": { + "type": "string", + "description": "Bluesky handle (resolved to DID if no did provided)" + }, + "notes": { + "type": "string", + "maxLength": 500 + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "201": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "participant": { + "type": "object", + "properties": { + "did": { + "type": "string" + }, + "handle": { + "type": "string", + "nullable": true + }, + "notes": { + "type": "string", + "nullable": true + } + } + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/participants/{did}": { + "delete": { + "summary": "Remove participant", + "tags": [ + "Admin" + ], + "description": "Soft-removes a participant by setting removed_at. Does not delete the record.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "did", + "required": true, + "description": "DID of the participant to remove" + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/waitlist": { + "get": { + "summary": "List waitlist requests", + "tags": [ + "Admin" + ], + "description": "Returns voting-pilot waitlist requests filtered by status (default pending), oldest first.", + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "pending", + "approved", + "rejected", + "all" + ], + "default": "pending" + }, + "in": "query", + "name": "status", + "required": false + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "requests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "handle": { + "type": "string" + }, + "did": { + "type": "string", + "nullable": true + }, + "note": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "pending", + "approved", + "rejected" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "decided_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "decided_by": { + "type": "string", + "nullable": true + } + } + } + }, + "total": { + "type": "integer" + } + }, + "required": [ + "requests", + "total" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/waitlist/{id}/approve": { + "post": { + "summary": "Approve a waitlist request", + "tags": [ + "Admin" + ], + "description": "Resolves the stored handle to a DID, adds it to approved_participants (re-activating a previously removed row if needed), and marks the request approved. Fails with 400 if the handle cannot be resolved — the row stays pending and the account can be added by DID via the participants API.", + "parameters": [ + { + "schema": { + "type": "integer" + }, + "in": "path", + "name": "id", + "required": true + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "did": { + "type": "string" + }, + "handle": { + "type": "string" + } + }, + "required": [ + "success", + "did", + "handle" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/waitlist/{id}/reject": { + "post": { + "summary": "Reject a waitlist request", + "tags": [ + "Admin" + ], + "description": "Marks a pending waitlist request as rejected. The handle can still be approved later via the participants API.", + "parameters": [ + { + "schema": { + "type": "integer" + }, + "in": "path", + "name": "id", + "required": true + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/export/votes": { + "get": { + "summary": "Export anonymized votes", + "tags": [ + "Export" + ], + "description": "Returns anonymized vote records for a given epoch. Only includes votes from subscribers with research consent. Supports JSON (default) or CSV format.", + "parameters": [ + { + "schema": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "in": "query", + "name": "epoch_id", + "required": true + }, + { + "schema": { + "type": "string", + "enum": [ + "json", + "csv" + ], + "default": "json" + }, + "in": "query", + "name": "format", + "required": false + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "JSON response (format=json). CSV format returns text/csv file download.", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "JSON response (format=json). CSV format returns text/csv file download.", + "properties": { + "epoch_id": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "votes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "anon_voter_id": { + "type": "string" + }, + "epoch_id": { + "type": "integer" + }, + "recency_weight": { + "type": "number", + "nullable": true + }, + "engagement_weight": { + "type": "number", + "nullable": true + }, + "bridging_weight": { + "type": "number", + "nullable": true + }, + "source_diversity_weight": { + "type": "number", + "nullable": true + }, + "relevance_weight": { + "type": "number", + "nullable": true + }, + "include_keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "exclude_keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "topic_weight_votes": { + "type": "object", + "additionalProperties": true, + "nullable": true + }, + "voted_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "required": [ + "epoch_id", + "total", + "votes" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/export/scores": { + "get": { + "summary": "Export score decomposition", + "tags": [ + "Export" + ], + "description": "Returns full score decomposition (raw, weight, weighted per component) for an epoch. Paginated. Supports JSON (default) or CSV format. Golden Rule: all components stored.", + "parameters": [ + { + "schema": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "in": "query", + "name": "epoch_id", + "required": true + }, + { + "schema": { + "type": "string", + "enum": [ + "json", + "csv" + ], + "default": "json" + }, + "in": "query", + "name": "format", + "required": false + }, + { + "schema": { + "type": "number", + "minimum": 1, + "maximum": 10000, + "default": 1000 + }, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "type": "number", + "minimum": 0, + "default": 0 + }, + "in": "query", + "name": "offset", + "required": false + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "JSON response (format=json). CSV format returns text/csv file download.", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "JSON response (format=json). CSV format returns text/csv file download.", + "properties": { + "epoch_id": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "scores": { + "type": "array", + "items": { + "type": "object", + "properties": { + "post_uri": { + "type": "string" + }, + "epoch_id": { + "type": "integer" + }, + "recency_score": { + "type": "number" + }, + "engagement_score": { + "type": "number" + }, + "bridging_score": { + "type": "number" + }, + "source_diversity_score": { + "type": "number" + }, + "relevance_score": { + "type": "number" + }, + "recency_weight": { + "type": "number" + }, + "engagement_weight": { + "type": "number" + }, + "bridging_weight": { + "type": "number" + }, + "source_diversity_weight": { + "type": "number" + }, + "relevance_weight": { + "type": "number" + }, + "recency_weighted": { + "type": "number" + }, + "engagement_weighted": { + "type": "number" + }, + "bridging_weighted": { + "type": "number" + }, + "source_diversity_weighted": { + "type": "number" + }, + "relevance_weighted": { + "type": "number" + }, + "total_score": { + "type": "number" + }, + "topic_vector": { + "type": "object", + "additionalProperties": true, + "nullable": true + }, + "classification_method": { + "type": "string", + "enum": [ + "keyword", + "embedding" + ] + }, + "scored_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "required": [ + "epoch_id", + "total", + "limit", + "offset", + "scores" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/export/engagement": { + "get": { + "summary": "Export engagement attributions", + "tags": [ + "Export" + ], + "description": "Returns anonymized engagement attribution data for an epoch. Only includes records from subscribers with research consent. Supports JSON (default) or CSV format.", + "parameters": [ + { + "schema": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "in": "query", + "name": "epoch_id", + "required": true + }, + { + "schema": { + "type": "string", + "enum": [ + "json", + "csv" + ], + "default": "json" + }, + "in": "query", + "name": "format", + "required": false + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "JSON response (format=json). CSV format returns text/csv file download.", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "JSON response (format=json). CSV format returns text/csv file download.", + "properties": { + "epoch_id": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "engagement": { + "type": "array", + "items": { + "type": "object", + "properties": { + "post_uri": { + "type": "string" + }, + "anon_viewer_id": { + "type": "string" + }, + "epoch_id": { + "type": "integer" + }, + "engagement_type": { + "type": "string", + "nullable": true + }, + "position_in_feed": { + "type": "integer", + "nullable": true + }, + "served_at": { + "type": "string", + "format": "date-time" + }, + "engaged_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + } + } + }, + "required": [ + "epoch_id", + "total", + "engagement" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/export/epochs": { + "get": { + "summary": "Export epoch metadata", + "tags": [ + "Export" + ], + "description": "Returns metadata for all governance epochs including weights, vote counts, content rules, and topic weights. Supports JSON (default) or CSV format.", + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "json", + "csv" + ], + "default": "json" + }, + "in": "query", + "name": "format", + "required": false + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "JSON response (format=json). CSV format returns text/csv file download.", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "JSON response (format=json). CSV format returns text/csv file download.", + "properties": { + "total": { + "type": "integer" + }, + "epochs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "phase": { + "type": "string", + "nullable": true + }, + "recency_weight": { + "type": "number" + }, + "engagement_weight": { + "type": "number" + }, + "bridging_weight": { + "type": "number" + }, + "source_diversity_weight": { + "type": "number" + }, + "relevance_weight": { + "type": "number" + }, + "vote_count": { + "type": "integer" + }, + "content_rules": { + "type": "object", + "additionalProperties": true, + "nullable": true + }, + "topic_weights": { + "type": "object", + "additionalProperties": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "voting_started_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "voting_closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + } + } + }, + "required": [ + "total", + "epochs" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/export/audit": { + "get": { + "summary": "Export audit log", + "tags": [ + "Export" + ], + "description": "Returns anonymized governance audit log entries with optional date range filtering. DIDs are scrubbed from details. Supports JSON (default) or CSV format.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "in": "query", + "name": "start_date", + "required": false + }, + { + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "in": "query", + "name": "end_date", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "json", + "csv" + ], + "default": "json" + }, + "in": "query", + "name": "format", + "required": false + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "JSON response (format=json). CSV format returns text/csv file download.", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "JSON response (format=json). CSV format returns text/csv file download.", + "properties": { + "total": { + "type": "integer" + }, + "audit": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "action": { + "type": "string" + }, + "anon_actor_id": { + "type": "string", + "nullable": true + }, + "epoch_id": { + "type": "integer", + "nullable": true + }, + "details": { + "type": "object", + "additionalProperties": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "required": [ + "total", + "audit" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/export/full-dataset": { + "get": { + "summary": "Download full dataset as ZIP", + "tags": [ + "Export" + ], + "description": "Generates and downloads a ZIP archive containing all anonymized data for an epoch: votes.csv, scores.csv, engagement.csv, epoch_metadata.json, topics/catalog.json, and topics/community-weights.json.", + "parameters": [ + { + "schema": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "in": "query", + "name": "epoch_id", + "required": true + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "ZIP archive containing CSV and JSON files for the epoch", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Invalid export query", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/topics": { + "get": { + "summary": "List all topics", + "tags": [ + "Admin" + ], + "description": "Returns all topics in the catalog with post counts and current community weights from the active epoch.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "parentSlug": { + "type": "string", + "nullable": true + }, + "terms": { + "type": "array", + "items": { + "type": "string" + } + }, + "contextTerms": { + "type": "array", + "items": { + "type": "string" + } + }, + "antiTerms": { + "type": "array", + "items": { + "type": "string" + } + }, + "isActive": { + "type": "boolean" + }, + "postCount": { + "type": "integer" + }, + "currentWeight": { + "type": "number", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + }, + "post": { + "summary": "Create topic", + "tags": [ + "Admin" + ], + "description": "Creates a new topic in the catalog with terms, context terms, and anti-terms for keyword classification.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "pattern": "^[a-z0-9-]+$", + "minLength": 2, + "maxLength": 50 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "parentSlug": { + "type": "string" + }, + "terms": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "minItems": 1, + "maxItems": 50 + }, + "contextTerms": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "maxItems": 30, + "default": [] + }, + "antiTerms": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 50 + }, + "maxItems": 10, + "default": [] + } + }, + "required": [ + "slug", + "name", + "terms" + ], + "additionalProperties": false + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "201": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "topic": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "parentSlug": { + "type": "string", + "nullable": true + }, + "terms": { + "type": "array", + "items": { + "type": "string" + } + }, + "contextTerms": { + "type": "array", + "items": { + "type": "string" + } + }, + "antiTerms": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/topics/classify": { + "post": { + "summary": "Test-classify text", + "tags": [ + "Admin" + ], + "description": "Classifies text against the current taxonomy using keyword matching. If embedding classifier is available, includes a comparison result.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 5000 + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "matchedTopics": { + "type": "array", + "items": { + "type": "string" + } + }, + "vector": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "embedding": { + "type": "object", + "nullable": true, + "additionalProperties": { + "type": "number" + } + }, + "embedding_available": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" + } + }, + "required": [ + "error", + "message" + ] + } + } + } + } + } + } + }, + "/api/admin/topics/{slug}/backfill": { + "post": { + "summary": "Backfill topic classification", "tags": [ - "Health" + "Admin" + ], + "description": "Re-classifies all existing posts for the given topic. Processes in batches of 500.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "slug", + "required": true, + "description": "Topic slug to backfill" + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } ], - "description": "Returns a redacted health status (ok or degraded). Does not expose component details.", "responses": { "200": { "description": "Default Response", @@ -3650,17 +12523,45 @@ "schema": { "type": "object", "properties": { - "status": { + "classified": { + "type": "integer", + "description": "Total posts processed" + }, + "matched": { + "type": "integer", + "description": "Posts matching this topic" + }, + "elapsed_ms": { + "type": "integer" + } + } + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { "type": "string", - "enum": [ - "ok", - "degraded" - ], - "description": "Overall system health" + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" } }, "required": [ - "status" + "error", + "message" ] } } @@ -3669,13 +12570,75 @@ } } }, - "/health/live": { - "get": { - "summary": "Liveness probe", + "/api/admin/topics/{slug}": { + "patch": { + "summary": "Update topic", "tags": [ - "Health" + "Admin" + ], + "description": "Updates a topic's fields. Only provided fields are changed. Terms are normalized to lowercase.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "terms": { + "type": "array", + "items": { + "type": "string" + } + }, + "contextTerms": { + "type": "array", + "items": { + "type": "string" + } + }, + "antiTerms": { + "type": "array", + "items": { + "type": "string" + } + }, + "isActive": { + "type": "boolean" + } + }, + "additionalProperties": false + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "slug", + "required": true, + "description": "Topic slug to update" + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } ], - "description": "Returns 200 if the process is running. Used by Kubernetes liveness probes.", "responses": { "200": { "description": "Default Response", @@ -3684,52 +12647,104 @@ "schema": { "type": "object", "properties": { - "status": { + "success": { + "type": "boolean" + }, + "topic": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { "type": "string", - "enum": [ - "live" - ], - "description": "Process is running" + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" } }, "required": [ - "status" + "error", + "message" ] } } } }, - "503": { + "404": { "description": "Default Response", "content": { "application/json": { "schema": { "type": "object", "properties": { - "status": { + "error": { "type": "string", - "enum": [ - "not live" - ] + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" } }, "required": [ - "status" + "error", + "message" ] } } } } } - } - }, - "/health/ready": { - "get": { - "summary": "Readiness probe", + }, + "delete": { + "summary": "Deactivate topic", "tags": [ - "Health" + "Admin" + ], + "description": "Soft deactivates a topic (sets is_active = false). Does not delete from DB — topic vectors referencing it stay valid.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "slug", + "required": true, + "description": "Topic slug to deactivate" + } + ], + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } ], - "description": "Returns 200 if database and Redis are healthy. Used by Kubernetes readiness probes.", "responses": { "200": { "description": "Default Response", @@ -3738,37 +12753,40 @@ "schema": { "type": "object", "properties": { - "status": { - "type": "string", - "enum": [ - "ready" - ], - "description": "All critical dependencies healthy" + "success": { + "type": "boolean" } }, "required": [ - "status" + "success" ] } } } }, - "503": { + "404": { "description": "Default Response", "content": { "application/json": { "schema": { "type": "object", "properties": { - "status": { + "error": { "type": "string", - "enum": [ - "not ready" - ] + "description": "Error code (e.g. \"ValidationError\", \"NotFound\")" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "correlationId": { + "type": "string", + "description": "Request correlation ID for debugging" } }, "required": [ - "status" + "error", + "message" ] } } @@ -3776,6 +12794,28 @@ } } } + }, + "/api/admin/vitals": { + "get": { + "summary": "System vitals", + "tags": [ + "Admin" + ], + "description": "Aggregates disk, database, Redis, ingestion, scoring, feed, cleanup, and engagement health into a single response.", + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } } }, "servers": [ @@ -3805,6 +12845,10 @@ "name": "Transparency", "description": "Score explanations, feed stats, and audit logs" }, + { + "name": "Demo", + "description": "Public, isolated shadow-governance sessions for the reviewer walkthrough" + }, { "name": "Admin", "description": "Admin-only endpoints (requires BOT_ADMIN_DIDS)" diff --git a/ops/receipts/2026-07-13/PROJ-1821/validation.md b/ops/receipts/2026-07-13/PROJ-1821/validation.md new file mode 100644 index 00000000..e4380fff --- /dev/null +++ b/ops/receipts/2026-07-13/PROJ-1821/validation.md @@ -0,0 +1,78 @@ +# PROJ-1821 validation receipt + +Date: 2026-07-13 (America/Los_Angeles) +Repository: `andrewnordstrom-eng/bluesky-community-feed` +Pull request: `#357` + +## Delivered behavior + +Corgi's public product story now uses one truthful cold-start journey across the +homepage, `/how-it-works`, `/demo`, `/start`, authenticated governance surfaces, +current documentation, and the public API description: + +- Corgi Commons is the live Bluesky feed and proof surface. +- Bluesky renders ordered posts; Corgi exposes policy and ranking receipts. +- Five global signal weights are separate from topic preferences, which affect + relevance, and content rules, which affect eligibility. +- One ballot joins other community ballots. Reviewed results are applied only + after operator approval and then trigger rescoring. +- The anonymous demo runs isolated shadow governance over a frozen comparison + corpus and cannot mutate the public feed. +- Public viewing and the shadow demo remain open; production governance is an + approved waitlist pilot. + +Birders-specific teaching content and stale automatic-application claims were +removed from primary public surfaces. Corgi annotations remain outside the +Bluesky-style post chrome. + +## Governance and safety + +- Approved lifecycle application covers signal weights, topic weights, and + adopted content rules before rescoring. +- Demo routes remain Redis-isolated, rate-limited, idempotent, and unable to + write production votes, epochs, audit logs, exports, or feed publication. +- Demo content rules are bounded to exclusion-only mechanics for this release. +- Snapshot capture fails closed: a failed quality gate cannot leave or publish a + stale approved manifest or review sheet. +- The Corgi Commons record updater preserves the existing DID, rkey, avatar, + creation timestamp, and uses compare-and-swap publication. + +## Deterministic verification + +- Full repository verification passed with 1,689 tests. +- Root/backend, SDK, CLI, legacy web, and `web-next` builds passed. +- `web-next` lint and TypeScript checks passed. +- Documentation verification passed. +- GitHub backend, frontend, docs, report-script, quality, security, secret-scan, + CodeQL, Linear-policy, and review-thread checks passed on the PR head. +- Focused review-fix verification passed 92 backend and contract tests plus 14 + demo release-blocker checks. +- The initial CodeRabbit review reported 16 actionable findings. Every finding + was verified, corrected where valid, tested, replied to, and resolved before + the final exact-head review request. +- A subsequent exact-head review reported 14 additional findings. The valid + contract, concurrency, provenance, documentation, and admin-state findings + were corrected and covered by the final verification run. + +## Browser acceptance + +The production-shaped local static export was reviewed in the in-app browser: + +- Desktop `1440x1100`, tablet `756x762`, and mobile `390x844` showed no + horizontal overflow on `/` and `/how-it-works`. +- The educational replay changed the ranked order when its policy preset + changed. +- The mobile menu closed on Escape and returned focus to its trigger. +- The hero, trust line, product stage, and Corgi Commons explanation retained a + calm visual hierarchy across desktop and mobile. + +## Release boundaries + +Production deployment, the live Bluesky record update, Corgi Commons snapshot +quality capture, exact-SHA route/API/CSP smoke tests, and the five unbriefed +human comprehension sessions are post-merge evidence. They must be recorded in +Linear and must not be claimed complete before they occur. + +If the live snapshot fails its objective quality gates, the release retains the +clearly labeled mechanics fixture. It must not be described as a reviewed live +snapshot. diff --git a/package.json b/package.json index f0193b7e..7e565c6f 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "publish-feed": "tsx scripts/publish-feed.ts", "feed:birders-scout": "tsx scripts/birders-feed-scout.ts", "demo:capture-community-gov": "tsx scripts/demo-snapshot-capture.ts --manifest /tmp/corgi-community-gov-manifest.json --report /tmp/corgi-community-gov-report.json --review-sheet /tmp/corgi-community-gov-review.html", + "demo:approve-community-gov": "tsx scripts/approve-demo-snapshot.ts", "test": "npm run sdk:build && vitest tests", "generate:component": "tsx scripts/generate-scoring-component.ts", "generate:route": "tsx scripts/generate-route.ts", diff --git a/scripts/approve-demo-snapshot.ts b/scripts/approve-demo-snapshot.ts new file mode 100644 index 00000000..752a56e9 --- /dev/null +++ b/scripts/approve-demo-snapshot.ts @@ -0,0 +1,201 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { + COMMUNITY_GOV_FEED_NAME, + DEMO_SOURCE_SNAPSHOT_LIMIT, + communityGovManifestDigest, + parseApprovedSnapshotManifest, +} from '../src/feed/demo-snapshot-source.js'; +import { + captureReportApprovalFailures, + parseSnapshotCaptureReport, + snapshotApprovalWriteFlag, +} from '../src/demo/snapshot-capture.js'; + +interface ApprovalOptions { + manifestPath: string; + reportPath: string; + outputPath: string; + reviewedAt: string; + reviewAcknowledged: boolean; + force: boolean; +} + +function parseOptions(args: readonly string[]): ApprovalOptions { + const values = new Map(); + let reviewAcknowledged = false; + let force = false; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === '--review-acknowledged') { + reviewAcknowledged = true; + continue; + } + if (argument === '--force') { + force = true; + continue; + } + if (!argument?.startsWith('--')) { + throw new Error(`Unexpected snapshot approval argument: ${String(argument)}`); + } + const value = args[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`Snapshot approval argument requires a value: ${argument}`); + } + values.set(argument.slice(2), value); + index += 1; + } + + const manifestPath = values.get('manifest'); + const reportPath = values.get('report'); + const outputPath = values.get('output'); + const reviewedAtInput = values.get('reviewed-at'); + if (!manifestPath || !reportPath || !outputPath || !reviewedAtInput || !reviewAcknowledged) { + throw new Error( + 'Expected --manifest, --report, --output, --reviewed-at, and --review-acknowledged after every eligible review-sheet row has been checked', + ); + } + if (manifestPath === outputPath) { + throw new Error('Snapshot approval refuses to overwrite the unapproved capture manifest in place'); + } + const reviewedAtDate = new Date(reviewedAtInput); + if (Number.isNaN(reviewedAtDate.getTime())) { + throw new Error(`Snapshot review timestamp is invalid: ${reviewedAtInput}`); + } + + return { + manifestPath, + reportPath, + outputPath, + reviewedAt: reviewedAtDate.toISOString(), + reviewAcknowledged, + force, + }; +} + +function asManifestRecord(input: unknown): Record { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Captured snapshot manifest must be a JSON object'); + } + return input as Record; +} + +async function main(): Promise { + const options = parseOptions(process.argv.slice(2)); + if (!options.reviewAcknowledged) { + throw new Error('Snapshot review acknowledgement is required'); + } + + const captured = asManifestRecord(JSON.parse(await readFile(options.manifestPath, 'utf8')) as unknown); + if (captured.feedName !== COMMUNITY_GOV_FEED_NAME) { + throw new Error( + `Snapshot feed name must be ${COMMUNITY_GOV_FEED_NAME}: received ${String(captured.feedName)}`, + ); + } + if (captured.reviewedAt !== null) { + throw new Error('Snapshot capture must be unapproved before review acknowledgement'); + } + if (!Array.isArray(captured.entries) || captured.entries.length !== DEMO_SOURCE_SNAPSHOT_LIMIT) { + throw new Error( + `Snapshot capture must contain exactly ${DEMO_SOURCE_SNAPSHOT_LIMIT} ordered source entries`, + ); + } + const capturedDigest = captured.snapshotDigest; + const computedCapturedDigest = communityGovManifestDigest(captured); + if (capturedDigest !== computedCapturedDigest) { + throw new Error( + `Captured snapshot digest mismatch: expected=${String(capturedDigest)} computed=${computedCapturedDigest}`, + ); + } + if (typeof capturedDigest !== 'string') { + throw new Error('Captured snapshot digest must be a string'); + } + const report = parseSnapshotCaptureReport( + JSON.parse(await readFile(options.reportPath, 'utf8')) as unknown + ); + const reportFailures = captureReportApprovalFailures(report, capturedDigest); + if (reportFailures.length > 0) { + throw new Error(`Snapshot capture report is not approval-ready: ${reportFailures.join('; ')}`); + } + if (report.productionEpochId !== captured.productionEpochId || report.sourceRunId !== captured.sourceRunId) { + throw new Error( + 'Snapshot capture report provenance does not match the captured manifest production epoch and run' + ); + } + if (report.sourceCount !== captured.entries.length) { + throw new Error( + `Snapshot capture report source count ${report.sourceCount} does not match manifest entries ${captured.entries.length}` + ); + } + if (report.capturedAt !== captured.capturedAt) { + throw new Error('Snapshot capture report timestamp does not match the captured manifest'); + } + const reviewedEntries = captured.entries.filter((entry) => { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return false; + const frozen = (entry as Record).frozen; + if (frozen === null || typeof frozen !== 'object' || Array.isArray(frozen)) return false; + return typeof (frozen as Record).reviewedCid === 'string'; + }); + if (report.eligibleCount !== reviewedEntries.length || report.displayableCount !== reviewedEntries.length) { + throw new Error( + `Snapshot capture report public counts do not match ${reviewedEntries.length} CID-bound manifest entries` + ); + } + const reviewedAuthorCounts = new Map(); + for (const entry of reviewedEntries) { + const frozen = (entry as Record).frozen as Record; + const authorDid = frozen.authorDid; + if (typeof authorDid !== 'string') { + throw new Error('CID-bound snapshot entry is missing its frozen author DID'); + } + reviewedAuthorCounts.set(authorDid, (reviewedAuthorCounts.get(authorDid) ?? 0) + 1); + } + const highestAuthorCount = Math.max(0, ...reviewedAuthorCounts.values()); + const manifestTopAuthorConcentration = reviewedEntries.length === 0 + ? 0 + : Number((highestAuthorCount / reviewedEntries.length).toFixed(3)); + if ( + report.uniqueAuthorCount !== reviewedAuthorCounts.size + || report.topAuthorConcentration !== manifestTopAuthorConcentration + ) { + throw new Error('Snapshot capture report authorship metrics do not match the CID-bound manifest entries'); + } + + const approvedWithoutDigest: Record = { + ...captured, + reviewedAt: options.reviewedAt, + }; + const approved = { + ...approvedWithoutDigest, + snapshotDigest: communityGovManifestDigest(approvedWithoutDigest), + }; + parseApprovedSnapshotManifest(approved); + + try { + await writeFile(options.outputPath, `${JSON.stringify(approved, null, 2)}\n`, { + encoding: 'utf8', + flag: snapshotApprovalWriteFlag(options.force), + }); + } catch (error) { + if ( + error !== null + && typeof error === 'object' + && 'code' in error + && error.code === 'EEXIST' + ) { + throw new Error( + `Approved snapshot output already exists: ${options.outputPath}. Review it or pass --force to replace it explicitly.` + ); + } + throw error; + } + process.stdout.write(`Approved Corgi Commons snapshot: ${options.outputPath}\n`); + process.stdout.write(`Reviewed at: ${options.reviewedAt}\n`); + process.stdout.write(`Snapshot digest: ${approved.snapshotDigest}\n`); +} + +void main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Corgi Commons snapshot approval failed: ${message}\n`); + process.exitCode = 1; +}); diff --git a/scripts/demo-snapshot-capture.ts b/scripts/demo-snapshot-capture.ts index 54f1f9e3..2eb0cc3a 100644 --- a/scripts/demo-snapshot-capture.ts +++ b/scripts/demo-snapshot-capture.ts @@ -1,12 +1,14 @@ -import { writeFile } from 'node:fs/promises'; import { db } from '../src/db/client.js'; import { redis } from '../src/db/redis.js'; import { config } from '../src/config.js'; -import { loadShadowDemoCorpus, communityGovSnapshotGateFailures } from '../src/demo/corpus.js'; +import { loadCommunityGovCaptureCorpus, communityGovSnapshotGateFailures } from '../src/demo/corpus.js'; import { canonicalizeFrozenEmbedUrl, + clearSnapshotCaptureArtifacts, createBoundedDemoFetch, scoreCompletenessRate, + writeSnapshotCaptureArtifacts, + type SnapshotCaptureReport, } from '../src/demo/snapshot-capture.js'; import { readPostScore, type PostScoreRecord } from '../src/scoring/score-reader.js'; import { FEED_URL_DEDUP_DECAY } from '../src/scoring/feed-publication.js'; @@ -32,6 +34,7 @@ const appViewFetch = createBoundedDemoFetch(APPVIEW_REQUEST_TIMEOUT_MS); async function main(): Promise { try { const paths = parsePaths(process.argv.slice(2)); + await clearSnapshotCaptureArtifacts(paths); const capturedAt = new Date(); const readLiveSnapshot = createLivePublishedFeedSnapshotReader(); const snapshot = await readLiveSnapshot(DEMO_SOURCE_SNAPSHOT_LIMIT); @@ -46,14 +49,27 @@ async function main(): Promise { } const frozenEntries = await buildFrozenEntries(snapshot, scores); const frozenSnapshot: PublishedFeedSnapshot = { ...snapshot, entries: frozenEntries }; - const corpus = await loadShadowDemoCorpus({ - communityId: 'community_gov', + const publicationPolicy = { + urlDedupEnabled: config.FEED_DEDUP_ENABLED, + minimumOriginalTextLength: config.FEED_DEDUP_MIN_TEXT, + minimumRelevance: config.FEED_MIN_RELEVANCE, + decay: [...FEED_URL_DEDUP_DECAY], + }; + const corpus = await loadCommunityGovCaptureCorpus({ now: capturedAt, fetchFn: appViewFetch, dbPool: db, readScore: readPostScore, readPublishedSnapshot: async () => frozenSnapshot, + policy: { + signalWeights: capturedPolicy.signalWeights, + topicCatalog: capturedPolicy.topicCatalog, + publicationPolicy, + }, }); + if (corpus.health.source !== 'production_feed_snapshot') { + throw new Error(`Capture corpus must remain live production data; received ${corpus.health.source}`); + } const publicItems = corpus.items.filter((item) => item.displayPost.kind === 'public_post'); const reviewedCidByUri = new Map(publicItems.flatMap((item) => item.displayPost.kind === 'public_post' ? [[item.postUri, item.displayPost.cid] as const] : [])); @@ -76,10 +92,7 @@ async function main(): Promise { baselineOrderDigest: snapshot.baselineOrderDigest, signalWeights: capturedPolicy.signalWeights, publicationPolicy: { - urlDedupEnabled: config.FEED_DEDUP_ENABLED, - minimumOriginalTextLength: config.FEED_DEDUP_MIN_TEXT, - minimumRelevance: config.FEED_MIN_RELEVANCE, - decay: FEED_URL_DEDUP_DECAY, + ...publicationPolicy, }, topicCatalog: capturedPolicy.topicCatalog, entries: approvedEntries, @@ -106,8 +119,11 @@ async function main(): Promise { const gateFailures = communityGovSnapshotGateFailures(corpus.health); if (completeness !== 1) gateFailures.push(`score decomposition completeness ${completeness} < 1`); if (!sourceLinksValid) gateFailures.push('one or more public source links failed validation'); - const report = { + const report: SnapshotCaptureReport = { schemaVersion: manifest.schemaVersion, + artifactKind: 'live_production_snapshot_capture', + corpusSource: 'production_feed_snapshot', + approvable: gateFailures.length === 0, manifestDigest: manifest.snapshotDigest, capturedAt: capturedAt.toISOString(), productionEpochId: snapshot.productionEpochId, @@ -133,13 +149,20 @@ async function main(): Promise { }, warnings: corpus.warnings, }; - await Promise.all([ - writeFile(paths.manifest, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'), - writeFile(paths.report, `${JSON.stringify(report, null, 2)}\n`, 'utf8'), - writeFile(paths.reviewSheet, reviewSheetHtml(manifest, corpus.items), 'utf8'), - ]); + await writeSnapshotCaptureArtifacts({ + paths, + report, + manifestJson: `${JSON.stringify(manifest, null, 2)}\n`, + reviewSheetHtml: reviewSheetHtml(manifest, corpus.items), + }); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); - if (report.gateFailures.length > 0) process.exitCode = 2; + if (!report.approvable) { + process.stderr.write( + `Corgi Commons snapshot is not approvable; report written to ${paths.report}. ` + + 'No manifest or review sheet was emitted.\n' + ); + process.exitCode = 2; + } } finally { await Promise.allSettled([db.end(), redis.quit()]); } @@ -309,6 +332,6 @@ function escapeHtml(value: string): string { void main().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`Community Governed Feed snapshot capture failed: ${message}\n`); + process.stderr.write(`Corgi Commons snapshot capture failed: ${message}\n`); process.exitCode = 1; }); diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index 3c79240e..4942c14b 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -12,6 +12,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; const ADMIN_TAGS = new Set(['Admin', 'Export']); const DEFAULT_OUTPUT = path.resolve(process.cwd(), 'docs', 'openapi.json'); @@ -21,6 +22,33 @@ interface OpenApiSpec { [key: string]: unknown; } +export async function runWithOpenApiCleanup( + operation: () => Promise, + cleanupOperations: readonly (() => Promise)[], + reportCleanupFailure: (error: AggregateError) => void +): Promise { + const outcome = await operation().then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }) + ); + const cleanupResults = await Promise.allSettled( + cleanupOperations.map((cleanup) => Promise.resolve().then(cleanup)) + ); + const cleanupFailures = cleanupResults + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map((result) => result.reason); + const cleanupError = cleanupFailures.length > 0 + ? new AggregateError(cleanupFailures, 'OpenAPI generator cleanup failed') + : null; + + if (!outcome.ok) { + if (cleanupError) reportCleanupFailure(cleanupError); + throw outcome.error; + } + if (cleanupError) throw cleanupError; + return outcome.value; +} + async function main(): Promise { const args = process.argv.slice(2); const publicOnly = args.includes('--public-only'); @@ -33,18 +61,16 @@ async function main(): Promise { const { createServer } = await import('../src/feed/server.js'); const app = await createServer(); - // Start listening on a random port - await app.listen({ port: 0, host: '127.0.0.1' }); - - try { + await runWithOpenApiCleanup(async () => { + // Start listening on a random port + await app.listen({ port: 0, host: '127.0.0.1' }); const response = await app.inject({ method: 'GET', url: '/api/openapi.json', }); if (response.statusCode !== 200) { - console.error(`Failed to fetch spec: HTTP ${response.statusCode}`); - process.exit(1); + throw new Error(`Failed to fetch spec: HTTP ${response.statusCode}`); } let spec: OpenApiSpec = JSON.parse(response.body); @@ -69,9 +95,13 @@ async function main(): Promise { console.log(`OpenAPI spec written to ${outputPath}`); console.log(` Routes: ${routeCount}`); console.log(` Mode: ${publicOnly ? 'public-only' : 'full'}`); - } finally { - await app.close(); - } + }, [ + () => app.close(), + () => import('../src/db/client.js').then(({ db }) => db.end()), + () => import('../src/db/redis.js').then(({ redis }) => redis.quit()), + ], (cleanupError) => { + console.error('OpenAPI generator cleanup also failed after the primary error:', cleanupError); + }); } function stripAdminRoutes(spec: OpenApiSpec): OpenApiSpec { @@ -98,7 +128,12 @@ function stripAdminRoutes(spec: OpenApiSpec): OpenApiSpec { return filtered; } -main().catch((err) => { - console.error('Fatal error:', err); - process.exit(1); -}); +const isEntrypoint = process.argv[1] !== undefined + && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isEntrypoint) { + main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); + }); +} diff --git a/scripts/publish-feed.ts b/scripts/publish-feed.ts index 9846b7d0..b7116029 100644 --- a/scripts/publish-feed.ts +++ b/scripts/publish-feed.ts @@ -61,10 +61,10 @@ async function main() { // Create the feed generator record const record = { did: serviceDid, - displayName: 'Community Governed Feed', + displayName: 'Corgi Commons', description: - 'A community-governed feed where subscribers vote on algorithm weights and topic priorities. ' + - '25 steerable topics, transparent scoring, democratic control.', + 'Corgi Commons is a Bluesky feed shaped by community votes on ranking signals, topic priorities, and content rules. ' + + 'Inspect policies and ranking receipts at feed.corgi.network.', acceptsInteractions: true, createdAt: new Date().toISOString(), }; @@ -91,7 +91,7 @@ async function main() { console.log('Next steps:'); console.log('1. Update FEEDGEN_PUBLISHER_DID in .env to:', agent.session!.did); console.log('2. The feed should now appear when searching in Bluesky'); - console.log('3. Users can find it by searching for "Community Governed Feed"'); + console.log('3. Users can find it by searching for "Corgi Commons"'); console.log(''); console.log('To unpublish later:'); console.log(` npx tsx scripts/unpublish-feed.ts`); diff --git a/scripts/update-corgi-commons-record.ts b/scripts/update-corgi-commons-record.ts new file mode 100644 index 00000000..cadf8419 --- /dev/null +++ b/scripts/update-corgi-commons-record.ts @@ -0,0 +1,155 @@ +import { Agent, AppBskyFeedGenerator, CredentialSession, XRPCError } from '@atproto/api'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const COLLECTION = 'app.bsky.feed.generator'; +const FEED_RKEY = 'community-gov'; +const DISPLAY_NAME = 'Corgi Commons'; +const DESCRIPTION = 'Corgi Commons is a Bluesky feed shaped by community votes on ranking signals, topic priorities, and content rules. Inspect policies and ranking receipts at feed.corgi.network.'; +const BSKY_SERVICE = new URL('https://bsky.social'); +const NETWORK_TIMEOUT_MS = 15_000; + +const fetchWithTimeout: typeof globalThis.fetch = async (input, init) => { + const timeoutController = new AbortController(); + const timeout = setTimeout(() => { + timeoutController.abort(new Error(`Bluesky request timed out after ${NETWORK_TIMEOUT_MS}ms`)); + }, NETWORK_TIMEOUT_MS); + const signals = init?.signal + ? [init.signal, timeoutController.signal] + : [timeoutController.signal]; + + try { + return await globalThis.fetch(input, { + ...init, + signal: AbortSignal.any(signals), + }); + } catch (error) { + if (timeoutController.signal.aborted && !init?.signal?.aborted) { + throw new Error(`Bluesky request timed out after ${NETWORK_TIMEOUT_MS}ms`, { cause: error }); + } + throw error; + } finally { + clearTimeout(timeout); + } +}; + +function requireEnvironmentValue(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`Required environment variable is missing: ${name}`); + } + return value; +} + +function errorSummary(error: unknown): string { + if (error instanceof XRPCError) { + return `${error.error} (HTTP ${error.status}): ${error.message}`; + } + if (error instanceof Error) { + return error.message; + } + return 'Unknown feed-record update failure'; +} + +async function main(): Promise { + const apply = process.argv.includes('--apply'); + const identifier = requireEnvironmentValue('BSKY_IDENTIFIER'); + const password = requireEnvironmentValue('BSKY_APP_PASSWORD'); + const session = new CredentialSession(BSKY_SERVICE, fetchWithTimeout); + const agent = new Agent(session); + + await session.login({ identifier, password }); + const publisherDid = agent.did; + if (!publisherDid) { + throw new Error('Bluesky login completed without a publisher DID'); + } + + const currentResponse = await agent.com.atproto.repo.getRecord({ + repo: publisherDid, + collection: COLLECTION, + rkey: FEED_RKEY, + }); + const expectedRecordUri = `at://${publisherDid}/${COLLECTION}/${FEED_RKEY}`; + if (currentResponse.data.uri !== expectedRecordUri) { + throw new Error( + `Refusing to update an unexpected feed record: expected=${expectedRecordUri} actual=${currentResponse.data.uri}`, + ); + } + const validation = AppBskyFeedGenerator.validateRecord(currentResponse.data.value); + if (!validation.success) { + throw new Error(`Existing ${COLLECTION}/${FEED_RKEY} record failed lexicon validation`); + } + + const currentRecord = validation.value; + const currentServiceDid = currentRecord.did; + if (!currentServiceDid.startsWith('did:')) { + throw new Error(`Existing feed record has an invalid service DID: ${currentServiceDid}`); + } + + const updatedRecord: AppBskyFeedGenerator.Record = { + ...currentRecord, + displayName: DISPLAY_NAME, + description: DESCRIPTION, + }; + + process.stdout.write(`Publisher DID: ${publisherDid}\n`); + process.stdout.write(`Record: ${COLLECTION}/${FEED_RKEY}\n`); + process.stdout.write(`Preserved service DID: ${currentServiceDid}\n`); + process.stdout.write(`Current display name: ${currentRecord.displayName}\n`); + process.stdout.write(`Target display name: ${DISPLAY_NAME}\n`); + process.stdout.write(`Current description: ${currentRecord.description ?? ''}\n`); + process.stdout.write(`Target description: ${DESCRIPTION}\n`); + + if (currentRecord.displayName === DISPLAY_NAME && currentRecord.description === DESCRIPTION) { + process.stdout.write('Feed record already matches the approved Corgi Commons copy.\n'); + return; + } + + if (!apply) { + process.stdout.write('Dry run only. Re-run with --apply after reviewing the target copy.\n'); + return; + } + + try { + await agent.com.atproto.repo.putRecord({ + repo: publisherDid, + collection: COLLECTION, + rkey: FEED_RKEY, + record: updatedRecord, + swapRecord: currentResponse.data.cid, + }); + } catch (error) { + if (error instanceof XRPCError && /swap|cid|conflict/i.test(`${error.error} ${error.message}`)) { + throw new Error( + `Feed record changed after the dry run; compare-and-swap refused the update. Re-run without --apply and review the latest record. ${errorSummary(error)}`, + { cause: error }, + ); + } + throw error; + } + + const verificationResponse = await agent.com.atproto.repo.getRecord({ + repo: publisherDid, + collection: COLLECTION, + rkey: FEED_RKEY, + }); + const verification = AppBskyFeedGenerator.validateRecord(verificationResponse.data.value); + if (!verification.success) { + throw new Error('Updated feed record failed lexicon validation'); + } + if ( + verification.value.did !== currentServiceDid + || verification.value.displayName !== DISPLAY_NAME + || verification.value.description !== DESCRIPTION + ) { + throw new Error('Feed record verification did not match the approved Corgi Commons copy'); + } + + process.stdout.write(`Updated feed record at CID ${verificationResponse.data.cid}.\n`); +} + +main().catch((error: unknown) => { + process.stderr.write(`Corgi Commons feed-record update failed: ${errorSummary(error)}\n`); + process.exitCode = 1; +}); diff --git a/src/admin/routes/export.ts b/src/admin/routes/export.ts index 36c941fa..88bbb4f8 100644 --- a/src/admin/routes/export.ts +++ b/src/admin/routes/export.ts @@ -741,14 +741,23 @@ export function registerExportRoutes(app: FastifyInstance): void { 'and topics/community-weights.json.', security: adminSecurity, querystring: FullDatasetQueryJsonSchema, - produces: ['application/zip'], response: { 200: { - type: 'string', - format: 'binary', description: 'ZIP archive containing CSV and JSON files for the epoch', + content: { + 'application/zip': { + schema: { type: 'string', format: 'binary' }, + }, + }, + }, + 400: { + description: 'Invalid export query', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, }, - 400: ErrorResponseSchema, }, }, }, async (request: FastifyRequest, reply: FastifyReply) => { diff --git a/src/admin/routes/governance.ts b/src/admin/routes/governance.ts index b1b0db86..304fc9af 100644 --- a/src/admin/routes/governance.ts +++ b/src/admin/routes/governance.ts @@ -2122,40 +2122,13 @@ export function registerGovernanceRoutes(app: FastifyInstance): void { summary: 'Direct transition disabled', description: 'Direct round transitions are disabled. Use start voting, end voting, results review, and approval so the complete policy is applied before rescoring.', security: adminSecurity, - body: { - type: 'object', - properties: { - force: { type: 'boolean', default: false }, - }, - }, response: { - 200: { - type: 'object', - properties: { - success: { type: 'boolean' }, - newRoundId: { type: 'integer' }, - }, - required: ['success'], - }, - 400: ErrorResponseSchema, 409: ErrorResponseSchema, 500: ErrorResponseSchema, }, }, }, async (request: FastifyRequest, reply: FastifyReply) => { getAdminDid(request); - const parseResult = z - .object({ force: z.boolean().optional().default(false) }) - .safeParse(request.body ?? {}); - - if (!parseResult.success) { - return reply.code(400).send({ - error: 'ValidationError', - message: 'Invalid end-round payload', - details: parseResult.error.issues, - }); - } - return reply.code(409).send({ error: 'DirectTransitionDisabled', message: 'Direct round transitions are disabled. Start voting, close voting, review the proposed policy, and approve results.', diff --git a/src/admin/routes/status.ts b/src/admin/routes/status.ts index d6f99921..b5b93791 100644 --- a/src/admin/routes/status.ts +++ b/src/admin/routes/status.ts @@ -46,7 +46,7 @@ export function registerStatusRoutes(app: FastifyInstance): void { schema: { tags: ['Admin'], summary: 'System status', - description: 'Returns a full system overview for the admin dashboard: epoch state, feed stats, scoring info, content rules, and subscriber count.', + description: 'Returns a full system overview for the admin dashboard: epoch state, feed stats, scoring info, content rules, subscriber count, and approved pilot participant count.', security: adminSecurity, response: { 200: { @@ -83,7 +83,12 @@ export function registerStatusRoutes(app: FastifyInstance): void { lastScoringRun: { type: 'string', format: 'date-time', nullable: true }, lastScoringDuration: { type: 'number', nullable: true, description: 'Duration in seconds' }, subscriberCount: { type: 'integer' }, + approvedParticipantCount: { + type: 'integer', + description: 'Approved active feed subscribers currently eligible for pilot governance', + }, }, + required: ['totalPosts', 'postsLast24h', 'scoredPosts', 'subscriberCount', 'approvedParticipantCount'], }, contentRules: { type: 'object', @@ -145,6 +150,14 @@ export function registerStatusRoutes(app: FastifyInstance): void { `SELECT COUNT(*) as count FROM subscribers WHERE is_active = TRUE` ); + const approvedParticipantResult = await db.query( + `SELECT COUNT(*) as count + FROM subscribers s + INNER JOIN approved_participants ap + ON ap.did = s.did AND ap.removed_at IS NULL + WHERE s.is_active = TRUE` + ); + // Get scoring stats from system_status table let lastScoringRun: string | null = null; let lastScoringDuration: number | null = null; @@ -200,6 +213,7 @@ export function registerStatusRoutes(app: FastifyInstance): void { lastScoringRun, lastScoringDuration, subscriberCount: parseInt(subResult.rows[0].count, 10), + approvedParticipantCount: parseInt(approvedParticipantResult.rows[0].count, 10), }, contentRules: { includeKeywords: contentRules.includeKeywords || [], diff --git a/src/bot/announcements.ts b/src/bot/announcements.ts index 783f65d8..0712f379 100644 --- a/src/bot/announcements.ts +++ b/src/bot/announcements.ts @@ -65,13 +65,13 @@ export function generateVotingOpenedText(payload: VotingOpenedPayload): string { const feedUrl = `https://${config.FEEDGEN_HOSTNAME}/vote`; let text = `Voting is now open for Epoch ${payload.epochId}!\n\n`; - text += `Your vote shapes what appears in this feed. Cast your preferences:\n${feedUrl}\n\n`; + text += `Approved pilot participants can propose how this feed ranks posts:\n${feedUrl}\n\n`; if (payload.weights) { text += `Current weights:\n${formatWeights(payload.weights)}\n\n`; } - text += `Voting closes when ${config.GOVERNANCE_MIN_VOTES}+ subscribers have voted.`; + text += 'Voting closes at the configured deadline. Results are reviewed before an approved policy can affect the feed.'; return text; } diff --git a/src/demo/content-rules.ts b/src/demo/content-rules.ts index 00480121..6ff553fe 100644 --- a/src/demo/content-rules.ts +++ b/src/demo/content-rules.ts @@ -14,7 +14,7 @@ * - adoption threshold is computed over the full 25-ballot electorate. */ -import { checkContentRules } from '../governance/content-filter.js'; +import { checkContentRules } from '../governance/content-rule-matcher.js'; import { normalizeKeywords } from '../governance/governance.types.js'; import { SHADOW_DEMO_CONTENT_RULE_SUPPORT_THRESHOLD, diff --git a/src/demo/corpus.ts b/src/demo/corpus.ts index 95aa4bd0..c00fb364 100644 --- a/src/demo/corpus.ts +++ b/src/demo/corpus.ts @@ -26,15 +26,19 @@ import { type ShadowDemoTopicIntent, type ShadowDemoTopicKey, type ShadowDemoTopicCatalogEntry, + type ShadowDemoPublicationPolicy, type ShadowDemoWeights, } from './types.js'; import { equalShadowWeights, internalRawScoresToShadow, internalWeightsToShadow } from './weights.js'; import { emptyShadowTopicIntent } from './topic-intent.js'; +import { COMMUNITY_GOV_SNAPSHOT_GATE } from './snapshot-capture.js'; + +export { COMMUNITY_GOV_SNAPSHOT_GATE } from './snapshot-capture.js'; export const DEMO_COMMUNITIES: Record = { community_gov: { id: 'community_gov', - name: 'Community Governed Feed', + name: 'Corgi Commons', status: 'live_shadow', description: 'The real public Corgi feed, frozen into an isolated comparison corpus for a replayable governance walkthrough.', @@ -77,13 +81,6 @@ export const DEMO_COMMUNITIES: Record Promise; } +export interface CommunityGovCapturePolicy { + signalWeights: ShadowDemoWeights; + topicCatalog: ShadowDemoTopicCatalogEntry[]; + publicationPolicy: ShadowDemoPublicationPolicy; +} + +interface CommunityGovCorpusBehavior { + policy: CommunityGovCapturePolicy; + gateFailureMode: 'fixture_fallback' | 'return_degraded_live'; + loadFailureMode: 'fixture_fallback' | 'throw'; +} + export async function loadShadowDemoCorpus(options: LoadShadowDemoCorpusOptions): Promise { if (options.communityId === 'community_gov') { - return loadCommunityGovCorpus(options); + return loadCommunityGovCorpus(options, { + policy: readApprovedCommunityGovPolicy(), + gateFailureMode: 'fixture_fallback', + loadFailureMode: 'fixture_fallback', + }); } if (options.communityId !== 'open_science_builders') { return fallbackCorpus({ @@ -227,6 +240,23 @@ export async function loadShadowDemoCorpus(options: LoadShadowDemoCorpusOptions) } } +export async function loadCommunityGovCaptureCorpus( + options: Omit & { policy: CommunityGovCapturePolicy } +): Promise { + return loadCommunityGovCorpus({ + communityId: 'community_gov', + now: options.now, + fetchFn: options.fetchFn, + dbPool: options.dbPool, + readScore: options.readScore, + readPublishedSnapshot: options.readPublishedSnapshot, + }, { + policy: options.policy, + gateFailureMode: 'return_degraded_live', + loadFailureMode: 'throw', + }); +} + export function createDefaultCorpusLoader(): (options: { communityId: ShadowDemoCommunityId; now: Date; @@ -241,9 +271,12 @@ export function createDefaultCorpusLoader(): (options: { }); } -async function loadCommunityGovCorpus(options: LoadShadowDemoCorpusOptions): Promise { +async function loadCommunityGovCorpus( + options: LoadShadowDemoCorpusOptions, + behavior: CommunityGovCorpusBehavior +): Promise { try { - const approvedPolicy = readApprovedCommunityGovPolicy(); + const approvedPolicy = behavior.policy; const readPublishedSnapshot = options.readPublishedSnapshot ?? createDefaultPublishedFeedSnapshotReader(); const snapshot = await readPublishedSnapshot(DEMO_SOURCE_SNAPSHOT_LIMIT); const epoch = await readEpochById(options.dbPool, snapshot.productionEpochId); @@ -287,11 +320,12 @@ async function loadCommunityGovCorpus(options: LoadShadowDemoCorpusOptions): Pro eligibleItems.length ); const gateFailures = communityGovSnapshotGateFailures(health); - if (gateFailures.length > 0) { + const reviewedHealth = applyCommunityGovSnapshotGateStatus(health); + if (gateFailures.length > 0 && behavior.gateFailureMode === 'fixture_fallback') { return fallbackCorpus({ communityId: options.communityId, now: options.now, - reason: `Community Governed Feed snapshot failed reviewer-safe gates: ${gateFailures.join('; ')}`, + reason: `Corgi Commons snapshot failed reviewer-safe gates: ${gateFailures.join('; ')}`, activeEpochId: epoch.id, baseWeights: approvedPolicy.signalWeights, baseTopicIntent, @@ -299,6 +333,13 @@ async function loadCommunityGovCorpus(options: LoadShadowDemoCorpusOptions): Pro sourceFeedUri: snapshot.feedUri, }); } + const warning = gateFailures.length > 0 + ? [{ + code: 'shadow_demo_snapshot_not_approvable', + message: `Corgi Commons snapshot failed reviewer-safe gates: ${gateFailures.join('; ')}`, + severity: 'degraded' as const, + }] + : []; return { corpusId: `corpus-${randomUUID()}`, communityId: options.communityId, @@ -308,8 +349,8 @@ async function loadCommunityGovCorpus(options: LoadShadowDemoCorpusOptions): Pro createdAt: options.now.toISOString(), expiresAt: expiresAt(options.now).toISOString(), items: eligibleItems, - health, - warnings: [], + health: reviewedHealth, + warnings: warning, topicCatalog, sourceFeedUri: snapshot.feedUri, sourceSnapshot: { @@ -330,14 +371,17 @@ async function loadCommunityGovCorpus(options: LoadShadowDemoCorpusOptions): Pro }; } catch (err) { const message = err instanceof Error ? err.message : String(err); - logger.warn({ err }, 'Community Governed Feed demo snapshot load failed'); + logger.warn({ err }, 'Corgi Commons demo snapshot load failed'); + if (behavior.loadFailureMode === 'throw') { + throw new Error(`Corgi Commons live snapshot capture could not be loaded: ${message}`, { cause: err }); + } // The v4 API requires the complete approved 26-topic policy. Snapshot-entry // failures may degrade to the fixture; policy corruption fails closed. const approvedPolicy = readApprovedCommunityGovPolicy(); return fallbackCorpus({ communityId: options.communityId, now: options.now, - reason: `Community Governed Feed snapshot could not be loaded: ${message}`, + reason: `Corgi Commons snapshot could not be loaded: ${message}`, activeEpochId: 0, baseWeights: approvedPolicy.signalWeights, baseTopicIntent: { @@ -372,6 +416,13 @@ export function communityGovSnapshotGateFailures(health: ShadowDemoCorpusHealth) return failures; } +export function applyCommunityGovSnapshotGateStatus( + health: ShadowDemoCorpusHealth +): ShadowDemoCorpusHealth { + if (communityGovSnapshotGateFailures(health).length === 0) return health; + return { ...health, status: 'degraded' }; +} + async function readEpochById(dbPool: Pick, epochId: number): Promise { const result = await dbPool.query( `SELECT id, recency_weight, engagement_weight, bridging_weight, source_diversity_weight, relevance_weight, diff --git a/src/demo/service.ts b/src/demo/service.ts index d7d6e919..a1f1656c 100644 --- a/src/demo/service.ts +++ b/src/demo/service.ts @@ -817,13 +817,13 @@ function corpusProvenanceFor(state: ShadowDemoSessionState): ShadowDemoSessionPa } if (state.communityId === 'community_gov' && state.corpus.sourceSnapshot) { if (!state.corpus.sourceFeedUri) { - throw new Error(`Community Governed Feed snapshot corpus is missing its source feed URI: ${state.corpusId}`); + throw new Error(`Corgi Commons snapshot corpus is missing its source feed URI: ${state.corpusId}`); } return { mode: 'production_feed_snapshot_session_frozen', label: 'Reviewer-safe snapshot of the live Community Governed Feed', description: - 'Posts were sourced from the published Community Governed Feed and frozen so rank movement is attributable to shadow policy changes.', + 'Posts were sourced from the published Corgi Commons feed and frozen so rank movement is attributable to shadow policy changes.', corpusId: state.corpusId, productionEpochId: state.corpus.baseProductionEpochId, sampledAt: state.corpus.health.sampledAt, @@ -954,13 +954,13 @@ function validateTopicIntentForSession( const expected = new Set(topicSlugs); const received = Object.keys(intent.topicWeights); if (expected.size === 0) { - throw new DemoValidationError('Community Governed Feed topic catalog is unavailable for this session'); + throw new DemoValidationError('Corgi Commons topic catalog is unavailable for this session'); } const missing = [...expected].filter((slug) => intent.topicWeights[slug] === undefined); const unknown = received.filter((slug) => !expected.has(slug)); if (missing.length > 0 || unknown.length > 0 || received.length !== expected.size) { throw new DemoValidationError( - `Community Governed Feed vote must include the complete frozen topic catalog; missing=${missing.join(',') || 'none'} unknown=${unknown.join(',') || 'none'}` + `Corgi Commons vote must include the complete frozen topic catalog; missing=${missing.join(',') || 'none'} unknown=${unknown.join(',') || 'none'}` ); } return intent; @@ -1191,7 +1191,7 @@ function counterfactualsForReceipt(options: { const previousCounterfactual = previousEpoch?.sequence === 1 && options.item.publishedRank !== undefined ? { label: 'previous_epoch' as const, - description: 'Published rank in the frozen Community Governed Feed baseline.', + description: 'Published rank in the frozen Corgi Commons baseline.', rank: options.item.publishedRank, deltaFromVisible: options.item.publishedRank - options.visibleRank, } diff --git a/src/demo/snapshot-capture.ts b/src/demo/snapshot-capture.ts index 680f9fa0..ac41ed53 100644 --- a/src/demo/snapshot-capture.ts +++ b/src/demo/snapshot-capture.ts @@ -1,5 +1,151 @@ import type { DemoFetchFunction } from './appview.js'; import type { PostScoreRecord } from '../scoring/score-reader.js'; +import { rm, writeFile } from 'node:fs/promises'; +import { z } from 'zod'; + +export const COMMUNITY_GOV_SNAPSHOT_GATE = { + minimumEligiblePosts: 40, + minimumDisplayablePosts: 12, + minimumEnglishTaggedShare: 0.8, + maximumTopAuthorConcentration: 0.1, + minimumRichMediaShare: 0.2, +} as const; + +const SnapshotCaptureReportSchema = z.object({ + schemaVersion: z.literal('2026-07-11.community-gov-snapshot.v3'), + artifactKind: z.literal('live_production_snapshot_capture'), + corpusSource: z.literal('production_feed_snapshot'), + approvable: z.boolean(), + manifestDigest: z.string().regex(/^[a-f0-9]{64}$/), + capturedAt: z.string().datetime({ offset: true }), + productionEpochId: z.number().int().positive(), + sourceRunId: z.string().trim().min(1), + sourceCount: z.number().int().positive(), + eligibleCount: z.number().int().nonnegative(), + displayableCount: z.number().int().nonnegative(), + scoreCompletenessRate: z.number().finite().min(0).max(1), + uniqueAuthorCount: z.number().int().nonnegative(), + topAuthorConcentration: z.number().finite().min(0).max(1), + englishTaggedShare: z.number().finite().min(0).max(1), + richMediaShare: z.number().finite().min(0).max(1), + languageDistribution: z.record(z.string(), z.number().int().nonnegative()), + mediaDistribution: z.record(z.string(), z.number().int().nonnegative()), + gateFailures: z.array(z.string().trim().min(1)), + safetyChecklist: z.object({ + appViewVisibilityApplied: z.literal(true), + nestedLabelsApplied: z.literal(true), + reviewerLanguageGateApplied: z.literal(true), + sourceLinksValidated: z.boolean(), + copiedPostTextInManifest: z.literal(false), + manualReviewComplete: z.literal(false), + }).strict(), + warnings: z.array(z.object({ + code: z.string().trim().min(1), + message: z.string().trim().min(1), + severity: z.enum(['info', 'warning', 'degraded']), + }).strict()), +}).strict(); + +export type SnapshotCaptureReport = z.infer; + +export interface SnapshotCaptureArtifactPaths { + manifest: string; + report: string; + reviewSheet: string; +} + +export function snapshotApprovalWriteFlag(force: boolean): 'w' | 'wx' { + return force ? 'w' : 'wx'; +} + +export async function clearSnapshotCaptureArtifacts(paths: SnapshotCaptureArtifactPaths): Promise { + await Promise.all([ + rm(paths.manifest, { force: true }), + rm(paths.report, { force: true }), + rm(paths.reviewSheet, { force: true }), + ]); +} + +async function clearSnapshotApprovalArtifacts(paths: SnapshotCaptureArtifactPaths): Promise { + await Promise.all([ + rm(paths.manifest, { force: true }), + rm(paths.reviewSheet, { force: true }), + ]); +} + +export async function writeSnapshotCaptureArtifacts(options: { + paths: SnapshotCaptureArtifactPaths; + report: SnapshotCaptureReport; + manifestJson: string; + reviewSheetHtml: string; +}): Promise { + await clearSnapshotCaptureArtifacts(options.paths); + await writeFile(options.paths.report, `${JSON.stringify(options.report, null, 2)}\n`, 'utf8'); + if (!options.report.approvable) return; + try { + await writeFile(options.paths.manifest, options.manifestJson, 'utf8'); + await writeFile(options.paths.reviewSheet, options.reviewSheetHtml, 'utf8'); + } catch (error) { + await clearSnapshotApprovalArtifacts(options.paths); + throw error; + } +} + +export function parseSnapshotCaptureReport(input: unknown): SnapshotCaptureReport { + const parsed = SnapshotCaptureReportSchema.safeParse(input); + if (!parsed.success) { + const detail = parsed.error.issues + .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) + .join('; '); + throw new Error(`Corgi Commons capture report is invalid: ${detail}`); + } + return parsed.data; +} + +export function captureReportApprovalFailures( + report: SnapshotCaptureReport, + expectedManifestDigest: string +): string[] { + const failures = [...report.gateFailures]; + if (report.manifestDigest !== expectedManifestDigest) { + failures.push( + `capture report manifest digest ${report.manifestDigest} does not match ${expectedManifestDigest}` + ); + } + if (!report.approvable) failures.push('capture report is marked non-approvable'); + if (report.scoreCompletenessRate !== 1) { + failures.push(`score decomposition completeness ${report.scoreCompletenessRate} < 1`); + } + if (report.eligibleCount < COMMUNITY_GOV_SNAPSHOT_GATE.minimumEligiblePosts) { + failures.push( + `eligible posts ${report.eligibleCount} < ${COMMUNITY_GOV_SNAPSHOT_GATE.minimumEligiblePosts}` + ); + } + if (report.displayableCount < COMMUNITY_GOV_SNAPSHOT_GATE.minimumDisplayablePosts) { + failures.push( + `displayable posts ${report.displayableCount} < ${COMMUNITY_GOV_SNAPSHOT_GATE.minimumDisplayablePosts}` + ); + } + if (report.englishTaggedShare < COMMUNITY_GOV_SNAPSHOT_GATE.minimumEnglishTaggedShare) { + failures.push( + `English-tagged share ${report.englishTaggedShare} < ${COMMUNITY_GOV_SNAPSHOT_GATE.minimumEnglishTaggedShare}` + ); + } + if (report.topAuthorConcentration > COMMUNITY_GOV_SNAPSHOT_GATE.maximumTopAuthorConcentration) { + failures.push( + `top-author concentration ${report.topAuthorConcentration} > ${COMMUNITY_GOV_SNAPSHOT_GATE.maximumTopAuthorConcentration}` + ); + } + if (report.richMediaShare < COMMUNITY_GOV_SNAPSHOT_GATE.minimumRichMediaShare) { + failures.push( + `rich-media share ${report.richMediaShare} < ${COMMUNITY_GOV_SNAPSHOT_GATE.minimumRichMediaShare}` + ); + } + if (!report.safetyChecklist.sourceLinksValidated) { + failures.push('one or more public source links failed validation'); + } + return [...new Set(failures)]; +} export function createBoundedDemoFetch(timeoutMs: number): DemoFetchFunction { if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { diff --git a/src/feed/demo-snapshot-source.ts b/src/feed/demo-snapshot-source.ts index 912a1298..dbde8615 100644 --- a/src/feed/demo-snapshot-source.ts +++ b/src/feed/demo-snapshot-source.ts @@ -5,7 +5,8 @@ import approvedManifestJson from '../demo/community-gov-release-snapshot.json' w export const COMMUNITY_GOV_FEED_URI = 'at://did:plc:amzyknmm4auxijvykyfgznw2/app.bsky.feed.generator/community-gov'; -export const COMMUNITY_GOV_FEED_NAME = 'Community Governed Feed'; +export const COMMUNITY_GOV_FEED_NAME = 'Corgi Commons'; +const LEGACY_COMMUNITY_GOV_FEED_NAME = 'Community Governed Feed'; export const DEMO_SOURCE_SNAPSHOT_LIMIT = 100; export const COMMUNITY_GOV_ACTIVE_TOPIC_COUNT = 26; @@ -97,7 +98,13 @@ const ApprovedPolicySchema = z.object({ const ApprovedSnapshotManifestSchema = z.object({ schemaVersion: z.literal('2026-07-11.community-gov-snapshot.v3'), feedUri: z.literal(COMMUNITY_GOV_FEED_URI), - feedName: z.literal(COMMUNITY_GOV_FEED_NAME), + // Accept the previously approved manifest until the mechanically captured + // Corgi Commons manifest lands. Runtime presentation always uses the current + // canonical name returned by readApprovedCommunityGovSnapshot(). + feedName: z.union([ + z.literal(COMMUNITY_GOV_FEED_NAME), + z.literal(LEGACY_COMMUNITY_GOV_FEED_NAME), + ]), productionEpochId: z.number().int().positive(), sourceRunId: z.string().trim().min(1), sourceUpdatedAt: z.string().datetime({ offset: true }), @@ -128,7 +135,7 @@ export async function readPublishedCommunityGovSnapshot( limit: number ): Promise { if (limit !== DEMO_SOURCE_SNAPSHOT_LIMIT) { - throw new Error(`Community Governed Feed release snapshot limit must equal ${DEMO_SOURCE_SNAPSHOT_LIMIT}: ${limit}`); + throw new Error(`Corgi Commons release snapshot limit must equal ${DEMO_SOURCE_SNAPSHOT_LIMIT}: ${limit}`); } const snapshotRead = await reader.eval( `local ranked = redis.call('ZREVRANGE', KEYS[1], 0, tonumber(ARGV[1]), 'WITHSCORES') @@ -142,12 +149,12 @@ export async function readPublishedCommunityGovSnapshot( limit - 1 ); if (!Array.isArray(snapshotRead) || !Array.isArray(snapshotRead[0]) || !Array.isArray(snapshotRead[1])) { - throw new Error('Community Governed Feed atomic snapshot read returned an invalid response'); + throw new Error('Corgi Commons atomic snapshot read returned an invalid response'); } const ranked = snapshotRead[0].map(String); const metadata = snapshotRead[1].map((value) => value === null ? null : String(value)); if (ranked.length !== limit * 2) { - throw new Error(`Community Governed Feed snapshot returned ${ranked.length} ranked values; expected ${limit * 2}`); + throw new Error(`Corgi Commons snapshot returned ${ranked.length} ranked values; expected ${limit * 2}`); } const productionEpochId = Number(metadata[0]); const sourceRunId = metadata[1]; @@ -159,19 +166,19 @@ export async function readPublishedCommunityGovSnapshot( || !sourceUpdatedAt || Number.isNaN(Date.parse(sourceUpdatedAt)) ) { - throw new Error('Community Governed Feed snapshot metadata is incomplete or invalid'); + throw new Error('Corgi Commons snapshot metadata is incomplete or invalid'); } const entries: PublishedFeedEntry[] = []; for (let index = 0; index < ranked.length; index += 2) { const uri = ranked[index]; const publishedScore = Number(ranked[index + 1]); if (!uri.startsWith('at://') || !Number.isFinite(publishedScore) || publishedScore < 0) { - throw new Error(`Community Governed Feed snapshot contains an invalid entry at rank ${index / 2 + 1}`); + throw new Error(`Corgi Commons snapshot contains an invalid entry at rank ${index / 2 + 1}`); } entries.push({ uri, publishedRank: index / 2 + 1, publishedScore }); } if (new Set(entries.map((entry) => entry.uri)).size !== entries.length) { - throw new Error('Community Governed Feed snapshot contains duplicate post URIs'); + throw new Error('Corgi Commons snapshot contains duplicate post URIs'); } return { feedUri: COMMUNITY_GOV_FEED_URI, @@ -201,7 +208,7 @@ export function createLivePublishedFeedSnapshotReader(): (limit: number) => Prom export function readApprovedCommunityGovSnapshot(limit: number): PublishedFeedSnapshot { if (limit !== DEMO_SOURCE_SNAPSHOT_LIMIT) { - throw new Error(`Approved Community Governed Feed snapshot must be read at its full ${DEMO_SOURCE_SNAPSHOT_LIMIT}-entry size`); + throw new Error(`Approved Corgi Commons snapshot must be read at its full ${DEMO_SOURCE_SNAPSHOT_LIMIT}-entry size`); } const manifest = parseApprovedSnapshotManifest(approvedManifestJson); const entries = manifest.entries.map((entry, index) => { @@ -210,7 +217,7 @@ export function readApprovedCommunityGovSnapshot(limit: number): PublishedFeedSn || !entry.uri.startsWith('at://') || !Number.isFinite(entry.publishedScore) ) { - throw new Error(`Approved Community Governed Feed snapshot contains an invalid entry at rank ${index + 1}`); + throw new Error(`Approved Corgi Commons snapshot contains an invalid entry at rank ${index + 1}`); } return { ...entry, @@ -222,11 +229,11 @@ export function readApprovedCommunityGovSnapshot(limit: number): PublishedFeedSn }; }); if (new Set(entries.map((entry) => entry.uri)).size !== entries.length) { - throw new Error('Approved Community Governed Feed snapshot contains duplicate post URIs'); + throw new Error('Approved Corgi Commons snapshot contains duplicate post URIs'); } const digest = communityGovManifestDigest(manifest); if (digest !== manifest.snapshotDigest) { - throw new Error(`Approved Community Governed Feed snapshot digest mismatch: expected ${manifest.snapshotDigest}, computed ${digest}`); + throw new Error(`Approved Corgi Commons snapshot digest mismatch: expected ${manifest.snapshotDigest}, computed ${digest}`); } return { feedUri: COMMUNITY_GOV_FEED_URI, @@ -261,7 +268,7 @@ export function parseApprovedCommunityGovPolicy(input: unknown): { const detail = parsed.error.issues .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) .join('; '); - throw new Error(`Approved Community Governed Feed policy manifest is invalid: ${detail}`); + throw new Error(`Approved Corgi Commons policy manifest is invalid: ${detail}`); } const policy = parsed.data; const topicSlugs = policy.topicCatalog.map((topic) => topic.slug); @@ -271,11 +278,11 @@ export function parseApprovedCommunityGovPolicy(input: unknown): { || policy.topicCatalog.some((topic) => !topic.slug || !topic.name || !Number.isFinite(topic.baselineWeight) || topic.baselineWeight < 0 || topic.baselineWeight > 1) || Object.values(policy.signalWeights).some((weight) => !Number.isFinite(weight) || weight < 0 || weight > 1) ) { - throw new Error('Approved Community Governed Feed policy manifest is invalid'); + throw new Error('Approved Corgi Commons policy manifest is invalid'); } const signalSum = Object.values(policy.signalWeights).reduce((sum, weight) => sum + weight, 0); if (Math.abs(signalSum - 1) > 1e-9) { - throw new Error(`Approved Community Governed Feed signal weights must sum to 1; received ${signalSum}`); + throw new Error(`Approved Corgi Commons signal weights must sum to 1; received ${signalSum}`); } return { signalWeights: { ...policy.signalWeights }, @@ -293,7 +300,7 @@ export function parseApprovedSnapshotManifest(input: unknown): ApprovedSnapshotM const detail = parsed.error.issues .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) .join('; '); - throw new Error(`Approved Community Governed Feed snapshot manifest is invalid: ${detail}`); + throw new Error(`Approved Corgi Commons snapshot manifest is invalid: ${detail}`); } return parsed.data; } diff --git a/src/feed/server.ts b/src/feed/server.ts index b52e861d..3bc8336f 100644 --- a/src/feed/server.ts +++ b/src/feed/server.ts @@ -107,12 +107,12 @@ export async function createServer(options?: CreateServerOptions) { info: { title: 'Community Feed API', description: - 'Community-governed Bluesky feed generator where subscribers democratically vote on ranking algorithm weights. ' + + 'Corgi Commons is a Bluesky feed with inspectable, community-shaped ranking. Approved pilot participants can vote on ranking signals, topic priorities, and content rules; reviewed policy changes are applied before rescoring. ' + 'Built on AT Protocol.\n\n' + '## Authentication\n' + '- **Governance endpoints** require a session cookie or bearer token from `POST /api/governance/auth/login`.\n' + '- **Admin endpoints** additionally require the caller\'s DID to be in the `BOT_ADMIN_DIDS` allowlist.\n' + - '- **Feed endpoints** are public (called by the Bluesky app). Auth is optional for subscriber tracking.\n' + + '- **Feed endpoints** are public (called by the Bluesky app). Viewing the feed and using the shadow demo do not require governance access.\n' + '- **Transparency endpoints** are public and unauthenticated.', version: '1.2.0', contact: { diff --git a/src/governance/content-filter.ts b/src/governance/content-filter.ts index c7394751..4e269a99 100644 --- a/src/governance/content-filter.ts +++ b/src/governance/content-filter.ts @@ -10,84 +10,17 @@ import { redis } from '../db/redis.js'; import { logger } from '../lib/logger.js'; import { ContentRules, - ContentFilterResult, ContentRulesRow, toContentRules, emptyContentRules, } from './governance.types.js'; +import { checkContentRules } from './content-rule-matcher.js'; + +export { checkContentRules } from './content-rule-matcher.js'; // Cache TTL for content rules (5 minutes - matches scoring interval) const CACHE_TTL_SECONDS = 300; const CACHE_KEY = 'content_rules:current'; -const ASCII_KEYWORD_PATTERN = /^[a-z0-9][a-z0-9\s-]*$/; -const UNICODE_WORD_CLASS = '\\p{L}\\p{N}'; -const keywordMatcherCache = new Map boolean>(); - -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Build a cached keyword matcher function. - * - * @param keyword - Keyword to match (will be normalized) - * @param prefixMatch - When true, drops the trailing word boundary so "kink" matches "kinks"/"kinky". - * Used for exclude keywords where catching variants is more important than precision. - * Include keywords always use strict boundaries (prefixMatch=false). - * @returns Matcher function that tests a lowercased text string - */ -function getKeywordMatcher(keyword: string, prefixMatch?: boolean): (text: string) => boolean { - const normalizedKeyword = keyword.trim().toLowerCase(); - const cacheKey = `${normalizedKeyword}:${prefixMatch ? 'prefix' : 'strict'}`; - const cached = keywordMatcherCache.get(cacheKey); - if (cached) { - return cached; - } - - // Keep substring matching for non-ASCII keywords/symbol-heavy values. - // Most governance keywords are normalized ASCII terms and use strict boundaries. - if (!ASCII_KEYWORD_PATTERN.test(normalizedKeyword)) { - const matcher = (text: string) => text.includes(normalizedKeyword); - keywordMatcherCache.set(cacheKey, matcher); - return matcher; - } - - const phrasePattern = normalizedKeyword - .split(/\s+/) - .filter((part) => part.length > 0) - .map(escapeRegex) - .join('[\\s_-]+'); - - // Prefix mode: match "kink" → "kinks", "kinky", "kinkier". - // Strict mode: exact word boundaries on both sides. - const trailingBoundary = prefixMatch ? '' : `(?=$|[^${UNICODE_WORD_CLASS}])`; - const regex = new RegExp( - `(^|[^${UNICODE_WORD_CLASS}])${phrasePattern}${trailingBoundary}`, - 'u' - ); - const matcher = (text: string) => regex.test(text); - keywordMatcherCache.set(cacheKey, matcher); - return matcher; -} - -/** - * Test whether lowercased text contains a keyword. - * - * @param text - Lowercased post text - * @param keyword - Keyword to search for - * @param prefixMatch - When true, uses prefix matching (e.g., "porn" matches "pornographic"). - * Default false (strict word boundaries). - * @returns True if keyword is found in text - */ -function matchesKeyword(text: string, keyword: string, prefixMatch?: boolean): boolean { - const normalizedKeyword = keyword.trim().toLowerCase(); - if (normalizedKeyword.length === 0) { - return false; - } - - return getKeywordMatcher(normalizedKeyword, prefixMatch)(text); -} - /** * Get current epoch's content rules with Redis caching. */ @@ -202,69 +135,6 @@ export async function invalidateContentRulesCache(): Promise { } } -/** - * Check if a post's text matches the content rules. - * - * Logic: - * 1. If excludeKeywords is non-empty and post contains ANY exclude keyword -> filtered out - * 2. If includeKeywords is non-empty and post contains NO include keywords -> filtered out - * 3. Otherwise -> post passes - * - * Exclude takes precedence over include. - * - * @param text - The post text to check (case-insensitive matching) - * @param rules - Content rules to apply - * @returns Result with pass/fail, reason, and matched keyword - */ -export function checkContentRules( - text: string | null, - rules: ContentRules -): ContentFilterResult { - // No rules = everything passes - if (rules.includeKeywords.length === 0 && rules.excludeKeywords.length === 0) { - return { passes: true }; - } - - // Posts without text (image-only) - if (!text) { - // If include rules exist, text-less posts fail - if (rules.includeKeywords.length > 0) { - return { passes: false, reason: 'no_text_with_include_filter' }; - } - // No include rules and no text to check against exclude = passes - return { passes: true }; - } - - const lowerText = text.toLowerCase(); - - // Check excludes first (exclude takes precedence). - // Prefix matching: "kink" catches "kinks"/"kinky", "porn" catches "pornographic". - for (const keyword of rules.excludeKeywords) { - if (matchesKeyword(lowerText, keyword, true)) { - return { - passes: false, - reason: 'excluded_keyword', - matchedKeyword: keyword, - }; - } - } - - // Check includes (if any are specified, post must match at least one). - // Strict word boundaries: include keywords require exact matches for precision. - if (rules.includeKeywords.length > 0) { - for (const keyword of rules.includeKeywords) { - if (matchesKeyword(lowerText, keyword, false)) { - return { passes: true, matchedKeyword: keyword }; - } - } - // No include keyword matched - return { passes: false, reason: 'no_include_match' }; - } - - // No include filter specified, post passes - return { passes: true }; -} - /** * Post type for filtering (minimal interface). */ diff --git a/src/governance/content-rule-matcher.ts b/src/governance/content-rule-matcher.ts new file mode 100644 index 00000000..589df46f --- /dev/null +++ b/src/governance/content-rule-matcher.ts @@ -0,0 +1,97 @@ +import type { ContentFilterResult, ContentRules } from './governance.types.js'; + +const ASCII_KEYWORD_PATTERN = /^[a-z0-9][a-z0-9\s-]*$/; +const UNICODE_WORD_CLASS = '\\p{L}\\p{N}'; +const MAX_KEYWORD_MATCHER_CACHE_SIZE = 256; +const keywordMatcherCache = new Map boolean>(); + +function cacheKeywordMatcher(cacheKey: string, matcher: (text: string) => boolean): void { + if (!keywordMatcherCache.has(cacheKey) && keywordMatcherCache.size >= MAX_KEYWORD_MATCHER_CACHE_SIZE) { + const oldestKey = keywordMatcherCache.keys().next().value as string | undefined; + if (oldestKey !== undefined) { + keywordMatcherCache.delete(oldestKey); + } + } + keywordMatcherCache.set(cacheKey, matcher); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function getKeywordMatcher(keyword: string, prefixMatch: boolean): (text: string) => boolean { + const normalizedKeyword = keyword.trim().toLowerCase(); + const cacheKey = `${normalizedKeyword}:${prefixMatch ? 'prefix' : 'strict'}`; + const cached = keywordMatcherCache.get(cacheKey); + if (cached) { + keywordMatcherCache.delete(cacheKey); + keywordMatcherCache.set(cacheKey, cached); + return cached; + } + + if (!ASCII_KEYWORD_PATTERN.test(normalizedKeyword)) { + const matcher = (text: string) => text.includes(normalizedKeyword); + cacheKeywordMatcher(cacheKey, matcher); + return matcher; + } + + const phrasePattern = normalizedKeyword + .split(/\s+/) + .filter((part) => part.length > 0) + .map(escapeRegex) + .join('[\\s_-]+'); + const trailingBoundary = prefixMatch ? '' : `(?=$|[^${UNICODE_WORD_CLASS}])`; + const regex = new RegExp( + `(^|[^${UNICODE_WORD_CLASS}])${phrasePattern}${trailingBoundary}`, + 'u' + ); + const matcher = (text: string) => regex.test(text); + cacheKeywordMatcher(cacheKey, matcher); + return matcher; +} + +function matchesKeyword(text: string, keyword: string, prefixMatch: boolean): boolean { + const normalizedKeyword = keyword.trim().toLowerCase(); + if (normalizedKeyword.length === 0) { + return false; + } + return getKeywordMatcher(normalizedKeyword, prefixMatch)(text); +} + +/** Apply production include/exclude matching without loading storage dependencies. */ +export function checkContentRules( + text: string | null, + rules: ContentRules +): ContentFilterResult { + if (rules.includeKeywords.length === 0 && rules.excludeKeywords.length === 0) { + return { passes: true }; + } + + if (!text) { + return rules.includeKeywords.length > 0 + ? { passes: false, reason: 'no_text_with_include_filter' } + : { passes: true }; + } + + const lowerText = text.toLowerCase(); + for (const keyword of rules.excludeKeywords) { + if (matchesKeyword(lowerText, keyword, true)) { + return { + passes: false, + reason: 'excluded_keyword', + matchedKeyword: keyword, + }; + } + } + + if (rules.includeKeywords.length > 0) { + for (const keyword of rules.includeKeywords) { + if (matchesKeyword(lowerText, keyword, false)) { + return { passes: true, matchedKeyword: keyword }; + } + } + return { passes: false, reason: 'no_include_match' }; + } + + return { passes: true }; +} diff --git a/src/governance/routes/epochs.ts b/src/governance/routes/epochs.ts index 2f7b1b62..04a90ff2 100644 --- a/src/governance/routes/epochs.ts +++ b/src/governance/routes/epochs.ts @@ -25,14 +25,6 @@ const EpochIdParamsSchema = z.object({ id: z.coerce.number().int().positive(), }); -const ForceFlagSchema = z - .union([z.boolean(), z.enum(['true', 'false'])]) - .transform((value) => (typeof value === 'boolean' ? value : value === 'true')); - -const TransitionQuerySchema = z.object({ - force: ForceFlagSchema.optional().default(false), -}); - /** JSON Schemas for OpenAPI documentation. */ const EpochListQueryJsonSchema = zodToJsonSchema( z.object({ @@ -99,6 +91,7 @@ export function registerEpochsRoute(app: FastifyInstance): void { vote_count: { type: 'integer' }, created_at: { type: 'string', format: 'date-time' }, closed_at: { type: 'string', format: 'date-time', nullable: true }, + results_approved_at: { type: 'string', format: 'date-time', nullable: true }, description: { type: 'string', nullable: true }, content_rules: contentRulesSchema, }, @@ -154,6 +147,7 @@ export function registerEpochsRoute(app: FastifyInstance): void { vote_count: parseInt(voteCount.rows[0].count), created_at: epoch.createdAt, closed_at: epoch.closedAt, + results_approved_at: row.results_approved_at ?? null, description: epoch.description, content_rules: { include_keywords: contentRules.includeKeywords, @@ -187,7 +181,7 @@ export function registerEpochsRoute(app: FastifyInstance): void { phase: { type: 'string' }, weights: weightsSchema, vote_count: { type: 'integer' }, - subscriber_count: { type: 'integer', description: 'Active subscribers (potential voters)' }, + subscriber_count: { type: 'integer', description: 'Approved active governance participants' }, created_at: { type: 'string', format: 'date-time' }, voting_started_at: { type: 'string', format: 'date-time', nullable: true }, voting_ends_at: { type: 'string', format: 'date-time', nullable: true }, @@ -223,9 +217,13 @@ export function registerEpochsRoute(app: FastifyInstance): void { [epoch.id] ); - // Get subscriber count (potential voters) + // Count only active subscribers who are currently approved for the voting pilot. const subscriberCount = await db.query( - `SELECT COUNT(*) as count FROM subscribers WHERE is_active = TRUE` + `SELECT COUNT(*) as count + FROM subscribers s + INNER JOIN approved_participants ap + ON ap.did = s.did AND ap.removed_at IS NULL + WHERE s.is_active = TRUE` ); return reply.send({ @@ -434,8 +432,6 @@ export function registerEpochsRoute(app: FastifyInstance): void { * Triggers epoch transition (admin only). * Requires valid session with DID in BOT_ADMIN_DIDS. * - * Query params: - * - force=true: Skip vote count check (for testing) */ app.post('/api/governance/epochs/transition', { schema: { @@ -444,25 +440,7 @@ export function registerEpochsRoute(app: FastifyInstance): void { description: 'Direct epoch transitions are disabled. Use the reviewed voting lifecycle to start voting, close voting, inspect the proposed policy, and approve results.', security: governanceSecurity, - querystring: { - type: 'object', - properties: { - force: { type: 'boolean', default: false, description: 'Skip vote count check (for testing)' }, - }, - }, response: { - 200: { - type: 'object', - properties: { - success: { type: 'boolean' }, - newEpochId: { type: 'integer' }, - forced: { type: 'boolean' }, - previousEpochId: { type: 'integer' }, - voteCount: { type: 'integer' }, - }, - required: ['success', 'newEpochId', 'forced'], - }, - 400: ErrorResponseSchema, 409: ErrorResponseSchema, 401: ErrorResponseSchema, 403: ErrorResponseSchema, @@ -498,14 +476,6 @@ export function registerEpochsRoute(app: FastifyInstance): void { }); } - const queryParseResult = TransitionQuerySchema.safeParse(request.query); - if (!queryParseResult.success) { - return reply.code(400).send({ - error: 'ValidationError', - message: 'Invalid query parameters', - details: queryParseResult.error.issues, - }); - } return reply.code(409).send({ error: 'DirectTransitionDisabled', message: 'Direct epoch transitions are disabled. Start voting, close voting, review the proposed policy, and approve results.', diff --git a/src/governance/routes/vote.ts b/src/governance/routes/vote.ts index dc6de143..80ae9107 100644 --- a/src/governance/routes/vote.ts +++ b/src/governance/routes/vote.ts @@ -3,7 +3,7 @@ * * POST /api/governance/vote * - * Allows authenticated subscribers to vote on algorithm weights. + * Allows authenticated, approved pilot participants to submit governance ballots. * - Validates weights sum to 1.0 * - Normalizes before storing * - Uses UPSERT to allow vote updates @@ -153,7 +153,8 @@ export function registerVoteRoute(app: FastifyInstance): void { summary: 'Submit or update a vote', description: 'Cast or update your vote for the current epoch. You can vote on algorithm weights, ' + - 'content keywords, topic weights, or any combination. Weights must sum to 1.0. Requires authentication and active subscription.', + 'content keywords, topic weights, or any combination. Weights must sum to 1.0. Requires authentication, ' + + 'an active feed subscription, and approved pilot participation when allowlisting is enabled.', security: governanceSecurity, body: { type: 'object', @@ -241,10 +242,10 @@ export function registerVoteRoute(app: FastifyInstance): void { }); } - // 2b. Private mode: require approved participant - if (config.FEED_PRIVATE_MODE) { + // 2b. The production voting pilot is allowlisted independently of feed visibility. + if (config.LOGIN_ALLOWLIST_ENABLED) { if (!await isParticipantApproved(voterDid)) { - throw Errors.FORBIDDEN('Private feed mode: approved participants only.'); + throw Errors.FORBIDDEN('Governance voting pilot: approved participants only.'); } } @@ -359,6 +360,28 @@ export function registerVoteRoute(app: FastifyInstance): void { await client.query('BEGIN'); transactionOpen = true; + // The cached preflight check gives a fast denial, but the database row is + // authoritative. Lock it in the vote transaction so a concurrent + // revocation either completes first (and denies this ballot) or waits + // until this already-approved ballot commits. + if (config.LOGIN_ALLOWLIST_ENABLED) { + const participantApproval = await client.query( + `SELECT did + FROM approved_participants + WHERE did = $1 AND removed_at IS NULL + FOR SHARE`, + [voterDid] + ); + if (participantApproval.rows.length === 0) { + await client.query('ROLLBACK'); + transactionOpen = false; + return reply.code(403).send({ + error: 'Forbidden', + message: 'Governance voting pilot: approved participants only.', + }); + } + } + // 6. UPSERT vote with weights, keywords, and/or topic weights // Use xmax = 0 to detect if this was an INSERT (new) or UPDATE (existing) // Use COALESCE to preserve existing values when only updating one aspect diff --git a/tests/admin-status.test.ts b/tests/admin-status.test.ts index 0b0833d6..84fbbd35 100644 --- a/tests/admin-status.test.ts +++ b/tests/admin-status.test.ts @@ -61,6 +61,7 @@ describe('admin status route', () => { .mockResolvedValueOnce({ rows: [{ count: '4' }] }) .mockResolvedValueOnce({ rows: [{ total_posts: '100', posts_24h: '20' }] }) .mockResolvedValueOnce({ rows: [{ count: '7' }] }) + .mockResolvedValueOnce({ rows: [{ count: '3' }] }) .mockResolvedValueOnce({ rows: [{ value: { timestamp: '2026-02-09T01:00:00.000Z', duration_ms: 1200, posts_scored: 8 } }], }); @@ -82,9 +83,15 @@ describe('admin status route', () => { system: { feed: { scoredPosts: 12, + subscriberCount: 7, + approvedParticipantCount: 3, }, }, }); + const approvedParticipantQuery = dbQueryMock.mock.calls.find(([sql]) => + String(sql).includes('INNER JOIN approved_participants') + ); + expect(approvedParticipantQuery).toBeDefined(); await app.close(); }); diff --git a/tests/content-filter-matching.test.ts b/tests/content-filter-matching.test.ts index 632b2604..01b85514 100644 --- a/tests/content-filter-matching.test.ts +++ b/tests/content-filter-matching.test.ts @@ -1,7 +1,28 @@ import { describe, expect, it } from 'vitest'; -import { checkContentRules } from '../src/governance/content-filter.js'; +import { checkContentRules } from '../src/governance/content-rule-matcher.js'; describe('content filter keyword matching', () => { + it('passes empty rules and handles missing text explicitly', () => { + expect(checkContentRules(null, { includeKeywords: [], excludeKeywords: [] })).toEqual({ passes: true }); + expect(checkContentRules(null, { includeKeywords: ['research'], excludeKeywords: [] })).toEqual({ + passes: false, + reason: 'no_text_with_include_filter', + }); + }); + + it('gives exclusion rules precedence and intentionally prefix-matches them', () => { + const result = checkContentRules('A sports research roundup', { + includeKeywords: ['research'], + excludeKeywords: ['sport'], + }); + + expect(result).toEqual({ + passes: false, + reason: 'excluded_keyword', + matchedKeyword: 'sport', + }); + }); + it('does not match partial words for ASCII keywords', () => { const result = checkContentRules('jon bon jovi is such a fossil', { includeKeywords: ['foss'], diff --git a/tests/corgi-cold-start-product-story.test.ts b/tests/corgi-cold-start-product-story.test.ts new file mode 100644 index 00000000..6a9a9798 --- /dev/null +++ b/tests/corgi-cold-start-product-story.test.ts @@ -0,0 +1,222 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function read(relativePath: string): string { + return readFileSync(path.join(REPO_ROOT, relativePath), 'utf8'); +} + +const PUBLIC_STORY_FILES = [ + 'web-next/app/page.tsx', + 'web-next/app/how-it-works/page.tsx', + 'web-next/app/start/page.tsx', + 'web-next/app/about/page.tsx', + 'web-next/app/docs/page.tsx', + 'web-next/app/demo/page.tsx', + 'web-next/app/demo/layout.tsx', + 'web-next/app/demo/shadow-demo-copy.ts', + 'web-next/app/history/page.tsx', + 'web-next/app/post/page.tsx', + 'web-next/app/proposals/page.tsx', + 'web-next/app/vote/page.tsx', + 'web-next/app/dashboard/page.tsx', + 'web-next/app/admin/page.tsx', + 'web-next/components/bento-section.tsx', + 'web-next/components/community-examples-section.tsx', + 'web-next/components/footer-section.tsx', + 'web-next/components/header.tsx', + 'web-next/components/hero-section.tsx', + 'web-next/components/landing-ctas.tsx', + 'web-next/components/replay-teaser.tsx', + 'web-next/components/how-it-works-replay.tsx', + 'web-next/components/demo/community-picker.tsx', + 'web-next/components/demo/live-proof-panel.tsx', + 'web-next/components/demo/vote-panel.tsx', + 'web-next/components/faq-section.tsx', + 'web-next/components/feed/corgi-rank-badge.tsx', + 'web-next/components/social-proof.tsx', + 'web-next/lib/replay-model.ts', +] as const; + +const CURRENT_DOC_FILES = [ + 'README.md', + 'docs/PRD.md', + 'docs/SYSTEM_OVERVIEW.md', + 'docs/agent/REPO_CONTRACT.md', + 'docs/docs-site/openapi.json', + 'scripts/publish-feed.ts', + 'src/bot/announcements.ts', + 'src/feed/server.ts', + 'src/governance/routes/epochs.ts', +] as const; + +describe('Corgi cold-start product story', () => { + it('uses Corgi Commons and removes the obsolete Birders public demo world', () => { + const publicStory = PUBLIC_STORY_FILES.map(read).join('\n'); + + expect(publicStory).toContain('Corgi Commons'); + expect(publicStory).not.toMatch(/Birders Who Code/i); + expect(publicStory).not.toMatch(/Community Governed Feed/i); + }); + + it('keeps the Bluesky and Corgi modality boundary explicit', () => { + const homepage = read('web-next/app/page.tsx'); + const howItWorks = read('web-next/app/how-it-works/page.tsx'); + const demoCopy = read('web-next/app/demo/shadow-demo-copy.ts'); + + expect(homepage).toContain('Bluesky shows the ordered posts. Corgi shows the policy and receipts.'); + expect(howItWorks).toContain('Rank badges and receipt panels in this page are Corgi annotations.'); + expect(demoCopy).toContain('not native Bluesky UI'); + }); + + it('does not claim ballots auto-apply or have equal aggregation influence', () => { + const currentStory = [...PUBLIC_STORY_FILES, ...CURRENT_DOC_FILES].map(read).join('\n'); + + expect(currentStory).not.toMatch(/subscribers democratically/i); + expect(currentStory).not.toMatch(/each vote counts equally/i); + expect(currentStory).not.toMatch(/aggregated weights become the next/i); + expect(currentStory).not.toMatch(/when the round closes[^.]{0,100}(?:go live|reranks?|becomes? the feed)/i); + expect(currentStory).not.toMatch(/creates? the next active epoch/i); + expect(currentStory).not.toMatch(/transition to the next round/i); + expect(currentStory).not.toMatch(/voting closes when[^.]*subscribers/i); + expect(currentStory).not.toMatch(/subscribers can vote/i); + expect(currentStory).toMatch(/results review/i); + expect(currentStory).toMatch(/operator approval/i); + expect(currentStory).toMatch(/approved pilot participants/i); + }); + + it('does not overclaim universal receipt coverage or read-only interactivity', () => { + const publicStory = PUBLIC_STORY_FILES.map(read).join('\n'); + + expect(publicStory).not.toMatch(/every ranked post shows its receipt/i); + expect(publicStory).not.toMatch(/every post (?:carries|keeps|has) a receipt/i); + expect(publicStory).not.toMatch(/read-only demo/i); + expect(publicStory).not.toMatch(/Explore the live demo/i); + }); + + it('states the three production ballot channels and the pilot boundary', () => { + const start = read('web-next/app/start/page.tsx'); + const docs = read('web-next/app/docs/page.tsx'); + const about = read('web-next/app/about/page.tsx'); + + expect(start).toMatch(/approved waitlist pilot/i); + expect(docs).toMatch(/five global signal weights/i); + expect(docs).toMatch(/Topic preferences are separate/i); + expect(docs).toMatch(/Content rules determine eligibility/i); + expect(about).toMatch(/signals, topic priorities, and content rules/i); + const runtimeConfig = read('src/config.ts'); + expect(runtimeConfig).toMatch(/DEMO_CONTENT_RULES_ENABLED:\s*zodEnvBool\(false\)/); + expect(runtimeConfig).toMatch(/LOGIN_ALLOWLIST_ENABLED:\s*zodEnvBool\(false\)/); + }); + + it('keeps the operator UI on the reviewed lifecycle instead of the rejected transition bypass', () => { + const adminPage = read('web-next/app/admin/page.tsx'); + + expect(adminPage).toContain('adminApi.startVoting(72, true)'); + expect(adminPage).toContain('adminApi.endVoting(false)'); + expect(adminPage).toContain('adminApi.approveResults(true)'); + expect(adminPage).toContain('adminApi.rejectResults()'); + expect(adminPage).not.toContain('adminApi.transitionEpoch'); + expect(adminPage).toContain('signal weights, topic priorities, and adopted content rules'); + expect(adminPage).toContain('error={adminMutationError(openMutation.error'); + expect(adminPage).toContain('error={adminMutationError(closeMutation.error'); + expect(adminPage).toContain('error={adminMutationError(approveMutation.error'); + expect(adminPage).toContain('error={adminMutationError(rejectMutation.error'); + expect(adminPage).toContain('role="alert"'); + }); + + it('shows only explicitly approved policies as enacted proposals', () => { + const proposalsPage = read('web-next/app/proposals/page.tsx'); + + expect(proposalsPage).toContain('epoch.results_approved_at != null'); + expect(proposalsPage).toContain('Enacted {formatDate(epoch.results_approved_at)}'); + expect(proposalsPage).not.toContain('epoch.closed_at != null && epoch.id !== current?.id'); + }); + + it('updates the Bluesky feed record by compare-and-swap without rebuilding it', () => { + const updater = read('scripts/update-corgi-commons-record.ts'); + + expect(updater).toContain("displayName: DISPLAY_NAME"); + expect(updater).toContain("description: DESCRIPTION"); + expect(updater).toContain('...currentRecord'); + expect(updater).toContain('const currentServiceDid = currentRecord.did'); + expect(updater).toContain('verification.value.did !== currentServiceDid'); + expect(updater).toContain('swapRecord: currentResponse.data.cid'); + expect(updater).toContain("if (!apply)"); + expect(updater).toContain("process.argv.includes('--apply')"); + expect(updater).toContain('Target description: ${DESCRIPTION}'); + expect(updater).toContain('new CredentialSession(BSKY_SERVICE, fetchWithTimeout)'); + expect(updater).toContain('new Agent(session)'); + expect(updater).toContain('NETWORK_TIMEOUT_MS = 15_000'); + expect(updater).toContain('compare-and-swap refused the update'); + expect(updater).not.toContain('BskyAgent'); + expect(updater).not.toContain("requireEnvironmentValue('FEEDGEN_SERVICE_DID')"); + expect(updater).not.toContain('createdAt: new Date'); + }); + + it('publishes truthful media types and disabled lifecycle contracts in OpenAPI', () => { + type OpenApiOperation = { + parameters?: unknown[]; + requestBody?: unknown; + responses: Record }>; + }; + type OpenApiSpec = { + paths: Record>; + }; + const specs = [ + 'docs/openapi.json', + 'docs/openapi-public.json', + 'docs/docs-site/openapi.json', + ].map((file) => ({ file, spec: JSON.parse(read(file)) as OpenApiSpec })); + + for (const lifecyclePath of ['/api/governance/epochs/transition', '/api/admin/governance/end-round']) { + const operations = specs.flatMap(({ file, spec }) => { + const operation = spec.paths[lifecyclePath]?.post; + return operation ? [{ file, operation }] : []; + }); + expect(operations.length, `${lifecyclePath} must be published by at least one spec`).toBeGreaterThan(0); + for (const { file, operation } of operations) { + expect(operation.responses['200'], `${file} ${lifecyclePath}`).toBeUndefined(); + expect(operation.responses['409'], `${file} ${lifecyclePath}`).toBeDefined(); + expect(JSON.stringify(operation), `${file} ${lifecyclePath}`).not.toContain('"force"'); + } + } + + const exportOperations = specs.flatMap(({ file, spec }) => { + const operation = spec.paths['/api/admin/export/full-dataset']?.get; + return operation ? [{ file, operation }] : []; + }); + expect(exportOperations.length).toBeGreaterThan(0); + for (const { file, operation } of exportOperations) { + expect(Object.keys(operation.responses['200']?.content ?? {}), file).toEqual(['application/zip']); + expect(Object.keys(operation.responses['400']?.content ?? {}), file).toEqual(['application/json']); + } + + const [canonical, ...variants] = specs.map(({ spec }) => spec); + expect(canonical).toBeDefined(); + const canonicalSharedContracts = { + voteRequest: canonical?.paths['/api/governance/vote']?.post?.requestBody, + waitlist: canonical?.paths['/api/governance/waitlist'], + statsStatusRequired: + canonical?.paths['/api/transparency/stats']?.get?.responses['200']?.content?.['application/json'], + }; + expect(canonicalSharedContracts.voteRequest).toBeDefined(); + expect(canonicalSharedContracts.waitlist).toBeDefined(); + expect(canonicalSharedContracts.statsStatusRequired).toBeDefined(); + for (const variant of variants) { + expect(variant.paths['/api/governance/vote']?.post?.requestBody) + .toEqual(canonicalSharedContracts.voteRequest); + expect(variant.paths['/api/governance/waitlist']) + .toEqual(canonicalSharedContracts.waitlist); + expect(variant.paths['/api/transparency/stats']?.get?.responses['200']?.content?.['application/json']) + .toEqual(canonicalSharedContracts.statsStatusRequired); + } + }); +}); diff --git a/tests/demo-content-rules.test.ts b/tests/demo-content-rules.test.ts index c24656e8..a01e915e 100644 --- a/tests/demo-content-rules.test.ts +++ b/tests/demo-content-rules.test.ts @@ -599,7 +599,7 @@ function corpus(): ShadowDemoCorpus { topicCatalog: [...TOPICS], sourceFeedUri: 'at://did:plc:amzyknmm4auxijvykyfgznw2/app.bsky.feed.generator/community-gov', sourceSnapshot: { - feedName: 'Community Governed Feed', digest: 'a'.repeat(64), runId: 'run-1', updatedAt: NOW.toISOString(), capturedAt: NOW.toISOString(), reviewedAt: NOW.toISOString(), + feedName: 'Corgi Commons', digest: 'a'.repeat(64), runId: 'run-1', updatedAt: NOW.toISOString(), capturedAt: NOW.toISOString(), reviewedAt: NOW.toISOString(), sourcePostCount: 100, selectionPolicyVersion: 'community-gov-reviewer-safe-v1', baselineOrderDigest: 'a'.repeat(64), publicationPolicy: { urlDedupEnabled: true, minimumOriginalTextLength: 200, minimumRelevance: 0, decay: [1, 0.7, 0.5, 0.3] }, }, diff --git a/tests/demo-v4-routes.test.ts b/tests/demo-v4-routes.test.ts index 9a89053a..db7b4b92 100644 --- a/tests/demo-v4-routes.test.ts +++ b/tests/demo-v4-routes.test.ts @@ -155,7 +155,7 @@ describe('shadow demo v4 route contract', () => { publicationAdjustment: expect.any(Number), componentScore: expect.any(Number), provenance: { - sourceFeedName: 'Community Governed Feed', + sourceFeedName: 'Corgi Commons', sourceRunId: 'run-1', sourceSnapshotDigest: 'a'.repeat(64), }, @@ -345,7 +345,7 @@ function corpus(): ShadowDemoCorpus { topicCatalog: [...TOPICS], sourceFeedUri: 'at://did:plc:amzyknmm4auxijvykyfgznw2/app.bsky.feed.generator/community-gov', sourceSnapshot: { - feedName: 'Community Governed Feed', digest: 'a'.repeat(64), runId: 'run-1', updatedAt: NOW.toISOString(), capturedAt: NOW.toISOString(), reviewedAt: NOW.toISOString(), + feedName: 'Corgi Commons', digest: 'a'.repeat(64), runId: 'run-1', updatedAt: NOW.toISOString(), capturedAt: NOW.toISOString(), reviewedAt: NOW.toISOString(), sourcePostCount: 100, selectionPolicyVersion: 'community-gov-reviewer-safe-v1', baselineOrderDigest: 'a'.repeat(64), publicationPolicy: { urlDedupEnabled: true, minimumOriginalTextLength: 200, minimumRelevance: 0, decay: [1, 0.7, 0.5, 0.3] }, }, diff --git a/tests/demo-v4-snapshot-source.test.ts b/tests/demo-v4-snapshot-source.test.ts index 964da3c7..22e77887 100644 --- a/tests/demo-v4-snapshot-source.test.ts +++ b/tests/demo-v4-snapshot-source.test.ts @@ -10,26 +10,36 @@ import { } from '../src/feed/demo-snapshot-source.js'; import { COMMUNITY_GOV_SNAPSHOT_GATE, + applyCommunityGovSnapshotGateStatus, communityGovSnapshotGateFailures, isEnglishLanguageTag, + loadCommunityGovCaptureCorpus, loadShadowDemoCorpus, } from '../src/demo/corpus.js'; import { applyFeedUrlDedup, FEED_URL_DEDUP_DECAY } from '../src/scoring/feed-publication.js'; import { canonicalizeFrozenEmbedUrl, + captureReportApprovalFailures, createBoundedDemoFetch, + parseSnapshotCaptureReport, scoreCompletenessRate, + snapshotApprovalWriteFlag, + writeSnapshotCaptureArtifacts, + type SnapshotCaptureReport, } from '../src/demo/snapshot-capture.js'; import { ShadowDemoService } from '../src/demo/service.js'; import { MemoryDemoStore } from '../src/demo/store.js'; import type { PostScoreRecord } from '../src/scoring/score-reader.js'; import { readFileSync } from 'node:fs'; +import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; afterEach(() => { vi.unstubAllGlobals(); }); -describe('Community Governed Feed release snapshot', () => { +describe('Corgi Commons release snapshot', () => { it('provides safe temporary output paths for the documented capture command', () => { const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { scripts: Record; @@ -37,6 +47,110 @@ describe('Community Governed Feed release snapshot', () => { expect(packageJson.scripts['demo:capture-community-gov']).toContain('--manifest /tmp/'); expect(packageJson.scripts['demo:capture-community-gov']).toContain('--report /tmp/'); expect(packageJson.scripts['demo:capture-community-gov']).toContain('--review-sheet /tmp/'); + expect(packageJson.scripts['demo:approve-community-gov']).toBe('tsx scripts/approve-demo-snapshot.ts'); + expect(snapshotApprovalWriteFlag(false)).toBe('wx'); + expect(snapshotApprovalWriteFlag(true)).toBe('w'); + }); + + it('refuses to replace an existing approved output unless force is explicit', async () => { + const directory = await mkdtemp(join(tmpdir(), 'corgi-snapshot-approval-output-')); + const outputPath = join(directory, 'approved.json'); + try { + await writeFile(outputPath, 'existing approval', 'utf8'); + + await expect(writeFile(outputPath, 'replacement', { + encoding: 'utf8', + flag: snapshotApprovalWriteFlag(false), + })).rejects.toMatchObject({ code: 'EEXIST' }); + expect(await readFile(outputPath, 'utf8')).toBe('existing approval'); + + await writeFile(outputPath, 'reviewed replacement', { + encoding: 'utf8', + flag: snapshotApprovalWriteFlag(true), + }); + expect(await readFile(outputPath, 'utf8')).toBe('reviewed replacement'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('emits only a non-approvable report when live snapshot gates fail', async () => { + const directory = await mkdtemp(join(tmpdir(), 'corgi-snapshot-fail-closed-')); + const paths = { + manifest: join(directory, 'manifest.json'), + report: join(directory, 'report.json'), + reviewSheet: join(directory, 'review.html'), + }; + await Promise.all([ + writeFile(paths.manifest, 'stale manifest', 'utf8'), + writeFile(paths.reviewSheet, 'stale review sheet', 'utf8'), + ]); + const report = captureReport({ + approvable: false, + eligibleCount: 8, + displayableCount: 8, + gateFailures: ['eligible posts 8 < 40'], + }); + + try { + await writeSnapshotCaptureArtifacts({ + paths, + report, + manifestJson: '{"must":"not be written"}\n', + reviewSheetHtml: '

must not be written

', + }); + + expect(JSON.parse(await readFile(paths.report, 'utf8'))).toMatchObject({ + artifactKind: 'live_production_snapshot_capture', + corpusSource: 'production_feed_snapshot', + approvable: false, + }); + await expect(access(paths.manifest)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(access(paths.reviewSheet)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(captureReportApprovalFailures(report, report.manifestDigest)).toContain( + 'capture report is marked non-approvable' + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('removes both approval artifacts when either approvable write fails', async () => { + const directory = await mkdtemp(join(tmpdir(), 'corgi-snapshot-partial-write-')); + const paths = { + manifest: join(directory, 'manifest.json'), + report: join(directory, 'report.json'), + reviewSheet: join(directory, 'missing', 'review.html'), + }; + const report = captureReport({ approvable: true }); + + try { + await expect(writeSnapshotCaptureArtifacts({ + paths, + report, + manifestJson: '{"approved":true}\n', + reviewSheetHtml: '

reviewed

', + })).rejects.toMatchObject({ code: 'ENOENT' }); + + expect(JSON.parse(await readFile(paths.report, 'utf8'))).toMatchObject({ approvable: true }); + await expect(access(paths.manifest)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(access(paths.reviewSheet)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('rejects fixture provenance and report-manifest mismatches at approval', () => { + const report = captureReport({ approvable: true }); + + expect(captureReportApprovalFailures(report, report.manifestDigest)).toEqual([]); + expect(captureReportApprovalFailures(report, 'f'.repeat(64))).toContain( + `capture report manifest digest ${report.manifestDigest} does not match ${'f'.repeat(64)}` + ); + expect(() => parseSnapshotCaptureReport({ + ...report, + corpusSource: 'fixture_fallback', + })).toThrow(/corpusSource/); }); it('removes tracking and fragment data from frozen external preview URLs', () => { @@ -50,6 +164,7 @@ describe('Community Governed Feed release snapshot', () => { it('loads the approved manifest with exact contiguous ranks and a verified digest', () => { const snapshot = readApprovedCommunityGovSnapshot(DEMO_SOURCE_SNAPSHOT_LIMIT); + expect(snapshot.feedName).toBe('Corgi Commons'); expect(snapshot.entries).toHaveLength(100); expect(snapshot.entries.map((entry) => entry.publishedRank)).toEqual( Array.from({ length: 100 }, (_unused, index) => index + 1) @@ -154,7 +269,7 @@ describe('Community Governed Feed release snapshot', () => { } }); - it('requires exactly 26 active frozen Community Governed Feed topics', () => { + it('requires exactly 26 active frozen Corgi Commons topics', () => { const approved = readManifestFixture() as { topicCatalog: unknown[] }; expect(() => parseApprovedSnapshotManifest({ ...approved, topicCatalog: approved.topicCatalog.slice(0, 25) })).toThrow(/26/); expect(() => parseApprovedSnapshotManifest({ ...approved, topicCatalog: [...approved.topicCatalog, approved.topicCatalog[0]] })).toThrow(/26/); @@ -323,6 +438,24 @@ describe('Community Governed Feed release snapshot', () => { expect(degraded.health).toMatchObject({ status: 'degraded', source: 'fixture_fallback' }); expect(degraded.warnings[0]?.message).toContain('eligible posts 39 < 40'); expect(readScore).not.toHaveBeenCalled(); + + const captureCorpus = await loadCommunityGovCaptureCorpus({ + now: new Date(snapshot.capturedAt), + fetchFn: limitedFetchFn, + dbPool, + readScore, + readPublishedSnapshot: vi.fn(async () => snapshot), + policy: readApprovedCommunityGovPolicy(), + }); + expect(captureCorpus.health).toMatchObject({ + status: 'degraded', + source: 'production_feed_snapshot', + eligiblePostCount: 39, + publicScoredPosts: 39, + }); + expect(captureCorpus.items).toHaveLength(39); + expect(captureCorpus.warnings[0]?.code).toBe('shadow_demo_snapshot_not_approvable'); + expect(captureCorpus.items.some((item) => item.postUri.endsWith('/bird1'))).toBe(false); }); it('fails release quality when any objective gate is below threshold', () => { @@ -348,6 +481,49 @@ describe('Community Governed Feed release snapshot', () => { expect(communityGovSnapshotGateFailures({ ...passing, richMediaShare: 0.19 })[0]).toContain('rich-media share'); }); + it.each([ + [{ englishTaggedShare: 0.79 }, 'English-tagged share'], + [{ richMediaShare: 0.19 }, 'rich-media share'], + [{ topAuthorConcentration: 0.11 }, 'top-author concentration'], + ] as const)('degrades capture health for an independent %s failure', (override, expectedFailure) => { + const passing = { + status: 'live' as const, + source: 'production_feed_snapshot' as const, + candidatePosts72h: 100, + publicScoredPosts: 40, + uniqueAuthors72h: 40, + bridgePostShare: 0.4, + topAuthorConcentration: COMMUNITY_GOV_SNAPSHOT_GATE.maximumTopAuthorConcentration, + sampledAt: '2026-07-11T22:22:13.710Z', + eligiblePostCount: 40, + englishTaggedShare: COMMUNITY_GOV_SNAPSHOT_GATE.minimumEnglishTaggedShare, + richMediaShare: COMMUNITY_GOV_SNAPSHOT_GATE.minimumRichMediaShare, + }; + const health = { ...passing, ...override }; + + expect(communityGovSnapshotGateFailures(health)[0]).toContain(expectedFailure); + expect(applyCommunityGovSnapshotGateStatus(health).status).toBe('degraded'); + }); + + it('keeps capture health live at every reviewer-safe quality boundary', () => { + const health = { + status: 'live' as const, + source: 'production_feed_snapshot' as const, + candidatePosts72h: 100, + publicScoredPosts: COMMUNITY_GOV_SNAPSHOT_GATE.minimumDisplayablePosts, + uniqueAuthors72h: 40, + bridgePostShare: 0.4, + topAuthorConcentration: COMMUNITY_GOV_SNAPSHOT_GATE.maximumTopAuthorConcentration, + sampledAt: '2026-07-11T22:22:13.710Z', + eligiblePostCount: COMMUNITY_GOV_SNAPSHOT_GATE.minimumEligiblePosts, + englishTaggedShare: COMMUNITY_GOV_SNAPSHOT_GATE.minimumEnglishTaggedShare, + richMediaShare: COMMUNITY_GOV_SNAPSHOT_GATE.minimumRichMediaShare, + }; + + expect(communityGovSnapshotGateFailures(health)).toEqual([]); + expect(applyCommunityGovSnapshotGateStatus(health)).toBe(health); + }); + it('uses the same ordered URL decay for production publication and shadow reranking', () => { const result = applyFeedUrlDedup([ { id: 'first', score: 1, embedUrl: 'https://example.com/a', textLength: 20, value: 'first' }, @@ -414,15 +590,43 @@ describe('Community Governed Feed release snapshot', () => { }); function readManifestFixture(): unknown { - const snapshot = readApprovedCommunityGovSnapshot(DEMO_SOURCE_SNAPSHOT_LIMIT); - const policy = readApprovedCommunityGovPolicy(); + return JSON.parse(readFileSync( + new URL('../src/demo/community-gov-release-snapshot.json', import.meta.url), + 'utf8' + )) as unknown; +} + +function captureReport(overrides: Partial): SnapshotCaptureReport { return { schemaVersion: '2026-07-11.community-gov-snapshot.v3', - ...snapshot, - reviewedAt: snapshot.reviewedAt, - signalWeights: policy.signalWeights, - topicCatalog: policy.topicCatalog, - publicationPolicy: policy.publicationPolicy, + artifactKind: 'live_production_snapshot_capture', + corpusSource: 'production_feed_snapshot', + approvable: true, + manifestDigest: 'a'.repeat(64), + capturedAt: '2026-07-11T22:22:13.710Z', + productionEpochId: 2, + sourceRunId: 'run-123', + sourceCount: 100, + eligibleCount: 40, + displayableCount: 40, + scoreCompletenessRate: 1, + uniqueAuthorCount: 40, + topAuthorConcentration: 0.1, + englishTaggedShare: 0.8, + richMediaShare: 0.2, + languageDistribution: { en: 40 }, + mediaDistribution: { external: 8, none: 32 }, + gateFailures: [], + safetyChecklist: { + appViewVisibilityApplied: true, + nestedLabelsApplied: true, + reviewerLanguageGateApplied: true, + sourceLinksValidated: true, + copiedPostTextInManifest: false, + manualReviewComplete: false, + }, + warnings: [], + ...overrides, }; } diff --git a/tests/generate-openapi-cleanup.test.ts b/tests/generate-openapi-cleanup.test.ts new file mode 100644 index 00000000..0545bf6d --- /dev/null +++ b/tests/generate-openapi-cleanup.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; +import { runWithOpenApiCleanup } from '../scripts/generate-openapi.js'; + +describe('OpenAPI generator cleanup', () => { + it('preserves a primary generation error when database and Redis cleanup also fail', async () => { + const primaryError = new Error('Failed to fetch spec: HTTP 503'); + const databaseCleanup = vi.fn(async () => { + throw new Error('database cleanup failed'); + }); + const redisCleanup = vi.fn(async () => { + throw new Error('Redis cleanup failed'); + }); + const reportCleanupFailure = vi.fn<(error: AggregateError) => void>(); + + await expect(runWithOpenApiCleanup( + async () => { + throw primaryError; + }, + [databaseCleanup, redisCleanup], + reportCleanupFailure + )).rejects.toBe(primaryError); + + expect(databaseCleanup).toHaveBeenCalledTimes(1); + expect(redisCleanup).toHaveBeenCalledTimes(1); + expect(reportCleanupFailure).toHaveBeenCalledTimes(1); + expect(reportCleanupFailure.mock.calls[0]?.[0]).toBeInstanceOf(AggregateError); + expect(reportCleanupFailure.mock.calls[0]?.[0].errors).toHaveLength(2); + }); + + it('surfaces cleanup failure when generation itself succeeds', async () => { + await expect(runWithOpenApiCleanup( + async () => 'generated', + [async () => { + throw new Error('cleanup failed'); + }], + vi.fn() + )).rejects.toThrow('OpenAPI generator cleanup failed'); + }); + + it('returns the generation result after successful cleanup', async () => { + await expect(runWithOpenApiCleanup( + async () => 'generated', + [async () => undefined], + vi.fn() + )).resolves.toBe('generated'); + }); +}); diff --git a/tests/governance-admin.test.ts b/tests/governance-admin.test.ts index 29ecff51..11994e8b 100644 --- a/tests/governance-admin.test.ts +++ b/tests/governance-admin.test.ts @@ -650,14 +650,13 @@ describe('admin governance routes', () => { await app.close(); }); - it('rejects the legacy direct end-round bypass even when force is requested', async () => { + it('rejects the legacy direct end-round bypass', async () => { const app = Fastify(); registerGovernanceRoutes(app); const response = await app.inject({ method: 'POST', url: '/governance/end-round', - payload: { force: true }, }); expect(response.statusCode).toBe(409); diff --git a/tests/governance-query-validation.test.ts b/tests/governance-query-validation.test.ts index 11802166..9afce57e 100644 --- a/tests/governance-query-validation.test.ts +++ b/tests/governance-query-validation.test.ts @@ -101,6 +101,133 @@ describe('governance route query validation', () => { await app.close(); }); + it('returns approval provenance and counts only approved active pilot participants', async () => { + const app = Fastify(); + app.setValidatorCompiler(() => () => true); + registerEpochsRoute(app); + dbQueryMock + .mockResolvedValueOnce({ + rows: [{ + id: 4, + status: 'active', + phase: 'running', + recency_weight: 0.2, + engagement_weight: 0.2, + bridging_weight: 0.2, + source_diversity_weight: 0.2, + relevance_weight: 0.2, + vote_count: 3, + created_at: '2026-07-13T00:00:00.000Z', + closed_at: null, + results_approved_at: '2026-07-13T01:00:00.000Z', + description: null, + content_rules: null, + }], + }) + .mockResolvedValueOnce({ rows: [{ count: '3' }] }); + + const historyResponse = await app.inject({ method: 'GET', url: '/api/governance/epochs?limit=1' }); + + expect(historyResponse.statusCode).toBe(200); + expect(historyResponse.json().epochs[0]).toMatchObject({ + id: 4, + results_approved_at: '2026-07-13T01:00:00.000Z', + }); + + dbQueryMock + .mockResolvedValueOnce({ + rows: [{ + id: 4, + status: 'active', + phase: 'running', + recency_weight: 0.2, + engagement_weight: 0.2, + bridging_weight: 0.2, + source_diversity_weight: 0.2, + relevance_weight: 0.2, + vote_count: 3, + created_at: '2026-07-13T00:00:00.000Z', + closed_at: null, + description: null, + content_rules: null, + }], + }) + .mockResolvedValueOnce({ rows: [{ count: '3' }] }) + .mockResolvedValueOnce({ rows: [{ count: '2' }] }); + + const currentResponse = await app.inject({ method: 'GET', url: '/api/governance/epochs/current' }); + + expect(currentResponse.statusCode).toBe(200); + expect(currentResponse.json()).toMatchObject({ subscriber_count: 2 }); + const subscriberQuery = String(dbQueryMock.mock.calls.at(-1)?.[0]); + expect(subscriberQuery).toContain('INNER JOIN approved_participants'); + expect(subscriberQuery).toContain('ap.removed_at IS NULL'); + + await app.close(); + }); + + it('preserves null approval provenance for a round awaiting operator review', async () => { + const app = Fastify(); + app.setValidatorCompiler(() => () => true); + registerEpochsRoute(app); + dbQueryMock + .mockResolvedValueOnce({ + rows: [{ + id: 5, + status: 'active', + phase: 'results', + recency_weight: 0.2, + engagement_weight: 0.2, + bridging_weight: 0.2, + source_diversity_weight: 0.2, + relevance_weight: 0.2, + created_at: '2026-07-13T00:00:00.000Z', + closed_at: null, + results_approved_at: null, + description: null, + content_rules: null, + }], + }) + .mockResolvedValueOnce({ rows: [{ count: '0' }] }); + + const response = await app.inject({ method: 'GET', url: '/api/governance/epochs?limit=1' }); + + expect(response.statusCode).toBe(200); + expect(response.json().epochs[0].results_approved_at).toBeNull(); + await app.close(); + }); + + it('returns zero when no approved active governance participants exist', async () => { + const app = Fastify(); + app.setValidatorCompiler(() => () => true); + registerEpochsRoute(app); + dbQueryMock + .mockResolvedValueOnce({ + rows: [{ + id: 5, + status: 'active', + phase: 'running', + recency_weight: 0.2, + engagement_weight: 0.2, + bridging_weight: 0.2, + source_diversity_weight: 0.2, + relevance_weight: 0.2, + created_at: '2026-07-13T00:00:00.000Z', + closed_at: null, + description: null, + content_rules: null, + }], + }) + .mockResolvedValueOnce({ rows: [{ count: '0' }] }) + .mockResolvedValueOnce({ rows: [{ count: '0' }] }); + + const response = await app.inject({ method: 'GET', url: '/api/governance/epochs/current' }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ vote_count: 0, subscriber_count: 0 }); + await app.close(); + }); + it('rejects the authenticated direct transition endpoint', async () => { const app = Fastify(); app.setValidatorCompiler(() => () => true); @@ -108,7 +235,7 @@ describe('governance route query validation', () => { const response = await app.inject({ method: 'POST', - url: '/api/governance/epochs/transition?force=true', + url: '/api/governance/epochs/transition', }); expect(response.statusCode).toBe(409); diff --git a/tests/topic-voting.test.ts b/tests/topic-voting.test.ts index 23f19dc5..b753f323 100644 --- a/tests/topic-voting.test.ts +++ b/tests/topic-voting.test.ts @@ -6,14 +6,15 @@ */ import Fastify from 'fastify'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // --- Mocks (vi.hoisted runs before imports) --- -const { dbQueryMock, dbConnectMock, clientReleaseMock, getAuthenticatedDidMock } = vi.hoisted(() => ({ +const { dbQueryMock, dbConnectMock, clientReleaseMock, getAuthenticatedDidMock, participantLockMock } = vi.hoisted(() => ({ dbQueryMock: vi.fn(), dbConnectMock: vi.fn(), clientReleaseMock: vi.fn(), getAuthenticatedDidMock: vi.fn(), + participantLockMock: vi.fn(), })); vi.mock('../src/db/client.js', () => ({ @@ -47,6 +48,7 @@ vi.mock('../src/lib/logger.js', () => ({ import { registerVoteRoute } from '../src/governance/routes/vote.js'; import { registerTopicRoutes } from '../src/governance/routes/topics.js'; +import { config } from '../src/config.js'; import { parseStoredProposedTopicWeights, parseStoredTopicWeights, @@ -61,6 +63,7 @@ interface TopicVoteEpoch { } const DATABASE_NOW = '2026-07-12T12:00:00.000Z'; +const ORIGINAL_LOGIN_ALLOWLIST_ENABLED = config.LOGIN_ALLOWLIST_ENABLED; const ACTIVE_EPOCH: TopicVoteEpoch = { id: 5, @@ -111,6 +114,9 @@ function setupDbMock(opts?: { }); } // Atomic vote insert locks the still-open epoch in the same statement. + if (sql.includes('FROM approved_participants') && sql.includes('FOR SHARE')) { + return participantLockMock(sql, _params); + } if (sql.includes('WITH open_epoch')) { expect(sql).toContain("AND status IN ('active', 'voting')"); expect(sql).toContain("AND phase = 'voting'"); @@ -284,6 +290,12 @@ describe('stored topic-weight policy parsing', () => { describe('POST /api/governance/vote (topic_weights)', () => { beforeEach(() => { vi.clearAllMocks(); + participantLockMock.mockResolvedValue({ rows: [{ did: 'did:plc:voter' }] }); + (config as { LOGIN_ALLOWLIST_ENABLED: boolean }).LOGIN_ALLOWLIST_ENABLED = false; + }); + + afterEach(() => { + (config as { LOGIN_ALLOWLIST_ENABLED: boolean }).LOGIN_ALLOWLIST_ENABLED = ORIGINAL_LOGIN_ALLOWLIST_ENABLED; }); it('returns 401 without authentication', async () => { @@ -329,6 +341,61 @@ describe('POST /api/governance/vote (topic_weights)', () => { await app.close(); }); + it('rejects a ballot when participant access is revoked before the transactional write', async () => { + getAuthenticatedDidMock.mockResolvedValue('did:plc:voter'); + setupDbMock(); + (config as { LOGIN_ALLOWLIST_ENABLED: boolean }).LOGIN_ALLOWLIST_ENABLED = true; + participantLockMock.mockResolvedValue({ rows: [] }); + + const app = Fastify(); + registerVoteRoute(app); + + const response = await app.inject({ + method: 'POST', + url: '/api/governance/vote', + payload: { topic_weights: { 'software-development': 0.9 } }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json()).toMatchObject({ error: 'Forbidden' }); + expect(participantLockMock).toHaveBeenCalledTimes(1); + expect(String(participantLockMock.mock.calls[0]?.[0])).toContain('FOR SHARE'); + expect(dbQueryMock.mock.calls.some(([sql]) => String(sql).includes('WITH open_epoch'))).toBe(false); + expect(dbQueryMock.mock.calls.some(([sql]) => String(sql) === 'ROLLBACK')).toBe(true); + + await app.close(); + }); + + it('waits for the participant row lock before attempting a concurrent ballot write', async () => { + getAuthenticatedDidMock.mockResolvedValue('did:plc:voter'); + setupDbMock(); + (config as { LOGIN_ALLOWLIST_ENABLED: boolean }).LOGIN_ALLOWLIST_ENABLED = true; + let resolveApproval!: (value: { rows: Array<{ did: string }> }) => void; + participantLockMock.mockReturnValue(new Promise((resolve) => { + resolveApproval = resolve; + })); + + const app = Fastify(); + registerVoteRoute(app); + const responsePromise = app.inject({ + method: 'POST', + url: '/api/governance/vote', + payload: { topic_weights: { 'software-development': 0.9 } }, + }); + + await vi.waitFor(() => expect(participantLockMock).toHaveBeenCalledTimes(1)); + expect(dbQueryMock.mock.calls.some(([sql]) => String(sql).includes('WITH open_epoch'))).toBe(false); + + resolveApproval({ rows: [{ did: 'did:plc:voter' }] }); + const response = await responsePromise; + + expect(response.statusCode).toBe(200); + expect(dbQueryMock.mock.calls.some(([sql]) => String(sql).includes('WITH open_epoch'))).toBe(true); + expect(dbQueryMock.mock.calls.some(([sql]) => String(sql) === 'COMMIT')).toBe(true); + + await app.close(); + }); + it('rolls back the wide ballot when normalized component storage fails', async () => { getAuthenticatedDidMock.mockResolvedValue('did:plc:voter'); setupDbMock(); diff --git a/tests/votable-params-record-shape.test.ts b/tests/votable-params-record-shape.test.ts index b0ee01b4..6a645308 100644 --- a/tests/votable-params-record-shape.test.ts +++ b/tests/votable-params-record-shape.test.ts @@ -7,7 +7,7 @@ * what the compile-time literal union used to enforce. */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { dbQueryMock, @@ -61,6 +61,9 @@ import { WIDE_COLUMN_COMPONENT_KEYS, assertWideColumnsRegistered, } from '../src/scoring/registry.js'; +import { config } from '../src/config.js'; + +const ORIGINAL_LOGIN_ALLOWLIST_ENABLED = config.LOGIN_ALLOWLIST_ENABLED; async function postVote(payload: unknown) { const app = buildTestApp(); @@ -104,6 +107,9 @@ describe('votable-params record shape (PROJ-816)', () => { if (text.includes('FROM governance_epochs')) { return { rows: [{ id: 1, status: 'active', phase: 'voting' }] }; } + if (text.includes('FROM approved_participants') && text.includes('FOR SHARE')) { + return { rows: [{ did: 'did:plc:alice' }] }; + } if (text.includes('INSERT INTO governance_votes')) { return { rows: [{ id: 'vote-uuid-1', is_new_vote: true }] }; } @@ -111,6 +117,10 @@ describe('votable-params record shape (PROJ-816)', () => { }); }); + afterEach(() => { + config.LOGIN_ALLOWLIST_ENABLED = ORIGINAL_LOGIN_ALLOWLIST_ENABLED; + }); + describe('voteFieldForKey', () => { it('maps camelCase keys to snake_case `_weight` columns', () => { expect(voteFieldForKey('recency')).toBe('recency_weight'); @@ -141,6 +151,37 @@ describe('votable-params record shape (PROJ-816)', () => { }); describe('vote route rejects unregistered weight keys', () => { + it('enforces pilot approval whenever the login allowlist is enabled', async () => { + config.LOGIN_ALLOWLIST_ENABLED = true; + isParticipantApprovedMock.mockResolvedValue(false); + + const res = await postVote(registeredWeights(0.2)); + + expect(res.statusCode).toBe(403); + expect(res.json()).toMatchObject({ message: 'Governance voting pilot: approved participants only.' }); + expect(isParticipantApprovedMock).toHaveBeenCalledWith('did:plc:alice'); + }); + + it('does not apply the pilot allowlist when the feature flag is disabled', async () => { + config.LOGIN_ALLOWLIST_ENABLED = false; + isParticipantApprovedMock.mockResolvedValue(false); + + const res = await postVote(registeredWeights(0.2)); + + expect(res.statusCode).toBe(200); + expect(isParticipantApprovedMock).not.toHaveBeenCalled(); + }); + + it('accepts an approved pilot participant when the login allowlist is enabled', async () => { + config.LOGIN_ALLOWLIST_ENABLED = true; + isParticipantApprovedMock.mockResolvedValue(true); + + const res = await postVote(registeredWeights(0.2)); + + expect(res.statusCode).toBe(200); + expect(isParticipantApprovedMock).toHaveBeenCalledWith('did:plc:alice'); + }); + it('returns 400 UnregisteredWeightKey when an unknown `*_weight` field is submitted', async () => { const res = await postVote({ recency_weight: 0.2, diff --git a/tests/web-next-admin-lifecycle.test.ts b/tests/web-next-admin-lifecycle.test.ts new file mode 100644 index 00000000..cbc8e8a0 --- /dev/null +++ b/tests/web-next-admin-lifecycle.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import { + approvedParticipationPercent, + completeAdminLifecycleRefresh, +} from '../web-next/lib/admin-lifecycle'; + +describe('web-next admin lifecycle', () => { + it('uses approved participants rather than all feed subscribers for participation', () => { + const subscriberCount = 100; + const approvedParticipantCount = 10; + + expect(approvedParticipationPercent(5, approvedParticipantCount)).toBe(50); + expect(approvedParticipationPercent(5, subscriberCount)).toBe(5); + expect(approvedParticipationPercent(0, 0)).toBe(0); + }); + + it('keeps lifecycle completion pending until every invalidation resolves', async () => { + let resolveInvalidation!: () => void; + const invalidation = new Promise((resolve) => { + resolveInvalidation = resolve; + }); + const closeConfirmation = vi.fn(); + const completion = completeAdminLifecycleRefresh( + async () => invalidation, + closeConfirmation + ); + + await Promise.resolve(); + expect(closeConfirmation).not.toHaveBeenCalled(); + + resolveInvalidation(); + await completion; + expect(closeConfirmation).toHaveBeenCalledTimes(1); + }); + + it('wires every lifecycle mutation to awaited refresh and pending controls', () => { + const pageSource = readFileSync( + new URL('../web-next/app/admin/page.tsx', import.meta.url), + 'utf8' + ); + + expect(pageSource).toContain('approvedParticipationPercent(epoch.voteCount, feed.approvedParticipantCount)'); + expect(pageSource).toContain('completeAdminLifecycleRefresh(invalidate, () => setConfirm(null))'); + expect(pageSource).toMatch(/await Promise\.all\(\[/); + expect(pageSource.match(/onSuccess: refreshThenClose/g)).toHaveLength(4); + expect(pageSource.match(/loading=\{(?:open|close|approve|reject)Mutation\.isPending\}/g)).toHaveLength(4); + }); +}); diff --git a/tests/web-next-demo-fixtures.test.ts b/tests/web-next-demo-fixtures.test.ts index 01552834..d68abd97 100644 --- a/tests/web-next-demo-fixtures.test.ts +++ b/tests/web-next-demo-fixtures.test.ts @@ -244,8 +244,8 @@ function publicReceiptDocSections(): PublicReceiptDocSection[] { return [ { - label: 'README.md live production snapshot', - content: extractLineContaining(readme, 'Live production snapshot:'), + label: 'README.md current product overview', + content: extractLineContaining(readme, 'Corgi Commons is a production Bluesky custom feed'), }, { label: 'docs/dev-journal.md PROJ-1433 entry', diff --git a/tests/web-next-governance-status.test.ts b/tests/web-next-governance-status.test.ts new file mode 100644 index 00000000..8339a8d1 --- /dev/null +++ b/tests/web-next-governance-status.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { policyApplied } from '../web-next/lib/governance-status'; + +describe('web-next governance status', () => { + it.each([ + [{ status: 'active', phase: 'running' }, true], + [{ status: 'active' }, true], + [{ status: 'active', phase: 'voting' }, false], + [{ status: 'active', phase: 'review' }, false], + [{ status: 'active', phase: 'results' }, false], + [{ status: 'closed', phase: 'running' }, false], + [{ status: 'closed' }, false], + ] as const)('identifies applied policy for %o as %s', (epoch, expected) => { + expect(policyApplied(epoch)).toBe(expected); + }); +}); diff --git a/tests/web-next-homepage-anchors.test.ts b/tests/web-next-homepage-anchors.test.ts index d6fe85c0..83efc40c 100644 --- a/tests/web-next-homepage-anchors.test.ts +++ b/tests/web-next-homepage-anchors.test.ts @@ -48,13 +48,34 @@ describe('web-next homepage anchors', () => { expect(demoContent).not.toContain('snapshot-rank'); }); - it('labels the landing replay as a local illustration and points to live verification', () => { + it('labels the landing replay as an illustrative Corgi Commons preview', () => { const pageContent = readRepoFile('web-next/app/page.tsx'); - - expect(pageContent).toContain('illustrative preview reorder here'); - expect(pageContent).toMatch(/]*>\s*live shadow demo\s*<\/Link>/s); - expect(pageContent).toContain('to verify the full flow'); + const renderedHomepageContent = [ + 'web-next/app/page.tsx', + 'web-next/components/hero-section.tsx', + 'web-next/components/replay-teaser.tsx', + 'web-next/components/community-examples-section.tsx', + 'web-next/components/social-proof.tsx', + 'web-next/components/bento-section.tsx', + 'web-next/components/faq-section.tsx', + 'web-next/components/cta-section.tsx', + 'web-next/components/footer-section.tsx', + ].map(readRepoFile).join('\n'); + + expect(pageContent).toContain('Corgi Commons brings together open-network building'); + expect(pageContent).toContain('Change the illustrative policy below'); + expect(pageContent).toContain('Bluesky shows the ordered posts. Corgi shows the policy and receipts.'); expect(pageContent).not.toContain('same feed reorders in Bluesky'); + expect(renderedHomepageContent).toContain('Illustrative preview'); + expect(renderedHomepageContent).toContain('frozen snapshot sourced from Corgi Commons'); + expect(renderedHomepageContent).not.toContain('Birders Who Code'); + }); + + it('shows lifecycle arrows only when all six steps share one row', () => { + const bentoContent = readRepoFile('web-next/components/bento-section.tsx'); + + expect(bentoContent).toContain('className="hidden text-primary/45 xl:block"'); + expect(bentoContent).not.toContain('className="hidden text-primary/45 md:block"'); }); it('keeps public support reachable without repository access', () => { @@ -87,7 +108,12 @@ describe('web-next homepage anchors', () => { // The replay anchor now uses the
layout primitive, which renders a //
at runtime — accept either the component or raw tag. expect(replayContent).toMatch(/<(?:S|s)ection id="replay"/); - expect(replayContent).toContain('Standard Bluesky clients render the ordered posts, not Corgi score panels.'); + expect(replayContent).toMatch(/standard Bluesky clients render the ordered posts, not Corgi score panels\./i); expect(howItWorksContent).toContain('Rank badges and receipt panels in this page are Corgi annotations.'); + expect(howItWorksContent).toContain('Render in Bluesky'); + expect(howItWorksContent).not.toMatch(/Bluesky (?:receives|publishes) (?:the )?posts/i); + const docsContent = readRepoFile('web-next/app/docs/page.tsx'); + expect(docsContent).toContain('serves an ordered feed for Bluesky'); + expect(docsContent).not.toMatch(/serves an ordered feed back to Bluesky/i); }); }); diff --git a/tests/web-next-landing-ctas-render.test.tsx b/tests/web-next-landing-ctas-render.test.tsx index 4ac81743..38ade3e6 100644 --- a/tests/web-next-landing-ctas-render.test.tsx +++ b/tests/web-next-landing-ctas-render.test.tsx @@ -99,6 +99,7 @@ describe('web-next landing CTAs', () => { expect(countMatches(markup, /

- Corgi is a community-governed feed for Bluesky. Members vote on how much each ranking signal - matters, the feed reranks accordingly, and every post carries a receipt showing exactly why it landed where it - did. Bluesky renders the ordered posts; Corgi shows the math. + Corgi is a community-shaped feed for Bluesky. During the limited pilot, approved participants + can vote on ranking signals, topic priorities, and content rules. Approved policy changes are applied before + the feed is rescored. Bluesky renders the ordered posts; Corgi shows the policy and available ranking receipts.

), @@ -53,10 +53,12 @@ const SECTIONS = [ <>

The governance loop is deliberately simple and auditable:

    -
  • Propose & vote — during an open round, members set weights for five signals: recency, engagement, bridging, source diversity, and topic relevance.
  • -
  • Aggregate — votes are combined with a trimmed mean (outliers on each end are dropped) so no single voter can swing the feed.
  • -
  • Apply — when the round closes, the aggregated weights become the next epoch, and the live feed reranks.
  • -
  • Record — every weight change and operator action is written to an append-only audit log that anyone can inspect.
  • +
  • Open a round — a vote can be scheduled or opened manually, with a configurable response window.
  • +
  • Vote — approved participants can submit five global signal weights, topic priorities that shape relevance, and include or exclude content rules.
  • +
  • Aggregate — fewer than 10 ballots use an arithmetic mean; 10 or more use a 10% trimmed mean. Content rules require at least 30% support among content-rule ballots.
  • +
  • Review & approve — closed-round results are reviewed before an operator approves or rejects the complete proposed policy.
  • +
  • Apply & rescore — approval applies signals, topics, and adopted rules together to the active epoch, returns it to running, and queues a durable feed rescore.
  • +
  • Record — policy changes and operator actions are written to the governance audit trail.

You can watch this happen on the how-it-works page, or inspect the @@ -71,7 +73,7 @@ const SECTIONS = [ body: ( <>

    -
  • Inspectable by default — the ranking weights, the per-post score breakdowns, and the audit log are always public.
  • +
  • Inspectable by default — active ranking policy, available per-post score breakdowns, and governance history are exposed on Corgi.
  • Your vote is private, the outcome is public — individual votes are never exposed; only the aggregated result is.
  • Open source — the implementation is public so the community can verify, fork, and improve it.
  • No dark patterns — no ads, no engagement bait, no selling data.
  • @@ -89,8 +91,8 @@ const SECTIONS = [ We never receive your main password, and you can revoke access at any time from your Bluesky settings.

    - We collect only what’s needed to run governance: your Bluesky handle/DID, your vote preferences, and your - research-consent status. Full details are in the{" "} + Governance access is currently an approved waitlist pilot. For participants, we collect only what is needed to + operate the pilot: your Bluesky handle/DID, ballot preferences, and research-consent status. Full details are in the{" "} Privacy Policy.

    diff --git a/web-next/app/admin/page.tsx b/web-next/app/admin/page.tsx index 24574bef..b498cf49 100644 --- a/web-next/app/admin/page.tsx +++ b/web-next/app/admin/page.tsx @@ -10,6 +10,7 @@ import { WeightBar } from "@/components/ui/weight-bar" import { Button } from "@/components/ui/button" import { EmptyState, ErrorCard, Skeleton } from "@/components/ui/state-kit" import { adminApi, type AdminStatus } from "@/lib/api/admin" +import { approvedParticipationPercent, completeAdminLifecycleRefresh } from "@/lib/admin-lifecycle" // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -38,6 +39,17 @@ function fmtDateTime(iso: string | null) { }) } +function adminMutationError(error: unknown, fallback: string): string | null { + if (error === null || error === undefined) return null + if (axios.isAxiosError(error)) { + const data = error.response?.data + if (data !== null && typeof data === "object" && "message" in data && typeof data.message === "string") { + return data.message + } + } + return fallback +} + type CurrentEpoch = NonNullable type EpochPhase = "running" | "voting" | "review" | "waiting" @@ -66,9 +78,9 @@ function SectionHeader({ title, sub }: { title: string; sub?: string }) { // ── Confirm Modal (Escape-to-close + initial focus on Cancel) ────────────────── function ConfirmModal({ - title, body, confirmLabel = "Confirm", danger = false, loading = false, onConfirm, onCancel, + title, body, error, confirmLabel = "Confirm", danger = false, loading = false, onConfirm, onCancel, }: { - title: string; body: string; confirmLabel?: string; danger?: boolean; loading?: boolean + title: string; body: string; error?: string | null; confirmLabel?: string; danger?: boolean; loading?: boolean onConfirm: () => void; onCancel: () => void }) { const cancelRef = useRef(null) @@ -85,10 +97,19 @@ function ConfirmModal({ return (
    -
+
+
    + {rankingJourney.map((step, index) => ( +
  1. +

    {String(index + 1).padStart(2, "0")}

    +

    {step.label}

    +

    {step.body}

    +
  2. + ))} +
+
+
@@ -69,12 +98,6 @@ export default function HowItWorksPage() { This separation is intentional: people get a normal Bluesky feed, and the explanation layer stays inspectable on Corgi.

-
- -
-
- -
{surfaceBoundaries.map((boundary) => (
+
+
+
+

Any community

+

+ The mechanism is general. The policy is local. +

+
+
    + {communityExamples.map((example) => ( +
  • {example}
  • + ))} +
+
+
+
@@ -101,7 +140,7 @@ export default function HowItWorksPage() { Inspect the live receipt path.

- The walkthrough above is illustrative. The public demo is where live ranking claims use Corgi receipts and snapshot data. + The walkthrough above is illustrative. The public demo starts from a frozen comparison corpus sourced from Corgi Commons and keeps every shadow vote isolated from production.

@@ -110,7 +149,7 @@ export default function HowItWorksPage() {

- Demo posts are illustrative; live ranking claims use Corgi receipts and snapshot data. + Demo posts on this page are illustrative. The reviewer demo identifies its frozen live-public snapshot and links back to source posts.

diff --git a/web-next/app/not-found.tsx b/web-next/app/not-found.tsx index 07a40084..4d815421 100644 --- a/web-next/app/not-found.tsx +++ b/web-next/app/not-found.tsx @@ -27,7 +27,7 @@ export default function NotFound() { href="/demo" className="rounded-full px-5 py-3 text-base font-medium text-foreground/70 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background" > - Explore the live demo → + Try the governance demo → diff --git a/web-next/app/page.tsx b/web-next/app/page.tsx index 7d85632b..ef6061ee 100644 --- a/web-next/app/page.tsx +++ b/web-next/app/page.tsx @@ -1,6 +1,5 @@ "use client" -import Link from "next/link" import { Header } from "@/components/header" import { HeroSection } from "@/components/hero-section" import { ReplayTeaser } from "@/components/replay-teaser" @@ -28,20 +27,18 @@ export default function LandingPage() {
-

- Interactive preview +

+ Corgi Commons preview

- See a community reshape its own feed. + One feed. A policy the community can inspect.

- Meet Birders Who Code, a sample community of - developers who bird. Choose how they rank posts and watch this illustrative preview reorder here, with - the Corgi score behind every move. Then use the{" "} - - live shadow demo - {" "} - to verify the full flow. + Corgi Commons brings together open-network building, research, software, data, and the conversations + connecting them. Change the illustrative policy below to see how the same posts reorder. +

+

+ Bluesky shows the ordered posts. Corgi shows the policy and receipts.

diff --git a/web-next/app/post/page.tsx b/web-next/app/post/page.tsx index a1a79444..5d20d72f 100644 --- a/web-next/app/post/page.tsx +++ b/web-next/app/post/page.tsx @@ -586,7 +586,7 @@ function PostExplanationInner() { ))}

- Topic scores reflect how strongly this post matches each community-weighted topic. + Topic scores show how the post’s sparse topic vector combines with the approved topic-priority map inside the relevance signal.

) : ( @@ -604,7 +604,7 @@ function PostExplanationInner() {

- These are the community-voted weights applied at the time this post was scored. + These are the approved signal weights applied at the time this post was scored.

diff --git a/web-next/app/proposals/page.tsx b/web-next/app/proposals/page.tsx index 243029f6..4e9a6a06 100644 --- a/web-next/app/proposals/page.tsx +++ b/web-next/app/proposals/page.tsx @@ -8,7 +8,7 @@ import { PolicyBar, PolicyLegend } from "@/components/ui/policy-bar" import { ErrorCard, Skeleton } from "@/components/ui/state-kit" import { SIGNAL_KEYS, SIGNAL_LABELS } from "@/lib/signals" import { weightsApi, transparencyApi, type EpochResponse } from "@/lib/api/client" -import { votingState, votingClosedDate } from "@/lib/governance-status" +import { policyApplied, votingState, votingClosedDate } from "@/lib/governance-status" function pct(value: number): string { return `${Math.round(value * 100)}%` @@ -27,8 +27,8 @@ function phaseLabel(epoch: EpochResponse): string { return policyApplied(epoch) ? "Active policy" : "Closed" } -function policyApplied(epoch: EpochResponse): boolean { - return epoch.status === "active" || epoch.closed_at == null +function wasEnacted(epoch: EpochResponse): boolean { + return epoch.results_approved_at != null } /** The signal carrying the most weight in a policy — the round's headline. */ @@ -50,7 +50,7 @@ export default function ProposalsPage() { }) const current = currentQuery.data - const past = (historyQuery.data?.epochs ?? []).filter((epoch) => epoch.closed_at != null && epoch.id !== current?.id) + const past = (historyQuery.data?.epochs ?? []).filter((epoch) => wasEnacted(epoch) && epoch.id !== current?.id) return ( @@ -59,9 +59,9 @@ export default function ProposalsPage() {

Governance

Proposals

- Every governance round is a proposal to reweight the feed. Members vote on the five ranking signals; when the - round closes, the aggregated weights become the next epoch and the feed reranks. Each enacted proposal is - recorded below and in the{" "} + Every governance round proposes a complete policy: five global signal weights, topic priorities, and content + rules. When the voting window closes, ballots are aggregated for results review. An operator approves or + rejects the proposal before the active policy is changed and rescored. Enacted proposals are recorded below and in the{" "} ) : past.length === 0 ? (

- No past rounds yet. When a round closes, the enacted policy will appear here. + No enacted proposals yet. An approved policy will appear here after results review.

) : (
@@ -161,7 +161,7 @@ export default function ProposalsPage() {

Round #{epoch.id}

- Enacted {formatDate(epoch.closed_at ?? epoch.created_at)} · {epoch.vote_count} votes + Enacted {formatDate(epoch.results_approved_at)} · {epoch.vote_count} votes

diff --git a/web-next/app/start/page.tsx b/web-next/app/start/page.tsx index b07dfa78..473b709c 100644 --- a/web-next/app/start/page.tsx +++ b/web-next/app/start/page.tsx @@ -7,9 +7,9 @@ import { Container } from "@/components/ui/layout" import { PageHero, HeroGlow, HERO_TOP } from "@/components/ui/page-hero" export const metadata: Metadata = { - title: "Get started — add the Corgi feed in Bluesky", + title: "Get started — Corgi Commons and the governance demo", description: - "Add the community-governed Corgi feed to your Bluesky app, explore it read-only, and connect an app password when you're ready to vote.", + "View Corgi Commons on Bluesky, try the anonymous governance demo, or request access to the limited voting pilot.", alternates: { canonical: "/start/" }, } @@ -18,28 +18,22 @@ const CORGI_FEED_URL = "https://bsky.app/profile/corgi-network.bsky.social/feed/ const steps = [ { n: "01", - title: "Open the Corgi feed on Bluesky", + title: "View and subscribe to Corgi Commons", body: "Open the feed in your Bluesky app or on the web, then use Bluesky's feed controls to save it to your Home. No Corgi account is required to browse it.", cta: { label: "Open the feed on Bluesky", href: CORGI_FEED_URL, external: true }, }, { n: "02", - title: "Explore it read-only", - body: "Browse the ranked feed and open any post's receipt to see exactly why it ranked where it did. You can inspect everything without signing in.", - cta: { label: "Open the live demo", href: "/demo", external: false }, + title: "Try isolated shadow governance", + body: "Use the anonymous demo to propose a policy, combine it with 24 scripted deterministic voter archetypes, rerank a frozen comparison corpus, and inspect what moved. The session never changes the public feed.", + cta: { label: "Try the governance demo", href: "/demo", external: false }, }, { n: "03", - title: "Join the waitlist to vote", - body: "Voting is in a limited pilot. Request access with your Bluesky handle; once you're approved, Corgi signs you in with a Bluesky app password — a scoped credential you can revoke anytime. Your real password never touches Corgi.", + title: "Request access to the voting pilot", + body: "Governance participation is currently limited to approved waitlist participants. If approved, Corgi signs you in with a Bluesky app password — a scoped credential you can revoke anytime. Your real password never touches Corgi.", cta: { label: "Join the waitlist", href: "/sign-in", external: false }, }, - { - n: "04", - title: "Shape the feed with your community", - body: "During an open round, set how much each ranking signal should matter. When the round closes, the community's aggregated weights become the feed's next policy.", - cta: { label: "See the ballot", href: "/vote", external: false }, - }, ] as const export default function StartPage() { @@ -54,8 +48,8 @@ export default function StartPage() { size="md" align="center" eyebrow="Get started" - title="Add the Corgi feed in Bluesky." - subtitle="Corgi is a normal Bluesky custom feed — with one difference: your community governs how it ranks. Add it in a minute, explore it read-only, and vote when you’re ready." + title="Open Corgi Commons, try the demo, or join the pilot." + subtitle="Anyone can view the live Bluesky feed and use the anonymous shadow demo. Governance participation is currently available through an approved waitlist pilot." actions={ <> ) : null} diff --git a/web-next/components/footer-section.tsx b/web-next/components/footer-section.tsx index 1f9dea22..44578fac 100644 --- a/web-next/components/footer-section.tsx +++ b/web-next/components/footer-section.tsx @@ -48,17 +48,17 @@ export function FooterSection() { {/* Trust line */}

- No-login demo first · connect Bluesky when ready · inspectable ranking + No login required · shadow votes never change the public feed · governance is in a limited pilot

{/* Scroll cue — invites the visitor down into the interactive demo. */} diff --git a/web-next/components/how-it-works-replay.tsx b/web-next/components/how-it-works-replay.tsx index 35a36a55..1b2b8440 100644 --- a/web-next/components/how-it-works-replay.tsx +++ b/web-next/components/how-it-works-replay.tsx @@ -3,7 +3,7 @@ import Image from "next/image" import { useMemo, useState } from "react" import { motion, useReducedMotion } from "framer-motion" -import { Bird, Code2, Database } from "lucide-react" +import { Code2, Database, FlaskConical } from "lucide-react" import { BlueskyPostCard, RANK_COL_CLASS, RankColumnHeader } from "@/components/feed/bluesky-feed" import { CorgiRankBadge } from "@/components/feed/corgi-rank-badge" import { badgeMovementFor, rankSignalsFor } from "@/components/feed/replay-adapter" @@ -93,7 +93,7 @@ function FeedCard(props: { likeCount={post.stats.likes} /> -
+
+
) @@ -173,8 +182,8 @@ function CounterfactualComparison() {
{[ - { label: "Engagement-heavy ranking", rankedPosts: engagementRanking }, - { label: "Community-governed ranking", rankedPosts: communityRanking }, + { label: "Conversation-heavy ranking", rankedPosts: engagementRanking }, + { label: "Corgi Commons policy", rankedPosts: communityRanking }, ].map((ranking) => (

{ranking.label}

@@ -211,7 +220,7 @@ function ScoringMath() { Inspect the scoring math.

- The demo keeps raw post scores fixed so you can see the core product idea: community policy weights decide how much each signal counts. + This illustrative replay keeps raw post scores fixed so you can isolate the policy change. Production rescoring recomputes candidate scores before publication.

@@ -263,15 +272,27 @@ function ScoringMath() { For the bridge-building policy, the weights sum to 100%: recency {formatPercent(bridgeEpoch.weights.recency)}, engagement {formatPercent(bridgeEpoch.weights.engagement)}, bridging {formatPercent(bridgeEpoch.weights.bridging)}, source diversity {formatPercent(bridgeEpoch.weights.sourceDiversity)}, relevance {formatPercent(bridgeEpoch.weights.relevance)}.

+
+

Topics shape relevance

+

+ Topic preferences are a separate policy map. They change the relevance signal only; they are not five more global ranking signals. +

+
+
+

Rules shape eligibility

+

+ Adopted include rules act as an allowlist, exclude rules take precedence, and production adoption requires at least 30% support among ballots that submit content rules. +

+

What an epoch means

- An epoch is a stored feed policy from a voting round. The post signals stay the same in this demo, but the active epoch changes how much each signal matters. + An epoch is a stored feed policy. In production, a closed round is aggregated and reviewed before operator approval applies the complete policy and triggers a rescore.

- Demo posts are illustrative; live ranking claims use Corgi receipts and snapshot data. + Component scores explain the weighted sum. Publication-time adjustments, such as duplicate-link handling, can still affect final order and are called out in live receipts.

@@ -345,16 +366,16 @@ export function HowItWorksReplay() {
-
@@ -374,7 +395,7 @@ export function HowItWorksReplay() { />
- {["Discover", "Following", "Birders Who Code", "Tools"].map((tab, index) => ( + {["Discover", "Following", "Corgi Commons", "Tools"].map((tab, index) => ( {tab} {index === 2 ? : null} @@ -407,7 +428,7 @@ export function HowItWorksReplay() {

- The rank numbers and receipt panel are Corgi-site annotations for this product demo. Standard Bluesky clients render the ordered posts, not Corgi score panels. + Select a post to inspect its ranking. The rank numbers and receipt panel are Corgi-site annotations; standard Bluesky clients render the ordered posts, not Corgi score panels.

diff --git a/web-next/components/landing-ctas.tsx b/web-next/components/landing-ctas.tsx index 7b3a0423..9c35e172 100644 --- a/web-next/components/landing-ctas.tsx +++ b/web-next/components/landing-ctas.tsx @@ -16,7 +16,7 @@ export function DemoCTA({ className }: LandingCTAProps) { )} > - Explore the live demo + Try the governance demo ) diff --git a/web-next/components/replay-teaser.tsx b/web-next/components/replay-teaser.tsx index 8e181f7d..2c6dfad0 100644 --- a/web-next/components/replay-teaser.tsx +++ b/web-next/components/replay-teaser.tsx @@ -101,10 +101,10 @@ export function ReplayTeaser() { Bluesky
- {["Discover", "Following", "Birders Who Code", "Tools"].map((tab) => ( + {["Discover", "Following", "Corgi Commons", "Tools"].map((tab) => ( {tab} @@ -162,18 +162,17 @@ export function ReplayTeaser() {

- Tap Why on any post for its receipt. Rank badges and receipts are - Corgi annotations, not native Bluesky chrome. + Select Inspect on any post for its score breakdown. Rank badges and receipts are Corgi annotations, not native Bluesky chrome.

- Illustrative demo — live ranking claims use Corgi receipts and snapshot data. + Illustrative preview — the governance demo uses a frozen snapshot sourced from Corgi Commons. - See the full math and every receipt → + See the full policy and receipt walkthrough →
diff --git a/web-next/components/social-proof.tsx b/web-next/components/social-proof.tsx index b8e0b933..e56ffba9 100644 --- a/web-next/components/social-proof.tsx +++ b/web-next/components/social-proof.tsx @@ -30,8 +30,8 @@ export function SocialProof() { label: "recency, engagement, bridging, diversity, relevance", }, { - value: "Community-governed", - label: "member votes set the weights", + value: "Three ballot channels", + label: "signals, topic priorities, and content rules", }, { value: "Open source", @@ -89,7 +89,7 @@ export function SocialProof() { })}

- Every ranked post shows its receipt —{" "} + Bluesky shows the ordered feed; ranking receipts are inspected on Corgi —{" "} inspect them in the no-login demo diff --git a/web-next/e2e/production-hard-refresh.spec.ts b/web-next/e2e/production-hard-refresh.spec.ts index 2cfbdc9d..05198212 100644 --- a/web-next/e2e/production-hard-refresh.spec.ts +++ b/web-next/e2e/production-hard-refresh.spec.ts @@ -18,7 +18,7 @@ interface RouteFailure { const PUBLIC_ROUTES: readonly PublicRoute[] = [ { path: "/", heading: /Make Bluesky care about what your community cares about/i }, - { path: "/demo/", heading: /Re-rank a frozen Community Governed Feed snapshot/i }, + { path: "/demo/", heading: /Re-rank a frozen Corgi Commons snapshot/i }, { path: "/how-it-works/", heading: /Watch the same posts become a different feed/i }, { path: "/start/", heading: /Add the Corgi feed in Bluesky/i }, ] diff --git a/web-next/lib/admin-lifecycle.ts b/web-next/lib/admin-lifecycle.ts new file mode 100644 index 00000000..4afb478c --- /dev/null +++ b/web-next/lib/admin-lifecycle.ts @@ -0,0 +1,15 @@ +export function approvedParticipationPercent( + voteCount: number, + approvedParticipantCount: number +): number { + if (approvedParticipantCount <= 0) return 0; + return Math.round((voteCount / approvedParticipantCount) * 100); +} + +export async function completeAdminLifecycleRefresh( + invalidate: () => Promise, + closeConfirmation: () => void +): Promise { + await invalidate(); + closeConfirmation(); +} diff --git a/web-next/lib/api/admin.ts b/web-next/lib/api/admin.ts index 5177ef6d..1cde0764 100644 --- a/web-next/lib/api/admin.ts +++ b/web-next/lib/api/admin.ts @@ -104,6 +104,7 @@ export interface AdminStatus { currentEpoch: { id: number; status: string; + phase?: 'running' | 'voting' | 'results'; votingOpen: boolean; votingEndsAt: string | null; autoTransition: boolean; @@ -119,6 +120,7 @@ export interface AdminStatus { lastScoringRun: string | null; lastScoringDuration: number | null; subscriberCount: number; + approvedParticipantCount: number; }; contentRules: { includeKeywords: string[]; @@ -349,35 +351,6 @@ export const adminApi = { return response.data; }, - async updateEpoch(data: { - votingOpen?: boolean; - votingEndsAt?: string | null; - autoTransition?: boolean; - }): Promise<{ success: boolean; epoch: Partial }> { - const response = await api.patch('/api/admin/epochs/current', data); - return response.data; - }, - - async transitionEpoch(options: { force?: boolean; announceResults?: boolean } = {}): Promise<{ - success: boolean; - previousEpoch: { id: number; totalVotes: number }; - newEpoch: { id: number }; - announcement: { postUrl: string } | null; - }> { - const response = await api.post('/api/admin/epochs/transition', options); - return response.data; - }, - - async closeVoting(): Promise<{ success: boolean }> { - const response = await api.post('/api/admin/epochs/close-voting'); - return response.data; - }, - - async openVoting(): Promise<{ success: boolean }> { - const response = await api.post('/api/admin/epochs/open-voting'); - return response.data; - }, - async getAnnouncements(): Promise<{ announcements: Announcement[] }> { const response = await api.get('/api/admin/announcements'); return response.data; @@ -482,29 +455,11 @@ export const adminApi = { return response.data; }, - async applyResults(): Promise<{ - success: boolean; - voteCount: number; - appliedWeights: boolean; - weights: GovernanceWeights; - contentRules: ContentRules; - round: RoundSummary; - rescoreTriggered: boolean; - }> { - const response = await api.post('/api/admin/governance/apply-results'); - return response.data; - }, - async getRoundDetails(id: number): Promise { const response = await api.get(`/api/admin/governance/rounds/${id}`); return response.data; }, - async endRound(force = false): Promise<{ success: boolean; newRoundId: number }> { - const response = await api.post('/api/admin/governance/end-round', { force }); - return response.data; - }, - async startVoting(durationHours: number, announce = true): Promise<{ success: boolean; round: RoundSummary }> { const response = await api.post('/api/admin/governance/start-voting', { durationHours, @@ -517,6 +472,7 @@ export const adminApi = { success: boolean; voteCount: number; proposedWeights: GovernanceWeights; + proposedTopicWeights: Record; proposedContentRules: ContentRules; round: RoundSummary; }> { @@ -529,6 +485,7 @@ export const adminApi = { async approveResults(announce = true): Promise<{ success: boolean; weights: GovernanceWeights; + topicWeights: Record; contentRules: ContentRules; rescoreTriggered: boolean; round: RoundSummary; diff --git a/web-next/lib/api/client.ts b/web-next/lib/api/client.ts index d79286d1..71de55a8 100644 --- a/web-next/lib/api/client.ts +++ b/web-next/lib/api/client.ts @@ -224,6 +224,7 @@ export interface EpochResponse { subscriber_count?: number; created_at: string; closed_at?: string; + results_approved_at?: string | null; description?: string; voting_started_at?: string | null; voting_ends_at?: string | null; @@ -251,6 +252,7 @@ interface EpochApiResponse { subscriber_count?: number; created_at: string; closed_at?: string; + results_approved_at?: string | null; description?: string; voting_started_at?: string | null; voting_ends_at?: string | null; @@ -277,6 +279,7 @@ function toEpochResponse(epoch: EpochApiResponse): EpochResponse { subscriber_count: epoch.subscriber_count, created_at: epoch.created_at, closed_at: epoch.closed_at, + results_approved_at: epoch.results_approved_at, description: epoch.description, voting_started_at: epoch.voting_started_at, voting_ends_at: epoch.voting_ends_at, diff --git a/web-next/lib/governance-status.ts b/web-next/lib/governance-status.ts index e02654d8..ce0e2d7d 100644 --- a/web-next/lib/governance-status.ts +++ b/web-next/lib/governance-status.ts @@ -46,6 +46,13 @@ export function policyIsActive(epoch: { status: string; closed_at?: string | nul return epoch.status === "active" || epoch.closed_at == null } +/** Whether the current response identifies this epoch as the applied policy. + * Older responses may omit `phase`; an explicit non-running phase is not + * treated as applied. */ +export function policyApplied(epoch: { status: string; phase?: string }): boolean { + return epoch.status === "active" && (epoch.phase === undefined || epoch.phase === "running") +} + /** Below this member count, "N of M voted · X%" reads as a ghost town rather * than transparency — show plain ballot counts instead of participation math. */ export const MIN_MEANINGFUL_MEMBERS = 10 diff --git a/web-next/lib/replay-model.ts b/web-next/lib/replay-model.ts index 4c47af81..ef2a059f 100644 --- a/web-next/lib/replay-model.ts +++ b/web-next/lib/replay-model.ts @@ -91,7 +91,7 @@ export const signals: readonly Signal[] = [ key: "relevance", label: "Relevance", shortLabel: "Match", - description: "How well the post matches the community's topic.", + description: "How well the post matches the community's topic priorities.", barColor: "#C8612C", }, ] as const @@ -103,8 +103,8 @@ export const demoPosts: readonly DemoPost[] = [ handle: "@maya-keene.bsky.social", time: "14m", avatarSrc: "/images/avatars/maya-keene.png", - text: "Built a tiny script to log neighborhood finch sightings from my morning walks.", - tags: ["birding", "code", "field notes"], + text: "Mapped accessibility gaps in open transit data, then shared the notebook and cleaned CSV.", + tags: ["open data", "accessibility", "transit"], scores: { recency: 0.72, engagement: 0.42, bridging: 0.92, sourceDiversity: 0.68, relevance: 0.9 }, stats: { replies: "18", reposts: "42", likes: "164" }, }, @@ -114,8 +114,8 @@ export const demoPosts: readonly DemoPost[] = [ handle: "@toastwindow.bsky.social", time: "6m", avatarSrc: "/images/avatars/claire-rowan.png", - text: "Rare tanager spotted near the east trailhead this morning.", - tags: ["sighting", "local", "fresh"], + text: "A maintainer note on making privacy-preserving defaults easier to find in project docs.", + tags: ["open source", "privacy", "documentation"], scores: { recency: 0.96, engagement: 0.35, bridging: 0.28, sourceDiversity: 0.55, relevance: 0.82 }, stats: { replies: "9", reposts: "25", likes: "112" }, }, @@ -125,8 +125,8 @@ export const demoPosts: readonly DemoPost[] = [ handle: "@arjunmehta.dev", time: "31m", avatarSrc: "/images/avatars/arjun-mehta.png", - text: "This CSS bug has haunted me for three days.", - tags: ["code", "debugging"], + text: "Found a timezone bug in our benchmark harness. Patch and before-and-after runs are in the thread.", + tags: ["software", "reproducibility", "benchmarking"], scores: { recency: 0.64, engagement: 0.58, bridging: 0.22, sourceDiversity: 0.42, relevance: 0.48 }, stats: { replies: "21", reposts: "36", likes: "208" }, }, @@ -136,8 +136,8 @@ export const demoPosts: readonly DemoPost[] = [ handle: "@eli-overthinking.bsky.social", time: "18m", avatarSrc: "/images/avatars/eli-moreno.png", - text: "Programmers will do anything except go outside.", - tags: ["joke", "viral"], + text: "Every open-source project has one file everyone is scared to touch.", + tags: ["open source", "joke", "viral"], scores: { recency: 0.7, engagement: 0.95, bridging: 0.3, sourceDiversity: 0.35, relevance: 0.36 }, stats: { replies: "86", reposts: "511", likes: "4.2K" }, }, @@ -147,8 +147,8 @@ export const demoPosts: readonly DemoPost[] = [ handle: "@thocknotes.bsky.social", time: "23m", avatarSrc: "/images/avatars/theo-kim.png", - text: "Open-source bird-call classifier dataset just dropped.", - tags: ["dataset", "birding", "ml"], + text: "Published a dataset comparing alt-text quality across image models, plus the scoring rubric.", + tags: ["dataset", "accessibility", "machine learning"], scores: { recency: 0.68, engagement: 0.62, bridging: 0.88, sourceDiversity: 0.84, relevance: 0.94 }, stats: { replies: "32", reposts: "96", likes: "340" }, }, @@ -158,8 +158,8 @@ export const demoPosts: readonly DemoPost[] = [ handle: "@ninavaldez.bsky.social", time: "1h", avatarSrc: "/images/avatars/nina-valdez.png", - text: "Field notes from a rainy owl survey, plus the messy CSV.", - tags: ["field notes", "csv", "survey"], + text: "Field notes from a community mesh-network test, including the messy CSV and failed runs.", + tags: ["community networks", "field notes", "data"], scores: { recency: 0.46, engagement: 0.28, bridging: 0.74, sourceDiversity: 0.72, relevance: 0.86 }, stats: { replies: "7", reposts: "19", likes: "88" }, }, @@ -169,8 +169,8 @@ export const demoPosts: readonly DemoPost[] = [ handle: "@leilahart.bsky.social", time: "16m", avatarSrc: "/images/avatars/leila-hart.png", - text: "My camera roll is 80% blurry sparrows and 20% screenshots of stack traces.", - tags: ["birding", "code", "funny"], + text: "The best research software is half methods section, half apology to future maintainers.", + tags: ["research software", "methods", "culture"], scores: { recency: 0.75, engagement: 0.66, bridging: 0.7, sourceDiversity: 0.52, relevance: 0.76 }, stats: { replies: "15", reposts: "54", likes: "261" }, }, @@ -180,7 +180,7 @@ export const epochs: readonly Epoch[] = [ { id: "engagement", eyebrow: "Epoch 07", - label: "Engagement-heavy", + label: "Conversation-heavy", headline: "Likes dominate the feed.", body: "This is the failure mode the community wants to fix: the viral joke wins even though it is a weak match.", weights: { recency: 0.05, engagement: 0.65, bridging: 0.05, sourceDiversity: 0.05, relevance: 0.2 }, @@ -188,7 +188,7 @@ export const epochs: readonly Epoch[] = [ { id: "bridge", eyebrow: "Epoch 08", - label: "Bridge-building policy", + label: "Bridge-building", headline: "The community boosts posts that connect subgroups.", body: "The policy rewards posts that carry useful context across both sides of the feed.", weights: { recency: 0.15, engagement: 0.1, bridging: 0.35, sourceDiversity: 0.15, relevance: 0.25 }, @@ -196,17 +196,17 @@ export const epochs: readonly Epoch[] = [ { id: "field", eyebrow: "Epoch 09", - label: "Field-notes policy", - headline: "Useful sources get more room.", - body: "The feed shifts toward relevance and source diversity so datasets, surveys, and field notes do not vanish under jokes.", + label: "Research and tooling", + headline: "Reusable work gets more room.", + body: "The feed shifts toward relevance and source diversity so datasets, methods, and useful tooling do not vanish under jokes.", weights: { recency: 0.15, engagement: 0.1, bridging: 0.15, sourceDiversity: 0.3, relevance: 0.3 }, }, { id: "freshness", eyebrow: "Epoch 10", label: "Freshness push", - headline: "Time-sensitive sightings rise.", - body: "When the community cares about today's field context, fresh sightings move up without making likes the whole policy.", + headline: "Time-sensitive work rises.", + body: "When the community cares about what is happening now, fresh releases and active conversations move up without making likes the whole policy.", weights: { recency: 0.55, engagement: 0.05, bridging: 0.05, sourceDiversity: 0.05, relevance: 0.3 }, }, ] as const @@ -214,36 +214,36 @@ export const epochs: readonly Epoch[] = [ export const demoPersonas: readonly DemoPersona[] = [ { id: "field-notes", - label: "Field Notes Birder", - role: "Wants useful sightings and field context to survive the scroll.", - body: "Boosts source-rich notes and posts that match the Birders Who Code topic.", + label: "Research Steward", + role: "Wants careful methods and useful context to survive the scroll.", + body: "Boosts source-rich work and posts that match the community's research priorities.", weights: { recency: 0.18, engagement: 0.07, bridging: 0.14, sourceDiversity: 0.31, relevance: 0.3 }, }, { id: "dataset-maintainer", label: "Dataset Maintainer", role: "Cares about reproducible data, open tooling, and reusable context.", - body: "Raises datasets, messy CSVs, classifiers, and well-labeled observations.", + body: "Raises datasets, reproducible runs, useful tools, and well-labeled observations.", weights: { recency: 0.1, engagement: 0.06, bridging: 0.24, sourceDiversity: 0.28, relevance: 0.32 }, }, { id: "freshness-watcher", label: "Freshness Watcher", - role: "Wants the feed to notice time-sensitive sightings before they go stale.", + role: "Wants the feed to notice time-sensitive releases and conversations before they go stale.", body: "Pushes recency without letting generic virality take over the community.", weights: { recency: 0.52, engagement: 0.06, bridging: 0.08, sourceDiversity: 0.08, relevance: 0.26 }, }, { id: "joke-enjoyer", - label: "Joke Enjoyer", - role: "Likes the culture posts too, as long as people can rebalance them.", + label: "Conversation Follower", + role: "Values the culture and conversation posts too, as long as people can rebalance them.", body: "Gives engagement real weight so funny posts can win when the community chooses that.", weights: { recency: 0.13, engagement: 0.55, bridging: 0.08, sourceDiversity: 0.04, relevance: 0.2 }, }, { id: "bridge-builder", label: "Bridge Builder", - role: "Wants posts that connect birders, coders, data people, and local observers.", + role: "Wants posts that connect researchers, builders, data people, and open-network communities.", body: "Rewards posts that carry useful context across subgroups inside the feed.", weights: { recency: 0.12, engagement: 0.08, bridging: 0.42, sourceDiversity: 0.13, relevance: 0.25 }, },