mirror of
https://github.com/saymrwulf/proof-aware-crypto-tooling-agent.git
synced 2026-09-04 20:03:40 +00:00
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:
parent
b3239dba45
commit
00be6a396e
7 changed files with 579 additions and 79 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
21
WALLET.md
21
WALLET.md
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 R0–R5, 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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 wallet’s 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 wallet’s 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 wallet’s '
|
||||||
|
'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> — R0–R5 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 & 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 (R0–R5)</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 & 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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue