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..19352c44
--- /dev/null
+++ b/debate-tree/PLAN.md
@@ -0,0 +1,233 @@
+# 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?").
+- **Steelmanning loop** — a contributor drafts a point; the AI reflects back
+ the strongest version of it; the contributor revises or accepts; capped at
+ ~3 rounds, with a "publish as-is (flagged unrefined)" escape hatch. Only
+ the version the *contributor* approves is published. The original text
+ stays attached (visible on click) — nobody's voice is erased.
+- **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 (sketch)**:
+- `space` — a debate context (e.g. "QIPs", "PQ-migration"), holds config:
+ question threshold N QUAN, share target, model tier.
+- `node` — id, space, parent, kind (`question | answer | pro | con`),
+ published text, original text, author account, source citations (for
+ seeded nodes), content hash (for optional on-chain anchoring), status.
+- `steelman_session` — node draft, transcript, round count, state.
+- `share_token` / `balance_attestation` — consumed gate proofs.
+
+**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**:
+- [ ] Steelman-loop spike (see §5 — build first, throwaway UI)
+- [ ] Tree UI (read): collapsible tree, node detail w/ original text +
+ citations, shareable 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; 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/index.html b/debate-tree/spike/index.html
new file mode 100644
index 00000000..5e9d5145
--- /dev/null
+++ b/debate-tree/spike/index.html
@@ -0,0 +1,221 @@
+
+
+
+
+
+ Steelman spike
+
+
+
+
Steelman negotiation spike — the AI moderator write-path, isolated
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/debate-tree/spike/prompts.mjs b/debate-tree/spike/prompts.mjs
new file mode 100644
index 00000000..598c75f5
--- /dev/null
+++ b/debate-tree/spike/prompts.mjs
@@ -0,0 +1,67 @@
+// 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
+STEELMAN it: produce the strongest, clearest version of THEIR point,
+which they must recognize as their own.
+
+Hard rules:
+1. Never change the author's position, weaken it into agreeableness, or
+ add hedges they didn't imply. If they are against something, the
+ steelman is firmly against it.
+2. Strengthen: sharpen the core claim, make implicit reasoning explicit,
+ replace insults and sneers with force of argument, cut filler.
+3. Keep the author's voice: first person if they wrote in first person,
+ similar length (never more than ~1.5x), plain language. No debate-club
+ jargon, no "one might argue".
+4. Do not invent facts, sources, or examples the author didn't reference
+ or clearly imply. If their claim depends on a fact you're unsure of,
+ keep their phrasing rather than "correcting" it.
+5. If the argument bundles several points, keep the strongest one central
+ rather than flattening it into a list.
+6. If (and only if) the author's position is genuinely ambiguous, ask ONE
+ short clarifying question.
+
+The author will review your draft and may push back. When they do, their
+feedback is authoritative about what they meant — revise to match their
+intent, not to defend your previous draft.
+
+Respond with ONLY a JSON object, no markdown fences:
+{
+ "steelman": "",
+ "notes": "<1-3 short bullets, each starting with '- ', saying what you changed and why>",
+ "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}
+---
+
+Steelman it.`;
+}
+
+export function buildRevisionPrompt({ original, draft, feedback, round }) {
+ return `This is revision round ${round}. Reminder of the author's original, verbatim:
+---
+${original}
+---
+
+Your previous draft:
+---
+${draft}
+---
+
+The author's feedback on your draft:
+---
+${feedback}
+---
+
+Revise the steelman to match the author's intent. Their feedback wins over
+your judgment about what makes the argument "better".`;
+}
diff --git a/debate-tree/spike/server.mjs b/debate-tree/spike/server.mjs
new file mode 100644
index 00000000..b46c76f5
--- /dev/null
+++ b/debate-tree/spike/server.mjs
@@ -0,0 +1,147 @@
+// 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
+//
+// 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";
+
+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 ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-5";
+const OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-5.2";
+
+const provider = ANTHROPIC_KEY ? "anthropic" : OPENAI_KEY ? "openai" : "stub";
+console.log(`model provider: ${provider}`);
+
+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;
+}
+
+// Offline stand-in so the interaction flow is testable without a key: it
+// performs a crude mechanical "steelman" and echoes feedback acknowledgment.
+function callStub(messages) {
+ const last = messages[messages.length - 1].content;
+ const original = (last.match(/---\n([\s\S]*?)\n---/) || [, last])[1].trim();
+ const isRevision = /revision round/.test(last);
+ const cleaned = original
+ .replace(/\b(stupid|idiotic|insane|moronic|garbage|bullshit)\b/gi, "deeply flawed")
+ .replace(/!+/g, ".")
+ .trim();
+ const steelman = isRevision
+ ? cleaned + " (revised per your feedback)"
+ : "The core of my position: " + cleaned;
+ return Promise.resolve(
+ JSON.stringify({
+ steelman,
+ notes: "- [stub model] softened insults, kept your position\n- set an API key for real steelmanning",
+ question: null,
+ })
+ );
+}
+
+const callModel = provider === "anthropic" ? callAnthropic : provider === "openai" ? callOpenAI : callStub;
+
+function parseModelJson(text) {
+ // Models occasionally wrap JSON in fences despite instructions.
+ const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim();
+ const parsed = JSON.parse(cleaned);
+ if (typeof parsed.steelman !== "string") throw new Error("model reply missing steelman");
+ return {
+ steelman: parsed.steelman,
+ 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({ steelman: r.draft }) });
+ messages.push({
+ role: "user",
+ content: buildRevisionPrompt({
+ original: body.original,
+ 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));
+}
+
+const server = createServer(async (req, res) => {
+ try {
+ if (req.method === "POST" && req.url === "/api/steelman") {
+ return await handleSteelman(req, res);
+ }
+ if (req.method === "GET" && (req.url === "/" || req.url === "/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) }));
+ }
+});
+
+server.listen(PORT, () => console.log(`steelman spike: http://127.0.0.1:${PORT}/`));