cockpit: UX law — the cockpit never leaves a human in the dark

Operator verdict on the first cockpit: unusable, jargon-walled. The
evidence layer was honest but the presentation assumed the reader
already lived inside warden's head. This rebuilds the presentation
layer around a testable UX contract, additive to the design law:

- every page: verdict IN WORDS first (CUSTODY HEALTHY / CUSTODY FROZEN
  (LATCHED) / CUSTODY EVIDENCE BROKEN), then evidence, then provenance
- every page: plain-language lead saying what the page answers
- every panel: 'How to read this panel' expander interpreting every
  column and pill; jargon carries a ? linking to the glossary
- new /guide view: what warden is, how to read any page, color code,
  five-minute tour, 12-term glossary, honest 'what this cockpit cannot
  tell you'
- navigation: two-line tabs stating the question each view answers,
  present on every page; /estate gets a back-to-cockpit chip
- explained empty states (incidents: 'empty is the good state')
- narrow-viewport fix: tables/pre scroll in their own containers
- estate fact chip updated 130->135 green (both renderings)

UX contract is test-enforced (guide terms, lead+nav+explainers on every
view, empty states); read-only byte-guarantee sweep now covers /guide.
Verified by looking: served --demo, walked all six views in a browser.
Suite 131 -> 135 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrwulf 2026-07-21 16:15:00 +02:00
parent b3239dba45
commit 00be6a396e
7 changed files with 579 additions and 79 deletions

View file

@ -9,7 +9,7 @@ machinery hub and the only repo that changes freely.
State snapshot (2026-07-19): log **13 leaves**, root `3488a2d0…`, key State snapshot (2026-07-19): log **13 leaves**, root `3488a2d0…`, key
fingerprint `874c8a00…`, paper **v0.9 camera-ready (23 pp)**, five fingerprint `874c8a00…`, paper **v0.9 camera-ready (23 pp)**, five
attested components, pacta suite 130 green. attested components, pacta suite 135 green.
```mermaid ```mermaid
flowchart LR flowchart LR

View file

@ -145,14 +145,19 @@ pacta wallet mcp --wallet ./my-warden # stdio JSON-RPC MCP server
## The custody cockpit (human surface, read-only) ## The custody cockpit (human surface, read-only)
`pacta wallet cockpit --wallet <dir>` serves a local web UI for the `pacta wallet cockpit --wallet <dir>` (or `--demo` for a throwaway,
operator: posture (latch, ledger chain re-verified, pinned quorum), custody-inert wallet to explore from zero) serves a local web UI for the
the airgap signature queue (observed, never operated), the incident and operator: posture (verdict banner, latch, ledger chain re-verified,
refusal browser, and a receipt inspector driven by the deployed pinned quorum), the airgap signature queue (observed, never operated),
verifier. Design law: it renders recomputed evidence with provenance the incident and refusal browser, a receipt inspector driven by the
lines, never cached status; it cannot approve, sign, unlatch, or modify deployed verifier, the estate map, and a plain-language guide with a
custody state (byte-level read-only guarantee in full glossary. Design law: it renders recomputed evidence with
`tests/test_walletui.py`). Details: [docs/cockpit.md](docs/cockpit.md). provenance lines, never cached status; it cannot approve, sign, unlatch,
or modify custody state (byte-level read-only guarantee in
`tests/test_walletui.py`). UX law: every page explains itself — verdict
in words, per-panel "how to read this" expanders, jargon linked to the
`/guide` glossary (also test-enforced). Details:
[docs/cockpit.md](docs/cockpit.md).
## Agent-native surface (MCP) ## Agent-native surface (MCP)

View file

