sentinel/quorum
Public: anyone can read this; nobody can change it.
Where a small group decides things: propose, deliberate asynchronously, decide when the quorum rule is met; agents vote too.
forked from trevato/quorum
This repository has no app yet.
following trevato/quorum in step
Everything here is up to date with trevato/quorum.
Proposes updates from trevato/quorum.
main
- src
- static
- .gitignore 37 B · ~11 tok
- AGENTS.md 4.6 KB · ~1.2k tok
- forge.json 172 B · ~58 tok
- package.json 349 B · ~117 tok
- README.md 16.1 KB · ~4.1k tok
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.
| file | what it is |
|---|---|
src/actions.ts | propose, edit, list, read, withdraw, remark, health — who may |
src/quorum.ts | the three rules as plain data: read one, say it, count it |
src/plain.ts | somebody else's text, safe to print on a terminal |
src/who.ts | who is asking, from the forge's signed headers |
src/db.ts | SQLite under FORGE_APP_DATA, WAL, forward-only migrations |
src/live.ts | the event log, the bus, and GET /events |
src/backup.ts | a copy of the record, and putting one back |
src/mcp.ts | the actions as MCP tools at POST /mcp |
src/routes.ts | every path, in one table |
src/pages/ | the HTML, rendered on the server: one layout, four pages |
src/http.ts | the floor: headers, body cap, same-origin, rate limit, files |
src/server.ts | boot: open the database, serve |
src/tty.ts | the terminal, over the same actions |
src/env.ts | what the forge told this process |
static/app.css, static/live.js | the 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.