| lastUpdated | 2026-10-29 |
|---|
A debate engine that records what people actually claimed — precisely enough that a machine can address, quote, and rule on one proposition at a time.
Most discussion tools store prose. Prose is easy to write and nearly impossible to verify: you cannot mechanically ask "did these two people actually disagree?" or "is this specific proposition true?" because there is no this specific proposition — only a paragraph.
db8 stores each argument twice: once as the text a human wrote, and once as a structured claim a machine can walk. Every node of that structure has a stable address, so a judge rules on "the study says X" and "X" separately. Each round is canonicalized, hashed, and signed into a journal.
If you read only this far: db8 turns "someone argued something" into "this exact proposition, in this exact context."
Status: research prototype, not deployable as-is. The claim model, the canonicalization, the round lifecycle, and the storage layer are real and tested. The authentication layer is not: the server currently reads no
Authorizationheader on any route, so identity in a request body is asserted rather than proven. Read Security posture before pointing this at anything real.
Here is one real submission. Everything below refers back to it.
A debater writes an ordinary sentence:
The evidence on remote work is contested.
and attaches one structured claim, which reads: the study says remote work reduces productivity.
{
"room_id": "00000000-0000-0000-0000-000000000001",
"round_id": "00000000-0000-0000-0000-000000000002",
"author_id": "00000000-0000-0000-0000-000000000003",
"phase": "submit",
"deadline_unix": 4102444800,
"content": "The evidence on remote work is contested.",
"claims": [
{
"id": "c1",
"term": {
"kind": "framed",
"frame": { "kind": "attribution", "source": { "kind": "named", "name": "the_study" } },
"body": {
"kind": "claim",
"subject": { "kind": "named", "name": "remote_work" },
"predicate": "reduces",
"object": "productivity"
}
},
"support": [{ "kind": "citation", "ref": "https://example.com/study" }]
}
],
"citations": [{ "url": "https://example.com/a" }, { "url": "https://example.com/b" }],
"client_nonce": "fixednonce123"
}The outer node is a frame — an attribution, meaning someone else said
this. Inside it sits the proposition itself: subject remote_work, predicate
reduces, object productivity.
That distinction is the whole idea, and it is not pedantry. The debater has committed to "the study says X." They have not committed to X. Those are two different assertions, they can have two different truth values, and a judge must be able to rule on them separately. Hold onto that — everything in the next three sections depends on it.
Intuition to carry forward: a claim is a tree, not a sentence. Different parts of the tree carry different commitments, and each part has an address.
Before going further, here are the seven nouns db8 uses. Every one appears in the example above.
| Term | What it is |
|---|---|
| room | One debate. Owns its configuration and its participants. |
| round | One turn of a room. Has a phase and deadlines. |
| phase | Where a round is in its life: submit, published, or final. |
| participant | A member of a room, with role debater, host, or judge. |
| submission | One debater's content plus their structured claims, for one round. |
| claim term | The tree above — the machine-readable form of an argument. |
| verdict | A judge's ruling on one node of a claim term. |
These nest exactly as you would expect, and the nesting is what makes a verdict addressable. The diagram below shows the ownership chain; note especially that a verdict points at a claim path, not merely at a claim.
erDiagram
ROOM ||--o{ ROUND : "has many"
ROOM ||--o{ PARTICIPANT : "has many"
ROUND ||--o{ SUBMISSION : "collects"
PARTICIPANT ||--o{ SUBMISSION : "authors"
SUBMISSION ||--o{ CLAIM : "carries"
CLAIM ||--|| CLAIM_TERM : "is a tree of nodes"
CLAIM_TERM ||--o{ CLAIM_PATH : "every node has one"
CLAIM_PATH ||--o{ VERDICT : "a judge rules here"
ROUND ||--|| JOURNAL : "is sealed into"
Figure 1 - The db8 domain model
Figure 1 caption: Ownership runs top to bottom. The load-bearing relationship is the last-but-one: a verdict attaches to a claim path — one node inside one claim — rather than to the claim as a whole. That single indirection is what lets two judges disagree about different parts of the same sentence.
In summary, db8's data model is an ordinary nesting of rooms, rounds, and submissions, with one deliberate refinement: the unit a judge rules on is a node inside an argument, not the argument itself.
The claim term language exists because natural language routinely wraps a proposition in something that changes what the speaker is committing to. db8 makes that wrapping explicit, gives every node an address, and refuses to let a machine "simplify" the structure in ways that change meaning.
There are seven node kinds. Each names its children, and those child names are fixed forever, because they are what addresses are built from:
| Node kind | Children | Reads as |
|---|---|---|
claim |
(none) | subject–predicate–object; the only node that asserts anything by itself |
framed |
body |
a proposition wrapped in a frame (see below) |
all |
parts |
A and B and C |
either |
options |
A or B |
denial |
body |
not A |
conditional |
when, then |
if A then B |
concession |
even_if, still |
even if A, still B |
A frame is what makes the outer node non-committal. There are seven, and they split into two groups that behave very differently:
| Frame | Group | Effect on commitment |
|---|---|---|
attribution, belief, hypothetical, hedge, evaluative |
opaque | You do not assert the inside. "The study says X" leaves X open. |
temporal, domain |
transparent | You do assert the inside, just situated. "In 2024, X" still asserts X. |
An address into this tree is a claim path: $ is the root, $.body is the
child of a frame or denial, $.parts[1] is the second conjunct. So in our
running example:
$is "the study says remote work reduces productivity" — an attribution.$.bodyis "remote work reduces productivity" — the bare proposition.
A judge can rule $ false (the study says no such thing) while ruling
$.body true (it happens to be true anyway). Those are stored as two rows,
and the scoring aggregate reports them separately. Merge them and you have
destroyed the only distinction that mattered.
The system needs to recognize its own negative case. Consider:
{
"kind": "framed",
"frame": { "kind": "hedge", "expression": "may" },
"body": {
"kind": "framed",
"frame": { "kind": "attribution", "source": { "kind": "named", "name": "some_people" } },
"body": {
"kind": "claim",
"subject": { "kind": "named", "name": "remote_work" },
"predicate": "reduces",
"object": "productivity"
}
}
}"It may be that some people say remote work reduces productivity." Every node is well-formed. The tree is valid. And it contains zero checkable propositions, because the descent stops at the first opaque frame and never reaches the claim. db8 can say so mechanically. A debater who wraps everything this way has taken no position, and the system knows it.
In summary, the tree is not decoration: it is what makes "what did you actually commit to?" a question with a computable answer.
Structure alone is not enough — a transcript nobody can verify is just a database someone can edit. db8 closes that gap with three mechanisms that build on each other: a canonical form, a digest, and a signed chain.
The problem is that JSON has no single byte representation. {"a":1,"b":2} and
{"b":2,"a":1} are the same value and different bytes, so a naive hash would
change every time a client serialized differently. db8 therefore canonicalizes
first — by default RFC 8785 (JCS), which fixes key order, number formatting, and
escaping — and hashes the result.
You can watch this work right now. Save the submission from the top of this page
as draft.json, then run:
node bin/db8.js draft validate --path draft.json --nonce fixednonce123 --json{"ok":true,"canonical_sha256":"e05fa5dfd6cd4b0b06ff21bb75babb044a1dff79057c41a3946b2f5def39ccd0"}
That digest is reproducible: run it twice and it is identical. Now shuffle the
top-level keys of the file and run it again — the digest does not move, which
is exactly the point of canonicalization. Then change one word, reduces to
increases, and run it a third time:
{"ok":true,"canonical_sha256":"f3f69ba0625bc6ae66230c3d38541a9f3bc3a4d6d2c868668f919920d8514ed0"}
Completely different. Formatting is invisible to the digest; meaning is not.
Those digests are then collected per round into a journal. Its core lists
the round's submission digests and the previous round's journal hash; the journal
carries hash = sha256(canonicalize(core)) and an Ed25519 signature over that
hash. Recomputing hash from core and checking the signature is what would make
an altered argument detectable.
db8 journal verify does only part of that today. It fetches the journals from the
API, checks each signature over the stored hash, and checks the prev_hash
links. It does not recompute hash from core, so a core edited in the
database, with its old hash and signature kept, still passes
(#209; see
Security posture).
In summary, canonical form makes a digest meaningful, the digest makes a claim addressable by content, and a signed chain can make a round tamper-evident once verification recomputes each hash.
With the model established, the golden path is easy to follow. A submission crosses four boundaries — HTTP, validation, canonicalization, and persistence — and is rejected at the first one it fails.
sequenceDiagram
participant CLI as "db8 CLI or browser"
participant API as "Express API"
participant Zod as "Zod schema"
participant Terms as "claim term validator"
participant PG as "Postgres RPC"
CLI->>API: "POST /rpc/submission.create"
API->>Zod: "parse body"
Zod-->>API: "400 on malformed input"
API->>Terms: "validateTerm on each claim"
Terms-->>API: "400 invalid_claim_term with the offending path"
API->>API: "canonicalize then sha256"
API->>PG: "submission_upsert"
PG-->>API: "row id, idempotent on client_nonce"
API-->>CLI: "200 with canonical_sha256"
CLI->>CLI: "compare digests, exit 2 on mismatch"
Figure 2 - The submission golden path
Figure 2 caption: Each arrow back toward the client is a refusal point. Note the final step: the CLI recomputes the digest and compares it against the server's, so a client learns immediately if the server stored something other than what was sent.
The same journey in words, including what each stage rejects:
| Stage | Job | Rejects with |
|---|---|---|
| CORS | Allow-list check; never a wildcard | no Access-Control-Allow-Origin |
| Rate limit | Throttle rapid calls | 429 |
| Zod | Shape and types | 400 with the failing field path |
validateTerm |
Claim tree legality, depth ≤ 16, size ≤ 256 nodes | 400 invalid_claim_term |
| Canonicalize | Byte-exact form, then SHA-256 | — |
submission_upsert |
Durable write, one row per (round, author) |
SQL error |
Idempotency is worth a sentence: a participant has one submission per round, and
each client_nonce names one document. Retrying a submission after a timeout with
the same nonce returns the same row rather than creating a second one; a revision
takes a fresh nonce and replaces the row; the same nonce with a different document
is refused (nonce_reused). Retry safety is a property of the schema, not of
client discipline.
In summary, a submission is validated in widening circles — transport, shape, meaning, then storage — and only becomes a durable, addressable, hashable fact once it has passed all four.
Rounds advance on deadlines and on submissions, not on someone clicking a button. A background watcher process polls for rounds that are due and moves them.
stateDiagram-v2
[*] --> submit
submit --> published : "deadline passes, or every debater has submitted"
published --> [*] : "continue vote wins: next round opens in submit"
published --> final : "continue vote closes without continuing; room closes"
final --> [*]
Figure 3 - The round phase machine
Figure 3 caption: With a database, the only actor that moves a round is the
watcher. In memory mode (no DATABASE_URL) there is no watcher; reading /state
advances a round whose deadline has passed. A round in submit accepts
submissions; publication is what makes them visible to everyone, and it is
irreversible. A won continue vote leaves the round published and opens a new
round in submit.
| From | To | Trigger | Who acts |
|---|---|---|---|
submit |
published |
submit deadline passes, or every debater has submitted | watcher |
published |
next round opens submit |
continue window closes with more continue than end |
watcher |
published |
final |
continue window closes otherwise; the room's status closes | watcher |
"Who acts" is for database mode. In memory mode, RoomService.getRoomState
applies the deadline transitions when /state is read; it has no early publish.
Room status is separate from round phase. round_open_next sets the round to
final and the room to closed in the same tick; nothing waits for final votes
to be tallied.
Publication is what makes submissions visible, so that everyone argues without
seeing the others first. /state enforces that (#226); the database policy that
should enforce it for every reader does not take effect yet — see
Security posture.
What is enforced by the database is the voting rules: a final vote outside the
final phase is refused by vote_final_submit itself, one ballot per voter is a
uniqueness constraint, and only a judge or host may file a verdict.
In summary, time drives the state machine, and some of the rules that matter are enforced where they cannot be bypassed — but not yet all of them.
Requirements: Node 22 or newer (see .nvmrc). Docker for Postgres — optional
for unit tests, required for the database-backed ones.
Node 22 is a floor, not a preference: eslint-plugin-unicorn evaluates
Set.prototype.union at module load, and that method does not exist before Node
22, so npm run lint dies before it lints anything.
npm install
npm --prefix web install.npmrc sets legacy-peer-deps=true. That is required, not sloppiness — see
CONTRIBUTING.md for why removing it breaks npm ci.
Hash a document without a server or a database. Save the submission example at
the top of this page as draft.json, then:
node bin/db8.js draft validate --path draft.json --nonce fixednonce123 --jsonIt prints the digest shown earlier (e05fa5df…). db8 draft open writes an empty
template to db8/round-0/anon/draft.json, not to --path
(#263), and the template's content
and citations must be filled in before it validates.
Run the whole stack. The API and the web app are separate origins, and that matters — the API allow-lists origins, so serving the web app from an unexpected port produces a room page with no submission form:
npm run dev:db # Postgres on 54329
export DATABASE_URL=postgresql://postgres:test@localhost:54329/db8_test
npm run test:prepare-db # apply schema, RPCs and RLS
node server/rpc.js # API -> http://localhost:3000
node server/watcher.js # advances rounds; needs DATABASE_URL
npm --prefix web run dev # web -> http://localhost:3001Run each long-running command in its own terminal with DATABASE_URL set. Without
DATABASE_URL the API runs in memory mode: nothing survives a restart, and reading
/state advances rounds instead of the watcher.
Run the tests:
npm test # full suite, brings up Postgres in Docker
npm run test:inner # vitest only; assumes a database is listeningnpm test runs the suite twice against the same database on purpose. The second
pass is an idempotency gate: a test that only passes on a pristine database fails
there. Tests also run in randomized order with a pinned seed, so a suite that
depends on test ordering fails rather than passing by luck.
Everything the CLI can do:
node bin/db8.js helpBeing precise about this matters more than sounding impressive, so here is the honest split between what the cryptography achieves and what it does not.
What holds today. Canonicalization is real and reproducible — the digests
above are the actual output. A journal whose hash is edited directly in
Postgres fails its Ed25519 signature check, and re-signing needs the private key, which is written
0600 and adopted-never-regenerated (a mismatched or missing half is a hard
error at boot rather than a silently new identity). The database genuinely
enforces the voting rules, inside SECURITY DEFINER functions a client cannot
route around: one ballot per voter, a final vote only in the final phase, and
a verdict only from a judge or host.
What does not hold. Four gaps, all real, none subtle:
- There is no authentication (#210). No route reads the
Authorizationheader — it appears in the codebase only as an allow-listed CORS header name. Theauthor_id,voter_id, andreporter_idin a request body are taken at face value, so anyone who can reach the API can submit, vote, or rule as anyone. The tokendb8 loginstores is sent and ignored, and the JWT the server mints carriesalg: "none"with the literal stringsigwhere a signature belongs. db8 journal verifydoes not bindcoretohash(#209). It checks the signature overhashbut never recomputessha256(canonicalize(core)), so rewriting an entire round record — tallies, transcript hashes, timestamps — leaves verification passing. It also takes the verifying public key from the journal it is verifying, with no pinned key or trust anchor.- Signatures are never checked on the write path (#11).
submission.createacceptssignature_kind,signature_b64, andsigner_fingerprint, and reads none of them; they are not part of the digest either. Verification exists only as a separate, caller-drivenPOST /rpc/provenance.verify, and nothing stores its result. - The database does not hide submissions before publication (#208).
db/rls.sqlcarries a policy that says it does, and it does not take effect: the API connects as the table owner, which is a superuser withrolbypassrls, and no table setsFORCE ROW LEVEL SECURITY./statefilters by phase itself since #226, so a debater cannot read an opponent's unpublished submission through it. Any other read of the submissions table runs with no such filter. Simultaneity is the point of the format, so this is the gap that matters most for the product rather than for the threat model.
The consequence is worth stating plainly: db8 detects an edit to a journal's
signed hash, but not yet an edit to the core it summarizes (gap 2), and it
does not protect against a dishonest server or an unauthenticated client. The claim model and the canonical form are the parts
you can rely on. Closing these gaps is tracked work, not a design disagreement —
and gap 4 in particular is a two-line fix (FORCE ROW LEVEL SECURITY plus a
non-owner application role) rather than a redesign.
| Path | Contents |
|---|---|
server/ |
Express RPCs, SSE endpoints, watcher, journal signer, claim-term engine |
bin/ |
The db8 CLI |
db/ |
Postgres schema, SQL RPCs, row level security |
web/ |
Next.js UI — room page, claim-term editor, journal viewer |
docs/ |
Documentation map |
- New to the system? Getting Started, then the Teardown for an end-to-end walk through the code.
- Want the concepts? Claim Terms is the spec behind this page's central idea.
- Running it? Ops covers cross-origin config, the dead-letter queue, failover, and key rotation.
- Contributing? CONTRIBUTING.md, plus the testing and documentation standards.
Working end to end: the round lifecycle and watcher, submissions carrying structured claims, strict predicate vocabularies per room, claim-path-addressed verdicts and their aggregates, canonicalization and per-submission digests, journal signing and the hash chain, SSE, continue and final voting, research caching and quotas.
Built but incomplete, and tracked as such rather than implied to be finished:
- Research fetching is a stub —
research.fetchperforms no HTTP request and every snapshot it stores is a placeholder. The quotas around it are real. - The Elo update is a SQL function reachable only from an HTTP route, and nothing schedules it. It applies each round once (#257).
- There is no scoring UI.
- The claim-term projection (
checkableClaims,assertsNothing,termHash) is complete and tested but has no production caller. - Row level security is written but does not take effect. See Security posture.
The stable parts to build on are the database schema, the claim-term engine, and the canonical form. Milestones M1–M7 were declared complete, and the open issue list says otherwise; that reconciliation is in progress.
Licensed under Apache 2.0.