diff --git a/captcha/.gitignore b/captcha/.gitignore
new file mode 100644
index 00000000..849ddff3
--- /dev/null
+++ b/captcha/.gitignore
@@ -0,0 +1 @@
+dist/
diff --git a/captcha/README.md b/captcha/README.md
new file mode 100644
index 00000000..426310c6
--- /dev/null
+++ b/captcha/README.md
@@ -0,0 +1,87 @@
+# Quantus Captcha
+
+A proof-of-work captcha whose work is a **real Quantus mining share**.
+No tracking, no image puzzles, no data labeling — the visitor's device does
+~1 second of Poseidon2 hashing over the current block header, and the site
+host earns any block the aggregate share stream happens to find.
+
+Components:
+
+- **Pool service** (`quantus-miner/crates/pool-service`) — connects to a
+ `quantus-node` as an external miner, hands out low-difficulty share
+ challenges with disjoint nonce ranges, verifies solves, mints single-use
+ tokens, submits network-difficulty shares as blocks.
+- **WASM solver** (`quantus-miner/crates/solver-wasm`) — raw C-ABI WASM
+ module (no wasm-bindgen), built with plain `cargo build`.
+- **Widget** (`widget/`) — drop-in JS: checkbox UI, Web Worker solver,
+ hidden token input, `quan-captcha-solved` event.
+- **Demo** (`demo/`) — a spam-protected form.
+
+## Quick start (standalone demo, no node needed)
+
+```sh
+# 1. Build the WASM solver into dist/
+./scripts/build-solver.sh
+
+# 2. Run the pool in standalone mode, serving this directory
+cd ../../quantus-miner
+cargo run -p pool-service -- --serve-dir ../quantus-apps/captcha
+
+# 3. Open the demo
+open http://127.0.0.1:8787/demo/
+```
+
+## Against a real node
+
+```sh
+quantus-node --dev # external-miner QUIC endpoint on :9833
+cargo run -p pool-service -- \
+ --node-addr 127.0.0.1:9833 \
+ --share-difficulty 2000 \
+ --site-secret "$(openssl rand -hex 16)" \
+ --serve-dir ../quantus-apps/captcha
+```
+
+## Integrating on a website
+
+```html
+
+
+```
+
+Server-side, redeem the submitted `quan-captcha-token` exactly once
+(mirrors the reCAPTCHA/Turnstile siteverify shape):
+
+```sh
+curl -X POST https://pool.example.com/siteverify \
+ -H 'content-type: application/json' \
+ -d '{"secret": "", "response": ""}'
+# -> {"success": true, "challenge_ts": 1751600000}
+```
+
+## API
+
+| Endpoint | Caller | Purpose |
+|---|---|---|
+| `POST /api/session` | widget | issue challenge: header, disjoint nonce range, share target |
+| `POST /api/share` | widget | verify solved nonce, mint single-use token |
+| `POST /siteverify` | site backend | redeem token (secret + response) |
+| `GET /api/stats` | anyone | pool counters |
+
+## Threat model, honestly
+
+- This is a **rate limiter, not sybil resistance**: it prices requests in
+ compute, it does not identify humans. A GPU farm pays less per share than
+ a phone; keep the share difficulty in "annoying to bots, invisible to
+ humans" territory and layer account/balance gates for high-value actions.
+- Sessions are single-use, expire in 120 s, and shares are only valid inside
+ the session's assigned nonce range over the session's header snapshot —
+ no precomputation, no replay, no share theft.
+- Tokens are single-use and expire in 300 s.
+- Work runs only on explicit user action (Coinhive's fatal mistake was
+ ambient page-load mining without consent — see the plan doc in
+ `../debate-tree/PLAN.md`).
diff --git a/captcha/demo/index.html b/captcha/demo/index.html
new file mode 100644
index 00000000..d1115f6c
--- /dev/null
+++ b/captcha/demo/index.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+ Quantus Captcha — demo
+
+
+
+
Quantus Captcha demo
+
+ The checkbox below does ~1 second of real Poseidon2 proof-of-work over the
+ current Quantus block header. The work is a genuine mining share: if it
+ ever meets full network difficulty, the pool submits it as a block.
+ No tracking, no image puzzles, no data labeling.
+
+
+
+
+
+
+
+ After solving, the widget puts a single-use token in the form. A real
+ backend would redeem it server-side via POST /siteverify
+ with its secret. This demo calls it from the page for illustration only —
+ never expose your secret in production.
+
+
+
+
+
+
diff --git a/captcha/scripts/build-solver.sh b/captcha/scripts/build-solver.sh
new file mode 100755
index 00000000..c8d96ede
--- /dev/null
+++ b/captcha/scripts/build-solver.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+# Builds the WASM solver from quantus-miner and copies it into dist/.
+set -euo pipefail
+
+CAPTCHA_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+MINER_DIR="${QUANTUS_MINER_DIR:-$CAPTCHA_DIR/../../quantus-miner}"
+
+echo "Building solver-wasm from $MINER_DIR"
+(cd "$MINER_DIR" && CARGO_TARGET_DIR=target cargo build -p solver-wasm --target wasm32-unknown-unknown --release)
+
+mkdir -p "$CAPTCHA_DIR/dist"
+cp "$MINER_DIR/target/wasm32-unknown-unknown/release/solver_wasm.wasm" "$CAPTCHA_DIR/dist/"
+echo "Wrote $CAPTCHA_DIR/dist/solver_wasm.wasm ($(wc -c < "$CAPTCHA_DIR/dist/solver_wasm.wasm") bytes)"
diff --git a/captcha/widget/quan-captcha.js b/captcha/widget/quan-captcha.js
new file mode 100644
index 00000000..3273a93a
--- /dev/null
+++ b/captcha/widget/quan-captcha.js
@@ -0,0 +1,179 @@
+// Quantus Captcha widget.
+//
+// Usage:
+//
+//
+//
+// On solve, a hidden input named "quan-captcha-token" is added to the widget's
+// enclosing form (or the widget element itself), and a "quan-captcha-solved"
+// CustomEvent (detail: { token }) is dispatched on the widget element.
+// The site backend then redeems the token: POST {endpoint}/siteverify
+// with JSON {"secret": "...", "response": ""}.
+
+(function () {
+ "use strict";
+
+ const WIDGET_CLASS = "quan-captcha";
+
+ const STYLE = `
+ .quan-captcha-box {
+ display: inline-flex; align-items: center; gap: 10px;
+ border: 1px solid #d0d5dd; border-radius: 8px; padding: 10px 14px;
+ font: 14px/1.4 system-ui, -apple-system, sans-serif; color: #1f2937;
+ background: #fff; min-width: 260px; user-select: none;
+ }
+ .quan-captcha-check {
+ width: 22px; height: 22px; border: 2px solid #98a2b3; border-radius: 5px;
+ display: inline-flex; align-items: center; justify-content: center;
+ cursor: pointer; flex: none; background: #fff; transition: border-color .15s;
+ }
+ .quan-captcha-box[data-state="idle"] .quan-captcha-check:hover { border-color: #2563eb; }
+ .quan-captcha-box[data-state="solving"] .quan-captcha-check {
+ border-color: #2563eb; border-top-color: transparent; border-radius: 50%;
+ animation: quan-captcha-spin .8s linear infinite;
+ }
+ .quan-captcha-box[data-state="solved"] .quan-captcha-check {
+ border-color: #16a34a; background: #16a34a; color: #fff; cursor: default;
+ }
+ .quan-captcha-box[data-state="error"] .quan-captcha-check { border-color: #dc2626; }
+ .quan-captcha-label { flex: 1; }
+ .quan-captcha-sub { display: block; font-size: 11px; color: #667085; margin-top: 1px; }
+ @keyframes quan-captcha-spin { to { transform: rotate(360deg); } }
+ `;
+
+ function injectStyle() {
+ if (document.getElementById("quan-captcha-style")) return;
+ const style = document.createElement("style");
+ style.id = "quan-captcha-style";
+ style.textContent = STYLE;
+ document.head.appendChild(style);
+ }
+
+ function formatHashrate(h) {
+ if (h >= 1e6) return (h / 1e6).toFixed(1) + " MH/s";
+ if (h >= 1e3) return (h / 1e3).toFixed(1) + " kH/s";
+ return h.toFixed(0) + " H/s";
+ }
+
+ function initWidget(el) {
+ if (el.dataset.quanCaptchaInit) return;
+ el.dataset.quanCaptchaInit = "1";
+
+ const endpoint = (el.dataset.endpoint || "").replace(/\/$/, "");
+ const workerUrl = el.dataset.workerUrl || endpoint + "/widget/solver-worker.js";
+ const wasmUrl = el.dataset.wasmUrl || endpoint + "/dist/solver_wasm.wasm";
+
+ const box = document.createElement("div");
+ box.className = "quan-captcha-box";
+ box.dataset.state = "idle";
+ box.innerHTML =
+ '' +
+ 'I\'m not a spammer' +
+ 'Quantus proof-of-work · no tracking, no puzzles' +
+ "";
+ el.appendChild(box);
+
+ const check = box.querySelector(".quan-captcha-check");
+ const sub = box.querySelector(".quan-captcha-sub");
+ let worker = null;
+ const startedAt = { t: 0 };
+
+ function setState(state, subText) {
+ box.dataset.state = state;
+ check.setAttribute("aria-checked", state === "solved" ? "true" : "false");
+ // Own the mark in both directions so error/retry states never keep a
+ // stale checkmark from a previous solved transition.
+ check.textContent = state === "solved" ? "\u2713" : "";
+ if (subText) sub.textContent = subText;
+ }
+
+ function fail(message) {
+ if (worker) { worker.terminate(); worker = null; }
+ setState("error", message + " — click to retry");
+ }
+
+ async function start() {
+ if (box.dataset.state === "solving" || box.dataset.state === "solved") return;
+ setState("solving", "requesting challenge…");
+ try {
+ const res = await fetch(endpoint + "/api/session", { method: "POST" });
+ if (!res.ok) throw new Error("challenge unavailable (" + res.status + ")");
+ const session = await res.json();
+
+ setState("solving", "computing (~" + session.expected_hashes + " hashes)…");
+ startedAt.t = performance.now();
+
+ worker = new Worker(workerUrl);
+ worker.onerror = () => fail("solver failed to load");
+ worker.onmessage = async (e) => {
+ const msg = e.data;
+ if (msg.type === "progress") {
+ const secs = (performance.now() - startedAt.t) / 1000;
+ sub.textContent = "computing… " + formatHashrate(msg.hashes / Math.max(secs, 0.001));
+ } else if (msg.type === "error") {
+ fail(msg.message);
+ } else if (msg.type === "found") {
+ worker.terminate();
+ worker = null;
+ try {
+ const shareRes = await fetch(endpoint + "/api/share", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ session_id: session.session_id, nonce: msg.nonce }),
+ });
+ const share = await shareRes.json();
+ if (!share.success) throw new Error(share.error || "share rejected");
+
+ // Do all fallible tail work BEFORE showing the solved state, so
+ // a throw here lands in fail() without leaving solved visuals.
+ const form = el.closest("form") || el;
+ let input = form.querySelector('input[name="quan-captcha-token"]');
+ if (!input) {
+ input = document.createElement("input");
+ input.type = "hidden";
+ input.name = "quan-captcha-token";
+ form.appendChild(input);
+ }
+ input.value = share.token;
+
+ const secs = ((performance.now() - startedAt.t) / 1000).toFixed(1);
+ setState("solved", "verified in " + secs + "s (" + msg.hashes + " hashes)" +
+ (share.block_found ? " — BLOCK FOUND!" : ""));
+
+ el.dispatchEvent(new CustomEvent("quan-captcha-solved", {
+ bubbles: true,
+ detail: { token: share.token, blockFound: !!share.block_found },
+ }));
+ } catch (err) {
+ fail(String(err.message || err));
+ }
+ }
+ };
+ worker.postMessage({
+ wasmUrl: wasmUrl,
+ headerHash: session.header_hash,
+ nonceStart: session.nonce_start,
+ shareTarget: session.share_target,
+ });
+ } catch (err) {
+ fail(String(err.message || err));
+ }
+ }
+
+ check.addEventListener("click", start);
+ check.addEventListener("keydown", (e) => {
+ if (e.key === " " || e.key === "Enter") { e.preventDefault(); start(); }
+ });
+ }
+
+ function initAll() {
+ injectStyle();
+ document.querySelectorAll("." + WIDGET_CLASS).forEach(initWidget);
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", initAll);
+ } else {
+ initAll();
+ }
+})();
diff --git a/captcha/widget/solver-worker.js b/captcha/widget/solver-worker.js
new file mode 100644
index 00000000..ebe9c1a2
--- /dev/null
+++ b/captcha/widget/solver-worker.js
@@ -0,0 +1,55 @@
+// Web Worker: loads the WASM solver and grinds nonces in chunks, reporting
+// progress so the widget can animate. Terminated by the main thread when done.
+
+const CHUNK_ITERS = 2048;
+
+// I/O buffer layout inside the WASM module (see solver-wasm/src/lib.rs)
+const HEADER_OFF = 0;
+const NONCE_OFF = 32;
+const TARGET_OFF = 96;
+const IO_SIZE = 224;
+
+function hexToBytes(hex, length) {
+ const clean = hex.replace(/^0x/, "").padStart(length * 2, "0");
+ const bytes = new Uint8Array(length);
+ for (let i = 0; i < length; i++) {
+ bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
+ }
+ return bytes;
+}
+
+function bytesToHex(bytes) {
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
+}
+
+self.onmessage = async (e) => {
+ const { wasmUrl, headerHash, nonceStart, shareTarget } = e.data;
+ try {
+ const response = await fetch(wasmUrl);
+ if (!response.ok) throw new Error(`failed to fetch solver wasm: ${response.status}`);
+ const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), {});
+ const { io_ptr, io_len, solve, memory } = instance.exports;
+
+ if (io_len() !== IO_SIZE) throw new Error("solver wasm I/O layout mismatch");
+ const ioBase = io_ptr();
+ const io = () => new Uint8Array(memory.buffer, ioBase, IO_SIZE);
+
+ io().set(hexToBytes(headerHash, 32), HEADER_OFF);
+ io().set(hexToBytes(nonceStart, 64), NONCE_OFF);
+ io().set(hexToBytes(shareTarget, 64), TARGET_OFF);
+
+ let hashes = 0;
+ for (;;) {
+ const found = solve(CHUNK_ITERS);
+ hashes += CHUNK_ITERS;
+ if (found === 1) {
+ const nonce = bytesToHex(io().slice(NONCE_OFF, NONCE_OFF + 64));
+ self.postMessage({ type: "found", nonce, hashes });
+ return;
+ }
+ self.postMessage({ type: "progress", hashes });
+ }
+ } catch (err) {
+ self.postMessage({ type: "error", message: String(err && err.message ? err.message : err) });
+ }
+};
diff --git a/debate-tree/PLAN.md b/debate-tree/PLAN.md
new file mode 100644
index 00000000..63b29723
--- /dev/null
+++ b/debate-tree/PLAN.md
@@ -0,0 +1,251 @@
+# Debate Tree — Project Plan
+
+AI-moderated structured debate for the Quantus ecosystem, spam-protected by
+Quantus-native primitives (mining-share proof-of-work and QUAN balance gates).
+
+This plan covers three deliverables across two repos:
+
+| # | Component | Location | What it is |
+|---|-----------|----------|------------|
+| 1 | **Share Pool** (pool middleman) | `quantus-miner/pool-service` | A pool-like service that turns captcha solves into real mining shares and pays hosts |
+| 2 | **Captcha Gadget** | `quantus-apps/captcha` | Embeddable widget + verify API — a drop-in Turnstile/reCAPTCHA replacement |
+| 3 | **Debate Tree** | `quantus-apps/debate-tree` | The debate webapp: tree UI, AI steelman moderator, chain-gated writes |
+
+Dependency direction: `debate-tree` → `captcha` → `pool-service` → `quantus-node`.
+Each layer is independently useful; the captcha is a product in its own right.
+
+---
+
+## 1. Product vision
+
+**Debate Tree** is a structured-debate platform: a question at the root,
+candidate answers below it, pros/cons under each answer, and responses to
+those, recursively. (Prior art: Kialo — the tree format is proven. Our
+differentiators are the AI moderator and the chain-native spam economics.)
+
+**The AI is a moderator on the write path, not a participant:**
+
+- **Deduplication** — before a new node is accepted, embeddings + a cheap LLM
+ pass check whether the point already exists in the tree; near-duplicates are
+ redirected to the existing node ("upvote / extend this instead?").
+- **Disentangle + steelman loop** — a contributor drafts a post; the AI first
+ splits bundled points into separate claims (one tree node = one claim), then
+ offers a succinct steelman of each. The contributor picks a claim, revises or
+ accepts (capped at ~3 rounds per claim). Only AI-generated versions are
+ publishable — there is no "publish my raw original" path; the contributor's
+ control is exercised through accept/revise, not through bypassing the
+ moderator. The original text stays attached (visible on click) so the
+ author's own words are never lost, but the tree node itself is always a
+ steelman the author approved.
+- **The AI never rewrites imported/seeded content.** Seeded arguments cite
+ their sources verbatim.
+
+**Spam / cost protection** (also bounds our LLM spend):
+
+- **Reading**: free, no account, indexable. The tree is the growth asset.
+- **Creating a question**: requires a signed challenge from a wallet holding
+ ≥ N QUAN (threshold configurable per space). Capital-at-stake, nothing
+ locked or slashed.
+- **Posting answers/pros/cons + starting a steelman session**: requires a
+ proof-of-work share via the captcha gadget. Rate limiter, not identity.
+- App-layer rate limits and per-user/global LLM spend caps on top.
+
+**Governance tie-in**: Quantus currently has no community governance lane
+(the runtime's referenda are tech-collective-only) and QIPs have no
+discussion venue. Debate Tree is the deliberation layer; on-chain referenda
+remain the decision layer. Tree conclusions link to referenda; content
+itself stays off-chain (optionally hash-anchored per published node).
+
+---
+
+## 2. Component 1 — Share Pool (`quantus-miner/pool-service`)
+
+A new crate alongside `miner-service`, reusing `pow-core` hashing and the
+`quantus-miner-api` types.
+
+**Concept**: the node's external-miner protocol already broadcasts
+`NewJob { header_hash, difficulty }` and accepts `JobResult`. The pool
+service sits between a node and thousands of weak browser solvers:
+
+```
+quantus-node ──NewJob──► pool-service ──job + nonce-range + share-target──► browser solvers
+quantus-node ◄──block── pool-service ◄──────────share (nonce)──────────── browser solvers
+```
+
+- Holds the current job from an upstream node (QUIC, existing protocol).
+- Issues **captcha sessions**: `{ header_hash, disjoint nonce range, share
+ target (≪ network difficulty), expiry }`. The nonce range is the session
+ binding — a returned nonce identifies which session earned it. Freshness
+ is free: shares are only valid against the current block template.
+- Verifies submitted shares (one hash) and issues a single-use
+ **share token** consumed by the captcha verify API.
+- If a share also meets full network difficulty → submit as a real block;
+ reward accrues to the pool operator's account.
+- Tracks per-host share counts for **pro-rata (PPLNS-style) payouts** to
+ registered captcha hosts. Self-hosters can point their share stream at a
+ community pool or run solo.
+
+**Economics honesty** (goes in the README, not just here): expected revenue
+per captcha is (client work ÷ network hashrate) × emission rate — dust once
+the chain has real hashrate. Early-chain revenue is real; long-term the
+honest pitch is *non-wasteful* PoW (work secures the network instead of
+being burned, unlike Friendly Captcha / Anubis) plus dust. **Pay the host,
+never the solver** — paying solvers pays people to spam.
+
+**Deliverables**:
+- [x] `pool-service` crate: upstream QUIC client, session issuance API,
+ share verification, share-token store, block submission
+- [x] `siteverify` endpoint (see Component 2 — same service, site-facing)
+- [ ] Host registration + payout ledger (payouts can be manual at first)
+- [ ] Metrics (reuse `metrics` crate patterns), Docker image
+- [x] Integration test against `quantus-node --dev` (manual e2e: browser
+ solved real shares against a dev node's block headers, 2026-07-04)
+
+## 3. Component 2 — Captcha Gadget (`quantus-apps/captcha`)
+
+A drop-in, privacy-first captcha. Positioning: Turnstile's UX without
+Cloudflare, Friendly Captcha / Anubis mechanics but the work is real mining.
+No puzzles, no tracking, no data labeling. Coinhive's captcha proved the UX;
+Coinhive's death defines our guardrails:
+
+- Work only on explicit user action (form submit), never ambient page-load.
+- Bounded and disclosed: "~1s of computation supports this site."
+- Open source, first-party-servable loader (no single CDN domain to blocklist).
+
+**Pieces**:
+- `solver/` — Rust → WASM build of `pow-core` hashing (lives here or under
+ `quantus-miner/web-miner`, which already has a Vite+WebGPU scaffold;
+ decide when wiring the build). WebGPU fast path, WASM fallback.
+- `widget/` — TS embed: `
` +
+ ~3 kB loader. Renders checkbox → fetches session from pool-service →
+ solves → posts share → emits share token into the form.
+- Server-side verify: site backend calls `POST /siteverify {token, secret}`
+ on pool-service (mirrors reCAPTCHA/Turnstile API shape for trivial migration).
+- `demo/` — demo page + abuse-cost calculator.
+
+**Deliverables**:
+- [x] WASM solver package (`quantus-miner/crates/solver-wasm`, raw C ABI,
+ ~40 kB; measured ≈120 kH/s in-browser on an M-series laptop)
+ — WebGPU fast path still open
+- [x] Embed widget + loader, Turnstile-compatible verify API
+- [x] Docs: integration guide, threat model (rate-limiter not sybil-proof;
+ native-GPU attacker pays less per share than a phone — tune share
+ target accordingly), Coinhive-lessons disclosure
+- [x] Demo site (`demo/`, served by pool-service `--serve-dir`)
+
+## 4. Component 3 — Debate Tree webapp (`quantus-apps/debate-tree`)
+
+**Stack** (proposed): TypeScript web frontend + backend (framework TBD at
+kickoff), Postgres + pgvector (tree + embeddings), LLM API for
+steelman/dedup (cheap model for loop turns, stronger model for final
+published version). Wallet auth via ML-DSA signature verification —
+`quantus_sdk`'s Rust bridge and `rust-transaction-parser` are references;
+server-side verification can link the same Rust crates.
+
+**Data model** (implemented in `spike/schema.sql` — pure adjacency tree,
+plain Postgres so it ports from the spike's PGlite to hosted PG verbatim):
+- `space` — a debate context (e.g. "QIPs", "PQ-migration"), holds `config`
+ jsonb: question threshold N QUAN, share target, model tier.
+- `node` — id, space, `parent_id` (null = direct answer to the question),
+ kind (`answer | pro | con`), `published_text` (author-approved steelman =
+ the node), `original_text` (verbatim, always attached), `transcript` jsonb
+ (negotiation provenance, inlined — no separate session table needed for
+ durable state), `content_hash` (optional on-chain anchoring), `embedding`
+ (dedup search). Walked with a recursive CTE / adjacency list; no `ltree`,
+ no `status`/redirect, no `node_edge` — dupes are simply not inserted.
+- `vote` — (node, account, value ±1), one row per account per node.
+- **Dropped for now**: `gate_proof` (consumed share-token / balance-attestation
+ ledger + replay guard) lands with the captcha write-path integration.
+ Ephemeral steelman-negotiation state lives in server memory, not the DB.
+
+Spike DB is **PGlite** (Postgres compiled to WASM, in-process, persisted to
+`spike/pgdata/`): zero external server, same SQL as prod. `pgvector` is an
+optional add-on there; when absent, `embedding` falls back to jsonb and dedup
+is disabled (deferred anyway). Reset the dev tree by deleting `spike/pgdata/`.
+
+**Write path**:
+1. Client requests action → backend issues nonce challenge.
+2. Question: wallet signs `{nonce, action, timestamp}`; backend verifies
+ signature + balance ≥ N via node RPC / `quantus_subsquid`.
+ Answer/pro/con: captcha share token required to open a steelman session.
+3. Dedup check (embedding similarity → LLM confirm on borderline).
+4. Steelman loop (≤ 3 rounds) → contributor approves → publish.
+
+**Seed content — the djb hybrid-vs-pure-PQ debate**:
+- Question: *"Should TLS 1.3 standardize pure ML-KEM key agreement, or
+ require hybrid (ECC+PQ)?"*
+- Curated from the public record with per-node citations: IETF TLS WG
+ mailing list, djb's IESG appeals (Oct/Dec 2025), blog.cr.yp.to, LWN
+ coverage. **No AI paraphrasing of imported arguments** — verbatim quotes
+ + neutral summaries with links.
+- Map the *technical* debate only; keep the process/consensus-legitimacy
+ fight (appeals drama) out of the seed tree.
+- **Neutrality disclosure, prominent**: Quantus is a pure-PQ chain and
+ therefore a party to this debate. "We have a stake; here's the map;
+ correct us." Invite corrections before promoting it anywhere.
+- Second space: QIP discussions (own community, real decisions, zero
+ current venue).
+
+**Deliverables**:
+- [x] Steelman-loop spike (see §5) — disentangle + succinct steelman loop
+- [x] DB-backed tree: PGlite schema, publish/tree/vote endpoints, seeded space
+- [x] Tree UI (read + write): nested pro/con/answer nodes, votes, reply-to-node,
+ original text on click — served from the DB
+- [ ] Node detail: source citations, shareable per-node links
+- [ ] Wallet auth + balance gate; captcha gate integration
+- [ ] Dedup + steelman write path with round caps and spend caps
+- [ ] Seeded djb tree + QIP space
+- [ ] Optional: per-node hash anchoring via `system.remark` (defer)
+
+---
+
+## 5. Build order
+
+**Track A (start now): pool-service + captcha as ONE vertical slice.**
+Neither is testable end-to-end without the other — a pool with no solver
+client proves nothing, a widget with no verifier is a mock. Milestone:
+demo page on a laptop solves a share against `quantus-node --dev`, verify
+endpoint accepts the token, dashboard shows accrued shares.
+
+**Track B (parallel, cheap): steelman-loop spike.**
+The single highest product risk is whether the steelman negotiation feels
+respectful rather than condescending — no chain deps, just an LLM chat
+loop + prompt iteration on real contentious arguments. A weekend spike;
+throwaway code, keep the prompts.
+
+**Then: Debate Tree webapp** consuming both tracks, launching with the
+seeded djb tree + QIP space. Rationale for not building the webapp first:
+its write path *is* the gates + the steelman loop; building it first means
+building it twice. The captcha is also independently shippable/marketable
+regardless of how Debate Tree evolves.
+
+**Sequencing summary**:
+1. Track A slice (pool + widget + demo) — Track B spike in parallel
+2. Debate Tree read UI + seeded djb/QIP content (valuable even before
+ writes open — "the map" is the marketing artifact)
+3. Debate Tree write path (gates + moderator)
+4. Payouts polish, on-chain anchoring, additional spaces
+
+## 6. Risks
+
+| Risk | Mitigation |
+|------|------------|
+| Cryptojacking stigma / AV & adblock flagging | Consent + bounded work + first-party loader + open source; never ambient mining |
+| Share revenue ≈ dust as hashrate grows | Market as non-wasteful + host-paid, not get-rich; pool aggregation for variance |
+| Native-GPU spammers vs. phone users (PoW asymmetry) | Share target tuned low (rate limiter framing); balance gate for high-value actions |
+| AI steelman feels condescending / voice laundering | Spike first; contributor approval required (accept/revise, no raw-publish bypass); original text always attached |
+| Moderator bias becomes tree bias | Publish steelman prompts; show diff original→published |
+| Quantus not neutral on the seed debate | Prominent disclosure; verbatim citations; invite corrections |
+| djb reacts badly to AI paraphrase | Never AI-rewrite imported content |
+| LLM spend abuse | Share token required per steelman session; round caps; per-user/global spend caps |
+| Balance gate = plutocratic speech | Gate only question creation; answers need only PoW; bonds-not-balances revisit later |
+
+## 7. Open questions
+
+- Pool payout cadence/mechanism (manual → automated on-chain batch?).
+- Where the WASM solver crate lives (`quantus-apps/captcha/solver` vs
+ `quantus-miner/web-miner`) — decide when wiring the build.
+- Webapp framework + hosting; whether backend verifies ML-DSA sigs via
+ linked Rust crate or a small verifier sidecar.
+- Per-space QUAN thresholds — governance-adjustable? fiat-pegged?
+- Whether/when to anchor node hashes on-chain.
diff --git a/debate-tree/spike/.gitignore b/debate-tree/spike/.gitignore
new file mode 100644
index 00000000..c1f1e5be
--- /dev/null
+++ b/debate-tree/spike/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+.env
+pgdata/
diff --git a/debate-tree/spike/db.mjs b/debate-tree/spike/db.mjs
new file mode 100644
index 00000000..1af6c325
--- /dev/null
+++ b/debate-tree/spike/db.mjs
@@ -0,0 +1,142 @@
+// Debate Tree spike DB — PGlite (Postgres in-process, persisted to ./pgdata).
+// Same SQL runs on a hosted Postgres later; only the connection line changes.
+
+import { PGlite } from "@electric-sql/pglite";
+import { readFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const DIR = dirname(fileURLToPath(import.meta.url));
+
+let db;
+let hasVector = false;
+
+function slugify(s) {
+ return (
+ s
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 60) || "space"
+ );
+}
+
+// Default seed: djb's hybrid-vs-pure-PQ TLS question, so the tree isn't empty.
+const DEFAULT_SPACE = {
+ slug: "tls-pq-hybrid",
+ question:
+ "Should TLS 1.3 standardize pure ML-KEM key agreement, or require hybrid (ECC+PQ)?",
+};
+
+export async function initDb() {
+ // pgvector is an optional add-on for PGlite (not bundled in 0.5.x). If it's
+ // present we enable it and give `embedding` a real vector type for dedup;
+ // otherwise we fall back to jsonb so the tree still works. Dedup is deferred,
+ // so the fallback is expected for now — on hosted Postgres you just
+ // `create extension vector;` and the same schema gets vector(1024).
+ let extensions = {};
+ try {
+ const { vector } = await import("@electric-sql/pglite/vector");
+ extensions = { vector };
+ hasVector = true;
+ } catch {
+ hasVector = false;
+ }
+
+ const dataDir = process.env.PGDATA_DIR
+ ? join(DIR, process.env.PGDATA_DIR)
+ : join(DIR, "pgdata");
+ db = new PGlite(dataDir, { extensions });
+ await db.waitReady;
+
+ if (hasVector) {
+ await db.exec("create extension if not exists vector;");
+ }
+
+ let schema = await readFile(join(DIR, "schema.sql"), "utf8");
+ schema = schema.replace(
+ /EMBEDDING_COL/,
+ hasVector ? "vector(1024)" : "jsonb"
+ );
+ await db.exec(schema);
+
+ await getOrCreateSpace(DEFAULT_SPACE.question, DEFAULT_SPACE.slug);
+ console.log(
+ `db ready (pgvector: ${hasVector ? "on" : "off — dedup disabled"})`
+ );
+ return db;
+}
+
+export async function getOrCreateSpace(question, slug) {
+ const s = slug || slugify(question);
+ const existing = await db.query(
+ "select * from space where slug = $1 or question = $2 limit 1",
+ [s, question]
+ );
+ if (existing.rows[0]) return existing.rows[0];
+ const inserted = await db.query(
+ "insert into space (slug, question) values ($1, $2) returning *",
+ [s, question]
+ );
+ return inserted.rows[0];
+}
+
+export async function listSpaces() {
+ const r = await db.query(
+ `select s.*, (select count(*) from node n where n.space_id = s.id)::int as node_count
+ from space s order by s.created_at asc`
+ );
+ return r.rows;
+}
+
+export async function insertNode({
+ spaceId,
+ parentId,
+ kind,
+ authorAccount,
+ publishedText,
+ originalText,
+ transcript,
+}) {
+ const r = await db.query(
+ `insert into node
+ (space_id, parent_id, kind, author_account, published_text, original_text, transcript)
+ values ($1, $2, $3, $4, $5, $6, $7)
+ returning *`,
+ [
+ spaceId,
+ parentId || null,
+ kind,
+ authorAccount || "anon",
+ publishedText,
+ originalText,
+ transcript ? JSON.stringify(transcript) : null,
+ ]
+ );
+ return r.rows[0];
+}
+
+// Whole tree for a space as flat rows with vote tallies. The client nests them.
+export async function getTree(spaceId) {
+ const r = await db.query(
+ `select n.id, n.parent_id, n.kind, n.author_account,
+ n.published_text, n.original_text, n.created_at,
+ coalesce(sum(v.value), 0)::int as score,
+ count(v.*)::int as vote_count
+ from node n
+ left join vote v on v.node_id = n.id
+ where n.space_id = $1
+ group by n.id
+ order by n.created_at asc`,
+ [spaceId]
+ );
+ return r.rows;
+}
+
+export async function castVote(nodeId, account, value) {
+ await db.query(
+ `insert into vote (node_id, account, value) values ($1, $2, $3)
+ on conflict (node_id, account) do update set value = excluded.value`,
+ [nodeId, account, value]
+ );
+}
diff --git a/debate-tree/spike/index.html b/debate-tree/spike/index.html
new file mode 100644
index 00000000..f3e79764
--- /dev/null
+++ b/debate-tree/spike/index.html
@@ -0,0 +1,451 @@
+
+
+
+
+
+ Steelman spike
+
+
+
+
Debate Tree — AI-moderated argument map (spike)
+
+
+
The tree
+
+
Loading…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/debate-tree/spike/package-lock.json b/debate-tree/spike/package-lock.json
new file mode 100644
index 00000000..26bc74a6
--- /dev/null
+++ b/debate-tree/spike/package-lock.json
@@ -0,0 +1,22 @@
+{
+ "name": "spike",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "spike",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "@electric-sql/pglite": "^0.5.4"
+ }
+ },
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.4.tgz",
+ "integrity": "sha512-yYZUyyXrHU7tPlCjwZQJ6hIG9DscdCCn7Uk0mYKwC1FeHX286AbcmFveMiRBEak8e9iPupjsoVImN3yJZVed2g==",
+ "license": "Apache-2.0"
+ }
+ }
+}
diff --git a/debate-tree/spike/package.json b/debate-tree/spike/package.json
new file mode 100644
index 00000000..88510ae1
--- /dev/null
+++ b/debate-tree/spike/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "spike",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "type": "module",
+ "dependencies": {
+ "@electric-sql/pglite": "^0.5.4"
+ }
+}
diff --git a/debate-tree/spike/prompts.mjs b/debate-tree/spike/prompts.mjs
new file mode 100644
index 00000000..f8e04f19
--- /dev/null
+++ b/debate-tree/spike/prompts.mjs
@@ -0,0 +1,97 @@
+// The keeper artifact of this spike: the steelman prompts.
+// The code around them is throwaway; iterate on these.
+
+export const SYSTEM_PROMPT = `You are the moderator of a structured debate platform. A participant has
+written an argument they want to add to a debate tree. Your job is to
+DISENTANGLE and STEELMAN their submission.
+
+Step 1 — Disentangle: if the post bundles multiple independent claims
+(technical + procedural, several reasons, a list of objections), split
+them into separate entries in "claims". One tree node = one claim. Do not
+merge distinct points into a single paragraph. A heated rant may still
+contain 2–4 separable claims — extract them.
+
+Step 2 — Steelman each claim: for every entry, write the strongest, clearest
+version of THAT specific point, which the author must recognize as theirs.
+
+Hard rules:
+1. Never change the author's position, weaken it into agreeableness, or
+ add hedges they didn't imply.
+2. Strengthen: sharpen the core claim, make implicit reasoning explicit,
+ replace insults with force of argument, cut filler.
+3. SUCCINCT: each steelman is at most 2 sentences or ~45 words. Debate
+ trees need scannable nodes, not essays. If the author was verbose, you
+ compress — you do not add length.
+4. Keep the author's voice: first person if they wrote in first person,
+ plain language. No debate-club jargon, no "one might argue".
+5. Do not invent facts, sources, or examples the author didn't reference
+ or clearly imply. Keep their phrasing for disputed facts.
+6. "label" is a 3–8 word handle for the claim (for navigation), not a
+ new argument.
+7. If (and only if) a specific claim is genuinely ambiguous, put ONE
+ short clarifying question in "question" (applies to the whole submission).
+
+The author will review and may push back on one claim at a time. Their
+feedback is authoritative — revise to match their intent, not your taste.
+
+Respond with ONLY a JSON object, no markdown fences:
+{
+ "claims": [
+ {
+ "id": "1",
+ "label": "",
+ "steelman": ""
+ }
+ ],
+ "notes": "<1–3 short bullets, each starting with '- ', on splits and edits>",
+ "question": ""
+}`;
+
+export function buildFirstRoundPrompt({ question, parent, stance, original }) {
+ return `Debate question: ${question}
+${parent ? `The participant is responding to this claim: ${parent}\n` : ""}Their stance on it: ${stance}
+
+Their argument, verbatim:
+---
+${original}
+---
+
+Disentangle into separate claims if needed, then steelman each succinctly.`;
+}
+
+export function buildRevisionPrompt({ original, claim, draft, feedback, round }) {
+ return `This is revision round ${round} for claim "${claim.label}" (id ${claim.id}).
+
+Author's full original submission (context only):
+---
+${original}
+---
+
+The claim you are revising:
+---
+${claim.steelman}
+---
+
+Your previous draft for this claim:
+---
+${draft}
+---
+
+The author's feedback:
+---
+${feedback}
+---
+
+Revise ONLY this claim's steelman. Stay succinct (max 2 sentences / ~45 words).
+Their feedback wins over your judgment.
+
+Respond with ONLY the same JSON object shape as before, containing exactly this
+one revised claim, no markdown fences:
+{
+ "claims": [
+ { "id": "${claim.id}", "label": "", "steelman": "" }
+ ],
+ "notes": "<1-3 short bullets on what you changed>",
+ "question": null
+}`;
+}
diff --git a/debate-tree/spike/schema.sql b/debate-tree/spike/schema.sql
new file mode 100644
index 00000000..b7a49db4
--- /dev/null
+++ b/debate-tree/spike/schema.sql
@@ -0,0 +1,36 @@
+-- Debate Tree — spike schema.
+-- Plain Postgres (runs on PGlite in the spike, ports verbatim to hosted PG).
+-- Deliberately minimal: no gate_proof (spam gate lands with the captcha),
+-- no ltree (PGlite has no ltree ext; we walk the adjacency list with a
+-- recursive CTE, which is plenty at spike scale).
+
+create table if not exists space (
+ id uuid primary key default gen_random_uuid(),
+ slug text unique not null,
+ question text not null,
+ config jsonb not null default '{}'::jsonb,
+ created_at timestamptz not null default now()
+);
+
+create table if not exists node (
+ id uuid primary key default gen_random_uuid(),
+ space_id uuid not null references space(id) on delete cascade,
+ parent_id uuid references node(id) on delete cascade, -- null = direct answer to the space question
+ kind text not null check (kind in ('answer','pro','con')),
+ author_account text not null default 'anon',
+ published_text text not null, -- author-approved steelman = the node
+ original_text text not null, -- verbatim submission, always attached
+ transcript jsonb, -- negotiation provenance
+ content_hash bytea, -- for optional on-chain anchoring later
+ embedding EMBEDDING_COL, -- dedup search (populated later); see db.mjs
+ created_at timestamptz not null default now()
+);
+
+create index if not exists node_space_parent_idx on node (space_id, parent_id);
+
+create table if not exists vote (
+ node_id uuid not null references node(id) on delete cascade,
+ account text not null,
+ value smallint not null check (value in (-1, 1)),
+ primary key (node_id, account)
+);
diff --git a/debate-tree/spike/server.mjs b/debate-tree/spike/server.mjs
new file mode 100644
index 00000000..8b25cde9
--- /dev/null
+++ b/debate-tree/spike/server.mjs
@@ -0,0 +1,324 @@
+// Steelman-loop spike server. Throwaway code; the prompts are the artifact.
+//
+// node server.mjs # stub model (offline, tests the flow)
+// ANTHROPIC_API_KEY=... node server.mjs
+// OPENAI_API_KEY=... node server.mjs
+// OPENROUTER_API_KEY=... OPENROUTER_MODEL=anthropic/claude-sonnet-4 node server.mjs
+//
+// Zero dependencies; serves index.html and POST /api/steelman.
+
+import { createServer } from "node:http";
+import { readFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { SYSTEM_PROMPT, buildFirstRoundPrompt, buildRevisionPrompt } from "./prompts.mjs";
+import {
+ initDb,
+ getOrCreateSpace,
+ listSpaces,
+ insertNode,
+ getTree,
+ castVote,
+} from "./db.mjs";
+
+const PORT = process.env.PORT || 8788;
+const DIR = dirname(fileURLToPath(import.meta.url));
+
+const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY;
+const OPENAI_KEY = process.env.OPENAI_API_KEY;
+const OPENROUTER_KEY = process.env.OPENROUTER_API_KEY;
+const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-5";
+const OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-5.2";
+const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || "anthropic/claude-sonnet-4";
+const OPENROUTER_REFERER = process.env.OPENROUTER_REFERER || "http://localhost:8788";
+const OPENROUTER_TITLE = process.env.OPENROUTER_TITLE || "Debate Tree steelman spike";
+
+const provider = ANTHROPIC_KEY
+ ? "anthropic"
+ : OPENAI_KEY
+ ? "openai"
+ : OPENROUTER_KEY
+ ? "openrouter"
+ : "stub";
+console.log(`model provider: ${provider}${provider === "openrouter" ? ` (${OPENROUTER_MODEL})` : ""}`);
+
+async function callAnthropic(messages) {
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-api-key": ANTHROPIC_KEY,
+ "anthropic-version": "2023-06-01",
+ },
+ body: JSON.stringify({
+ model: ANTHROPIC_MODEL,
+ max_tokens: 1024,
+ system: SYSTEM_PROMPT,
+ messages,
+ }),
+ });
+ if (!res.ok) throw new Error(`anthropic ${res.status}: ${await res.text()}`);
+ const body = await res.json();
+ return body.content.map((c) => c.text || "").join("");
+}
+
+async function callOpenAI(messages) {
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${OPENAI_KEY}`,
+ },
+ body: JSON.stringify({
+ model: OPENAI_MODEL,
+ messages: [{ role: "system", content: SYSTEM_PROMPT }, ...messages],
+ }),
+ });
+ if (!res.ok) throw new Error(`openai ${res.status}: ${await res.text()}`);
+ const body = await res.json();
+ return body.choices[0].message.content;
+}
+
+// OpenRouter exposes an OpenAI-compatible chat API; handy for swapping models
+// without changing provider code.
+async function callOpenRouter(messages) {
+ const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${OPENROUTER_KEY}`,
+ "HTTP-Referer": OPENROUTER_REFERER,
+ "X-Title": OPENROUTER_TITLE,
+ },
+ body: JSON.stringify({
+ model: OPENROUTER_MODEL,
+ messages: [{ role: "system", content: SYSTEM_PROMPT }, ...messages],
+ }),
+ });
+ if (!res.ok) throw new Error(`openrouter ${res.status}: ${await res.text()}`);
+ const body = await res.json();
+ return body.choices[0].message.content;
+}
+
+// Offline stand-in: crude split on sentence boundaries + short prefix.
+function callStub(messages) {
+ const last = messages[messages.length - 1].content;
+ const isRevision = /revision round/.test(last);
+
+ if (isRevision) {
+ const draft = (last.match(/Your previous draft[^]*?---\n([\s\S]*?)\n---/) || [, ""])[1].trim();
+ return Promise.resolve(
+ JSON.stringify({
+ claims: [{ id: "1", label: "Revised", steelman: draft + " (revised per your feedback)" }],
+ notes: "- [stub model] echoed feedback",
+ question: null,
+ })
+ );
+ }
+
+ const original = (last.match(/---\n([\s\S]*?)\n---/) || [, last])[1].trim();
+ const sentences = original
+ .split(/(?<=[.!?])\s+/)
+ .map((s) =>
+ s
+ .replace(/\b(stupid|idiotic|insane|moronic|garbage|bullshit)\b/gi, "deeply flawed")
+ .replace(/!+/g, ".")
+ .trim()
+ )
+ .filter((s) => s.length > 20);
+
+ const chunks = sentences.length >= 2 ? sentences.slice(0, 4) : [original];
+ const claims = chunks.map((chunk, i) => ({
+ id: String(i + 1),
+ label: `Point ${i + 1}`,
+ steelman: chunk.length > 120 ? chunk.slice(0, 117) + "…" : chunk,
+ }));
+
+ return Promise.resolve(
+ JSON.stringify({
+ claims,
+ notes:
+ claims.length > 1
+ ? `- [stub model] split into ${claims.length} claims\n- set an API key for real disentangling`
+ : "- [stub model] single claim\n- set an API key for real steelmanning",
+ question: null,
+ })
+ );
+}
+
+const callModel =
+ provider === "anthropic"
+ ? callAnthropic
+ : provider === "openai"
+ ? callOpenAI
+ : provider === "openrouter"
+ ? callOpenRouter
+ : callStub;
+
+function parseModelJson(text) {
+ const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim();
+ const parsed = JSON.parse(cleaned);
+
+ let claims = parsed.claims;
+ // Tolerate shapes a model tends to drift into, especially on revision:
+ // { steelman: "..." } (old single-claim shape)
+ // { claim: { steelman } } (singular key)
+ // { revised: "..." } | { text: "..." }
+ if (!Array.isArray(claims)) {
+ const single =
+ (parsed.claim && typeof parsed.claim === "object" && parsed.claim) ||
+ (typeof parsed.steelman === "string" && { steelman: parsed.steelman }) ||
+ (typeof parsed.revised === "string" && { steelman: parsed.revised }) ||
+ (typeof parsed.text === "string" && { steelman: parsed.text });
+ if (single) {
+ claims = [{ id: single.id ?? "1", label: single.label ?? "Main point", steelman: single.steelman }];
+ }
+ }
+ if (!Array.isArray(claims) || claims.length === 0) {
+ throw new Error("model reply missing claims");
+ }
+ claims = claims.map((c, i) => {
+ if (typeof c.steelman !== "string" || !c.steelman.trim()) {
+ throw new Error(`claim ${i + 1} missing steelman`);
+ }
+ return {
+ id: String(c.id ?? i + 1),
+ label: typeof c.label === "string" && c.label.trim() ? c.label.trim() : `Point ${i + 1}`,
+ steelman: c.steelman.trim(),
+ };
+ });
+
+ return {
+ claims,
+ notes: typeof parsed.notes === "string" ? parsed.notes : "",
+ question: typeof parsed.question === "string" ? parsed.question : null,
+ };
+}
+
+async function handleSteelman(req, res) {
+ let raw = "";
+ for await (const chunk of req) raw += chunk;
+ const body = JSON.parse(raw);
+
+ // Rebuild the conversation from the client-held transcript. rounds is
+ // [{draft, feedback}, ...] for completed rounds.
+ const messages = [
+ { role: "user", content: buildFirstRoundPrompt(body) },
+ ];
+ (body.rounds || []).forEach((r, i) => {
+ messages.push({
+ role: "assistant",
+ content: JSON.stringify({
+ claims: [{ id: r.claimId, label: r.claimLabel, steelman: r.draft }],
+ }),
+ });
+ messages.push({
+ role: "user",
+ content: buildRevisionPrompt({
+ original: body.original,
+ claim: { id: r.claimId, label: r.claimLabel, steelman: r.draft },
+ draft: r.draft,
+ feedback: r.feedback,
+ round: i + 1,
+ }),
+ });
+ });
+
+ const reply = await callModel(messages);
+ const parsed = parseModelJson(reply);
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify(parsed));
+}
+
+async function readBody(req) {
+ let raw = "";
+ for await (const chunk of req) raw += chunk;
+ return raw ? JSON.parse(raw) : {};
+}
+
+function sendJson(res, status, obj) {
+ res.writeHead(status, { "content-type": "application/json" });
+ res.end(JSON.stringify(obj));
+}
+
+// GET /api/tree?space= — space row + flat node list.
+async function handleTree(req, res, url) {
+ const key = url.searchParams.get("space") || "";
+ const question = url.searchParams.get("question") || key;
+ const space = await getOrCreateSpace(question, /\s/.test(key) ? undefined : key);
+ const nodes = await getTree(space.id);
+ sendJson(res, 200, { space, nodes });
+}
+
+async function handleSpaces(_req, res) {
+ sendJson(res, 200, { spaces: await listSpaces() });
+}
+
+// POST /api/publish — persist an author-approved steelman as a tree node.
+// parent_id null => 'answer' (direct to the question); otherwise pro/con by stance.
+async function handlePublish(req, res) {
+ const b = await readBody(req);
+ if (!b.publishedText || !b.originalText || !b.question) {
+ return sendJson(res, 400, { error: "question, publishedText, originalText required" });
+ }
+ const space = await getOrCreateSpace(b.question, b.spaceSlug);
+ const kind = !b.parentId
+ ? "answer"
+ : /oppos|con/i.test(b.stance || "")
+ ? "con"
+ : "pro";
+ const node = await insertNode({
+ spaceId: space.id,
+ parentId: b.parentId,
+ kind,
+ authorAccount: b.authorAccount,
+ publishedText: b.publishedText,
+ originalText: b.originalText,
+ transcript: b.transcript,
+ });
+ sendJson(res, 200, { node, space });
+}
+
+async function handleVote(req, res) {
+ const b = await readBody(req);
+ if (!b.nodeId || ![1, -1].includes(b.value)) {
+ return sendJson(res, 400, { error: "nodeId and value (1|-1) required" });
+ }
+ await castVote(b.nodeId, b.account || "anon", b.value);
+ sendJson(res, 200, { ok: true });
+}
+
+const server = createServer(async (req, res) => {
+ try {
+ const url = new URL(req.url, `http://${req.headers.host}`);
+ if (req.method === "POST" && url.pathname === "/api/steelman") {
+ return await handleSteelman(req, res);
+ }
+ if (req.method === "POST" && url.pathname === "/api/publish") {
+ return await handlePublish(req, res);
+ }
+ if (req.method === "POST" && url.pathname === "/api/vote") {
+ return await handleVote(req, res);
+ }
+ if (req.method === "GET" && url.pathname === "/api/tree") {
+ return await handleTree(req, res, url);
+ }
+ if (req.method === "GET" && url.pathname === "/api/spaces") {
+ return await handleSpaces(req, res);
+ }
+ if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
+ const html = await readFile(join(DIR, "index.html"));
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
+ return res.end(html);
+ }
+ res.writeHead(404);
+ res.end("not found");
+ } catch (err) {
+ console.error(err);
+ res.writeHead(500, { "content-type": "application/json" });
+ res.end(JSON.stringify({ error: String(err.message || err) }));
+ }
+});
+
+await initDb();
+server.listen(PORT, () => console.log(`steelman spike: http://127.0.0.1:${PORT}/`));