@ -16,6 +16,36 @@ 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 cached green and no neutral gray — a cockpit that shows unverified green
lights would be the anti-warden. lights would be the anti-warden.
## The UX law (the design law's twin)
**The cockpit never leaves a human in the dark.** A person who has never
heard of warden must be able to read every screen. Concretely, every
page is built from the same anatomy, top to bottom:
1. **Verdict in words** — e.g. CUSTODY HEALTHY / CUSTODY FROZEN
(LATCHED) / CUSTODY EVIDENCE BROKEN — before any evidence, with one
sentence saying what that means and what to do.
2. **A plain-language lead** stating what the page shows and what it
cannot do.
3. **Panels that explain themselves**: each opens with a plain sentence,
carries a "How to read this panel" expander interpreting every column
and every pill, and links each jargon term to the glossary via a
small `?`.
4. **Explained empty states** — an empty list says what empty means and
whether it is good news (for incidents, it is).
5. **The provenance line** — the dashed footer naming the exact function
and timestamp that recomputed the panel.
The `/guide` view is the manual: what warden is, how to read any page,
the color code, a five-minute tour, a glossary of every term (capsule,
member, pinning, evidence grades R0R5, ledger, latch, incident, refusal
receipt, air-gap, attestation/receipt, provenance, DEMO), and an honest
"what this cockpit cannot tell you" section. Navigation tabs state the
question each view answers. This contract is enforced by tests
(`test_guide_view_explains_every_term`,
`test_every_view_carries_lead_nav_and_explainers`,
`test_empty_states_are_explained`).
## The read-only guarantee ## The read-only guarantee
The cockpit cannot approve, sign, unlatch, or modify custody state. It The cockpit cannot approve, sign, unlatch, or modify custody state. It
@ -27,15 +57,16 @@ wallet directory hash-identical. Human approve/deny is deliberately NOT
here — that would be a custody-semantics change, which belongs to a here — that would be a custody-semantics change, which belongs to a
separate, explicitly reviewed milestone. separate, explicitly reviewed milestone.
## The four views ## The six views
| view | shows | recomputed by | | view | answers | 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()` | | **Posture** (`/`) | *Is custody healthy right now?* Verdict banner, then: custody latch, ledger with full hash-chain re-verification, the pinned quorum members (backend, component, evidence grade, source commit, binary fingerprint), signing rules 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 | | **Queue** (`/queue`) | *What awaits the offline signer?* Parked air-gap 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` | | **Incidents** (`/incidents`) | *What has ever gone wrong?* Incident records and signed refusal receipts, verbatim, newest first — with the page explaining why empty is the good state | `incidents/*.json`, `receipts/*.json` |
| **Estate map** (`/estate`) | the whole endeavour — every repo, service, mirror, loop — with RUNTIME on every entity (always-on / on-demand / not-running / static) | rendering of ESTATE.md's model (drift-guarded by test) | | **Inspect** (`/inspect`) | *Can I check a receipt myself?* 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` |
| **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` | | **Estate map** (`/estate`) | *Where does this wallet sit in the whole endeavour?* Every repo, service, mirror, loop — with RUNTIME on every entity (always-on / on-demand / not-running / static) | rendering of ESTATE.md's model (drift-guarded by test) |
| **Guide** (`/guide`) | *What does any of this mean?* The manual: plain-language explanations, color code, tour, full glossary, honest limits — static, no live data | — |
Every panel also states what it does **not** prove (e.g. the quorum Every panel also states what it does **not** prove (e.g. the quorum
table says binary hashes are pinned but source-to-binary correspondence table says binary hashes are pinned but source-to-binary correspondence

View file

@ -693,6 +693,7 @@ def cmd_wallet_cockpit(args: argparse.Namespace) -> int:
server = serve(wallet_dir, host=args.host, port=args.port) server = serve(wallet_dir, host=args.host, port=args.port)
host, port = server.server_address[0], server.server_address[1] host, port = server.server_address[0], server.server_address[1]
print(f"warden cockpit (READ-ONLY) on http://{host}:{port} - Ctrl-C to stop") print(f"warden cockpit (READ-ONLY) on http://{host}:{port} - Ctrl-C to stop")
print(f" first time? start at http://{host}:{port}/guide - every term explained")
try: try:
server.serve_forever() server.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:

View file

@ -123,7 +123,7 @@ ESTATE_HTML = r'''<title>LTL estate map — repos, services, loops</title>
<span class="fact">key <b class="mono">874c8a00</b></span> <span class="fact">key <b class="mono">874c8a00</b></span>
<span class="fact">paper <b>v0.9 · 23 pp · camera-ready</b></span> <span class="fact">paper <b>v0.9 · 23 pp · camera-ready</b></span>
<span class="fact">attested components <b>5</b></span> <span class="fact">attested components <b>5</b></span>
<span class="fact">pacta suite <b>130 green</b></span> <span class="fact">pacta suite <b>135 green</b></span>
<span class="fact">state as of <b>2026-07-20</b></span> <span class="fact">state as of <b>2026-07-20</b></span>
</div> </div>
<div class="facts" style="padding-top:.15rem"> <div class="facts" style="padding-top:.15rem">

View file

@ -1,9 +1,9 @@
"""walletui - the warden custody cockpit (local, read-only). """walletui - the warden custody cockpit (local, read-only).
A localhost web surface over an existing wallet directory, for the human A localhost web surface over an existing wallet directory, for the human
operator who ultimately answers for the money. Four views: posture, the operator who ultimately answers for the money. Six views: posture, the
pending-signature queue (airgap outbox), the incident & refusal browser, pending-signature queue (airgap outbox), the incident & refusal browser,
and a receipt inspector. a receipt inspector, the estate map, and a plain-language guide.
Design law: THE COCKPIT RENDERS EVIDENCE, IT NEVER ASSERTS IT. Every Design law: THE COCKPIT RENDERS EVIDENCE, IT NEVER ASSERTS IT. Every
panel is recomputed from wallet state or submitted artifacts at request panel is recomputed from wallet state or submitted artifacts at request
@ -12,6 +12,13 @@ the function and timestamp that produced it. Anything that cannot be
recomputed renders as a loud FAILED-TO-VERIFY panel - there is no cached recomputed renders as a loud FAILED-TO-VERIFY panel - there is no cached
green and no neutral gray. green and no neutral gray.
UX law (the design law's twin): THE COCKPIT NEVER LEAVES A HUMAN IN THE
DARK. Every page opens with a plain-language statement of what it shows;
every verdict is stated in words, not just color; every panel carries a
"how to read this" explainer; every piece of jargon links to the /guide
glossary. A person who has never heard of warden must be able to read
every screen.
Read-only guarantee: this module calls only read paths (``Wallet.posture``, Read-only guarantee: this module calls only read paths (``Wallet.posture``,
``verify_ledger``, directory listings) and ``transparency.verify_receipt`` ``verify_ledger``, directory listings) and ``transparency.verify_receipt``
on submitted artifacts (parsed in memory / temp files outside the wallet). on submitted artifacts (parsed in memory / temp files outside the wallet).
@ -48,12 +55,23 @@ _STYLE = """
h2{font-size:1.05rem;margin:1.6rem 0 .5rem} h2{font-size:1.05rem;margin:1.6rem 0 .5rem}
code{font-family:ui-monospace,Menlo,Consolas,monospace;background:#eef0f3; code{font-family:ui-monospace,Menlo,Consolas,monospace;background:#eef0f3;
border-radius:4px;padding:.08rem .3rem;font-size:.88em} border-radius:4px;padding:.08rem .3rem;font-size:.88em}
.sub{color:var(--ink2);font-size:.85rem;margin:.3rem 0 .6rem}
nav{margin:.7rem 0 1rem;display:flex;gap:.5rem;flex-wrap:wrap} 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); nav a{color:var(--accent);text-decoration:none;border:1px solid var(--line);
background:#fff;border-radius:6px;padding:.25rem .7rem;font-size:.85rem} background:#fff;border-radius:6px;padding:.3rem .7rem;font-size:.85rem;
nav a.here{border-color:var(--accent);font-weight:600} display:flex;flex-direction:column;line-height:1.25;min-width:7.5rem}
nav a.here{border-color:var(--accent);font-weight:600;background:var(--accentbg)}
nav a .navsub{font-size:.67rem;color:var(--ink2);font-weight:400}
.banner{background:var(--warnbg);border:1px solid var(--warn);color:var(--warn); .banner{background:var(--warnbg);border:1px solid var(--warn);color:var(--warn);
border-radius:6px;padding:.45rem .8rem;font-size:.82rem;font-weight:600} border-radius:6px;padding:.45rem .8rem;font-size:.82rem;font-weight:600}
.banner a{color:var(--warn)}
.lead{font-size:.92rem;margin:.9rem 0 .2rem}
.verdict{border-radius:8px;padding:.8rem 1.1rem;margin:.8rem 0;border:1px solid}
.verdict strong{font-size:1.05rem;letter-spacing:.02em}
.verdict p{margin:.3rem 0 0;font-size:.88rem;font-weight:400}
.verdict.ok{background:var(--okbg);border-color:var(--ok);color:var(--ok)}
.verdict.warn{background:var(--warnbg);border-color:var(--warn);color:var(--warn)}
.verdict.bad{background:var(--badbg);border-color:var(--bad);color:var(--bad)}
.panel{background:#fff;border:1px solid var(--line);border-radius:8px; .panel{background:#fff;border:1px solid var(--line);border-radius:8px;
padding:.9rem 1.1rem;margin:.7rem 0} padding:.9rem 1.1rem;margin:.7rem 0}
.panel.bad{border-color:var(--bad);background:var(--badbg)} .panel.bad{border-color:var(--bad);background:var(--badbg)}
@ -64,11 +82,27 @@ _STYLE = """
.pill.ok{background:var(--okbg);color:var(--ok)} .pill.ok{background:var(--okbg);color:var(--ok)}
.pill.bad{background:var(--badbg);color:var(--bad)} .pill.bad{background:var(--badbg);color:var(--bad)}
.pill.warn{background:var(--warnbg);color:var(--warn)} .pill.warn{background:var(--warnbg);color:var(--warn)}
a.help{display:inline-block;width:1.05rem;height:1.05rem;line-height:1.05rem;text-align:center;
border-radius:50%;background:var(--accentbg);color:var(--accent);font-size:.72rem;
font-weight:700;text-decoration:none;vertical-align:.15em}
details.explain{margin-top:.55rem;font-size:.82rem}
details.explain summary{cursor:pointer;color:var(--accent);font-weight:600;font-size:.78rem}
details.explain .expl{color:var(--ink2);margin:.4rem 0 0;padding:.5rem .7rem;
background:var(--accentbg);border-radius:6px}
details.explain .expl ul{margin:.3rem 0;padding-left:1.1rem}
details.explain .expl li{margin:.15rem 0}
.plain{font-size:.88rem;margin:.3rem 0 .6rem}
.empty{color:var(--ink2);font-size:.88rem;background:var(--accentbg);border-radius:6px;
padding:.5rem .8rem}
pre{overflow-x:auto}
.tablewrap{overflow-x:auto}
table{border-collapse:collapse;width:100%;font-size:.88rem;background:#fff} 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} td,th{border:1px solid var(--line);padding:.4rem .6rem;text-align:left;vertical-align:top}
th{background:var(--accentbg)} th{background:var(--accentbg)}
ul.diag{margin:.4rem 0 0;padding-left:1.2rem} ul.diag{margin:.4rem 0 0;padding-left:1.2rem}
ul.diag li{font-size:.85rem;margin:.2rem 0} ul.diag li{font-size:.85rem;margin:.2rem 0}
dl.gloss dt{font-weight:700;margin-top:.8rem}
dl.gloss dd{margin:.15rem 0 0 0;font-size:.88rem;color:var(--ink)}
textarea{width:100%;min-height:7.5rem;font-family:ui-monospace,monospace;font-size:.8rem; 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} border:1px solid var(--line);border-radius:6px;padding:.5rem}
button{background:var(--accent);color:#fff;border:0;border-radius:6px; button{background:var(--accent);color:#fff;border:0;border-radius:6px;
@ -86,6 +120,18 @@ def _esc(value: Any) -> str:
return html.escape(str(value)) return html.escape(str(value))
def _help(anchor: str) -> str:
"""A small ? that jumps to the glossary entry for a term."""
return (f'<a class="help" href="/guide#{anchor}" '
f'title="what does this mean? — explained in the guide">?</a>')
def _explain(body: str) -> str:
"""The per-panel interpretation aid: always present, opt-in detail."""
return (f'<details class="explain"><summary>How to read this panel</summary>'
f'<div class="expl">{body}</div></details>')
def _provenance(via: str) -> str: def _provenance(via: str) -> str:
return f'<div class="prov">recomputed {_esc(_now())} via <code>{_esc(via)}</code> — nothing on this panel is cached or asserted.</div>' return f'<div class="prov">recomputed {_esc(_now())} via <code>{_esc(via)}</code> — nothing on this panel is cached or asserted.</div>'
@ -95,7 +141,10 @@ def _failed_panel(what: str, via: str, error: Exception) -> str:
f'<div class="panel bad"><span class="pill bad">FAILED TO VERIFY</span> ' f'<div class="panel bad"><span class="pill bad">FAILED TO VERIFY</span> '
f"<strong>{_esc(what)}</strong> could not be recomputed: " f"<strong>{_esc(what)}</strong> could not be recomputed: "
f"<code>{_esc(f'{type(error).__name__}: {error}')}</code>. " f"<code>{_esc(f'{type(error).__name__}: {error}')}</code>. "
f"A cockpit that cannot verify shows red, never a stale green." f"A cockpit that cannot verify shows red, never a stale green. "
f"<span class='muted'>What to do: check that the wallet directory still exists and is "
f"readable, then reload. If this persists, inspect from the command line with "
f"<code>pacta wallet posture</code>.</span>"
f"{_provenance(via)}</div>" f"{_provenance(via)}</div>"
) )
@ -184,29 +233,94 @@ def inspect_receipt(attestation_text: str, receipt_text: str,
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# renderers - pure string builders over collector output # page shell - one navigation, one lead paragraph, on every view
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_VIEWS = [("/", "Posture"), ("/queue", "Signature queue"), _VIEWS = [
("/incidents", "Incidents & refusals"), ("/inspect", "Receipt inspector"), ("/", "Posture", "is custody healthy right now?"),
("/estate", "Estate map")] ("/queue", "Queue", "what awaits the offline signer?"),
("/incidents", "Incidents", "what has ever gone wrong?"),
("/inspect", "Inspect", "check a receipt yourself"),
("/estate", "Estate map", "the whole system, drawn"),
("/guide", "Guide", "every term, explained"),
]
_LEADS = {
"/": ('This page answers one question: <strong>is custody healthy right now?</strong> '
'The verdict comes first, the evidence behind it below. Every panel ends with a '
'dashed provenance line naming the exact function that just recomputed it — '
'and every small <a class="help" href="/guide#glossary">?</a> jumps to a '
'plain-language explanation.'),
"/queue": ('The wallets signing key can live on an <em>air-gapped</em> device — a '
'computer with no network connection. To get something signed, the wallet '
'parks a request file in an outbox; a human carries it to the device; the '
'answer lands in an inbox. This page watches those two folders. It cannot '
'approve, refuse, or move anything.'),
"/incidents": ('The wallets paper trail. An <strong>incident</strong> is the wallet '
'noticing something wrong and writing it down on the spot. A '
'<strong>refusal receipt</strong> is the wallet saying <em>no</em> in '
'writing — with the rule it applied and what would fix the request. '
'An empty page here is good news.'),
"/inspect": ('Paste verification artifacts below and this page re-runs the wallets '
'own verifier on them, on your machine, without writing anything to the '
'wallet. Use it to check evidence somebody handed you before trusting it.'),
"/guide": ('Plain-language explanations for everything this cockpit shows. Nothing on '
'this page is live data — this is the manual. The other five tabs are the '
'instruments.'),
}
def _page(title: str, active: str, body: str, wallet_dir: str) -> str: def _page(title: str, active: str, body: str, wallet_dir: str) -> str:
nav = "".join( nav = "".join(
f'<a href="{href}"{" class=here" if href == active else ""}>{label}</a>' f'<a href="{href}"{" class=here" if href == active else ""}>{label}'
for href, label in _VIEWS) f'<span class="navsub">{sub}</span></a>'
for href, label, sub in _VIEWS)
demo_badge = ('<span class="pill warn" title="sealed by --demo; fake members; can sign '
'nothing real">DEMO WALLET — custody-inert</span> '
if "DEMO" in wallet_dir else "")
lead = _LEADS.get(active, "")
lead_html = f'<p class="lead">{lead}</p>' if lead else ""
return ( return (
"<!doctype html><html><head><meta charset='utf-8'>" "<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
f"<title>warden cockpit — {_esc(title)}</title>" f"<title>warden cockpit — {_esc(title)}</title>"
f"<style>{_STYLE}</style></head><body>" f"<style>{_STYLE}</style></head><body>"
f"<h1>warden cockpit <span class='muted mono'>{_esc(wallet_dir)}</span></h1>" f"<h1>warden custody cockpit {demo_badge}</h1>"
f"<p class='sub'>Watching wallet <code>{_esc(wallet_dir)}</code> — everything below is "
"recomputed live from that directory each time a page loads; nothing is cached, "
"nothing is taken on trust.</p>"
"<div class='banner'>READ-ONLY. This cockpit observes and recomputes; it cannot " "<div class='banner'>READ-ONLY. This cockpit observes and recomputes; it cannot "
"approve, sign, unlatch, or modify custody state.</div>" "approve, sign, unlatch, or modify custody state. First time here? Start with the "
f"<nav>{nav}</nav>{body}</body></html>" "<a href='/guide'>Guide</a> — every term on these pages is explained there.</div>"
f"<nav>{nav}</nav>{lead_html}{body}</body></html>"
) )
# ---------------------------------------------------------------------------
# renderers - pure string builders over collector output
# ---------------------------------------------------------------------------
def _posture_verdict(p: dict[str, Any]) -> str:
"""The one-glance answer, in words, before any evidence."""
ledger = p["ledger"]
if not ledger["chain_ok"] or ledger["problems"]:
return ("<div class='verdict bad'><strong>CUSTODY EVIDENCE BROKEN</strong>"
"<p>The ledger — the wallet's tamper-evident history — did not recompute "
"cleanly. Until you know why, treat this wallet as compromised: do not "
"trust its history and do not sign with it. The Ledger panel below shows "
"exactly which check failed.</p></div>")
if p["latch"].get("latched"):
return ("<div class='verdict warn'><strong>CUSTODY FROZEN (LATCHED)</strong>"
"<p>The wallet detected a problem and pulled its own emergency brake: all "
"outbound signing is frozen until a human investigates and deliberately "
"clears the latch. Nothing can be signed right now. The latch panel below "
"names the trigger; <code>docs/runbook-latch.md</code> is the step-by-step "
"recovery guide.</p></div>")
return ("<div class='verdict ok'><strong>CUSTODY HEALTHY</strong>"
"<p>History intact, quorum sealed, signing unfrozen. Each of those three claims "
"is re-checked — not remembered — in the panels below.</p></div>")
def render_posture(posture: dict[str, Any]) -> str: def render_posture(posture: dict[str, Any]) -> str:
if not posture["ok"]: if not posture["ok"]:
return _failed_panel("Custody posture", posture["via"], posture["error"]) return _failed_panel("Custody posture", posture["via"], posture["error"])
@ -224,38 +338,98 @@ def render_posture(posture: dict[str, Any]) -> str:
f"<td class='mono'>{_esc(m['source_commit'][:12])}…</td>" f"<td class='mono'>{_esc(m['source_commit'][:12])}…</td>"
f"<td class='mono'>{_esc(m['binary_sha256'][:16])}…</td></tr>" f"<td class='mono'>{_esc(m['binary_sha256'][:16])}…</td></tr>"
for m in p["members"]) for m in p["members"])
problems = "".join(f"<li>{_esc(x)}</li>" for x in ledger["problems"]) or "<li>none</li>" problems = "".join(f"<li>{_esc(x)}</li>" for x in ledger["problems"]) or (
"<li>none — every link in the chain held</li>")
latch_detail = "" latch_detail = ""
if latch.get("latched"): if latch.get("latched"):
latch_detail = (f"<p>reason: <code>{_esc(latch.get('reason'))}</code> · " latch_detail = (f"<p class='plain'>Trigger: <code>{_esc(latch.get('reason'))}</code> · "
f"incident: <code>{_esc(latch.get('incident'))}</code> · " f"recorded as incident <code>{_esc(latch.get('incident'))}</code> · "
f"since {_esc(latch.get('at'))} — see the " f"frozen since {_esc(latch.get('at'))}. Read the incident in the "
f"<a href='/incidents'>incident browser</a> and docs/runbook-latch.md.</p>") f"<a href='/incidents'>incident browser</a>, then follow "
f"<code>docs/runbook-latch.md</code> to recover.</p>")
spending = p.get("spending_policy") or {} spending = p.get("spending_policy") or {}
no_rules = (not spending) or ("note" in spending and len(spending) == 1)
spending_note = (
"<p class='empty'>No spending rules are configured for this wallet: beyond the "
"quorum gate and the latch, outbound signing is unrestricted — the wallet's own "
"words below say so. A real deployment would define limits and allowlists in "
"<code>policy.json</code>.</p>" if no_rules else
"<p class='plain'>These rules are enforced by the signing firewall before anything "
"is signed. Shown verbatim from <code>policy.json</code>.</p>")
return ( return (
f"<div class='panel'><h2 style='margin-top:0'>Custody latch {latch_pill}</h2>" _posture_verdict(p)
f"{latch_detail}{_provenance('Wallet.latch_state()')}</div>" # --- latch ---------------------------------------------------------
f"<div class='panel'><h2 style='margin-top:0'>Ledger {chain_pill}</h2>" + f"<div class='panel'><h2 style='margin-top:0'>Custody latch {_help('latch')} {latch_pill}</h2>"
f"<p>{ledger['entries']} entries · head <code>{_esc(ledger['head'][:24])}…</code></p>" "<p class='plain'>The latch is the wallet's emergency brake. It trips when the "
"quorum disagrees or tampering is suspected, and freezes all outbound signing "
"until an operator clears it through the wallet's own channels — never from "
"this page.</p>"
f"{latch_detail}"
+ _explain(
"<ul><li><span class='pill ok'>unlatched</span> — the brake is off; signing is "
"allowed (subject to quorum and policy).</li>"
"<li><span class='pill bad'>LATCHED</span> — the brake is on; every signing "
"request is refused with a receipt until a human resolves the trigger. "
"Recovery steps live in <code>docs/runbook-latch.md</code>.</li></ul>")
+ f"{_provenance('Wallet.latch_state()')}</div>"
# --- ledger --------------------------------------------------------
f"<div class='panel'><h2 style='margin-top:0'>Ledger — has history been tampered with? {_help('ledger')} {chain_pill}</h2>"
"<p class='plain'>The ledger is the wallet's append-only journal: every custody "
"event, in order, each entry carrying the hash of the one before it. Editing, "
"deleting, or reordering anything in the past breaks the chain visibly.</p>"
f"<p>{ledger['entries']} entries · newest-entry hash (the «head»): "
f"<code>{_esc(ledger['head'][:24])}…</code></p>"
f"<ul class='diag'>{problems}</ul>" f"<ul class='diag'>{problems}</ul>"
f"{_provenance('Wallet.verify_ledger() — full hash-chain recomputation')}</div>" + _explain(
f"<div class='panel'><h2 style='margin-top:0'>Quorum members " "<ul><li><span class='pill ok'>chain verified</span> — the cockpit just "
"re-hashed every entry from the first to the newest and every link held. This "
"happens again on every reload.</li>"
"<li><span class='pill bad'>CHAIN BROKEN</span> — at least one link failed: "
"history was altered, truncated, or corrupted. The list above names the first "
"entry that failed.</li></ul>")
+ f"{_provenance('Wallet.verify_ledger() — full hash-chain recomputation')}</div>"
# --- quorum --------------------------------------------------------
f"<div class='panel'><h2 style='margin-top:0'>Quorum — who must agree before anything is trusted? {_help('member')} "
f"<span class='pill ok'>{len(p['members'])} pinned</span></h2>" f"<span class='pill ok'>{len(p['members'])} pinned</span></h2>"
"<table><tr><th>backend</th><th>component</th><th>tier</th>" f"<p class='plain'>These are the {len(p['members'])} verifier programs this wallet "
"<th>source commit</th><th>binary sha256</th></tr>" "trusts, each built from a <em>different</em> formally verified codebase. Before "
f"{members}</table>" "the wallet accepts a cryptographic component, every member must independently "
"<p class='muted'>Every member is pinned by binary hash in the capsule; the capsule " "reach the same verdict — a single dissenter freezes custody instead.</p>"
f"hash is <code>{_esc(p['capsule_sha256'][:24])}…</code>. What this table does NOT " "<div class='tablewrap'><table><tr><th>member</th><th>built from</th>"
f"<th>evidence grade {_help('tier')}</th><th>source commit</th>"
f"<th>binary fingerprint {_help('pinned')}</th></tr>"
f"{members}</table></div>"
+ _explain(
"<ul><li><strong>member</strong> — short name of the verifier backend.</li>"
"<li><strong>built from</strong> — the formally verified repository the member "
"binary was compiled from.</li>"
"<li><strong>evidence grade</strong> — R0R5 scale of the formal evidence behind "
"the member: R0 = no usable evidence, R4 = machine-checked proofs covering the "
"full documented boundary, R5 would add reproducible builds and side-channel "
"assurance. This capsule requires R4.</li>"
"<li><strong>source commit</strong> — the exact git commit of those verified "
"sources (first 12 characters shown).</li>"
"<li><strong>binary fingerprint</strong> — SHA-256 hash of the member "
"executable (first 16 characters shown). The capsule pins the full value; a "
"swapped or modified binary fails the comparison and is rejected.</li></ul>")
+ "<p class='muted'>Everything above is sealed in the "
f"<a href='/guide#capsule'>custody capsule</a> — the wallet's founding document, "
f"fingerprint <code>{_esc(p['capsule_sha256'][:24])}…</code>, anchored in the "
"ledger's first entry so it cannot be quietly swapped. What this table does NOT "
"prove: that the binaries correspond to the attested sources (reproducible builds " "prove: that the binaries correspond to the attested sources (reproducible builds "
"are out of scope, stated in the paper and the claim cards)." "are out of scope — that gap is grade R5 — stated in the paper and the claim cards)."
f"{_provenance('Wallet.capsule() / Wallet.posture()')}</div>" f"{_provenance('Wallet.capsule() / Wallet.posture()')}</div>"
f"<div class='panel'><h2 style='margin-top:0'>Spending policy</h2>" # --- signing rules -------------------------------------------------
f"<div class='panel'><h2 style='margin-top:0'>Signing rules (spending policy)</h2>"
f"{spending_note}"
f"<pre style='margin:0;font-size:.8rem'>{_esc(json.dumps(spending, indent=2, sort_keys=True))}</pre>" f"<pre style='margin:0;font-size:.8rem'>{_esc(json.dumps(spending, indent=2, sort_keys=True))}</pre>"
f"{_provenance('Wallet.policy() (policy.json, verbatim)')}</div>" f"{_provenance('Wallet.policy() (policy.json, verbatim)')}</div>"
f"<div class='panel'><h2 style='margin-top:0'>Counters</h2>" # --- recorded history ----------------------------------------------
f"<p>incidents: <strong>{p['incidents']}</strong> · refusal receipts: " f"<div class='panel'><h2 style='margin-top:0'>Recorded history</h2>"
f"<strong>{p['refusal_receipts']}</strong> — browse them under " f"<p class='plain'>Incidents on file: <strong>{p['incidents']}</strong> · refusal "
"<a href='/incidents'>Incidents &amp; refusals</a>.</p>" f"receipts on file: <strong>{p['refusal_receipts']}</strong> — read every one, "
"verbatim, under <a href='/incidents'>Incidents</a>. An incident is the wallet "
"noticing something wrong; a refusal receipt is the wallet saying no, in writing.</p>"
f"{_provenance('directory counts, recomputed')}</div>" f"{_provenance('directory counts, recomputed')}</div>"
) )
@ -269,13 +443,23 @@ def render_queue(airgap: dict[str, Any]) -> str:
f"<td class='mono'>{_esc((r.get('payload_sha256') or '')[:24])}…</td>" f"<td class='mono'>{_esc((r.get('payload_sha256') or '')[:24])}…</td>"
f"<td>{'<span class=\"pill ok\">answered</span>' if r['answered'] else '<span class=\"pill warn\">awaiting device</span>'}</td></tr>" f"<td>{'<span class=\"pill ok\">answered</span>' if r['answered'] else '<span class=\"pill warn\">awaiting device</span>'}</td></tr>"
for r in airgap["data"]) for r in airgap["data"])
body = (f"<table><tr><th>request</th><th>created</th><th>payload sha256</th>" body = (f"<div class='tablewrap'><table><tr><th>request</th><th>created</th>"
f"<th>state</th></tr>{rows}</table>" if airgap["data"] f"<th>payload fingerprint</th><th>state</th></tr>{rows}</table></div>"
else "<p class='muted'>No parked signing requests.</p>") if airgap["data"]
else "<p class='empty'>No signing requests are waiting. The moment the wallet "
"parks one for the offline signer, it appears here.</p>")
return ( return (
"<div class='panel'><h2 style='margin-top:0'>Pending airgap signatures</h2>" f"<div class='panel'><h2 style='margin-top:0'>Waiting for the offline signer {_help('airgap')}</h2>"
+ body + + body
"<p class='muted'>This queue is OBSERVED, not operated: completing or refusing a " + _explain(
"<ul><li><span class='pill warn'>awaiting device</span> — the request file is "
"parked in the outbox; the offline signer has not answered yet.</li>"
"<li><span class='pill ok'>answered</span> — a response file for this request "
"has arrived in the inbox.</li>"
"<li><strong>payload fingerprint</strong> — SHA-256 of the exact bytes to be "
"signed (first 24 characters shown). The device signs those bytes and nothing "
"else, so you can compare fingerprints on both machines before approving.</li></ul>")
+ "<p class='muted'>This queue is OBSERVED, not operated: completing or refusing a "
"request happens through the wallet's own channels (<code>request_signature</code> " "request happens through the wallet's own channels (<code>request_signature</code> "
"over MCP, or the airgap device flow), never from this page.</p>" "over MCP, or the airgap device flow), never from this page.</p>"
+ _provenance("airgap outbox/inbox listing") + "</div>" + _provenance("airgap outbox/inbox listing") + "</div>"
@ -283,23 +467,55 @@ def render_queue(airgap: dict[str, Any]) -> str:
def render_incidents(incidents: dict[str, Any], refusals: dict[str, Any]) -> str: def render_incidents(incidents: dict[str, Any], refusals: dict[str, Any]) -> str:
def block(title: str, coll: dict[str, Any], via_note: str) -> str: def block(title: str, coll: dict[str, Any], intro: str, empty: str,
explain: str, via_note: str) -> str:
if not coll["ok"]: if not coll["ok"]:
return _failed_panel(title, coll["via"], coll["error"]) return _failed_panel(title, coll["via"], coll["error"])
items = coll["data"] items = coll["data"]
if not items: if not items:
body = "<p class='muted'>none recorded</p>" body = f"<p class='empty'>{empty}</p>"
else: else:
body = "".join( body = "".join(
f"<div class='panel' style='margin:.5rem 0'><code>{_esc(i['_file'])}</code>" f"<div class='panel' style='margin:.5rem 0'>"
f"<span class='muted'>file</span> <code>{_esc(i['_file'])}</code>"
f"<span class='muted'> — shown verbatim:</span>"
f"<pre style='font-size:.76rem;overflow-x:auto'>{_esc(json.dumps({k: v for k, v in i.items() if k != '_file'}, indent=2, sort_keys=True))}</pre></div>" f"<pre style='font-size:.76rem;overflow-x:auto'>{_esc(json.dumps({k: v for k, v in i.items() if k != '_file'}, indent=2, sort_keys=True))}</pre></div>"
for i in items[:50]) for i in items[:50])
return (f"<div class='panel'><h2 style='margin-top:0'>{_esc(title)}</h2>{body}" return (f"<div class='panel'><h2 style='margin-top:0'>{title}</h2>"
f"<p class='plain'>{intro}</p>{body}{_explain(explain)}"
f"{_provenance(via_note)}</div>") f"{_provenance(via_note)}</div>")
return (block("Incidents (quorum divergences, quarantines)", incidents, return (
"incidents/*.json, verbatim, newest first") block(f"Incidents — what the wallet noticed {_help('incident')}", incidents,
+ block("Refusal receipts (signed, machine-actionable)", refusals, "Each file below is the wallet recording, at the moment it happened, that "
"receipts/*.json, verbatim, newest first")) "something did not add up. The most serious kind is a <em>quorum "
"divergence</em>: the verifier members disagreed about the same input, "
"which must never happen if all of them are honest and intact.",
"None recorded — the wallet has never detected a divergence or tamper "
"event. Empty is the good state here.",
"<ul><li><strong>severity</strong> — how bad: a <code>divergence</code> or "
"<code>tamper</code> incident also trips the custody latch.</li>"
"<li><strong>detail</strong> — what exactly was observed, in the wallet's "
"own words.</li>"
"<li><strong>at</strong> — when it was recorded (UTC).</li>"
"<li><strong>payload_sha256</strong> — fingerprint of the input the members "
"disagreed about, so the case can be replayed later.</li>"
"<li>Incident files are never deleted; they are the permanent record.</li></ul>",
"incidents/*.json, verbatim, newest first")
+ block(f"Refusal receipts — every «no», in writing {_help('refusal')}", refusals,
"When the wallet declines to do something, it answers with a signed, "
"machine-readable receipt instead of a bare error: the rule it applied "
"(<code>code</code>), what was missing, and what would fix it "
"(<code>remediation</code>). An agent — or you — can read it, correct the "
"problem, and retry. No guessing.",
"None recorded — nothing has been refused yet.",
"<ul><li><strong>code</strong> — the rule that fired, e.g. "
"<code>POLICY_DENIED</code> (a spending rule) or <code>CUSTODY_LATCHED</code> "
"(the emergency brake is on).</li>"
"<li><strong>missing</strong> — the concrete precondition that was not "
"met.</li>"
"<li><strong>remediation</strong> — what would make the same request "
"succeed.</li></ul>",
"receipts/*.json, verbatim, newest first"))
def render_inspect(result: dict[str, Any] | None, def render_inspect(result: dict[str, Any] | None,
@ -318,23 +534,181 @@ def render_inspect(result: dict[str, Any] | None,
diags = "".join(f"<li>{_esc(x)}</li>" for x in result["diagnostics"]) or "<li>none</li>" diags = "".join(f"<li>{_esc(x)}</li>" for x in result["diagnostics"]) or "<li>none</li>"
verdict = ( verdict = (
f"<div class='panel'><h2 style='margin-top:0'>Verdict {pill}</h2>" f"<div class='panel'><h2 style='margin-top:0'>Verdict {pill}</h2>"
f"<table><tr><th>signature check</th><th>result</th></tr>{sigs}</table>" "<p class='plain'>A green verdict means every check passed: the "
"signatures are valid and the attestation really is recorded in the log "
"under the signed head. A red verdict means at least one check failed — "
"the diagnostics below name each check verbatim, so you can see exactly "
"which one.</p>"
f"<div class='tablewrap'><table><tr><th>signature check</th>"
f"<th>result</th></tr>{sigs}</table></div>"
f"<h2>Diagnostics</h2><ul class='diag'>{diags}</ul>" f"<h2>Diagnostics</h2><ul class='diag'>{diags}</ul>"
f"{_provenance(result['via'])}</div>") f"{_provenance(result['via'])}</div>")
return ( return (
verdict + verdict +
"<div class='panel'><h2 style='margin-top:0'>Inspect a receipt</h2>" f"<div class='panel'><h2 style='margin-top:0'>Check a receipt yourself {_help('attestation')}</h2>"
"<p class='muted'>Paste an attestation, its transparency receipt, and the log's " "<p class='plain'>An <strong>attestation</strong> is a signed statement that a "
"public key. The verdict is produced by the wallet's own deployed verifier — " "verification run happened — on an exact commit, with an exact toolchain, with a "
"this page adds nothing and hides nothing; the diagnostics list is verbatim.</p>" "stated result. Its <strong>transparency receipt</strong> proves that statement "
"<form method='post' action='/inspect'>" "is permanently recorded in the public Lean Transparency Log. The <strong>log "
f"<p><strong>attestation.json</strong><br><textarea name='attestation'>{_esc(d.get('attestation', ''))}</textarea></p>" "public key</strong> is what the log signs with. The verdict is produced by the "
f"<p><strong>receipt.json</strong><br><textarea name='receipt'>{_esc(d.get('receipt', ''))}</textarea></p>" "wallet's own deployed verifier — this page adds nothing and hides nothing.</p>"
f"<p><strong>log public key (PEM)</strong><br><textarea name='pubkey' style='min-height:4rem'>{_esc(d.get('pubkey', ''))}</textarea></p>" + _explain(
"<ul><li><strong>Where do I get these?</strong> For the live log: attestation "
"and receipt from <code>ltl.zkdefi.org/v1/attestation?component=…</code>, the "
"public key (<code>log.pub</code>) from the log's mirror repository. This "
"repository also ships samples under <code>examples/wallet-evidence/</code> — "
"paste those to see the verifier work.</li>"
"<li><strong>Is this safe?</strong> Yes: nothing you paste is stored. The "
"artifacts live in a throwaway temp folder outside the wallet for the "
"duration of the check, and the wallet directory is never written.</li>"
"<li><strong>If the input is malformed</strong>, the page shows FAILED TO "
"VERIFY rather than guessing.</li></ul>")
+ "<form method='post' action='/inspect'>"
f"<p><strong>attestation.json</strong> <span class='muted'>— the signed verification statement</span><br>"
f"<textarea name='attestation'>{_esc(d.get('attestation', ''))}</textarea></p>"
f"<p><strong>receipt.json</strong> <span class='muted'>— the log's proof that the statement is recorded</span><br>"
f"<textarea name='receipt'>{_esc(d.get('receipt', ''))}</textarea></p>"
f"<p><strong>log public key (PEM)</strong> <span class='muted'>— starts with "
f"<code>-----BEGIN PUBLIC KEY-----</code></span><br>"
f"<textarea name='pubkey' style='min-height:4rem'>{_esc(d.get('pubkey', ''))}</textarea></p>"
"<button type='submit'>Verify (read-only)</button></form></div>" "<button type='submit'>Verify (read-only)</button></form></div>"
) )
def render_guide() -> str:
"""The manual: static plain-language explanations, no live data."""
return (
# --- what is warden ------------------------------------------------
"<div class='panel'><h2 style='margin-top:0' id='what'>What is warden?</h2>"
"<p class='plain'>warden is a prototype custody wallet built on one idea: "
"<strong>no single program is trusted</strong>. Four verifier programs, each "
"built from a different formally verified codebase, must independently agree "
"before the wallet trusts a cryptographic component. If they ever disagree, the "
"wallet freezes itself and writes down what happened. Every decision leaves a "
"tamper-evident trace.</p></div>"
# --- what is the cockpit -------------------------------------------
"<div class='panel'><h2 style='margin-top:0' id='cockpit'>What is this cockpit?</h2>"
"<p class='plain'>A local, read-only window onto one wallet directory, for the "
"human who ultimately answers for the money. Its design law: <strong>the cockpit "
"renders evidence, it never asserts it</strong>. Every page is recomputed from "
"the wallet's files at the moment you load it, by the same functions the wallet "
"itself uses. It cannot approve, sign, unlatch, or change anything — the server "
"has no writing routes, and the test suite proves a full click-through changes "
"not one byte of wallet state.</p></div>"
# --- how to read ---------------------------------------------------
"<div class='panel'><h2 style='margin-top:0' id='reading'>How to read any page here</h2>"
"<ol>"
"<li><strong>Verdict first.</strong> The top of a page states the conclusion in "
"words — e.g. CUSTODY HEALTHY or CUSTODY FROZEN. If you read nothing else, read "
"that.</li>"
"<li><strong>Evidence below.</strong> Each panel shows the recomputed facts "
"behind the verdict. Every panel has a «How to read this panel» expander, and "
"every jargon term carries a small <a class='help' href='#glossary'>?</a> that "
"jumps here.</li>"
"<li><strong>Provenance last.</strong> The dashed line at the bottom of every "
"panel names the exact function and time that produced it — your proof that "
"nothing was cached.</li>"
"</ol>"
"<p class='plain'>Colors mean one thing each: "
"<span class='pill ok'>green</span> = re-checked just now and passed · "
"<span class='pill warn'>amber</span> = waiting, or needs your attention · "
"<span class='pill bad'>red</span> = re-checked and failed, or could not be "
"checked at all. A red <strong>FAILED TO VERIFY</strong> panel is the cockpit "
"being honest: it refuses to show a green it cannot back up right now. There is "
"no neutral gray anywhere.</p></div>"
# --- tour ----------------------------------------------------------
"<div class='panel'><h2 style='margin-top:0' id='tour'>A five-minute tour</h2>"
"<ol>"
"<li>Open <a href='/'>Posture</a> — read the verdict banner, then the panels top "
"to bottom: latch, ledger, quorum, signing rules, recorded history.</li>"
"<li>Open <a href='/incidents'>Incidents</a> — on a healthy wallet both lists are "
"empty, and the page says why that is the good state.</li>"
"<li>Open <a href='/queue'>Queue</a> — empty unless a signature is waiting for "
"the offline device.</li>"
"<li>Open <a href='/inspect'>Inspect</a> — paste the sample artifacts from "
"<code>examples/wallet-evidence/</code> and watch the deployed verifier run.</li>"
"<li>Open the <a href='/estate'>Estate map</a> — where this wallet sits in the "
"wider verified-crypto estate, and what is actually running where.</li>"
"</ol></div>"
# --- glossary ------------------------------------------------------
"<div class='panel'><h2 style='margin-top:0' id='glossary'>Glossary</h2>"
"<dl class='gloss'>"
"<dt id='capsule'>custody capsule</dt>"
"<dd>The wallet's founding document: which verifier members it trusts, their "
"pinned binary fingerprints, and the policy (unanimity, minimum members, required "
"evidence grade). Sealed when the wallet is created; its SHA-256 is anchored in "
"the ledger's first entry, so it cannot be quietly swapped later.</dd>"
"<dt id='member'>quorum member</dt>"
"<dd>One of the verifier programs the capsule names. Each is built from a "
"different formally verified Ed25519 codebase (dalek, anza, risc0, betrusted), so "
"a bug — or a backdoor — would have to exist in all of them independently for a "
"wrong verdict to slip through unanimously.</dd>"
"<dt id='pinned'>pinned (binary fingerprint)</dt>"
"<dd>The capsule stores the SHA-256 hash of each member executable. Before use, "
"the file on disk is re-hashed and compared; a modified or swapped binary fails "
"the comparison and is rejected. Pinning proves the file is <em>unchanged</em> — "
"not that it was honestly <em>built</em> (see the limits section below).</dd>"
"<dt id='tier'>evidence grade (R0R5)</dt>"
"<dd>This project's scale for how strong the formal evidence behind a component "
"is. R0 = no usable evidence; R4 = machine-checked proofs covering the component's "
"full documented boundary; R5 would add reproducible builds and side-channel "
"assurance — nothing holds R5 yet, and the gap is stated rather than hidden. This "
"wallet's capsule requires R4 of every member.</dd>"
"<dt id='ledger'>ledger / hash chain</dt>"
"<dd>The wallet's append-only journal of custody events. Every entry contains the "
"hash of the previous entry; the newest hash is called the «head». Rewriting, "
"deleting, or reordering anything in the past changes the hashes and breaks the "
"chain visibly — that is what «chain verified» re-checks on every page load.</dd>"
"<dt id='latch'>custody latch</dt>"
"<dd>The wallet's emergency brake. It trips on quorum divergence or suspected "
"tampering; while latched, all outbound signing is frozen and every request is "
"refused with a receipt. Only a deliberate operator action through the wallet's "
"own channels can clear it — never this cockpit. Recovery steps: "
"<code>docs/runbook-latch.md</code>.</dd>"
"<dt id='incident'>incident</dt>"
"<dd>A file the wallet writes the moment it notices something wrong — for "
"example, one member answering INVALID while the others answer OK. Incidents are "
"never deleted; serious ones also trip the latch.</dd>"
"<dt id='refusal'>refusal receipt</dt>"
"<dd>When the wallet declines to act, it answers in writing: a machine-readable "
"receipt naming the rule (<code>code</code>), what was missing, and what would "
"fix it (<code>remediation</code>). An agent can read it, correct the problem, "
"and retry — no guessing at error messages.</dd>"
"<dt id='airgap'>air-gap outbox / inbox</dt>"
"<dd>The signing key may live on a device that never touches a network. Signing "
"requests are parked as files in an outbox and carried across by a human; "
"responses come back through an inbox. The Queue page watches both folders and "
"touches neither.</dd>"
"<dt id='attestation'>attestation &amp; transparency receipt</dt>"
"<dd>An attestation is a signed statement that a verification run happened: which "
"repository, which exact commit, which toolchain, what result. Its transparency "
"receipt proves the statement is permanently recorded in the public Lean "
"Transparency Log — so it can never be quietly edited, backdated, or denied "
"later. The Inspect tab re-verifies both.</dd>"
"<dt id='provenance'>provenance line</dt>"
"<dd>The dashed footer on every panel, naming the exact function that recomputed "
"the panel and when. It is the cockpit's signature move: evidence of freshness "
"attached to every claim.</dd>"
"<dt id='demo'>DEMO wallet</dt>"
"<dd>A throwaway wallet sealed by <code>pacta wallet cockpit --demo</code> so you "
"can explore this cockpit before creating a real wallet. Its members are fake "
"shell stubs, every label says DEMO, and it can sign nothing real. Real wallets "
"are sealed with <code>pacta wallet init</code>.</dd>"
"</dl></div>"
# --- limits --------------------------------------------------------
"<div class='panel'><h2 style='margin-top:0' id='limits'>What this cockpit cannot tell you</h2>"
"<ul class='diag'>"
"<li>Whether the pinned binaries were honestly <em>built</em> from their attested "
"sources — reproducible builds are out of scope (the R5 gap), stated in the paper "
"and the claim cards rather than hidden.</li>"
"<li>Whether the machine this cockpit runs on is itself clean — a compromised "
"operating system can lie to any dashboard, including this one.</li>"
"<li>Anything it could not recompute just now — that renders as a red FAILED TO "
"VERIFY panel, never as a guess and never as a stale green.</li>"
"</ul></div>"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# demo wallet - custody-inert, for exploring the cockpit from zero # demo wallet - custody-inert, for exploring the cockpit from zero
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -415,6 +789,13 @@ def seal_demo_wallet(root: str | Path | None = None) -> Path:
# server # server
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_ESTATE_BACK_CHIP = (
'<a href="/" style="position:fixed;left:14px;bottom:14px;z-index:999;'
'background:#1c2430;color:#fff;padding:.45rem .8rem;border-radius:8px;'
'font:600 .8rem system-ui;text-decoration:none;'
'box-shadow:0 2px 8px rgba(0,0,0,.25)">← Back to cockpit</a>')
def make_handler(wallet_dir: Path): def make_handler(wallet_dir: Path):
class CockpitHandler(BaseHTTPRequestHandler): class CockpitHandler(BaseHTTPRequestHandler):
server_version = "warden-cockpit/1" server_version = "warden-cockpit/1"
@ -447,11 +828,15 @@ def make_handler(wallet_dir: Path):
self._send(_page("incidents", "/incidents", body, wd)) self._send(_page("incidents", "/incidents", body, wd))
elif route == "/inspect": elif route == "/inspect":
self._send(_page("receipt inspector", "/inspect", render_inspect(None), wd)) self._send(_page("receipt inspector", "/inspect", render_inspect(None), wd))
elif route == "/guide":
self._send(_page("guide", "/guide", render_guide(), wd))
elif route == "/estate": elif route == "/estate":
from .estateview import ESTATE_HTML from .estateview import ESTATE_HTML
self._send(ESTATE_HTML) self._send(ESTATE_HTML + _ESTATE_BACK_CHIP)
else: else:
self._send(_page("not found", "", "<div class='panel bad'>No such view.</div>", wd), 404) self._send(_page("not found", "",
"<div class='panel bad'>No such view. The tabs above list "
"everything this cockpit can show.</div>", wd), 404)
def do_POST(self) -> None: # noqa: N802 def do_POST(self) -> None: # noqa: N802
route = urllib.parse.urlparse(self.path).path route = urllib.parse.urlparse(self.path).path

View file

@ -82,6 +82,7 @@ def _dir_fingerprint(root: Path) -> dict[str, str]:
def test_posture_renders_recomputed_evidence(tmp_path): def test_posture_renders_recomputed_evidence(tmp_path):
wallet = _seal_wallet(tmp_path) wallet = _seal_wallet(tmp_path)
html = render_posture(collect("Wallet.posture()", wallet.posture)) html = render_posture(collect("Wallet.posture()", wallet.posture))
assert "CUSTODY HEALTHY" in html # the verdict, in words, before any evidence
assert "chain verified" in html assert "chain verified" in html
assert "unlatched" in html assert "unlatched" in html
assert "dalek-ed25519-verified" in html and "anza-ed25519-verified" in html assert "dalek-ed25519-verified" in html and "anza-ed25519-verified" in html
@ -96,6 +97,7 @@ def test_broken_ledger_renders_red(tmp_path):
ledger.write_text(ledger.read_text().replace("genesis", "gene-sis"), encoding="utf-8") ledger.write_text(ledger.read_text().replace("genesis", "gene-sis"), encoding="utf-8")
html = render_posture(collect("Wallet.posture()", wallet.posture)) html = render_posture(collect("Wallet.posture()", wallet.posture))
assert "CHAIN BROKEN" in html assert "CHAIN BROKEN" in html
assert "CUSTODY EVIDENCE BROKEN" in html # verdict banner states it in words
def test_collector_failure_is_loud_not_gray(tmp_path): def test_collector_failure_is_loud_not_gray(tmp_path):
@ -115,6 +117,7 @@ def test_latched_state_renders_frozen():
"ledger": {"entries": 1, "head": "cd" * 32, "chain_ok": True, "problems": []}, "ledger": {"entries": 1, "head": "cd" * 32, "chain_ok": True, "problems": []},
"incidents": 1, "refusal_receipts": 0, "generated_at": "now"}} "incidents": 1, "refusal_receipts": 0, "generated_at": "now"}}
html = render_posture(posture) html = render_posture(posture)
assert "CUSTODY FROZEN" in html # verdict banner states it in words
assert "LATCHED" in html and "outbound custody frozen" in html assert "LATCHED" in html and "outbound custody frozen" in html
assert "quorum divergence" in html and "runbook-latch" in html assert "quorum divergence" in html and "runbook-latch" in html
@ -175,7 +178,7 @@ def test_server_routes_and_read_only_guarantee(tmp_path):
thread = threading.Thread(target=server.serve_forever, daemon=True) thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start() thread.start()
try: try:
for route in ("/", "/queue", "/incidents", "/inspect"): for route in ("/", "/queue", "/incidents", "/inspect", "/guide"):
with urllib.request.urlopen(f"http://127.0.0.1:{port}{route}") as resp: with urllib.request.urlopen(f"http://127.0.0.1:{port}{route}") as resp:
body = resp.read().decode() body = resp.read().decode()
assert resp.status == 200 assert resp.status == 200
@ -248,3 +251,78 @@ def test_demo_wallet_seals_and_serves(tmp_path):
finally: finally:
server.shutdown() server.shutdown()
thread.join(timeout=5) thread.join(timeout=5)
# ---------------------------------------------------------------------------
# UX contract: the cockpit never leaves a human in the dark. Every view must
# explain itself in plain language, every verdict must be stated in words,
# every page must carry navigation and interpretation help.
# ---------------------------------------------------------------------------
def _serve(wallet_dir):
server = serve(wallet_dir, host="127.0.0.1", port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, thread, server.server_address[1]
def _get(port, route):
with urllib.request.urlopen(f"http://127.0.0.1:{port}{route}") as resp:
return resp.status, resp.read().decode()
def test_guide_view_explains_every_term(tmp_path):
wallet = _seal_wallet(tmp_path)
server, thread, port = _serve(wallet.dir)
try:
status, body = _get(port, "/guide")
assert status == 200
assert "renders evidence" in body # the design law, in the user's face
for term in ("custody capsule", "quorum member", "hash chain",
"custody latch", "refusal receipt", "air-gap",
"evidence grade", "DEMO wallet",
"What this cockpit cannot tell you",
"A five-minute tour"):
assert term in body, f"guide is missing: {term}"
finally:
server.shutdown()
thread.join(timeout=5)
def test_every_view_carries_lead_nav_and_explainers(tmp_path):
wallet = _seal_wallet(tmp_path)
server, thread, port = _serve(wallet.dir)
try:
for route in ("/", "/queue", "/incidents", "/inspect"):
status, body = _get(port, route)
assert status == 200
assert 'href="/guide"' in body, f"{route}: no path to the guide"
assert 'class="lead"' in body, f"{route}: no plain-language lead"
assert "<details" in body, f"{route}: no interpretation expander"
assert 'class="navsub"' in body, f"{route}: tabs do not say what they answer"
finally:
server.shutdown()
thread.join(timeout=5)
def test_empty_states_are_explained(tmp_path):
"""A blank table is a UX failure; every empty list must say what empty
means and whether it is good news."""
wallet = _seal_wallet(tmp_path)
q = render_queue(collect_airgap(wallet))
assert "No signing requests are waiting" in q
inc = render_incidents(collect_incidents(wallet), collect_refusals(wallet))
assert "Empty is the good state" in inc
assert "nothing has been refused" in inc
def test_estate_page_links_back_to_cockpit(tmp_path):
wallet = _seal_wallet(tmp_path)
server, thread, port = _serve(wallet.dir)
try:
status, body = _get(port, "/estate")
assert status == 200
assert "Back to cockpit" in body
finally:
server.shutdown()
thread.join(timeout=5)