trevato/quorum

Public: anyone can read this; the crew can change it.

Where a small group decides things: propose, deliberate asynchronously, decide when the quorum rule is met; agents vote too.

Running 2adbe7d https://quorum--forge-trevato-apps.open-platform.sh in production 10d 23h
History

README.md

quorum

Where a small group decides things: propose, deliberate asynchronously, decide when the quorum rule is met; agents vote too.

This is an app: the forge runs it. Production follows the default branch; every open pull gets a preview at its own address with its own copy of the data. Everything the app keeps lives under FORGE_APP_DATA, which is what makes "production data" and "preview data" two different things.

PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts   # run it here
bun run test                                            # the check every pull runs
bun test                                                # just the tests
FORGE_APP_DATA=./data bun run src/tty.ts                # the terminal
FORGE_APP_DATA=./data bun run src/db.ts --check         # schema version, integrity

The shape

One implementation, many doors. src/actions.ts is every operation as a plain function — (db, who, input) in, data or a named failure out — and the page, the JSON API, the MCP server and the terminal all call it and decide nothing themselves. AGENTS.md says how to add to it.

filewhat it is
src/actions.tspropose, edit, list, read, withdraw, remark, health — who may
src/quorum.tsthe three rules as plain data: read one, say it, count it
src/plain.tssomebody else's text, safe to print on a terminal
src/who.tswho is asking, from the forge's signed headers
src/db.tsSQLite under FORGE_APP_DATA, WAL, forward-only migrations
src/live.tsthe event log, the bus, and GET /events
src/backup.tsa copy of the record, and putting one back
src/mcp.tsthe actions as MCP tools at POST /mcp
src/routes.tsevery path, in one table
src/pages/the HTML, rendered on the server: one layout, four pages
src/http.tsthe floor: headers, body cap, same-origin, rate limit, files
src/server.tsboot: open the database, serve
src/tty.tsthe terminal, over the same actions
src/env.tswhat the forge told this process
static/app.css, static/live.jsthe look, and the list that stays current

Who you are

The forge says who is asking on every request it proxies, and the app verifies the signature (src/who.ts); nothing else is trusted. Three tiers:

  • anon — anyone. Reads.
  • user — signed in to the forge and able to see this app. Makes

proposals, as themselves, corrects and withdraws their own, says things on anybody's and deletes what they said; on a proposal they opened, they can accept a counter.

  • admin — able to write this repository. Withdraws any proposal, deletes

any remark, accepts a counter on any proposal, and takes a copy of the whole record. Not editing anybody's text: an admin takes things away, and does not put words under somebody else's name.

"Sign in" is /__forge/signin?to=<path> and "Sign out" is /__forge/signout; the forge answers both on the app's own host.

Any interface

The same actions, the same rules, at every door. A script is the person whose forge grant it carries; a bare script is anonymous.

In a browser, / is every open proposal — newest first, with its author, how many options it carries and where it stands — and the composer for whoever is signed in. /p/<id> is one proposal: the paragraph, the options in order, and a Withdraw form for its author or an admin while it is open. The composer picks the rule from a select, with one field beside it for the names and count a named: rule needs; /p/<id> says the rule in words above the options. A guest sees both pages and no forms. With nothing proposed yet, / says in four sentences what a proposal is rather than that it is empty.

Its proposer can correct the words: /p/<id>/edit is a form holding the title and the paragraph as they stand now, open to the person who wrote it and only while the proposal is open — an admin withdraws anybody's proposal and edits nobody's. What may be edited is the title and the paragraph, and nothing else: the options are what the group is choosing between, so changing them would change what a ballot already cast was cast for, and a counter is how an option is added. A correction is shown rather than hidden: /p/<id> says "edited" beside the proposer's name with the time it happened, the thread underneath is untouched, and saving the same words again changes nothing and claims nothing. (That mark comes from a column of its own, edited_at, and not from comparing updated_at with created_at — withdrawing a proposal and accepting a counter stamp updated_at too, and neither is an edit.)

Under the proposal is the argument about it: a composer for whoever is signed in, and then the thread, oldest first, each remark labelled with its kind. The thread is the last thing on the page and grows at the bottom, so a remark arriving live moves nothing above it — least of all the box somebody is typing in. An author sees a Delete on their own remark and an admin on every one; the proposal's author and an admin see Accept on a counter that names an option the proposal does not have yet.

