diff --git a/ESTATE.md b/ESTATE.md
index f7ac60d..6381223 100644
--- a/ESTATE.md
+++ b/ESTATE.md
@@ -32,7 +32,7 @@ flowchart LR
prov["provider service check · append · publish · site code · templates (CI-pinned)"]
sig["dogfood signer verified-dalek binary"]
lib["consumer library receipts · pin store · R0–R5"]
- wal["warden (code) quorum wallet · MCP"]
+ wal["warden (code) quorum wallet · MCP · cockpit (local, read-only)"]
pap["paper v0.9 + v0.1/v0.2 archives"]
crs["course + llms.txt 14 notebooks"]
key["SIGNING KEY (offline)"]
@@ -118,7 +118,7 @@ deployed verifier; see the corpus KNOWN-GAPS ledger).
| `dalek-` / `anza-` / `risc0-` / `betrusted-ed25519-verified` | subject | Rust source + Lean proofs; 16 certs each; attested (leaves 8–11, generations at 0–7) | frozen at attested commits; branch moves only for docs |
| `pasta-pallas-verified` | subject | field layer proven; curve layer pending; **not attested** | changes freely |
| `ltl-accumulator-verified` | subject | 61-cert corpus about the log's accumulator model; **entry-13 subject**, frozen `172a1d0` | frozen; doc-only commits allowed |
-| `proof-aware-crypto-tooling-agent` (this repo) | machinery | provider service, consumer library, warden, dogfood signer, paper, course, tests | **changes freely — the hub** |
+| `proof-aware-crypto-tooling-agent` (this repo) | machinery | provider service, consumer library, warden (+ local read-only cockpit), dogfood signer, paper, course, tests | **changes freely — the hub** |
| `lean-transparency-log` | published | the public mirror: leaves, heads, receipts, fail-closed `verify.py` + selftest | **generated by publish** — canonical files here, templates in pacta, CI-pinned |
| `verifying-crypto-with-lean` | published | undergraduate book; zero coupling to log state | changes freely |
| `swisspost-evoting-go-poc` | consumer | operator's PoC; prospective consumer (family-level dalek match only) | independent |
diff --git a/WALLET.md b/WALLET.md
index 4bf8a2c..5158d19 100644
--- a/WALLET.md
+++ b/WALLET.md
@@ -143,6 +143,17 @@ pacta wallet mcp --wallet ./my-warden # stdio JSON-RPC MCP server
---
+## The custody cockpit (human surface, read-only)
+
+`pacta wallet cockpit --wallet
` serves a local web UI for the
+operator: posture (latch, ledger chain re-verified, pinned quorum),
+the airgap signature queue (observed, never operated), the incident and
+refusal browser, and a receipt inspector driven by the deployed
+verifier. Design law: it renders recomputed evidence with provenance
+lines, never cached status; it cannot approve, sign, unlatch, or modify
+custody state (byte-level read-only guarantee in
+`tests/test_walletui.py`). Details: [docs/cockpit.md](docs/cockpit.md).
+
## Agent-native surface (MCP)
`pacta wallet mcp` speaks MCP over stdio JSON-RPC. Eight outcome-first
diff --git a/docs/cockpit.md b/docs/cockpit.md
new file mode 100644
index 0000000..fdf071c
--- /dev/null
+++ b/docs/cockpit.md
@@ -0,0 +1,51 @@
+# The custody cockpit — a read-only surface for the human operator
+
+`pacta wallet cockpit --wallet ` serves a local web UI
+(default `http://127.0.0.1:8471`) over an existing warden wallet.
+warden has always been agent-native (MCP) and CLI-native; the cockpit is
+the third surface — for the human who ultimately answers for the money.
+
+## The design law
+
+**The cockpit renders evidence; it never asserts it.** Every panel is
+recomputed at request time by the same functions the wallet itself uses
+(`Wallet.posture()`, `Wallet.verify_ledger()`, directory listings,
+`transparency.verify_receipt`), and every panel carries a provenance
+line naming the function and the timestamp. Anything that cannot be
+recomputed renders as a loud red FAILED-TO-VERIFY panel. There is no
+cached green and no neutral gray — a cockpit that shows unverified green
+lights would be the anti-warden.
+
+## The read-only guarantee
+
+The cockpit cannot approve, sign, unlatch, or modify custody state. It
+calls only read paths; the one POST route (the receipt inspector) parses
+submitted artifacts in memory and throwaway temp files, never near the
+wallet directory. `tests/test_walletui.py` asserts this at the byte
+level: a full request sweep, POST included, leaves every file in the
+wallet directory hash-identical. Human approve/deny is deliberately NOT
+here — that would be a custody-semantics change, which belongs to a
+separate, explicitly reviewed milestone.
+
+## The four views
+
+| view | shows | recomputed by |
+|---|---|---|
+| **Posture** (`/`) | custody latch state, ledger head with full hash-chain re-verification, the pinned quorum members (backend, component, tier, source commit, binary hash), spending policy verbatim, incident/refusal counts | `Wallet.posture()` / `Wallet.verify_ledger()` |
+| **Signature queue** (`/queue`) | parked airgap signing requests (outbox) and whether the device has answered (inbox) — observed, never operated | airgap outbox/inbox listing |
+| **Incidents & refusals** (`/incidents`) | incident records and signed refusal receipts, verbatim, newest first | `incidents/*.json`, `receipts/*.json` |
+| **Receipt inspector** (`/inspect`) | paste an attestation + transparency receipt + log public key; the verdict, per-signature results, and diagnostics come verbatim from the deployed verifier | `pacta.transparency.verify_receipt` |
+
+Every panel also states what it does **not** prove (e.g. the quorum
+table says binary hashes are pinned but source-to-binary correspondence
+is out of scope until reproducible builds).
+
+## Serving
+
+```bash
+pacta wallet cockpit --wallet ~/my-wallet # 127.0.0.1:8471
+pacta wallet cockpit --wallet ~/my-wallet --port 9000
+```
+
+The server binds localhost by default and is not meant to be exposed;
+there is no authentication because there is nothing to operate.
diff --git a/llms.txt b/llms.txt
index b98ec5c..11e6d7d 100644
--- a/llms.txt
+++ b/llms.txt
@@ -13,7 +13,7 @@
- [ESTATE.md](ESTATE.md): the map of the whole endeavour — every repo, service, mirror, operator-held entity, and the two self-referential loops.
- [README.md](README.md): what pacta is, the R0–R5 risk model, the dogfood loop.
-- [WALLET.md](WALLET.md): warden, the verified-custody wallet — the quorum boundary, the signing firewall, the R4 gate, the MCP surface.
+- [WALLET.md](WALLET.md): warden, the verified-custody wallet — the quorum boundary, the signing firewall, the R4 gate, the MCP surface, and the local read-only custody cockpit for the human operator (docs/cockpit.md).
- [docs/agent-native.md](docs/agent-native.md): why the wallet is agent-native first (AX, MCP, A2A, AP2, x402, ERC-8004) and what each idea became.
- [docs/products.md](docs/products.md): the four warden deployment profiles.
diff --git a/src/pacta/cli.py b/src/pacta/cli.py
index aae8fd9..86eaaf6 100644
--- a/src/pacta/cli.py
+++ b/src/pacta/cli.py
@@ -235,6 +235,12 @@ def build_parser() -> argparse.ArgumentParser:
w_mcp.add_argument("--log-url", default="https://ltl.zkdefi.org")
w_mcp.set_defaults(func=cmd_wallet_mcp)
+ w_cockpit = wsub.add_parser("cockpit", help="Serve the read-only custody cockpit (local web UI) for the human operator.")
+ w_cockpit.add_argument("--wallet", required=True)
+ w_cockpit.add_argument("--host", default="127.0.0.1", help="Bind address (default localhost; the cockpit is not meant to be exposed).")
+ w_cockpit.add_argument("--port", type=int, default=8471)
+ w_cockpit.set_defaults(func=cmd_wallet_cockpit)
+
w_ledger = wsub.add_parser("verify-ledger", help="Re-check the wallet's hash-chained ledger integrity.")
w_ledger.add_argument("--wallet", required=True)
w_ledger.set_defaults(func=cmd_wallet_verify_ledger)
@@ -673,6 +679,18 @@ def cmd_wallet_card(args: argparse.Namespace) -> int:
return 0
+def cmd_wallet_cockpit(args: argparse.Namespace) -> int:
+ from .walletui import serve
+ server = serve(args.wallet, host=args.host, port=args.port)
+ host, port = server.server_address[0], server.server_address[1]
+ print(f"warden cockpit (READ-ONLY) on http://{host}:{port} - Ctrl-C to stop")
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ server.shutdown()
+ return 0
+
+
def cmd_wallet_mcp(args: argparse.Namespace) -> int:
from .walletmcp import WalletMCP
diff --git a/src/pacta/walletui.py b/src/pacta/walletui.py
new file mode 100644
index 0000000..d195443
--- /dev/null
+++ b/src/pacta/walletui.py
@@ -0,0 +1,398 @@
+"""walletui - the warden custody cockpit (local, read-only).
+
+A localhost web surface over an existing wallet directory, for the human
+operator who ultimately answers for the money. Four views: posture, the
+pending-signature queue (airgap outbox), the incident & refusal browser,
+and a receipt inspector.
+
+Design law: THE COCKPIT RENDERS EVIDENCE, IT NEVER ASSERTS IT. Every
+panel is recomputed from wallet state or submitted artifacts at request
+time by the same functions the wallet itself uses, and every panel names
+the function and timestamp that produced it. Anything that cannot be
+recomputed renders as a loud FAILED-TO-VERIFY panel - there is no cached
+green and no neutral gray.
+
+Read-only guarantee: this module calls only read paths (``Wallet.posture``,
+``verify_ledger``, directory listings) and ``transparency.verify_receipt``
+on submitted artifacts (parsed in memory / temp files outside the wallet).
+It cannot approve, sign, unlatch, or modify custody state; the HTTP surface
+exposes no mutating route. Human approve/deny is deliberately NOT here -
+that would be a custody-semantics change, which belongs to a separate,
+explicitly reviewed milestone.
+
+The server binds 127.0.0.1 by default and is not meant to be exposed.
+"""
+from __future__ import annotations
+
+import html
+import json
+import tempfile
+import urllib.parse
+from datetime import datetime, timezone
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Any, Callable
+
+from .attestation import load_attestation
+from .transparency import load_receipt, verify_receipt
+from .wallet import Wallet
+
+_STYLE = """
+ :root{--ink:#1c2430;--ink2:#5a6675;--line:#dde2e9;--ok:#1e7f4f;--okbg:#e2f2e9;
+ --bad:#a3242c;--badbg:#fbe4e6;--warn:#a86a10;--warnbg:#fdf0da;
+ --accent:#3b4d8f;--accentbg:#eef0f7;--bg:#f8f9fa}
+ *{box-sizing:border-box}
+ body{font-family:system-ui,sans-serif;max-width:62rem;margin:0 auto;
+ padding:1.4rem 1.2rem 4rem;color:var(--ink);line-height:1.55;background:var(--bg)}
+ h1{font-size:1.35rem;margin:.2rem 0 0}
+ h2{font-size:1.05rem;margin:1.6rem 0 .5rem}
+ code{font-family:ui-monospace,Menlo,Consolas,monospace;background:#eef0f3;
+ border-radius:4px;padding:.08rem .3rem;font-size:.88em}
+ nav{margin:.7rem 0 1rem;display:flex;gap:.5rem;flex-wrap:wrap}
+ nav a{color:var(--accent);text-decoration:none;border:1px solid var(--line);
+ background:#fff;border-radius:6px;padding:.25rem .7rem;font-size:.85rem}
+ nav a.here{border-color:var(--accent);font-weight:600}
+ .banner{background:var(--warnbg);border:1px solid var(--warn);color:var(--warn);
+ border-radius:6px;padding:.45rem .8rem;font-size:.82rem;font-weight:600}
+ .panel{background:#fff;border:1px solid var(--line);border-radius:8px;
+ padding:.9rem 1.1rem;margin:.7rem 0}
+ .panel.bad{border-color:var(--bad);background:var(--badbg)}
+ .prov{color:var(--ink2);font-size:.72rem;margin-top:.6rem;border-top:1px dashed var(--line);
+ padding-top:.35rem}
+ .pill{display:inline-block;border-radius:9px;padding:.06rem .55rem;font-size:.76rem;
+ font-weight:700}
+ .pill.ok{background:var(--okbg);color:var(--ok)}
+ .pill.bad{background:var(--badbg);color:var(--bad)}
+ .pill.warn{background:var(--warnbg);color:var(--warn)}
+ table{border-collapse:collapse;width:100%;font-size:.88rem;background:#fff}
+ td,th{border:1px solid var(--line);padding:.4rem .6rem;text-align:left;vertical-align:top}
+ th{background:var(--accentbg)}
+ ul.diag{margin:.4rem 0 0;padding-left:1.2rem}
+ ul.diag li{font-size:.85rem;margin:.2rem 0}
+ textarea{width:100%;min-height:7.5rem;font-family:ui-monospace,monospace;font-size:.8rem;
+ border:1px solid var(--line);border-radius:6px;padding:.5rem}
+ button{background:var(--accent);color:#fff;border:0;border-radius:6px;
+ padding:.5rem 1.1rem;font-size:.9rem;cursor:pointer}
+ .muted{color:var(--ink2);font-size:.85rem}
+ .mono{font-family:ui-monospace,monospace}
+"""
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def _esc(value: Any) -> str:
+ return html.escape(str(value))
+
+
+def _provenance(via: str) -> str:
+ return f'
recomputed {_esc(_now())} via {_esc(via)} — nothing on this panel is cached or asserted.
FAILED TO VERIFY '
+ f"{_esc(what)} could not be recomputed: "
+ f"{_esc(f'{type(error).__name__}: {error}')}. "
+ f"A cockpit that cannot verify shows red, never a stale green."
+ f"{_provenance(via)}
{ledger['entries']} entries · head {_esc(ledger['head'][:24])}…
"
+ f"
{problems}
"
+ f"{_provenance('Wallet.verify_ledger() — full hash-chain recomputation')}
"
+ f"
Quorum members "
+ f"{len(p['members'])} pinned
"
+ "
backend
component
tier
"
+ "
source commit
binary sha256
"
+ f"{members}
"
+ "
Every member is pinned by binary hash in the capsule; the capsule "
+ f"hash is {_esc(p['capsule_sha256'][:24])}…. What this table does NOT "
+ "prove: that the binaries correspond to the attested sources (reproducible builds "
+ "are out of scope, stated in the paper and the claim cards)."
+ f"{_provenance('Wallet.capsule() / Wallet.posture()')}
{'answered' if r['answered'] else 'awaiting device'}
"
+ for r in airgap["data"])
+ body = (f"
request
created
payload sha256
"
+ f"
state
{rows}
" if airgap["data"]
+ else "
No parked signing requests.
")
+ return (
+ "
Pending airgap signatures
"
+ + body +
+ "
This queue is OBSERVED, not operated: completing or refusing a "
+ "request happens through the wallet's own channels (request_signature "
+ "over MCP, or the airgap device flow), never from this page.
"
+ )
+
+
+def render_incidents(incidents: dict[str, Any], refusals: dict[str, Any]) -> str:
+ def block(title: str, coll: dict[str, Any], via_note: str) -> str:
+ if not coll["ok"]:
+ return _failed_panel(title, coll["via"], coll["error"])
+ items = coll["data"]
+ if not items:
+ body = "
none recorded
"
+ else:
+ body = "".join(
+ f"
{_esc(i['_file'])}"
+ f"
{_esc(json.dumps({k: v for k, v in i.items() if k != '_file'}, indent=2, sort_keys=True))}
"
+ for i in items[:50])
+ return (f"
{_esc(title)}
{body}"
+ f"{_provenance(via_note)}
")
+ return (block("Incidents (quorum divergences, quarantines)", incidents,
+ "incidents/*.json, verbatim, newest first")
+ + block("Refusal receipts (signed, machine-actionable)", refusals,
+ "receipts/*.json, verbatim, newest first"))
+
+
+def render_inspect(result: dict[str, Any] | None,
+ defaults: dict[str, str] | None = None) -> str:
+ d = defaults or {}
+ verdict = ""
+ if result is not None:
+ if not result["ok"]:
+ verdict = _failed_panel("Receipt verification", result["via"],
+ RuntimeError(result["error"]))
+ else:
+ pill = ('ACCEPTED' if result["accepted"]
+ else 'REJECTED')
+ sigs = "".join(f"
{_esc(k)}
{_esc(v)}
"
+ for k, v in sorted(result["signatures"].items()))
+ diags = "".join(f"
{_esc(x)}
" for x in result["diagnostics"]) or "
none
"
+ verdict = (
+ f"
Verdict {pill}
"
+ f"
signature check
result
{sigs}
"
+ f"
Diagnostics
{diags}
"
+ f"{_provenance(result['via'])}
")
+ return (
+ verdict +
+ "
Inspect a receipt
"
+ "
Paste an attestation, its transparency receipt, and the log's "
+ "public key. The verdict is produced by the wallet's own deployed verifier — "
+ "this page adds nothing and hides nothing; the diagnostics list is verbatim.