diff --git a/ESTATE.md b/ESTATE.md index e088ca5..8f3745c 100644 --- a/ESTATE.md +++ b/ESTATE.md @@ -9,7 +9,7 @@ machinery hub and the only repo that changes freely. State snapshot (2026-07-19): log **13 leaves**, root `3488a2d0…`, key fingerprint `874c8a00…`, paper **v0.9 camera-ready (23 pp)**, five -attested components, pacta suite 130 green. +attested components, pacta suite 135 green. ```mermaid flowchart LR diff --git a/WALLET.md b/WALLET.md index 5158d19..9d4d147 100644 --- a/WALLET.md +++ b/WALLET.md @@ -145,14 +145,19 @@ pacta wallet mcp --wallet ./my-warden # stdio JSON-RPC MCP server ## The custody cockpit (human surface, read-only) -`pacta wallet cockpit --wallet ` serves a local web UI for the -operator: posture (latch, ledger chain re-verified, pinned quorum), -the airgap signature queue (observed, never operated), the incident and -refusal browser, and a receipt inspector driven by the deployed -verifier. Design law: it renders recomputed evidence with provenance -lines, never cached status; it cannot approve, sign, unlatch, or modify -custody state (byte-level read-only guarantee in -`tests/test_walletui.py`). Details: [docs/cockpit.md](docs/cockpit.md). +`pacta wallet cockpit --wallet ` (or `--demo` for a throwaway, +custody-inert wallet to explore from zero) serves a local web UI for the +operator: posture (verdict banner, latch, ledger chain re-verified, +pinned quorum), the airgap signature queue (observed, never operated), +the incident and refusal browser, a receipt inspector driven by the +deployed verifier, the estate map, and a plain-language guide with a +full glossary. Design law: it renders recomputed evidence with +provenance lines, never cached status; it cannot approve, sign, unlatch, +or modify custody state (byte-level read-only guarantee in +`tests/test_walletui.py`). 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) diff --git a/docs/cockpit.md b/docs/cockpit.md index 48312a6..a21f494 100644 --- a/docs/cockpit.md +++ b/docs/cockpit.md @@ -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 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 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 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()` | -| **Signature queue** (`/queue`) | parked airgap signing requests (outbox) and whether the device has answered (inbox) — observed, never operated | airgap outbox/inbox listing | -| **Incidents & refusals** (`/incidents`) | incident records and signed refusal receipts, verbatim, newest first | `incidents/*.json`, `receipts/*.json` | -| **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) | -| **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` | +| **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()` | +| **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** (`/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` | +| **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` | +| **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 table says binary hashes are pinned but source-to-binary correspondence diff --git a/src/pacta/cli.py b/src/pacta/cli.py index acf6bed..aa29a64 100644 --- a/src/pacta/cli.py +++ b/src/pacta/cli.py @@ -693,6 +693,7 @@ def cmd_wallet_cockpit(args: argparse.Namespace) -> int: server = serve(wallet_dir, host=args.host, port=args.port) host, port = server.server_address[0], server.server_address[1] print(f"warden cockpit (READ-ONLY) on http://{host}:{port} - Ctrl-C to stop") + print(f" first time? start at http://{host}:{port}/guide - every term explained") try: server.serve_forever() except KeyboardInterrupt: diff --git a/src/pacta/estateview.py b/src/pacta/estateview.py index f0db9b5..c2ea519 100644 --- a/src/pacta/estateview.py +++ b/src/pacta/estateview.py @@ -123,7 +123,7 @@ ESTATE_HTML = r'''LTL estate map — repos, services, loops key 874c8a00… paper v0.9 · 23 pp · camera-ready attested components 5 - pacta suite 130 green + pacta suite 135 green state as of 2026-07-20
diff --git a/src/pacta/walletui.py b/src/pacta/walletui.py index bf38964..f2654e9 100644 --- a/src/pacta/walletui.py +++ b/src/pacta/walletui.py @@ -1,9 +1,9 @@ """walletui - the warden custody cockpit (local, read-only). A localhost web surface over an existing wallet directory, for the human -operator who ultimately answers for the money. Four views: posture, the +operator who ultimately answers for the money. Six views: posture, the 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 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 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``, ``verify_ledger``, directory listings) and ``transparency.verify_receipt`` 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} code{font-family:ui-monospace,Menlo,Consolas,monospace;background:#eef0f3; 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 a{color:var(--accent);text-decoration:none;border:1px solid var(--line); - background:#fff;border-radius:6px;padding:.25rem .7rem;font-size:.85rem} - nav a.here{border-color:var(--accent);font-weight:600} + background:#fff;border-radius:6px;padding:.3rem .7rem;font-size:.85rem; + 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); 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; padding:.9rem 1.1rem;margin:.7rem 0} .panel.bad{border-color:var(--bad);background:var(--badbg)} @@ -64,11 +82,27 @@ _STYLE = """ .pill.ok{background:var(--okbg);color:var(--ok)} .pill.bad{background:var(--badbg);color:var(--bad)} .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} td,th{border:1px solid var(--line);padding:.4rem .6rem;text-align:left;vertical-align:top} th{background:var(--accentbg)} ul.diag{margin:.4rem 0 0;padding-left:1.2rem} ul.diag li{font-size:.85rem;margin:.2rem 0} + 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; border:1px solid var(--line);border-radius:6px;padding:.5rem} 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)) +def _help(anchor: str) -> str: + """A small ? that jumps to the glossary entry for a term.""" + return (f'?') + + +def _explain(body: str) -> str: + """The per-panel interpretation aid: always present, opt-in detail.""" + return (f'
How to read this panel' + f'
{body}
') + + def _provenance(via: str) -> str: return f'
recomputed {_esc(_now())} via {_esc(via)} — nothing on this panel is cached or asserted.
' @@ -95,7 +141,10 @@ def _failed_panel(what: str, via: str, error: Exception) -> str: f'
FAILED TO VERIFY ' f"{_esc(what)} could not be recomputed: " f"{_esc(f'{type(error).__name__}: {error}')}. " - f"A cockpit that cannot verify shows red, never a stale green." + f"A cockpit that cannot verify shows red, never a stale green. " + f"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"pacta wallet posture." f"{_provenance(via)}
" ) @@ -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"), - ("/incidents", "Incidents & refusals"), ("/inspect", "Receipt inspector"), - ("/estate", "Estate map")] +_VIEWS = [ + ("/", "Posture", "is custody healthy right now?"), + ("/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: is custody healthy right now? ' + '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 ? jumps to a ' + 'plain-language explanation.'), + "/queue": ('The wallet’s signing key can live on an air-gapped 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 incident is the wallet ' + 'noticing something wrong and writing it down on the spot. A ' + 'refusal receipt is the wallet saying no 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: nav = "".join( - f'{label}' - for href, label in _VIEWS) + f'{label}' + f'{sub}' + for href, label, sub in _VIEWS) + demo_badge = ('DEMO WALLET — custody-inert ' + if "DEMO" in wallet_dir else "") + lead = _LEADS.get(active, "") + lead_html = f'

{lead}

' if lead else "" return ( "" + "" f"warden cockpit — {_esc(title)}" f"" - f"

warden cockpit {_esc(wallet_dir)}

" + f"

warden custody cockpit {demo_badge}

" + f"

Watching wallet {_esc(wallet_dir)} — everything below is " + "recomputed live from that directory each time a page loads; nothing is cached, " + "nothing is taken on trust.

" "" - f"{body}" + "approve, sign, unlatch, or modify custody state. First time here? Start with the " + "Guide — every term on these pages is explained there.
" + f"{lead_html}{body}" ) +# --------------------------------------------------------------------------- +# 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 ("
CUSTODY EVIDENCE BROKEN" + "

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.

") + if p["latch"].get("latched"): + return ("
CUSTODY FROZEN (LATCHED)" + "

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; docs/runbook-latch.md is the step-by-step " + "recovery guide.

") + return ("
CUSTODY HEALTHY" + "

History intact, quorum sealed, signing unfrozen. Each of those three claims " + "is re-checked — not remembered — in the panels below.

") + + def render_posture(posture: dict[str, Any]) -> str: if not posture["ok"]: return _failed_panel("Custody posture", posture["via"], posture["error"]) @@ -224,38 +338,98 @@ def render_posture(posture: dict[str, Any]) -> str: f"{_esc(m['source_commit'][:12])}…" f"{_esc(m['binary_sha256'][:16])}…" for m in p["members"]) - problems = "".join(f"
  • {_esc(x)}
  • " for x in ledger["problems"]) or "
  • none
  • " + problems = "".join(f"
  • {_esc(x)}
  • " for x in ledger["problems"]) or ( + "
  • none — every link in the chain held
  • ") latch_detail = "" if latch.get("latched"): - latch_detail = (f"

    reason: {_esc(latch.get('reason'))} · " - f"incident: {_esc(latch.get('incident'))} · " - f"since {_esc(latch.get('at'))} — see the " - f"incident browser and docs/runbook-latch.md.

    ") + latch_detail = (f"

    Trigger: {_esc(latch.get('reason'))} · " + f"recorded as incident {_esc(latch.get('incident'))} · " + f"frozen since {_esc(latch.get('at'))}. Read the incident in the " + f"incident browser, then follow " + f"docs/runbook-latch.md to recover.

    ") spending = p.get("spending_policy") or {} + no_rules = (not spending) or ("note" in spending and len(spending) == 1) + spending_note = ( + "

    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 " + "policy.json.

    " if no_rules else + "

    These rules are enforced by the signing firewall before anything " + "is signed. Shown verbatim from policy.json.

    ") return ( - f"

    Custody latch {latch_pill}

    " - f"{latch_detail}{_provenance('Wallet.latch_state()')}
    " - f"

    Ledger {chain_pill}

    " - f"

    {ledger['entries']} entries · head {_esc(ledger['head'][:24])}…

    " + _posture_verdict(p) + # --- latch --------------------------------------------------------- + + f"

    Custody latch {_help('latch')} {latch_pill}

    " + "

    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.

    " + f"{latch_detail}" + + _explain( + "
    • unlatched — the brake is off; signing is " + "allowed (subject to quorum and policy).
    • " + "
    • LATCHED — the brake is on; every signing " + "request is refused with a receipt until a human resolves the trigger. " + "Recovery steps live in docs/runbook-latch.md.
    ") + + f"{_provenance('Wallet.latch_state()')}
    " + # --- ledger -------------------------------------------------------- + f"

    Ledger — has history been tampered with? {_help('ledger')} {chain_pill}

    " + "

    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.

    " + f"

    {ledger['entries']} entries · newest-entry hash (the «head»): " + f"{_esc(ledger['head'][:24])}…

    " f"
      {problems}
    " - f"{_provenance('Wallet.verify_ledger() — full hash-chain recomputation')}
    " - f"

    Quorum members " + + _explain( + "
    • chain verified — the cockpit just " + "re-hashed every entry from the first to the newest and every link held. This " + "happens again on every reload.
    • " + "
    • CHAIN BROKEN — at least one link failed: " + "history was altered, truncated, or corrupted. The list above names the first " + "entry that failed.
    ") + + f"{_provenance('Wallet.verify_ledger() — full hash-chain recomputation')}

    " + # --- quorum -------------------------------------------------------- + f"

    Quorum — who must agree before anything is trusted? {_help('member')} " f"{len(p['members'])} pinned

    " - "" - "" - f"{members}
    backendcomponenttiersource commitbinary sha256
    " - "

    Every member is pinned by binary hash in the capsule; the capsule " - f"hash is {_esc(p['capsule_sha256'][:24])}…. What this table does NOT " + f"

    These are the {len(p['members'])} verifier programs this wallet " + "trusts, each built from a different formally verified codebase. Before " + "the wallet accepts a cryptographic component, every member must independently " + "reach the same verdict — a single dissenter freezes custody instead.

    " + "
    " + f"" + f"" + f"{members}
    memberbuilt fromevidence grade {_help('tier')}source commitbinary fingerprint {_help('pinned')}
    " + + _explain( + "
    • member — short name of the verifier backend.
    • " + "
    • built from — the formally verified repository the member " + "binary was compiled from.
    • " + "
    • evidence grade — 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.
    • " + "
    • source commit — the exact git commit of those verified " + "sources (first 12 characters shown).
    • " + "
    • binary fingerprint — 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.
    ") + + "

    Everything above is sealed in the " + f"custody capsule — the wallet's founding document, " + f"fingerprint {_esc(p['capsule_sha256'][:24])}…, 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 " - "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()')}

    " - f"

    Spending policy

    " + # --- signing rules ------------------------------------------------- + f"

    Signing rules (spending policy)

    " + f"{spending_note}" f"
    {_esc(json.dumps(spending, indent=2, sort_keys=True))}
    " f"{_provenance('Wallet.policy() (policy.json, verbatim)')}
    " - f"

    Counters

    " - f"

    incidents: {p['incidents']} · refusal receipts: " - f"{p['refusal_receipts']} — browse them under " - "Incidents & refusals.

    " + # --- recorded history ---------------------------------------------- + f"

    Recorded history

    " + f"

    Incidents on file: {p['incidents']} · refusal " + f"receipts on file: {p['refusal_receipts']} — read every one, " + "verbatim, under Incidents. An incident is the wallet " + "noticing something wrong; a refusal receipt is the wallet saying no, in writing.

    " f"{_provenance('directory counts, recomputed')}
    " ) @@ -269,13 +443,23 @@ def render_queue(airgap: dict[str, Any]) -> str: f"{_esc((r.get('payload_sha256') or '')[:24])}…" f"{'answered' if r['answered'] else 'awaiting device'}" for r in airgap["data"]) - body = (f"" - f"{rows}
    requestcreatedpayload sha256state
    " if airgap["data"] - else "

    No parked signing requests.

    ") + body = (f"
    " + f"{rows}
    requestcreatedpayload fingerprintstate
    " + if airgap["data"] + else "

    No signing requests are waiting. The moment the wallet " + "parks one for the offline signer, it appears here.

    ") return ( - "

    Pending airgap signatures

    " - + body + - "

    This queue is OBSERVED, not operated: completing or refusing a " + f"

    Waiting for the offline signer {_help('airgap')}

    " + + body + + _explain( + "
    • awaiting device — the request file is " + "parked in the outbox; the offline signer has not answered yet.
    • " + "
    • answered — a response file for this request " + "has arrived in the inbox.
    • " + "
    • payload fingerprint — 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.
    ") + + "

    This queue is OBSERVED, not operated: completing or refusing a " "request happens through the wallet's own channels (request_signature " "over MCP, or the airgap device flow), never from this page.

    " + _provenance("airgap outbox/inbox listing") + "
    " @@ -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 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"]: return _failed_panel(title, coll["via"], coll["error"]) items = coll["data"] if not items: - body = "

    none recorded

    " + body = f"

    {empty}

    " else: body = "".join( - f"
    {_esc(i['_file'])}" + f"
    " + f"file {_esc(i['_file'])}" + f" — shown verbatim:" f"
    {_esc(json.dumps({k: v for k, v in i.items() if k != '_file'}, indent=2, sort_keys=True))}
    " for i in items[:50]) - return (f"

    {_esc(title)}

    {body}" + return (f"

    {title}

    " + f"

    {intro}

    {body}{_explain(explain)}" f"{_provenance(via_note)}
    ") - return (block("Incidents (quorum divergences, quarantines)", incidents, - "incidents/*.json, verbatim, newest first") - + block("Refusal receipts (signed, machine-actionable)", refusals, - "receipts/*.json, verbatim, newest first")) + return ( + block(f"Incidents — what the wallet noticed {_help('incident')}", incidents, + "Each file below is the wallet recording, at the moment it happened, that " + "something did not add up. The most serious kind is a quorum " + "divergence: 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.", + "
    • severity — how bad: a divergence or " + "tamper incident also trips the custody latch.
    • " + "
    • detail — what exactly was observed, in the wallet's " + "own words.
    • " + "
    • at — when it was recorded (UTC).
    • " + "
    • payload_sha256 — fingerprint of the input the members " + "disagreed about, so the case can be replayed later.
    • " + "
    • Incident files are never deleted; they are the permanent record.
    ", + "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), what was missing, and what would fix it " + "(remediation). An agent — or you — can read it, correct the " + "problem, and retry. No guessing.", + "None recorded — nothing has been refused yet.", + "
    • code — the rule that fired, e.g. " + "POLICY_DENIED (a spending rule) or CUSTODY_LATCHED " + "(the emergency brake is on).
    • " + "
    • missing — the concrete precondition that was not " + "met.
    • " + "
    • remediation — what would make the same request " + "succeed.
    ", + "receipts/*.json, verbatim, newest first")) def render_inspect(result: dict[str, Any] | None, @@ -318,23 +534,181 @@ def render_inspect(result: dict[str, Any] | None, diags = "".join(f"
  • {_esc(x)}
  • " for x in result["diagnostics"]) or "
  • none
  • " verdict = ( f"

    Verdict {pill}

    " - f"{sigs}
    signature checkresult
    " + "

    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.

    " + f"
    " + f"{sigs}
    signature checkresult
    " f"

    Diagnostics

      {diags}
    " f"{_provenance(result['via'])}
    ") return ( verdict + - "

    Inspect a receipt

    " - "

    Paste an attestation, its transparency receipt, and the log's " - "public key. The verdict is produced by the wallet's own deployed verifier — " - "this page adds nothing and hides nothing; the diagnostics list is verbatim.

    " - "
    " - f"

    attestation.json

    " - f"

    receipt.json

    " - f"

    log public key (PEM)

    " + f"

    Check a receipt yourself {_help('attestation')}

    " + "

    An attestation is a signed statement that a " + "verification run happened — on an exact commit, with an exact toolchain, with a " + "stated result. Its transparency receipt proves that statement " + "is permanently recorded in the public Lean Transparency Log. The log " + "public key is what the log signs with. The verdict is produced by the " + "wallet's own deployed verifier — this page adds nothing and hides nothing.

    " + + _explain( + "
    • Where do I get these? For the live log: attestation " + "and receipt from ltl.zkdefi.org/v1/attestation?component=…, the " + "public key (log.pub) from the log's mirror repository. This " + "repository also ships samples under examples/wallet-evidence/ — " + "paste those to see the verifier work.
    • " + "
    • Is this safe? 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.
    • " + "
    • If the input is malformed, the page shows FAILED TO " + "VERIFY rather than guessing.
    ") + + "" + f"

    attestation.json — the signed verification statement
    " + f"

    " + f"

    receipt.json — the log's proof that the statement is recorded
    " + f"

    " + f"

    log public key (PEM) — starts with " + f"-----BEGIN PUBLIC KEY-----
    " + f"

    " "
    " ) +def render_guide() -> str: + """The manual: static plain-language explanations, no live data.""" + return ( + # --- what is warden ------------------------------------------------ + "

    What is warden?

    " + "

    warden is a prototype custody wallet built on one idea: " + "no single program is trusted. 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.

    " + # --- what is the cockpit ------------------------------------------- + "

    What is this cockpit?

    " + "

    A local, read-only window onto one wallet directory, for the " + "human who ultimately answers for the money. Its design law: the cockpit " + "renders evidence, it never asserts it. 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.

    " + # --- how to read --------------------------------------------------- + "

    How to read any page here

    " + "
      " + "
    1. Verdict first. The top of a page states the conclusion in " + "words — e.g. CUSTODY HEALTHY or CUSTODY FROZEN. If you read nothing else, read " + "that.
    2. " + "
    3. Evidence below. 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 ? that " + "jumps here.
    4. " + "
    5. Provenance last. The dashed line at the bottom of every " + "panel names the exact function and time that produced it — your proof that " + "nothing was cached.
    6. " + "
    " + "

    Colors mean one thing each: " + "green = re-checked just now and passed · " + "amber = waiting, or needs your attention · " + "red = re-checked and failed, or could not be " + "checked at all. A red FAILED TO VERIFY panel is the cockpit " + "being honest: it refuses to show a green it cannot back up right now. There is " + "no neutral gray anywhere.

    " + # --- tour ---------------------------------------------------------- + "

    A five-minute tour

    " + "
      " + "
    1. Open Posture — read the verdict banner, then the panels top " + "to bottom: latch, ledger, quorum, signing rules, recorded history.
    2. " + "
    3. Open Incidents — on a healthy wallet both lists are " + "empty, and the page says why that is the good state.
    4. " + "
    5. Open Queue — empty unless a signature is waiting for " + "the offline device.
    6. " + "
    7. Open Inspect — paste the sample artifacts from " + "examples/wallet-evidence/ and watch the deployed verifier run.
    8. " + "
    9. Open the Estate map — where this wallet sits in the " + "wider verified-crypto estate, and what is actually running where.
    10. " + "
    " + # --- glossary ------------------------------------------------------ + "

    Glossary

    " + "
    " + "
    custody capsule
    " + "
    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.
    " + "
    quorum member
    " + "
    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.
    " + "
    pinned (binary fingerprint)
    " + "
    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 unchanged — " + "not that it was honestly built (see the limits section below).
    " + "
    evidence grade (R0–R5)
    " + "
    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.
    " + "
    ledger / hash chain
    " + "
    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.
    " + "
    custody latch
    " + "
    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: " + "docs/runbook-latch.md.
    " + "
    incident
    " + "
    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.
    " + "
    refusal receipt
    " + "
    When the wallet declines to act, it answers in writing: a machine-readable " + "receipt naming the rule (code), what was missing, and what would " + "fix it (remediation). An agent can read it, correct the problem, " + "and retry — no guessing at error messages.
    " + "
    air-gap outbox / inbox
    " + "
    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.
    " + "
    attestation & transparency receipt
    " + "
    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.
    " + "
    provenance line
    " + "
    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.
    " + "
    DEMO wallet
    " + "
    A throwaway wallet sealed by pacta wallet cockpit --demo 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 pacta wallet init.
    " + "
    " + # --- limits -------------------------------------------------------- + "

    What this cockpit cannot tell you

    " + "
      " + "
    • Whether the pinned binaries were honestly built from their attested " + "sources — reproducible builds are out of scope (the R5 gap), stated in the paper " + "and the claim cards rather than hidden.
    • " + "
    • Whether the machine this cockpit runs on is itself clean — a compromised " + "operating system can lie to any dashboard, including this one.
    • " + "
    • 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.
    • " + "
    " + ) + + # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- +_ESTATE_BACK_CHIP = ( + '← Back to cockpit') + + def make_handler(wallet_dir: Path): class CockpitHandler(BaseHTTPRequestHandler): server_version = "warden-cockpit/1" @@ -447,11 +828,15 @@ def make_handler(wallet_dir: Path): self._send(_page("incidents", "/incidents", body, wd)) elif route == "/inspect": self._send(_page("receipt inspector", "/inspect", render_inspect(None), wd)) + elif route == "/guide": + self._send(_page("guide", "/guide", render_guide(), wd)) elif route == "/estate": from .estateview import ESTATE_HTML - self._send(ESTATE_HTML) + self._send(ESTATE_HTML + _ESTATE_BACK_CHIP) else: - self._send(_page("not found", "", "
    No such view.
    ", wd), 404) + self._send(_page("not found", "", + "
    No such view. The tabs above list " + "everything this cockpit can show.
    ", wd), 404) def do_POST(self) -> None: # noqa: N802 route = urllib.parse.urlparse(self.path).path diff --git a/tests/test_walletui.py b/tests/test_walletui.py index b67833c..330363a 100644 --- a/tests/test_walletui.py +++ b/tests/test_walletui.py @@ -82,6 +82,7 @@ def _dir_fingerprint(root: Path) -> dict[str, str]: def test_posture_renders_recomputed_evidence(tmp_path): wallet = _seal_wallet(tmp_path) 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 "unlatched" 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") html = render_posture(collect("Wallet.posture()", wallet.posture)) 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): @@ -115,6 +117,7 @@ def test_latched_state_renders_frozen(): "ledger": {"entries": 1, "head": "cd" * 32, "chain_ok": True, "problems": []}, "incidents": 1, "refusal_receipts": 0, "generated_at": "now"}} 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 "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.start() 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: body = resp.read().decode() assert resp.status == 200 @@ -248,3 +251,78 @@ def test_demo_wallet_seals_and_serves(tmp_path): finally: server.shutdown() 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 "