/about, linked from the footer of every page, is the longer version: how a proposal is decided, the three rules printed from src/quorum.ts, two plain lists of what is built and what is not (voting, deliberation, a roster and bindings are not), and the four doors — this page, /api/proposals, POST /mcp and the terminal, in the exact words of FORGE_APP_SSH or off. Only the prose there is written by hand: every fact it states is read from the module that owns it, so it rots when a module changes and not before.

curl https://<app>/api/proposals            # every proposal, newest first
curl 'https://<app>/api/proposals?state=open'
curl https://<app>/api/proposals/1          # one, with its options in order
curl -X POST https://<app>/api/proposals -H 'content-type: application/json' \
  -d '{"title":"Adopt the four-day week","body":"A trial for one quarter.","options":["Yes","No"],"rule":"named:alice,bob,carol:2"}'
curl -X PATCH https://<app>/api/proposals/1 -H 'content-type: application/json' \
  -d '{"title":"Adopt the four-day week","body":"A trial for two quarters."}'
curl -X POST https://<app>/api/proposals/1/withdraw
curl https://<app>/api/proposals/1/remarks  # the thread, oldest first
curl -X POST https://<app>/api/proposals/1/remarks -H 'content-type: application/json' \
  -d '{"kind":"counter","body":"Neither room is warm in winter.","optionLabel":"The library"}'
curl -X POST https://<app>/api/remarks/1/accept   # the proposal's author, or an admin
curl -X DELETE https://<app>/api/remarks/1        # its author, or an admin
curl https://<app>/api/health              # 200 while sound, 503 when it is not

GET /api/health is the route a probe can hit forever: {ok, env, commit, schemaVersion, uptimeSeconds} for anyone, no write, no count of anybody's data, well under a millisecond. ok is false — and the status 503 — when the schema in the file is not the one this code knows, when FORGE_APP_DATA has stopped taking writes, or when SQLite reports the file corrupt (a quick_check, remembered for a minute so a probe stays cheap). An admin asking gets {identity, dataDir, databaseBytes, writable, terminal} on top: identity is verified when FORGE_APP_IDENTITY_KEY is set and disabled when it is not, and terminal is FORGE_APP_SSH exactly as the forge gave it, or off. At boot the app prints one line per misconfiguration — the name of the variable and what it costs, never its value.

The record, as a file

Everything decided here is one SQLite file under FORGE_APP_DATA, and an admin can take it away and put it back. Taking a copy is a read; putting one back is not on the web at all.

curl -OJ https://<app>/api/backup.db     # a copy, admin only, VACUUM INTO at that moment
curl https://<app>/api/backup.json       # the same rows as JSON, every table, admin only
FORGE_APP_DATA=./data bun run src/backup.ts --snapshot         # a copy beside the database
FORGE_APP_DATA=./data bun run src/backup.ts --restore <file>   # put one back, at the terminal

A restore moves the current file aside as app-before-<moment>.db — with its WAL, which would otherwise be replayed onto the file that replaced it — rather than deleting it, and refuses a file whose schema version is newer than this build's migration list. A preview and production each have their own FORGE_APP_DATA, so a copy taken from one is a copy of that one; restore it where it came from. /about shows an admin the file's size, its schema version, the last snapshot kept beside it and both download links, and shows nobody else anything.

Migrations, against real data

src/db.ts holds a forward-only list of migrations, applied once and in one transaction when the file is opened. A preview gets a copy of production's data, so a migration meets real rows there before a person does — and the two ways that goes wrong each say so in a sentence:

migration 7 failed: Cannot add a NOT NULL column with default value NULL
database is at schema version 8 but this build has 6 migrations: it was
written by a newer branch than this one, so nothing was applied.

Either one goes to stderr and the process exits 1 — at boot, at the terminal and from bun run src/db.ts --check — and never as a stack trace. The transaction is all-or-none: a failed migration leaves no half schema and no version recorded for it, so fixing the code and deploying again is the whole recovery. A database recorded ahead of the list is refused rather than migrated, because a preview from an older branch must not write versions over newer ones.

bun run test is bun test and then those migrations against a throwaway FORGE_APP_DATA, which is what the pull's check runs. The two halves catch different things, and the check needs both: --check on an empty directory proves the list applies at all, and bun test is what meets rows — src/db.test.ts has one case per shipped migration, where atVersion(n) builds a database with the first n migrations and a seeded row in every table that exists at that version, and the case asserts every row survives the next migration with PRAGMA integrity_check still ok. A migration that fails only against data — the common kind — fails there, before the preview's boot. A migration that has run is never edited; if one was wrong, the fix is another migration.

