diff --git a/WALLET.md b/WALLET.md index 9d4d147..c888960 100644 --- a/WALLET.md +++ b/WALLET.md @@ -146,18 +146,23 @@ pacta wallet mcp --wallet ./my-warden # stdio JSON-RPC MCP server ## The custody cockpit (human surface, read-only) `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). +custody-inert wallet to explore from zero) serves a local web UI +organized as a **bridge with six role stations** — Proposer, Quorum +bench, Operator (with an on-demand liveness board probing every public +service and local repo), Cryptographer, Architect, Newcomer — over +shared evidence instruments: 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. Three test-enforced laws: the 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`), the UX law (every page +explains itself — verdict in words, per-panel "how to read this" +expanders, jargon linked to the `/guide` glossary), and the crew law +(each station states its mission, duties as runnable commands — the +no-AI drill — its "never" list, and explicit handoffs; roles stay +distinct). Details: [docs/cockpit.md](docs/cockpit.md). ## Agent-native surface (MCP) diff --git a/docs/cockpit.md b/docs/cockpit.md index a21f494..afeffc4 100644 --- a/docs/cockpit.md +++ b/docs/cockpit.md @@ -1,9 +1,14 @@ -# The custody cockpit — a read-only surface for the human operator +# The custody cockpit — a bridge for the human crew `pacta wallet cockpit --wallet ` serves a local web UI (default `http://127.0.0.1:8471`) over an existing warden wallet. warden has always been agent-native (MCP) and CLI-native; the cockpit is -the third surface — for the human who ultimately answers for the money. +the third surface — for the humans who ultimately answer for the money. + +It is organized as a **bridge with six role stations** over shared +evidence instruments, in the control-room tradition (overview → station +→ instrument → raw files/CLI): the cockpit provides everything a human +crew would need to run this estate **if no AI were around**. ## The design law @@ -36,16 +41,56 @@ page is built from the same anatomy, top to bottom: 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`, +The `/guide` view is the manual: what warden is, the crew model, 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, +station, 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 crew law (roles, not a blur) + +**The crew is a team of distinct roles.** Running the estate takes six +roles; in production one financial agent can play every one of them — +but the roles stay separate, cooperate through explicit handoffs, and +never melt into each other. Separation of duties is a custody control: +the one who proposes never approves, the one who verifies never +proposes, the one who watches never overrides the bench. + +The **Bridge** (`/`) is the Level-1 overview: the whole-system verdict +strip (custody verdict in words + quorum/ledger/incident/queue chips), +the six crew cards with live data, and the dispatch (andon) board — "if +this happens, who acts". Each **station** (`/station/`) is one +role's console with a fixed anatomy: *Mission* → *Duties* (every duty a +runnable command — the no-AI drill) → live embedded instruments → +*"This station never…"* (the separation-of-duties list) → *Handoffs* +(receives ← / delivers →). + +| station | question | live instruments on the console | +|---|---|---| +| **Proposer** (`/station/proposer`) | I need something signed — how do I ask, and what do I do with the answer? | the Queue | +| **Quorum bench** (`/station/quorum`) | Would I stake custody on this evidence? Four seats, one answer each. | the live bench roster (capsule members) | +| **Operator** (`/station/operator`) | Is everything that should be running, running — and is custody unfrozen? | the **liveness board** (on-demand probes of every public service + every local repo), latch, recorded history | +| **Cryptographer** (`/station/cryptographer`) | Does the evidence really prove what it claims — no more, no less? | the Inspect verifier | +| **Architect** (`/station/architect`) | Does the map still match the territory? | the live **drift tripwire** (ESTATE.md vs estate view) | +| **Newcomer** (`/station/newcomer`) | What is all this? Where do I start? | the first-hour checklist | + +The liveness board probes **only when the operator presses «Probe +now»** — the cockpit never phones home on an ordinary page load. Probes +are read-only observations (HTTP GET on the public services, `git +rev-parse`/`status` on local checkouts) and report observed facts with +latency; liveness is pulses, not honesty — honesty is the +Cryptographer's replay. + +The crew law is test-enforced: `test_bridge_shows_crew_and_dispatch`, +`test_every_station_defines_role_contract` (mission/duties/commands/ +never-list/handoffs on all six), `test_stations_are_distinct_roles` +(each role's signature phrase appears on its own station and on no +other — no melting), `test_operator_probe_is_explicit_and_live`. + ## The read-only guarantee The cockpit cannot approve, sign, unlatch, or modify custody state. It @@ -57,11 +102,11 @@ 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 six views +## The instruments (shared evidence views) | view | answers | recomputed by | |---|---|---| -| **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()` | +| **Posture** (`/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` | diff --git a/src/pacta/liveness.py b/src/pacta/liveness.py new file mode 100644 index 0000000..dfbccfa --- /dev/null +++ b/src/pacta/liveness.py @@ -0,0 +1,191 @@ +"""liveness - the Operator station's board: is everything that should be +running actually running? + +Probes are READ-ONLY observations (HTTP GET on the public services, `git` +queries on local working copies) and run ONLY when the operator presses +«Probe now» - the cockpit never phones home on an ordinary page load. +Nothing here touches wallet state; the byte-level read-only guarantee in +tests/test_walletui.py covers the probe route too. + +Each probe answers exactly one question - reachable? present? - and shows +the observed facts (status, head, latency). Whether the observed facts +are HONEST is a different station's job (the Cryptographer replays; this +board only watches pulses). +""" +from __future__ import annotations + +import hashlib +import json +import subprocess +import time +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any + +from .uikit import esc, explain, provenance + +_TIMEOUT = 4 # seconds per probe; the board is on-demand, not a pager + +# (label, url, expectation as stated on the estate map) +SERVICES: list[tuple[str, str, str]] = [ + ("log head", "https://ltl.zkdefi.org/v1/sth", "ALWAYS ON (provider, read-only)"), + ("paper", "https://ltl.zkdefi.org/paper", "ALWAYS ON (static, behind caddy)"), + ("blog", "https://blog.zkdefi.org", "ALWAYS ON (static, behind caddy)"), + ("public mirror (Forgejo)", "https://zkdefi.org", "ALWAYS ON"), +] + +# local working copies expected as siblings of this repo checkout +LOCAL_REPOS: list[str] = [ + "lean-transparency-log", + "ltl-accumulator-verified", + "verifying-crypto-with-lean", + "dalek-ed25519-verified", + "anza-ed25519-verified", + "risc0-ed25519-verified", + "betrusted-ed25519-verified", + "pasta-pallas-verified", + "proof-aware-crypto-tooling-agent", +] + + +def default_repos_root() -> Path: + """Siblings of this repo checkout (…/FormalVerification).""" + return Path(__file__).resolve().parents[3] + + +def _probe_service(label: str, url: str, expect: str) -> dict[str, Any]: + start = time.monotonic() + try: + with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp: + body = resp.read() + status = resp.status + ms = int((time.monotonic() - start) * 1000) + fact = f"HTTP {status}" + if label == "log head": + try: + sth = json.loads(body) + fact = (f"HTTP {status} · tree_size {sth.get('tree_size')} · " + f"root {str(sth.get('root_hash', ''))[:8]}…") + except Exception: # noqa: BLE001 - fact stays the bare status + pass + elif label == "paper": + fact = f"HTTP {status} · sha256 {hashlib.sha256(body).hexdigest()[:8]}… · {len(body)//1024} KiB" + return {"label": label, "expect": expect, "ok": status == 200, + "fact": fact, "ms": ms} + except Exception as error: # noqa: BLE001 - a dead service is a result, not a crash + ms = int((time.monotonic() - start) * 1000) + return {"label": label, "expect": expect, "ok": False, + "fact": f"UNREACHABLE — {type(error).__name__}: {error}", "ms": ms} + + +def _probe_repo(root: Path, name: str) -> dict[str, Any]: + path = root / name + if not path.is_dir(): + return {"label": name, "expect": "local working copy", "ok": False, + "fact": f"MISSING — no directory at {path}", "ms": 0} + start = time.monotonic() + try: + head = subprocess.run( + ["git", "-C", str(path), "rev-parse", "--short", "HEAD"], + capture_output=True, text=True, timeout=10, check=True).stdout.strip() + dirty = subprocess.run( + ["git", "-C", str(path), "status", "--porcelain"], + capture_output=True, text=True, timeout=10, check=True).stdout + ms = int((time.monotonic() - start) * 1000) + n_dirty = len([line for line in dirty.splitlines() if line.strip()]) + if n_dirty: + return {"label": name, "expect": "local working copy", "ok": True, + "warn": True, "fact": f"HEAD {head} · {n_dirty} uncommitted change(s)", + "ms": ms} + return {"label": name, "expect": "local working copy", "ok": True, + "fact": f"HEAD {head} · clean", "ms": ms} + except Exception as error: # noqa: BLE001 + ms = int((time.monotonic() - start) * 1000) + return {"label": name, "expect": "local working copy", "ok": False, + "fact": f"NOT READABLE AS GIT — {type(error).__name__}: {error}", "ms": ms} + + +def collect_liveness(repos_root: Path | None = None) -> dict[str, Any]: + """Run every probe, in parallel, once — called only on explicit demand.""" + root = repos_root or default_repos_root() + try: + with ThreadPoolExecutor(max_workers=8) as pool: + services = list(pool.map(lambda s: _probe_service(*s), SERVICES)) + repos = list(pool.map(lambda n: _probe_repo(root, n), LOCAL_REPOS)) + return {"ok": True, "via": "live HTTP GET + git rev-parse/status, on demand", + "data": {"services": services, "repos": repos, "root": str(root)}} + except Exception as error: # noqa: BLE001 + return {"ok": False, "via": "live HTTP GET + git", "error": error} + + +def _row(p: dict[str, Any]) -> str: + if p["ok"] and p.get("warn"): + pill = 'alive, dirty' + elif p["ok"]: + pill = 'alive' + else: + pill = 'DOWN / MISSING' + return (f"{esc(p['label'])}{pill}" + f"{esc(p['fact'])}" + f"{esc(p['expect'])}" + f"{p['ms']} ms") + + +def render_liveness(result: dict[str, Any] | None) -> str: + """The board. result=None means: not probed yet this page load.""" + from .uikit import failed_panel + if result is None: + body = ( + "

This board has not probed yet — probes run only when " + "you press the button, so the cockpit never phones home on an ordinary " + "page load. Press «Probe now» to check every service and repo live.

" + "

Probe now

") + return (f"

Liveness board — what is " + f"actually running?

{body}" + + explain( + "
  • Service probes are plain HTTP GETs (4-second timeout) " + "reporting status, observed facts, and latency.
  • " + "
  • Local repos are checked with git rev-parse / " + "git status: present, at which commit, clean or " + "dirty.
  • " + "
  • alive = responded / present · " + "alive, dirty = present with " + "uncommitted changes · DOWN / MISSING " + "= no response or not found.
  • " + "
  • Liveness is not honesty: this board only checks pulses. " + "Whether the answers are cryptographically true is the " + "Cryptographer's replay work.
") + + provenance("no probe run this page load (press the button)") + + "
") + if not result["ok"]: + return failed_panel("Liveness board", result["via"], result["error"]) + d = result["data"] + service_rows = "".join(_row(p) for p in d["services"]) + repo_rows = "".join(_row(p) for p in d["repos"]) + down = [p["label"] for p in d["services"] + d["repos"] if not p["ok"]] + verdict = ('

ATTENTION not alive: ' + + ", ".join(f"{esc(x)}" for x in down) + "

" + if down else + '

all probed targets alive

') + return ( + f"

Liveness board — what is " + f"actually running?

{verdict}" + "

Public services

" + f"
" + f"{service_rows}
servicestateobservedexpectedlatency
" + f"

Local working copies under {esc(d['root'])}

" + f"
" + f"{repo_rows}
repostateobservedexpectedtook
" + "

Probe again

" + + explain( + "
  • «observed» is what the probe just saw: HTTP status and payload " + "facts for services, HEAD commit and cleanliness for repos.
  • " + "
  • «expected» is what the estate map says this target should be — a " + "target can be alive and still wrong (that is the Cryptographer's " + "beat).
  • " + "
  • DOWN on a public service: check your own network first, then the " + "server. MISSING on a repo: this machine simply has no checkout — clone " + "it if this machine should hold one.
") + + provenance(result["via"]) + "
" + ) diff --git a/src/pacta/stations.py b/src/pacta/stations.py new file mode 100644 index 0000000..0e73090 --- /dev/null +++ b/src/pacta/stations.py @@ -0,0 +1,452 @@ +"""stations - the role model of the warden bridge. + +The cockpit's third law (after the design law and the UX law): THE CREW +IS A TEAM OF DISTINCT ROLES, NOT A BLUR. If no AI were around, running +this estate would take a crew: a proposer, a quorum bench, an operator, +a cryptographer, an architect, and a newcomer finding their feet. In +production one financial agent can play every role - but the roles stay +separate, hand work to each other through explicit interfaces, and never +melt into one another. Separation of duties is a custody control, not a +metaphor: the one who proposes never approves, the one who verifies +never proposes, the one who watches never overrides the bench. + +Design lineage, deliberately borrowed: +- control-room HMI hierarchy (overview -> station -> instrument -> raw + detail; color reserved for state that needs attention), +- mission-control discipline consoles (one role, one console, explicit + handoffs on the loop), +- banking's maker-checker (four-eyes) separation of duties, +- checklist culture: every duty is a runnable command, not advice. + +This module is pure presentation over passed-in data: no wallet imports, +no I/O. Live evidence panels are built by walletui and handed in as +`embeds`, so the read-only guarantee and the provenance discipline stay +in one place. +""" +from __future__ import annotations + +from typing import Any + +from .uikit import cmd_block, esc, explain + +# --------------------------------------------------------------------------- +# the crew +# --------------------------------------------------------------------------- + +STATIONS: list[dict[str, Any]] = [ + { + "id": "proposer", "name": "Proposer", "monogram": "PR", + "hue": "#a86a10", "tint": "#fdf0da", + "question": "I need something signed — how do I ask, and what do I do with the answer?", + "card": "Originates signing requests; consumes signatures and refusal receipts.", + "lead": ("You are at the Proposer station. You originate signing " + "requests and you live with the answers — a signature or a written " + "refusal. You never approve anything yourself; that separation is what " + "makes your requests trustworthy."), + "mission": ("Turn an intent (\"pay this\", \"sign that\") into a precise, " + "fingerprinted request; submit it through the wallet's front door; " + "read every refusal receipt as instructions, not rejection."), + "duties": [ + ("Form the request precisely", + "Know the exact bytes you want signed and compute their fingerprint — the " + "device will sign those bytes and nothing else, and every later dispute is " + "settled by this hash.", + "sha256sum payload.bin"), + ("Submit through the wallet's front door", + "The proposal interface is agent-native: the request_signature " + "tool on the wallet's MCP surface. A human drives the same surface over " + "stdio (the JSON-RPC lines are in WALLET.md).", + "pacta wallet mcp --wallet WALLET_DIR\n# then call the request_signature tool"), + ("Track your request", + "Your request appears in the Queue instrument (embedded below) as " + "«awaiting device» until the offline signer answers.", + None), + ("On refusal: read the receipt, fix, retry", + "A refusal receipt names the rule (code), what was missing, and " + "the remediation. It is a to-do list, not a verdict on you.", + None), + ("On success: verify what you got", + "Never forward a signature you have not checked. For a Solana transaction, " + "the quorum re-verifies every signature locally:", + "pacta wallet treasury-verify --wallet WALLET_DIR --tx-file tx.bin"), + ], + "never": [ + "Approve or verify the evidence behind your own proposal — that is the " + "Quorum bench's seat, and the whole point is that it is not yours.", + "Touch the air-gap device or its outbox/inbox files — the Operator owns " + "that walk.", + "Clear a latch, or edit policy.json to make your own request " + "fit — policy changes are the Operator's deliberate, recorded act.", + ], + "receives": ["a signature, or a refusal receipt — from the wallet", + "custody posture answers — from the Operator"], + "delivers": ["fingerprinted signing requests — to the signing firewall", + "escalations after repeated refusals — to the Operator"], + "instruments": [("/queue", "Queue"), ("/incidents", "Incidents (refusals)")], + }, + { + "id": "quorum", "name": "Quorum bench", "monogram": "QM", + "hue": "#3b4d8f", "tint": "#eef0f7", + "question": "Would I stake custody on this evidence? Four seats, one answer each.", + "card": "Four independent verifier seats; unanimity admits, one dissent latches.", + "lead": ("You are at the Quorum bench — four seats, one per " + "independently built verifier (dalek, anza, risc0, betrusted). Each seat " + "answers for itself. The bench never averages: unanimity admits a " + "component, a single dissent freezes custody. Your value is your " + "independence."), + "mission": ("Hold an independent verdict on every piece of cryptographic " + "evidence the wallet is asked to trust. Your seat's judgment must " + "come from your seat's binary, built from your seat's verified " + "sources — nobody else's."), + "duties": [ + ("Know your seat", + "Which verified repository you are built from, at which commit, with which " + "binary fingerprint — the live bench roster is embedded below.", + None), + ("Rebuild your member from pinned proven sources", + "When sources or toolchains move, rebuild from the pinned fork checkouts " + "and let the capsule re-pin your fingerprint:", + "pacta wallet build-quorum --sources-root FORK_CHECKOUTS_DIR"), + ("Guard your independence", + "A shared toolchain is a shared bug. Do not copy another seat's build " + "artifacts, caches, or patches — four seats that agree because they are " + "secretly one seat protect nothing.", + None), + ("On divergence: your dissent worked", + "If your seat says INVALID while others say OK, custody latches and an " + "incident is written. That is the system succeeding, not you failing. Read " + "the incident (embedded on the Incidents instrument) and defend your " + "verdict to the Operator.", + None), + ], + "never": [ + "Propose a request — the bench judges evidence, it never originates spends.", + "Vote another seat's verdict, or harmonize before answering — the bench " + "never averages; unanimity or latch.", + "Clear a latch your own dissent caused — the Operator investigates; you are " + "a witness, not the judge of your own alarm.", + ], + "receives": ["component evidence (attestations + receipts) — from the wallet's " + "inbound boundary", + "rebuilt source workspaces — from the Architect's pinned forks"], + "delivers": ["a unanimous admit, or a latch-tripping dissent — to the wallet", + "divergence incidents — to the Operator"], + "instruments": [("/posture", "Posture (bench roster)"), ("/incidents", "Incidents")], + }, + { + "id": "operator", "name": "Operator", "monogram": "OP", + "hue": "#1e7f4f", "tint": "#e2f2e9", + "question": "Is everything that should be running, running — and is custody unfrozen?", + "card": "Watches liveness of every service and repo; owns latch recovery.", + "lead": ("You are at the Operator station. You watch the " + "liveness of everything — every service, every repo, the wallet's own " + "health — and you own the emergency procedures. When the latch trips, " + "everyone else stops and you start."), + "mission": ("Keep the estate observably alive: probe the public services, check " + "the local repos, re-verify the wallet daily, advance the log pin, " + "and run latch recovery by the book. You are the only station that " + "may clear a latch — deliberately, with a permanent written note."), + "duties": [ + ("Daily watch", + "Press «Probe now» on the liveness board below — it re-checks every " + "service and repo live, on demand. Then re-verify the wallet from the " + "command line:", + "pacta wallet status --wallet WALLET_DIR\npacta wallet verify-ledger --wallet WALLET_DIR"), + ("Advance the log pin", + "Fetch the latest signed head of the transparency log, verify signature " + "and consistency against your pinned size, and advance the pin — this is " + "the split-view/rollback defense:", + "pacta sth-refresh --url https://ltl.zkdefi.org \\\n --sth-store sth-store.json --log-public-key log.pub"), + ("Witness the published log", + "Audit a full clone: recompute every prefix root, check every historical " + "signed head. An unwatched log is safe only on paper — you are the watcher:", + "pacta witness-audit --published-dir path/to/lean-transparency-log"), + ("On latch: run the book", + "Follow docs/runbook-latch.md step by step. Only when the " + "cause is understood and fixed, clear the latch — the note is recorded " + "permanently in the ledger:", + 'pacta wallet unlatch --wallet WALLET_DIR --note "root cause and fix"'), + ], + "never": [ + "Clear a latch without a written root cause — the CLI refuses an empty " + "note, and the ledger keeps it forever.", + "Rewrite log history: never force-push the log repo, never edit or delete " + "under entries/, never re-sign a published head, never " + "backdate. The log is append-only or it is nothing.", + "Override the bench: if the quorum diverged, the answer is investigation, " + "not a fifth vote.", + "Sign or propose — you hold the brakes, not the pen.", + ], + "receives": ["incidents and latch events — from the wallet and the Quorum bench", + "escalations — from the Proposer"], + "delivers": ["unlatch decisions with permanent notes — to the wallet ledger", + "outage and repair notes — to the Architect (map updates)"], + "instruments": [("/posture", "Posture"), ("/incidents", "Incidents"), + ("/queue", "Queue")], + }, + { + "id": "cryptographer", "name": "Cryptographer", "monogram": "CR", + "hue": "#6d4a8f", "tint": "#f0e8f7", + "question": "Does the evidence really prove what it claims — no more, no less?", + "card": "Re-verifies receipts, replays the log offline, guards claim boundaries.", + "lead": ("You are at the Cryptographer station. You take " + "nothing on trust that you can recompute: receipts, inclusion proofs, " + "signed heads, the whole log. And you guard the boundary of every " + "claim — what is proven, and exactly where the proof stops."), + "mission": ("Independently re-verify any evidence artifact anyone hands you, " + "replay the public log offline, and keep everyone honest about what " + "the mathematics does and does not cover."), + "duties": [ + ("Verify a receipt end to end", + "Use the Inspect instrument (embedded below) or the CLI with the hardened " + "flags — pin store, freshness policy, verified-verifier requirement:", + "pacta receipt-verify --attestation a.json --receipt r.json \\\n" + " --log-public-key log.pub --sth-store sth-store.json \\\n" + " --max-sth-age-seconds 604800 --require-verified-verifier"), + ("Fetch fresh evidence yourself", + "Never verify only what you were handed — fetch from the live log and " + "verify locally:", + "pacta log-fetch --url https://ltl.zkdefi.org --component dalek-ed25519-verified"), + ("Replay the whole log offline", + "In a clone of the published log repo: a fail-closed, standard-library " + "verifier re-checks every leaf, every signed head, every receipt — with " + "its adversarial self-test shipped beside it:", + "python3 verify.py --all\npython3 verify_selftest.py"), + ("Guard the claim boundary", + "The evidence grades (R0–R5) are exact: R4 means machine-checked proofs on " + "the documented boundary — SHA-512 opaque, reproducible builds out of " + "scope (R5). A claim stretched past its boundary is a false claim. The " + "grading tools are pacta claims, pacta score, " + "pacta report.", + None), + ], + "never": [ + "Accept a green light you did not recompute — including this cockpit's.", + "Extend a claim beyond its stated boundary — «verified» never means more " + "than the certificate says.", + "Treat the log as a truth oracle — it is an accountability ledger: leaves " + "can lie, and only independent replay catches a fabricated claim.", + ], + "receives": ["evidence artifacts to audit — from anyone", + "fresh attestations and receipts — from the live log"], + "delivers": ["audit verdicts — to the Operator and the Proposer", + "claim-boundary corrections — to the Architect (docs and map)"], + "instruments": [("/inspect", "Inspect"), ("/guide", "Guide (limits)")], + }, + { + "id": "architect", "name": "Architect", "monogram": "AR", + "hue": "#2b5b78", "tint": "#e8eef2", + "question": "Does the map still match the territory — every repo, service, loop?", + "card": "Keeps the estate map true; watches the loops and the public boundary.", + "lead": ("You are at the Architect station. The estate is many " + "repos, services, mirrors, and two self-referential loops — too much " + "for anyone's head, which is why the map exists. Your job is that the " + "map never lies: about what exists, what runs, and what is public."), + "mission": ("Keep the estate map congruent with reality after every change, " + "keep its two renderings from drifting, watch the loops, and gate " + "what may be named in public."), + "duties": [ + ("Walk the map after every landed change", + "Open the Estate instrument and check the changed entity's card — " + "runtime (always-on / on-demand / not-running / static), mutability, " + "custody lane. The live drift tripwire is embedded below.", + None), + ("Keep the two renderings synced", + "The map exists twice: ESTATE.md (canonical, committed) and " + "the cockpit's estate view. A name-level tripwire test fails the suite if " + "they drift — run it after map edits:", + "python3 scripts/mini_pytest.py"), + ("Watch the loops", + "Loop 1: the dogfood signer is attested at leaf 8 of the very log it " + "signs. Loop 2: entry 13 — the log carries the kernel-checked mechanization " + "of its own accumulator's soundness. Both must stay tellable in one " + "breath; if an explanation of a loop stops being crisp, the estate has " + "drifted somewhere.", + None), + ("Gate the public boundary", + "Public documents list only entities whose existence is already public or " + "must be public for trust. Private infrastructure stays unnamed, " + "everywhere, always.", + None), + ], + "never": [ + "Name private infrastructure in a public artifact — not in maps, not in " + "docs, not in commit messages.", + "Let a generated file drift from its canonical source — when you fix a " + "published file, find and fix its generator in the same change.", + "Redraw the map from memory — the map is recomputed from the repos and " + "services, never from recollection.", + ], + "receives": ["outage and repair notes — from the Operator", + "claim-boundary corrections — from the Cryptographer"], + "delivers": ["the updated, drift-guarded map — to everyone " + "(ESTATE.md + the estate view)"], + "instruments": [("/estate", "Estate map"), ("/guide", "Guide")], + }, + { + "id": "newcomer", "name": "Newcomer", "monogram": "NC", + "hue": "#0f766e", "tint": "#e0f2f0", + "question": "What is all this? Where do I even start?", + "card": "Learns the system hands-on with the DEMO wallet; supplies fresh eyes.", + "lead": ("You are at the Newcomer station — everyone's first " + "station, including the people now sitting at the other five. Your " + "first hour is mapped out below, and your confusion is valuable: it " + "finds the gaps the veterans stopped seeing."), + "mission": ("Learn the system hands-on, with a wallet that cannot hurt " + "anything, until you can read the Bridge at a glance — then pick a " + "station and shadow it."), + "duties": [ + ("Your first hour, step 1: run the demo", + "A throwaway, custody-inert wallet with fake members — every view has " + "content, nothing can sign anything real:", + "pacta wallet cockpit --demo"), + ("Step 2: read the Guide", + "All of it — ten minutes. What warden is, how to read any page, the color " + "code, and a glossary of every term you will meet.", + None), + ("Step 3: take the five-minute tour", + "Posture → Incidents → Queue → Inspect → Estate, in that order, reading " + "each page's verdict first.", + None), + ("Step 4: verify something real", + "Paste the sample evidence from examples/wallet-evidence/ " + "into the Inspect instrument and watch the deployed verifier accept it — " + "then break one character and watch it refuse.", + None), + ("Step 5: pick a station and shadow it", + "Read that station's mission, duties, and its «never» list — the never " + "list is the fastest way to understand a role.", + None), + ], + "never": [ + "Pretend to understand — every other station once sat exactly here.", + "Assume a confusing page is your fault: if a page confuses you, that is a " + "bug in the page, not in you. Report it.", + ], + "receives": ["the Guide, the demo wallet, and patient answers — from every " + "station"], + "delivers": ["fresh eyes: every page that confuses you, reported — to the " + "Architect and the Operator"], + "instruments": [("/guide", "Guide"), ("/inspect", "Inspect"), + ("/estate", "Estate map")], + }, +] + +STATION_BY_ID = {s["id"]: s for s in STATIONS} + +# the andon board: event -> who acts, with what +DISPATCH: list[tuple[str, str, str]] = [ + ("A request was refused", "proposer", + "read the refusal receipt: code → missing → remediation, then retry"), + ("CUSTODY FROZEN, or a new incident", "operator", + "runbook-latch, root cause, permanent unlatch note (plus the Quorum bench if it " + "was a divergence)"), + ("A new component wants to be trusted", "quorum", + "evidence through the inbound boundary; unanimity admits, one dissent latches"), + ("Someone handed you evidence", "cryptographer", + "Inspect, or receipt-verify with the hardened flags — recompute, never trust"), + ("A service or repo looks dead", "operator", + "liveness board: Probe now"), + ("The map feels wrong", "architect", + "estate view + drift tripwire; fix the generator, not just the page"), + ("“I don’t understand any of this”", "newcomer", + "the Newcomer station is the entry point, not an insult"), +] + + +# --------------------------------------------------------------------------- +# renderers (pure) +# --------------------------------------------------------------------------- + +def _role_vars(s: dict[str, Any]) -> str: + return f"--role:{s['hue']};--roletint:{s['tint']}" + + +def render_bridge(strip_html: str, live: dict[str, str]) -> str: + """The Level-1 overview: whole-system verdict, then the crew.""" + cards = "".join( + f'
' + f'
{s["monogram"]} ' + f'{esc(s["name"])}
' + f'
{esc(s["question"])}
' + f'
{esc(s["card"])}
' + f'{live.get(s["id"], "")}' + f'Take this station →
' + for s in STATIONS) + dispatch_rows = "".join( + f'{event}' + f'{esc(STATION_BY_ID[sid]["name"])}' + f'{action}' + for event, sid, action in DISPATCH) + return ( + strip_html + + "

The crew — who does what

" + "

Running this estate without an AI takes six roles. Each " + "station page states its mission, its duties as runnable commands, what it " + "hands to whom — and what it never does. Roles cooperate through those " + "handoffs; they do not blur into each other.

" + f"
{cards}
" + "

If this happens, who acts

" + "
" + "" + f"{dispatch_rows}
eventstationfirst move
" + + explain( + "
  • This bridge is the one-glance overview: the verdict strip on top " + "is the whole system's state, recomputed on load.
  • " + "
  • The crew cards are the six roles; «Take this station» opens that " + "role's console with its duties and handoffs.
  • " + "
  • The dispatch table is the andon board: when something happens, it " + "names the station that acts first — nobody improvises ownership during an " + "incident.
  • " + "
  • The layout follows control-room practice: overview (this bridge) → " + "station (a role's console) → instrument (shared evidence panels) → raw " + "files and CLI. Deeper is always one click, never a guess.
") + ) + + +def render_station(s: dict[str, Any], embeds: list[str]) -> str: + """One role's console: mission, duties (with commands), live instruments, + the never-list, and explicit handoffs.""" + duties = "".join( + f'
{i}. {title}' + f'
{why}
' + f'{cmd_block(command) if command else ""}
' + for i, (title, why, command) in enumerate(s["duties"], start=1)) + nevers = "".join(f"
  • {item}
  • " for item in s["never"]) + receives = "".join(f"
  • {item}
  • " for item in s["receives"]) + delivers = "".join(f"
  • {item}
  • " for item in s["delivers"]) + instruments = " · ".join( + f'{esc(label)}' for href, label in s["instruments"]) + embedded = "".join(embeds) + return ( + f'
    ' + f'{s["monogram"]}' + f'

    {esc(s["name"])} station

    ' + f'
    {esc(s["question"])}
    ' + f'

    Mission

    ' + f'

    {s["mission"]}

    ' + f'

    Instruments this station works with: {instruments}

    ' + f'

    Duties — the no-AI drill

    ' + "

    Every duty is a runnable command or a concrete act — this is " + "the work, not advice about the work.

    " + f'{duties}
    ' + f'{embedded}' + f'
    ' + f'

    This station never…

    ' + "

    Separation of duties is the control that makes the team " + "trustworthy — these lines are load-bearing, not etiquette.

    " + f'
      {nevers}
    ' + f'

    Handoffs

    ' + f'
    ' + f'
    RECEIVES
      {receives}
    ' + f'
    DELIVERS
      {delivers}
    ' + f'
    ' + + explain( + "
    • Handoffs are the team's interfaces: what this station takes in, " + "what it hands out, and to whom. Work moves between stations only through " + "these — that is how distinct roles cooperate without merging.
    • " + "
    • If you are alone (or you are the AI), you may hold several stations — " + "but you switch between them explicitly, one at a time, and the handoffs " + "still apply to yourself.
    ") + + "
    " + ) diff --git a/src/pacta/uikit.py b/src/pacta/uikit.py new file mode 100644 index 0000000..98f375c --- /dev/null +++ b/src/pacta/uikit.py @@ -0,0 +1,162 @@ +"""uikit - shared presentation primitives for the warden cockpit. + +Pure string builders and the one stylesheet. No wallet imports, no I/O: +this module can be reasoned about (and tested) as text in, text out. +The cockpit's two laws live in walletui's docstring; every helper here +exists to serve them - provenance lines, loud failure panels, glossary +links, per-panel explainers. +""" +from __future__ import annotations + +import html +from datetime import datetime, timezone +from typing import Any + +STYLE = """ + :root{--ink:#1c2430;--ink2:#5a6675;--line:#dde2e9;--ok:#1e7f4f;--okbg:#e2f2e9; + --bad:#a3242c;--badbg:#fbe4e6;--warn:#a86a10;--warnbg:#fdf0da; + --accent:#3b4d8f;--accentbg:#eef0f7;--bg:#f8f9fa} + *{box-sizing:border-box} + body{font-family:system-ui,sans-serif;max-width:62rem;margin:0 auto; + padding:1.4rem 1.2rem 4rem;color:var(--ink);line-height:1.55;background:var(--bg)} + h1{font-size:1.35rem;margin:.2rem 0 0} + h2{font-size:1.05rem;margin:1.6rem 0 .5rem} + h3{font-size:.95rem;margin:1rem 0 .3rem} + 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} + .navrow{display:flex;gap:.5rem;flex-wrap:wrap;align-items:stretch;margin:.45rem 0} + .navtag{font-size:.62rem;font-weight:700;letter-spacing:.08em;color:var(--ink2); + align-self:center;min-width:6.2rem} + .navrow a{color:var(--accent);text-decoration:none;border:1px solid var(--line); + background:#fff;border-radius:6px;padding:.3rem .6rem;font-size:.82rem; + display:flex;flex-direction:column;line-height:1.25;min-width:6.4rem} + .navrow a.here{border-color:var(--accent);font-weight:600;background:var(--accentbg)} + .navrow a .navsub{font-size:.65rem;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)} + .prov{color:var(--ink2);font-size:.72rem;margin-top:.6rem;border-top:1px dashed var(--line); + padding-top:.35rem} + .pill{display:inline-block;border-radius:9px;padding:.06rem .55rem;font-size:.76rem; + font-weight:700} + .pill.ok{background:var(--okbg);color:var(--ok)} + .pill.bad{background:var(--badbg);color:var(--bad)} + .pill.warn{background:var(--warnbg);color:var(--warn)} + 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} + pre.cmd{background:#1c2430;color:#e8ecf2;border-radius:6px;padding:.55rem .8rem; + font-size:.78rem;line-height:1.5;font-family:ui-monospace,Menlo,Consolas,monospace} + .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; + padding:.5rem 1.1rem;font-size:.9rem;cursor:pointer} + .btnlink{display:inline-block;background:var(--accent);color:#fff;border-radius:6px; + padding:.45rem 1rem;font-size:.88rem;text-decoration:none} + .muted{color:var(--ink2);font-size:.85rem} + .mono{font-family:ui-monospace,monospace} + .breakany{overflow-wrap:anywhere} + /* bridge */ + .strip{display:flex;gap:.45rem;flex-wrap:wrap;margin:.8rem 0} + .chip{border:1px solid var(--line);background:#fff;border-radius:8px; + padding:.3rem .7rem;font-size:.82rem} + .chip b{font-weight:700} + .crew{display:grid;grid-template-columns:repeat(auto-fill,minmax(17rem,1fr)); + gap:.7rem;margin:.7rem 0} + .stationcard{background:#fff;border:1px solid var(--line);border-radius:8px; + padding:.8rem .95rem;border-left:4px solid var(--role,#3b4d8f); + display:flex;flex-direction:column;gap:.35rem} + .stationcard .q{color:var(--ink2);font-size:.82rem;font-style:italic} + .stationcard .live{font-size:.8rem} + .stationcard .take{margin-top:auto;font-size:.84rem;font-weight:600; + color:var(--accent);text-decoration:none} + .monogram{display:inline-block;min-width:1.9rem;height:1.9rem;line-height:1.9rem; + text-align:center;border-radius:6px;font-weight:800;font-size:.8rem; + background:var(--roletint,#eef0f7);color:var(--role,#3b4d8f)} + .rolehead{display:flex;align-items:center;gap:.6rem;margin:.9rem 0 .2rem; + padding:.7rem .9rem;background:#fff;border:1px solid var(--line); + border-radius:8px;border-left:5px solid var(--role,#3b4d8f)} + .rolehead h2{margin:0;font-size:1.15rem} + .rolehead .q{color:var(--ink2);font-size:.85rem;font-style:italic} + .duty{margin:.55rem 0 .9rem} + .duty .why{font-size:.85rem;color:var(--ink2);margin:.15rem 0 .3rem} + .never li{margin:.25rem 0;font-size:.88rem} + .hand{display:grid;grid-template-columns:1fr 1fr;gap:.7rem} + @media(max-width:40rem){.hand{grid-template-columns:1fr}} + .hand .hcol{background:var(--accentbg);border-radius:6px;padding:.5rem .8rem; + font-size:.86rem} + .hand .hcol b{display:block;margin-bottom:.2rem;font-size:.78rem; + letter-spacing:.05em} +""" + + +def now_utc() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def esc(value: Any) -> str: + return html.escape(str(value)) + + +def help_link(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_utc())} via {esc(via)}' + f' — nothing on this panel is cached or asserted.
    ') + + +def failed_panel(what: str, via: str, error: Exception) -> str: + return ( + 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"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 status." + f"{provenance(via)}
    " + ) + + +def cmd_block(command: str) -> str: + """A copy-paste command block: the no-AI drill in executable form.""" + return f'
    {esc(command)}
    ' diff --git a/src/pacta/walletui.py b/src/pacta/walletui.py index f2654e9..5cf99f9 100644 --- a/src/pacta/walletui.py +++ b/src/pacta/walletui.py @@ -1,153 +1,68 @@ -"""walletui - the warden custody cockpit (local, read-only). +"""walletui - the warden custody cockpit (local, read-only): a bridge +with six role stations over shared evidence instruments. -A localhost web surface over an existing wallet directory, for the human -operator who ultimately answers for the money. Six views: posture, the -pending-signature queue (airgap outbox), the incident & refusal browser, -a receipt inspector, the estate map, and a plain-language guide. +Information architecture (control-room style, four levels): + 1. BRIDGE (/) - whole-system verdict + the crew of six roles + 2. STATIONS (/station/*) - one console per role: mission, duties as + runnable commands, live instruments, the + never-list, explicit handoffs + 3. INSTRUMENTS - shared evidence views: posture, queue, + incidents, inspect, estate, guide + 4. RAW - the wallet files and the CLI themselves -Design law: THE COCKPIT RENDERS EVIDENCE, IT NEVER ASSERTS IT. Every +Three laws, each enforced by tests: + +DESIGN LAW - THE COCKPIT RENDERS EVIDENCE, IT NEVER ASSERTS IT. Every panel is recomputed from wallet state or submitted artifacts at request time by the same functions the wallet itself uses, and every panel names the function and timestamp that produced it. Anything that cannot be -recomputed renders as a loud FAILED-TO-VERIFY panel - there is no cached -green and no neutral gray. +recomputed renders as a loud FAILED-TO-VERIFY panel - no cached green, +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. +UX LAW - THE COCKPIT NEVER LEAVES A HUMAN IN THE DARK. Every page opens +with a plain-language lead; every verdict is stated in words; every +panel carries a "how to read this" explainer; every jargon term links to +the /guide glossary. + +CREW LAW - ROLES ARE DISTINCT AND COOPERATE THROUGH HANDOFFS. The bridge +presents everything a human crew would need if no AI were around: six +stations (proposer, quorum bench, operator, cryptographer, architect, +newcomer), each with runnable duties and a "this station never" list. +Separation of duties is a custody control; the stations do not melt into +each other. (Role content lives in stations.py; liveness probes in +liveness.py; shared primitives in uikit.py.) Read-only guarantee: this module calls only read paths (``Wallet.posture``, -``verify_ledger``, directory listings) and ``transparency.verify_receipt`` -on submitted artifacts (parsed in memory / temp files outside the wallet). -It cannot approve, sign, unlatch, or modify custody state; the HTTP surface -exposes no mutating route. Human approve/deny is deliberately NOT here - -that would be a custody-semantics change, which belongs to a separate, -explicitly reviewed milestone. +``verify_ledger``, directory listings), ``transparency.verify_receipt`` on +submitted artifacts (parsed in memory / temp files outside the wallet), +and - only when the operator explicitly presses "Probe now" - outbound +liveness observations (HTTP GET, git queries). It cannot approve, sign, +unlatch, or modify custody state; the HTTP surface exposes no mutating +route. Human approve/deny is deliberately NOT here - that would be a +custody-semantics change, which belongs to a separate, explicitly +reviewed milestone. The mutating acts a human crew needs are provided as +exact CLI commands on the stations instead. The server binds 127.0.0.1 by default and is not meant to be exposed. """ from __future__ import annotations -import html import json import tempfile import urllib.parse -from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any, Callable from .attestation import load_attestation +from .liveness import collect_liveness, render_liveness +from .stations import STATION_BY_ID, STATIONS, render_bridge, render_station from .transparency import load_receipt, verify_receipt +from .uikit import (STYLE, esc as _esc, explain as _explain, + failed_panel as _failed_panel, help_link as _help, + now_utc as _now, provenance as _provenance) from .wallet import Wallet -_STYLE = """ - :root{--ink:#1c2430;--ink2:#5a6675;--line:#dde2e9;--ok:#1e7f4f;--okbg:#e2f2e9; - --bad:#a3242c;--badbg:#fbe4e6;--warn:#a86a10;--warnbg:#fdf0da; - --accent:#3b4d8f;--accentbg:#eef0f7;--bg:#f8f9fa} - *{box-sizing:border-box} - body{font-family:system-ui,sans-serif;max-width:62rem;margin:0 auto; - padding:1.4rem 1.2rem 4rem;color:var(--ink);line-height:1.55;background:var(--bg)} - h1{font-size:1.35rem;margin:.2rem 0 0} - h2{font-size:1.05rem;margin:1.6rem 0 .5rem} - code{font-family:ui-monospace,Menlo,Consolas,monospace;background:#eef0f3; - border-radius:4px;padding:.08rem .3rem;font-size:.88em} - .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:.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)} - .prov{color:var(--ink2);font-size:.72rem;margin-top:.6rem;border-top:1px dashed var(--line); - padding-top:.35rem} - .pill{display:inline-block;border-radius:9px;padding:.06rem .55rem;font-size:.76rem; - font-weight:700} - .pill.ok{background:var(--okbg);color:var(--ok)} - .pill.bad{background:var(--badbg);color:var(--bad)} - .pill.warn{background:var(--warnbg);color:var(--warn)} - 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; - padding:.5rem 1.1rem;font-size:.9rem;cursor:pointer} - .muted{color:var(--ink2);font-size:.85rem} - .mono{font-family:ui-monospace,monospace} -""" - - -def _now() -> str: - return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -def _esc(value: Any) -> str: - return html.escape(str(value)) - - -def _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.
    ' - - -def _failed_panel(what: str, via: str, error: Exception) -> str: - return ( - 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"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)}
    " - ) - # --------------------------------------------------------------------------- # collectors - read-only, one wallet function each, exceptions contained @@ -232,25 +147,55 @@ def inspect_receipt(attestation_text: str, receipt_text: str, "error": f"{type(error).__name__}: {error}"} +def _collect_drift() -> dict[str, Any]: + """The Architect's live tripwire: do the two estate renderings agree?""" + def read() -> dict[str, Any]: + from .estateview import ESTATE_HTML + estate_md = (Path(__file__).resolve().parents[2] / "ESTATE.md").read_text( + encoding="utf-8") + sentinels = ["lean-transparency-log", "ltl-accumulator-verified", + "proof-aware-crypto-tooling-agent", "verifying-crypto-with-lean", + "dalek-ed25519-verified", "pasta-pallas-verified", + "ltl.zkdefi.org", "Forgejo"] + missing = ([f"{n} (estate view)" for n in sentinels if n not in ESTATE_HTML] + + [f"{n} (ESTATE.md)" for n in sentinels if n not in estate_md]) + return {"sentinels": len(sentinels), "missing": missing, + "runtime_in_both": ("What is running" in estate_md + and "ALWAYS ON" in ESTATE_HTML)} + return collect("name-level comparison of estateview.ESTATE_HTML vs ESTATE.md", read) + + # --------------------------------------------------------------------------- -# page shell - one navigation, one lead paragraph, on every view +# page shell - two-row navigation (stations / instruments), lead on every view # --------------------------------------------------------------------------- -_VIEWS = [ - ("/", "Posture", "is custody healthy right now?"), +_STATION_TABS = [("/", "Bridge", "the whole system at a glance")] + [ + (f"/station/{s['id']}", s["name"], sub) for s, sub in zip(STATIONS, [ + "ask for signatures", "four seats, one answer each", + "liveness + latch recovery", "recompute everything", + "map = territory", "start here"])] + +_INSTRUMENT_TABS = [ + ("/posture", "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"), + ("/estate", "Estate map", "the territory, 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.'), +_LEADS: dict[str, str] = { + "/": ('This is the bridge: the whole estate at one glance, then ' + 'the crew. The verdict strip is recomputed on load; each crew card opens a ' + 'station — one human role with its duties, commands, and ' + 'handoffs. If no AI were around, these six stations are how people would ' + 'run this system.'), + "/posture": ('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 ' @@ -265,16 +210,25 @@ _LEADS = { '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.'), + 'this page is live data — this is the manual. The Bridge and the stations ' + 'are the working surfaces; the instruments are the shared evidence.'), } +for _s in STATIONS: + _LEADS[f"/station/{_s['id']}"] = _s["lead"] + + +def _tabs(items: list[tuple[str, str, str]], active: str) -> str: + return "".join( + f'{label}' + f'{sub}' + for href, label, sub in items) def _page(title: str, active: str, body: str, wallet_dir: str) -> str: - nav = "".join( - f'{label}' - f'{sub}' - for href, label, sub in _VIEWS) + nav = (f'' + f'') demo_badge = ('DEMO WALLET — custody-inert ' if "DEMO" in wallet_dir else "") @@ -284,20 +238,22 @@ def _page(title: str, active: str, body: str, wallet_dir: str) -> str: "" "" f"warden cockpit — {_esc(title)}" - f"" + f"" 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"{lead_html}{body}" + "approve, sign, unlatch, or modify custody state — the stations give you the " + "exact commands for every act instead. First time here? Start with the " + "Guide or the Newcomer " + "station." + f"{nav}{lead_html}{body}" ) # --------------------------------------------------------------------------- -# renderers - pure string builders over collector output +# posture instrument - verdict banner + five evidence panels # --------------------------------------------------------------------------- def _posture_verdict(p: dict[str, Any]) -> str: @@ -321,25 +277,10 @@ def _posture_verdict(p: dict[str, Any]) -> str: "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"]) - p = posture["data"] +def _panel_latch(p: dict[str, Any]) -> str: latch = p["latch"] - ledger = p["ledger"] latch_pill = ('LATCHED — outbound custody frozen' if latch.get("latched") else 'unlatched') - chain_pill = ('chain verified' if ledger["chain_ok"] - else 'CHAIN BROKEN') - members = "".join( - f"{_esc(m['backend'])}" - f"{_esc(m['component'])}" - f"{_esc(m['risk_tier'])}" - 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 — every link in the chain held
  • ") latch_detail = "" if latch.get("latched"): latch_detail = (f"

    Trigger: {_esc(latch.get('reason'))} · " @@ -347,19 +288,8 @@ def render_posture(posture: dict[str, Any]) -> str: 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 ( - _posture_verdict(p) - # --- latch --------------------------------------------------------- - + f"

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

    " + 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 " @@ -371,8 +301,16 @@ def render_posture(posture: dict[str, Any]) -> str: "

  • 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"{_provenance('Wallet.latch_state()')}
    ") + + +def _panel_ledger(p: dict[str, Any]) -> str: + ledger = p["ledger"] + chain_pill = ('chain verified' if ledger["chain_ok"] + else 'CHAIN BROKEN') + problems = "".join(f"
  • {_esc(x)}
  • " for x in ledger["problems"]) or ( + "
  • none — every link in the chain held
  • ") + return ( 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, " @@ -387,8 +325,18 @@ def render_posture(posture: dict[str, Any]) -> str: "

  • 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"{_provenance('Wallet.verify_ledger() — full hash-chain recomputation')}") + + +def _panel_quorum(p: dict[str, Any]) -> str: + members = "".join( + f"{_esc(m['backend'])}" + f"{_esc(m['component'])}" + f"{_esc(m['risk_tier'])}" + f"{_esc(m['source_commit'][:12])}…" + f"{_esc(m['binary_sha256'][:16])}…" + for m in p["members"]) + return ( f"

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

    " f"

    These are the {len(p['members'])} verifier programs this wallet " @@ -418,22 +366,48 @@ def render_posture(posture: dict[str, Any]) -> str: "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 — that gap is grade R5 — stated in the paper and the claim cards)." - f"{_provenance('Wallet.capsule() / Wallet.posture()')}

    " - # --- signing rules ------------------------------------------------- + f"{_provenance('Wallet.capsule() / Wallet.posture()')}") + + +def _panel_policy(p: dict[str, Any]) -> str: + 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"

    Signing rules (spending policy)

    " f"{spending_note}" f"
    {_esc(json.dumps(spending, indent=2, sort_keys=True))}
    " - f"{_provenance('Wallet.policy() (policy.json, verbatim)')}
    " - # --- recorded history ---------------------------------------------- + f"{_provenance('Wallet.policy() (policy.json, verbatim)')}") + + +def _panel_history(p: dict[str, Any]) -> str: + return ( 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')}
    " - ) + f"{_provenance('directory counts, recomputed')}") +def render_posture(posture: dict[str, Any]) -> str: + if not posture["ok"]: + return _failed_panel("Custody posture", posture["via"], posture["error"]) + p = posture["data"] + return (_posture_verdict(p) + _panel_latch(p) + _panel_ledger(p) + + _panel_quorum(p) + _panel_policy(p) + _panel_history(p)) + + +# --------------------------------------------------------------------------- +# queue / incidents / inspect instruments +# --------------------------------------------------------------------------- + def render_queue(airgap: dict[str, Any]) -> str: if not airgap["ok"]: return _failed_panel("Signature queue", airgap["via"], airgap["error"]) @@ -468,7 +442,7 @@ 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], intro: str, empty: str, - explain: str, via_note: str) -> str: + explain_body: str, via_note: str) -> str: if not coll["ok"]: return _failed_panel(title, coll["via"], coll["error"]) items = coll["data"] @@ -482,7 +456,7 @@ def render_incidents(incidents: dict[str, Any], refusals: dict[str, Any]) -> str 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"

    {title}

    " - f"

    {intro}

    {body}{_explain(explain)}" + f"

    {intro}

    {body}{_explain(explain_body)}" f"{_provenance(via_note)}
    ") return ( block(f"Incidents — what the wallet noticed {_help('incident')}", incidents, @@ -575,6 +549,89 @@ def render_inspect(result: dict[str, Any] | None, ) +# --------------------------------------------------------------------------- +# bridge assembly - verdict strip + live crew snippets +# --------------------------------------------------------------------------- + +def _bridge_strip(posture: dict[str, Any], airgap: dict[str, Any]) -> str: + if not posture["ok"]: + return _failed_panel("Custody verdict", posture["via"], posture["error"]) + p = posture["data"] + ledger = p["ledger"] + chain = ('chain verified' if ledger["chain_ok"] + else 'CHAIN BROKEN') + pending: Any = "?" + if airgap["ok"]: + pending = sum(1 for r in airgap["data"] if not r["answered"]) + return ( + _posture_verdict(p) + + "
    " + f"quorum {len(p['members'])} pinned" + f"ledger {chain}" + f"incidents {p['incidents']} · refusals " + f"{p['refusal_receipts']}" + f"queue {pending} awaiting device" + "liveness — probe from " + "the Operator station" + "
    " + + _provenance("Wallet.posture() + airgap listing (liveness only on demand)") + ) + + +def _bridge_live(posture: dict[str, Any], airgap: dict[str, Any]) -> dict[str, str]: + live: dict[str, str] = { + "cryptographer": "
    instrument ready: Inspect
    ", + "architect": "
    estate view + drift tripwire ready
    ", + "newcomer": "
    start: the Guide, then the demo
    ", + } + if posture["ok"]: + p = posture["data"] + seats = ", ".join(f"{_esc(m['component'])}" + for m in p["members"]) + latch_word = ("LATCHED" if p["latch"].get("latched") else "unlatched") + live["quorum"] = f"
    seats: {seats}
    " + live["operator"] = (f"
    incidents on file: {p['incidents']}" + f" · latch: {latch_word}
    ") + if airgap["ok"]: + pending = sum(1 for r in airgap["data"] if not r["answered"]) + live["proposer"] = f"
    queue: {pending} awaiting device
    " + return live + + +def _render_drift_panel() -> str: + coll = _collect_drift() + if not coll["ok"]: + return _failed_panel("Estate drift tripwire", coll["via"], coll["error"]) + d = coll["data"] + if d["missing"] or not d["runtime_in_both"]: + missing = "".join(f"
  • {_esc(x)}
  • " for x in d["missing"]) or "" + runtime = ("" if d["runtime_in_both"] else + "
  • the runtime dimension is missing from one rendering
  • ") + body = (f"

    DRIFT the two renderings " + f"of the estate disagree:

      {missing}{runtime}
    ") + else: + body = (f"

    renderings agree all " + f"{d['sentinels']} sentinel names present in both ESTATE.md " + "and the cockpit estate view, and both carry the runtime dimension.

    ") + return ( + "

    Drift tripwire — map vs map

    " + "

    The estate map exists twice: the committed " + "ESTATE.md and the estate view. Two " + "renderings of one model need a tripwire — this panel compares them live, " + "name by name.

    " + + body + + _explain( + "
    • The comparison is name-level (sentinel entities + the runtime " + "dimension) — the same check the test suite runs.
    • " + "
    • On DRIFT: fix the stale rendering AND check its generator — a " + "published file that drifted from its source once will drift again.
    ") + + _provenance(coll["via"]) + "
    ") + + +# --------------------------------------------------------------------------- +# guide instrument - the manual +# --------------------------------------------------------------------------- + def render_guide() -> str: """The manual: static plain-language explanations, no live data.""" return ( @@ -589,12 +646,27 @@ def render_guide() -> str: # --- 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 " + "humans who ultimately answer 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.

    " + "not one byte of wallet state. What it does provide is the work: the " + "Bridge organizes everything a human crew would do if no AI were " + "around, as six role stations with runnable commands.

    " + # --- the crew ------------------------------------------------------ + "

    The crew model

    " + "

    Six roles run this estate: the Proposer asks " + "for signatures; the Quorum bench holds four independent " + "verifier seats; the Operator watches liveness and owns latch " + "recovery; the Cryptographer recomputes every piece of " + "evidence; the Architect keeps the estate map true; the " + "Newcomer learns — and supplies fresh eyes. Roles cooperate " + "through explicit handoffs and never blur: the one who proposes never approves, " + "the one who verifies never proposes, the one who watches never overrides the " + "bench. One person (or one agent) may hold several stations — explicitly, one " + "at a time, with the handoffs still applying. Each station page states its " + "mission, duties as commands, and what it never does.

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

    How to read any page here

    " "
      " @@ -619,8 +691,10 @@ def render_guide() -> str: # --- 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 the Bridge — the verdict strip is the whole system in " + "one line; the crew cards are who does what.
      4. " + "
      5. Open Posture — read the verdict banner, then the " + "panels top to bottom: latch, ledger, quorum, signing rules, recorded history.
      6. " "
      7. Open Incidents — on a healthy wallet both lists are " "empty, and the page says why that is the good state.
      8. " "
      9. Open Queue — empty unless a signature is waiting for " @@ -629,6 +703,8 @@ def render_guide() -> str: "examples/wallet-evidence/ and watch the deployed verifier run.
      10. " "
      11. Open the Estate map — where this wallet sits in the " "wider verified-crypto estate, and what is actually running where.
      12. " + "
      13. Then take the Newcomer station — your " + "first hour, mapped out.
      14. " "
      " # --- glossary ------------------------------------------------------ "

      Glossary

      " @@ -689,6 +765,11 @@ def render_guide() -> str: "
      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.
      " + "
      station
      " + "
      One human role's console on the Bridge: mission, duties as runnable " + "commands, live instruments, the never-list (separation of duties), and " + "handoffs. Six stations: Proposer, Quorum bench, Operator, Cryptographer, " + "Architect, Newcomer.
      " "
      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 " @@ -703,6 +784,8 @@ def render_guide() -> str: "and the claim cards rather than hidden." "
    1. Whether the machine this cockpit runs on is itself clean — a compromised " "operating system can lie to any dashboard, including this one.
    2. " + "
    3. Whether a live service is honest — the liveness board checks " + "pulses, not truth; truth is the Cryptographer's replay work.
    4. " "
    5. 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.
    6. " "
      " @@ -812,13 +895,45 @@ def make_handler(wallet_dir: Path): def _wallet(self) -> Wallet: return Wallet(wallet_dir) + def _station_embeds(self, station_id: str, probe: bool) -> list[str]: + wallet = self._wallet() + if station_id == "proposer": + return [render_queue(collect_airgap(wallet))] + if station_id == "quorum": + posture = collect("Wallet.posture()", wallet.posture) + return ([_panel_quorum(posture["data"])] if posture["ok"] else + [_failed_panel("Quorum bench roster", posture["via"], + posture["error"])]) + if station_id == "operator": + live = render_liveness(collect_liveness() if probe else None) + posture = collect("Wallet.posture()", wallet.posture) + if posture["ok"]: + p = posture["data"] + return [live, _panel_latch(p), _panel_history(p)] + return [live, _failed_panel("Custody posture", posture["via"], + posture["error"])] + if station_id == "cryptographer": + return [render_inspect(None)] + if station_id == "architect": + return [_render_drift_panel()] + return [] + def do_GET(self) -> None: # noqa: N802 - http.server API - route = urllib.parse.urlparse(self.path).path + parsed = urllib.parse.urlparse(self.path) + route = parsed.path + query = urllib.parse.parse_qs(parsed.query) wd = str(wallet_dir) if route == "/": + wallet = self._wallet() + posture = collect("Wallet.posture()", wallet.posture) + airgap = collect_airgap(wallet) + body = render_bridge(_bridge_strip(posture, airgap), + _bridge_live(posture, airgap)) + self._send(_page("bridge", "/", body, wd)) + elif route == "/posture": wallet = self._wallet() body = render_posture(collect("Wallet.posture()", wallet.posture)) - self._send(_page("posture", "/", body, wd)) + self._send(_page("posture", "/posture", body, wd)) elif route == "/queue": body = render_queue(collect_airgap(self._wallet())) self._send(_page("signature queue", "/queue", body, wd)) @@ -833,6 +948,19 @@ def make_handler(wallet_dir: Path): elif route == "/estate": from .estateview import ESTATE_HTML self._send(ESTATE_HTML + _ESTATE_BACK_CHIP) + elif route.startswith("/station/"): + station_id = route.removeprefix("/station/") + station = STATION_BY_ID.get(station_id) + if station is None: + self._send(_page("not found", "", + "
      No such station. The " + "STATIONS row above lists the whole crew.
      ", + wd), 404) + return + probe = query.get("probe", ["0"])[0] == "1" + body = render_station(station, + self._station_embeds(station_id, probe)) + self._send(_page(f"{station['name']} station", route, body, wd)) else: self._send(_page("not found", "", "
      No such view. The tabs above list " diff --git a/tests/test_walletui.py b/tests/test_walletui.py index 330363a..dd76cc0 100644 --- a/tests/test_walletui.py +++ b/tests/test_walletui.py @@ -178,7 +178,11 @@ 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", "/guide"): + routes = ("/", "/posture", "/queue", "/incidents", "/inspect", "/guide", + "/station/proposer", "/station/quorum", "/station/operator", + "/station/operator?probe=1", "/station/cryptographer", + "/station/architect", "/station/newcomer") + for route in routes: with urllib.request.urlopen(f"http://127.0.0.1:{port}{route}") as resp: body = resp.read().decode() assert resp.status == 200 @@ -293,7 +297,7 @@ 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"): + for route in ("/", "/posture", "/queue", "/incidents", "/inspect"): status, body = _get(port, route) assert status == 200 assert 'href="/guide"' in body, f"{route}: no path to the guide" @@ -326,3 +330,97 @@ def test_estate_page_links_back_to_cockpit(tmp_path): finally: server.shutdown() thread.join(timeout=5) + + +# --------------------------------------------------------------------------- +# Crew law: the bridge presents six DISTINCT role stations that cooperate +# through explicit handoffs — everything a human crew would need with no AI +# around, as runnable commands. Roles never melt into each other. +# --------------------------------------------------------------------------- + +STATION_IDS = ("proposer", "quorum", "operator", "cryptographer", + "architect", "newcomer") + + +def test_bridge_shows_crew_and_dispatch(tmp_path): + wallet = _seal_wallet(tmp_path) + server, thread, port = _serve(wallet.dir) + try: + status, body = _get(port, "/") + assert status == 200 + assert "CUSTODY HEALTHY" in body # whole-system verdict, in words, on top + for name in ("Proposer", "Quorum bench", "Operator", "Cryptographer", + "Architect", "Newcomer"): + assert name in body, f"bridge is missing the {name} card" + assert "Take this station" in body + assert "If this happens, who acts" in body # the dispatch (andon) board + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_every_station_defines_role_contract(tmp_path): + """Each station page must state: mission, duties as runnable commands, + the never-list (separation of duties), and explicit handoffs.""" + wallet = _seal_wallet(tmp_path) + server, thread, port = _serve(wallet.dir) + try: + for sid in STATION_IDS: + status, body = _get(port, f"/station/{sid}") + assert status == 200, f"{sid}: not served" + assert "Mission" in body, f"{sid}: no mission" + assert "Duties" in body, f"{sid}: no duties" + assert 'class="cmd"' in body, f"{sid}: no runnable command (the no-AI drill)" + assert "This station never" in body, f"{sid}: no separation-of-duties list" + assert "Handoffs" in body, f"{sid}: no handoffs" + assert "RECEIVES" in body and "DELIVERS" in body, f"{sid}: handoffs empty" + assert 'class="lead"' in body, f"{sid}: no lead" + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_stations_are_distinct_roles(tmp_path): + """No melting: each role's signature phrase appears on its own station + and on no other station.""" + markers = { + "proposer": "front door", + "quorum": "unanimity or latch", + "operator": "Probe now", + "cryptographer": "Replay the whole log offline", + "architect": "Drift tripwire", + "newcomer": "Your first hour", + } + wallet = _seal_wallet(tmp_path) + server, thread, port = _serve(wallet.dir) + try: + bodies = {sid: _get(port, f"/station/{sid}")[1] for sid in STATION_IDS} + for sid, marker in markers.items(): + assert marker in bodies[sid], f"{sid}: lost its signature duty ({marker})" + for other, body in bodies.items(): + if other != sid: + assert marker not in body, ( + f"{other} bleeds into {sid}'s role ({marker})") + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_operator_probe_is_explicit_and_live(tmp_path): + """The liveness board never phones home on an ordinary page load; probes + run only on the operator's explicit demand, then show per-target rows.""" + wallet = _seal_wallet(tmp_path) + server, thread, port = _serve(wallet.dir) + try: + status, body = _get(port, "/station/operator") + assert status == 200 + assert "has not probed" in body and "Probe now" in body + assert "Public services" not in body # no results without demand + status, body = _get(port, "/station/operator?probe=1") + assert status == 200 + assert "Public services" in body and "Local working copies" in body + for target in ("log head", "paper", "blog", "lean-transparency-log"): + assert target in body, f"probe results missing target: {target}" + finally: + server.shutdown() + thread.join(timeout=5)