A proposal is a title (1–120 characters), a paragraph (1–2000) and two to eight options, each labelled, each different. Every option is a line of text: a JSON or MCP caller that sends a number, an object or a list in that array is refused rather than having it coerced into a label. Its author or an admin can withdraw it while it is open, once.

rule says how it will be decided. The proposer picks it when they open the proposal — on the page, over the API, in MCP or at the terminal — and it is one line of text in one of three forms:

majority                  more than half of the group on one option decides
all                       everyone has voted and they all agree
named:alice,bob,carol:2   2 of those three agree; nobody else counts

Anything else is refused with a sentence naming the three forms, and no rule at all means majority. A proposal keeps the rule it was opened with.

Deliberation

The argument happens on the proposal and stays part of the record. A remark is 1 to 2000 characters by a signed-in person, and it is one of three kinds:

  • comment — something to say about the proposal: an opinion, a fact, a

caveat, anything that is not a question or a demand for another option.

  • question — something to ask its author, asked in the open so the

answer is in the open too.

  • counter — an argument for another option, which may name the option it

wants in optionLabel (1–80 characters).

A remark's author or an admin deletes it. The proposal's author or an admin can accept a counter that names an option, while the proposal is open: the label is appended to the proposal as one more option and the counter is stamped as accepted. Nothing else about the proposal moves, so every ballot already cast still means what it meant — the page says so where the accepted counter is. Accepting twice, accepting a counter that names nothing, naming an option the proposal already has, and accepting on a proposal that is withdrawn or decided are each refused.

A withdrawn or decided proposal still takes remarks: the record continues after the decision, and an argument about what was decided belongs where the decision is.

As an MCP server, for Claude Code or Claude Desktop — list_proposals, get_proposal, propose, withdraw_proposal, list_remarks, add_remark, health (the same action behind /api/health, gated the same way):

claude mcp add --transport http __TITLE__ https://<app>/mcp
{
  "mcpServers": { "__TITLE__": { "type": "http", "url": "https://<app>/mcp" } }
}

The page stays current over /events: a proposal someone else opens or withdraws, and a remark someone else writes, deletes or has accepted, appears without a reload — including one written at the terminal, which is another process on the same file. Every event is a row in event, so the server follows what it did not write itself while anyone is watching, and a browser that reconnects with Last-Event-ID is replayed from those rows even across a restart. The rows are trimmed to the last hour and the last thousand. Without JavaScript it is the same page, one reload behind.

A swap does not cost you your place. Before a region is replaced, static/live.js notes what has focus inside it — by id, or by name — and where the caret sits; afterwards the same control in the fresh markup gets both back, and if it is gone the region's heading is focused rather than <body>. A box you are still typing in is not replaced at all: an <input>, <textarea> or <select> that holds focus and holds something the server's copy does not have keeps its region as it is, and the region catches up the moment you leave it. Each region is aria-live="polite" and points at a one-line role="status" summary beside it — "A new remark", "A vote was cast" — written on the server from the event type (summarize in src/live.ts) and carried on the stream, so a screen reader hears a sentence rather than the whole region read again.

The terminal opens with the exact command in FORGE_APP_SSH when the app runs (ssh -p 2222 <name>@<forge> — the app is the login name, the forge the host; <name>--<pull>@<forge> for a preview's). It is src/tty.ts on a real pty over the same data, as the account at the keyboard: j/k moves, Enter opens the proposal under the cursor — with the thread on it underneath — n proposes (a title, then the paragraph, then options one per line until a blank line, then the rule — Enter alone for majority), c says something (1, 2 or 3 for the kind, then the text, then the option a counter wants), e corrects the title and the paragraph of one you opened (each arriving already typed), w withdraws, Esc goes back and q quits. A guest is told to sign in rather than shown a composer. Nothing anyone else wrote reaches that screen as a control character: it goes through src/plain.ts on the way out, so the only escape sequences a terminal here sees are the app's own.

The forge sets PORT, FORGE_APP_DATA, FORGE_APP_ENV (production or preview), FORGE_APP_URL, FORGE_APP_COMMIT, FORGE_APP_SSH and FORGE_APP_IDENTITY_KEY when it starts the app. forge.json says how.

↑↓ navigate ↵ open esc close

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs