Skip to content

design: multi-surface design foundation (DESIGN.md + CLAUDE.md) - #5

Merged
hellno merged 1 commit into
mainfrom
hellno/agent-wallet-design-foundation
Jun 5, 2026
Merged

design: multi-surface design foundation (DESIGN.md + CLAUDE.md)#5
hellno merged 1 commit into
mainfrom
hellno/agent-wallet-design-foundation

Conversation

@hellno

@hellno hellno commented Jun 5, 2026

Copy link
Copy Markdown
Owner

Evolves DESIGN.md from the v0 single-screen system into a unified multi-surface design foundation for Deckard, reframed as a human-sovereign / agent-delegated wallet. Locks the design language grounded in real Linear / Conductor / Splits product screenshots (not prose descriptions): a two-signal actor model (amber = human, cyan = agent), a Conductor-style sidebar IA (Projects → Wallets + Agents) with contextual views, full color/type/spacing tokens for dark + light, every component with its states, and the clear-signing / seed-reveal trust affordances. Incorporates a full design-review pass — neutral primary buttons (amber reserved for caution / where-you-are / hold / focus), whitespace grouping, circular status glyphs, and money $/precision rules. Adds CLAUDE.md routing all future UI work to DESIGN.md and the grounding rule. No app code changes; the agent registration / MCP surface is intentionally left open and noted in DESIGN.md.

…routing

Reframe to human-sovereign / agent-delegated. Locks the design language
grounded in REAL Linear/Conductor/Splits product shots (not prose):
- two-signal actor model (amber=human, cyan=agent) + shape backup
- IA: Conductor sidebar tree (Projects -> Wallets + Agents), 2-pane,
  contextual views; Splits-style wallet rows
- color/type/spacing tokens (dark + light), components WITH states,
  trust affordances (clear-signing, seed reveal, network warning)
- review-settled rules: neutral primary buttons (amber only for
  caution/where-you-are/hold/focus), thin threshold budget bars,
  whitespace grouping, circular status glyphs, money $/precision rules

Agent registration / MCP model intentionally left open (noted in DESIGN.md).
CLAUDE.md points all UI work at DESIGN.md and the grounding rule.
@hellno
hellno merged commit 68ed4d2 into main Jun 5, 2026
2 checks passed
hellno added a commit that referenced this pull request Jun 19, 2026
…a blind-approve regression)

A second adversarial pass over the prior fix commit (5734602) caught that two of those fixes
introduced NEW bugs — exactly why fixes get the same scrutiny as features.

1. BLIND-APPROVE regression (HIGH, shell.rs). Fix #5's filter fallback turned a safe no-op into a
   real wrong-row resolve: when a review for card X is open and a background poll expires X, the
   render falls through to the feed (it's &self, can't clear activity_reviewing) with a DIFFERENT
   over-cap card Y highlighted. A one-key ⌘Enter then approved Y — whose clear-signing card was
   never shown — violating "no blind-approve of a spend" (the agent would then execute Y's overage).
   Real fix: clear `activity_reviewing` IN refresh_activity, atomically with the snapshot swap, when
   its id leaves the pending set. Now approve sees None → re-opens the highlighted row's review
   (no blind-approve), and deny targets the visible highlighted row (fail-safe, not the old no-op).

2. False-positive `expired` match (HIGH, demo-agent.sh). mcp_is_terminal_refusal grepped a BARE
   "expired", which also matches transient RPC errors ("connection expired", "session expired")
   embedded in broadcast_failed/signer_error prose — mis-classifying a transient failure as terminal
   and abandoning a still-valid card's funds. Anchored to the exact daemon copy ("this request
   expired", "a human denied this request"). Verified: terminal copies match, RPC errors don't.

3. Two-signal mismatch (MED, activity_view.rs). The amber LABEL was re-keyed to `human_acted` but
   the "→ You" actor-chain link still gated on bare `!auto_allowed`, so a STOP-revoked auto-allow
   showed the amber label with NO human in the chain. Re-keyed `needed_human` to `human_acted` too.

4. Propose-path baseline clarity (demo-agent.sh). `BASELINE_WEI=public_wei` is numerically the
   just-now propose-time balance (symmetric with try_execute's PENDING_BASE), but read as "absorb
   the whole wallet". Rewrote as `wei_add BASELINE_WEI surplus` (identical value) so the intent —
   "skip exactly the funds the daemon just refused" — is plain.

Verified: fmt --check, just check (clippy -D, default + tray), cargo test --workspace (274 pass, 0
fail), shellcheck + bash -n, and a matcher-precision simulation.
hellno added a commit that referenced this pull request Jun 19, 2026
…nner (#61) + collapse/fold redesign (#67)

* feat(signerd): surface agent over-cap proposals to the approval inbox

Extend the wire contract + daemon so the GUI approval queue can render
agent-initiated over-cap proposals, ordered and counted down by expiry.

- contract: add ProposalOrigin{App,Agent}; carry it on SignerRequest::Propose;
  append remaining_ms + origin to PendingRecord (the inbox wire shape).
- daemon: stamp each PendingReq with its origin; PendingList now calls
  expire_stale() BEFORE building records (expiry is daemon-authoritative, never
  a bypassable UI timer) and computes remaining_ms from expires_at (panic-free).
- client: thread origin through propose / propose_blocking.
- tests: new pending_list suite (origin round-trip, expire-before-list,
  remaining_ms==0 on terminal); update propose callers across the e2e suites.

* feat(app): agent approval queue + Activity ledger (MVP)

A top-level Approvals inbox and Activity ledger built on the daemon's
PendingList. Money-safety: approval ALWAYS routes through the clear-signing
review (no blind-approve from a list row); agent proposals are Resolve-only —
the app never broadcasts, the agent executes its own write once Allowed.

- approvals_view: dense soonest-to-expire queue (loading / empty / fail-loud /
  expired states) + the clear-signing review (agent band, danger-early,
  ⌘Enter approve / Esc cancel).
- activity_view: terminal records as the two-actor "Atlas → You" chain,
  honestly labelled session-scoped (in-memory).
- shell: Approvals + Activity surfaces, ⌘⇧A, a PendingList poller, and
  resolve-only approve/deny that is non-optimistic (re-fetch authoritative
  status on success, fail loud on error).
- shell_chrome: sidebar Approvals entry with an amber pending-count badge,
  plus an Activity entry.
- palette: Approvals / Activity / Approve / Deny commands. signer: resolve_blocking.

* feat(mcp): tag agent proposals + point approval copy at the queue

- sidecar: propose with ProposalOrigin::Agent so the queue attributes the
  agent (Atlas) versus a human-composed action.
- copy: the needs-approval guidance (sidecar / server / failure) no longer
  says "the approval UI is not in this alpha" — it now sends the human to the
  Deckard app's Approvals queue (⌘⇧A) + clear-signing review card.
- acceptance: assert the shield tool docs carry the "Approvals queue" keyword.
- common: ignore origin when destructuring Propose in the mock.

* feat: the first agent demo — see-and-stop Activity feed (#60) + headless runner (#61) + Superhuman collapse/fold redesign

#60 — the Activity FEED (not just an inbox): a daemon-side ActivityRecord ledger
(additive, byte-stable wire) that retains auto-allowed + executed agent actions
(tx_hash, timestamp, the ACTUAL breached cap — per-tx vs daily, daemon-computed,
no hardcoded lie), an honest auto_allowed flag (hands-free vs human-approved), and
expire-on-read. App: a Superhuman-style inbox — a "NEEDS YOU" triage band you clear
to "All clear" + a settled LOG, inline clear-signing review, a STOP control, two-signal
fidelity (muted label / glyph carries color / amber on rows you acted on), and the
shield payoff made visible ("moved X ETH to your private balance").

#61 — a self-contained headless runner (scripts/demo-agent.sh + just demo-vault/
demo-deposit/demo-agent) that drives the same 6 key-less MCP tools in a
poll -> shield -> execute loop (idempotent ratchet, over-cap re-poll, STOP/locked
detection, mainnet refusal). No new authority, no new wire. The Claude-Desktop prompt
doc stays as the narrative version.

Redesign (post-review): collapsed the redundant Approvals surface into the feed and
deleted it; deleted the demo "agent acting" toggle + breathing pulse (the real runner
replaces them); folded the "Agent" sidebar entity INTO the Wallet (Atlas is key-less
automation on the same EOA, not a separate account) — the policy fence now lives in
the wallet cockpit.

Invariants held: agent stays key-less; ONE approval path (Resolve over the control
channel, no Execute on approve, no blind-approve); STOP zeroizes + denies in-flight;
the verdict path is frozen; wire is additive + byte-stable; never signs chain_id=0.

Verification: cargo fmt clean; clippy -D warnings clean (default + tray); 245 workspace
tests; shellcheck clean. Reviewed by a multi-agent workflow + codex (static); all
confirmed findings fixed (notably the CRITICAL blind-approve path and a policy-fence
honesty bug after STOP).

* docs: first-agent-demo screenshots (driving the real app)

* fix: pin the Activity STOP control + make the demo hands-free (clear inherited EIP-7702 delegation)

UX (#2): the feed heading + STOP control are now PINNED outside the scroll region, so the
panic brake can never scroll off screen — only the body (needs-you + log) scrolls beneath it.
(The header was inside the scroll container before; in a normal-width window STOP showed, but it
could scroll away — and a safety control shouldn't.)

Demo (#4 root cause): `just demo-deposit` now clears any inherited EIP-7702 delegation on the
demo wallet before funding it. The demo wallet is anvil's PUBLIC account-0 key, and on the
forked Sepolia someone had 7702-delegated that address (its on-chain code = 0xef0100<delegate>).
A plain ETH transfer to a 7702-delegated account runs the DELEGATE contract instead of crediting
the EOA, so the balance never rose and the agent's deposit-delta detector never fired (shields,
which only SIGN, still worked — which is why it looked half-broken). Clearing it back to a plain
EOA makes the loop fully hands-free: deposit arrives -> Atlas notices -> shields -> broadcasts,
no manual MCP commands. Verified end-to-end against the live app + runner.

Plus the new STOP-flow screenshots (pinned STOP, armed "Confirm STOP", the revoked banner).

fmt + clippy (default + tray) + 245 workspace tests + shellcheck all green.

* fix(swap): tag GUI swap orders App-origin so the feed doesn't label a human swap "Atlas"

Codex/two-signal finding: a user-initiated GUI swap produced two feed rows with MISMATCHED
actors — the order row hardcoded ProposalOrigin::Agent (cyan "Atlas wants to swap…") while its
companion exact-gross approve was correctly App (amber "You…"). A foreground human action narrated
as the agent's action is exactly the trust-signal confusion the two-signal model exists to prevent.
propose_order is only ever called from the app, so every order in the feed was mislabeled; the
Swap surface is unflagged + user-reachable, so it's user-visible on Sepolia/mainnet.

Fix (symmetric to Propose, which already carries origin): SignerRequest::ProposeOrder gains an
additive `origin` field, threaded through Daemon::propose_order + SignerClient::propose_order[_blocking];
the daemon stores the caller's origin instead of a hardcoded Agent; the app's GUI swap (swap.rs +
shell.rs) passes App. Reserves Agent for a future sidecar swap path. Display-only — the verdict path
(evaluate_order) is untouched. Round-trip test extended for both origins; all call sites updated.

Verified: fmt --check, clippy -D warnings (default + tray), full workspace tests (0 failures).

* fix(activity): responsive feed width (max-w 760, shrink on narrow windows so the STOP control + row trailing cluster never clip off the right edge)

* fix: close the wrong-deny race in the activity feed + tighten the demo-deposit chain guard

Codex full-pass must-fix: refresh_activity reconciled the feed selection by RAW NUMERIC INDEX, not
by request_id. The ~2s poller can insert/remove proposed rows between renders, so a stale index
could point at a DIFFERENT pending record than the operator sees highlighted — and `x`/deny (and
the palette deny-selected) resolve the SELECTED record. Result: a deny could fire against the wrong
pending card under background churn. (Wrong-APPROVE was never reachable — approve self-guards by
opening the request_id-keyed review first.) Fix: preserve the highlight by request_id across every
refresh — capture the selected id from the old snapshot, re-key to it in the new pending subset, and
clamp only if it's gone. Corrected the now-accurate approve/deny comment (deny is one-key by design:
it only REFUSES a spend — the fail-safe direction — and now targets the highlighted record by id).

demo-deposit HIGH footgun: the chain guard only refused chain 1, so any OTHER real chain fronted at
:8545 (a forwarded Sepolia/Holesky node, a tunnel) would pass and `cast send` with a PUBLIC dev key.
Switched to an allowlist — refuse unless the RPC reports the exact demo chain id (matches the `demo`
recipe's own enforcement).

Verified: fmt --check, clippy -D warnings (default + tray), full workspace tests (0 failures), shellcheck.

* fix: STOP-cancel expired-but-settleable swap orders + redesign the demo runner + amber-honesty

Three codex-pass follow-ups, all approved as fix-now.

1. STOP-integrity hole (HIGH, swap path). `stop()` skipped the on-chain `invalidateOrder` for any
   order whose LOCAL approval record had lapsed to `ApprovalStatus::Expired` (the 120s approval
   TTL). But a signed order is loose on the CoW orderbook and settleable until its REAL `valid_to`
   (up to 24h) — those are different clocks. So a signed order could still settle AFTER STOP. Fix:
   gate STOP's cancel on the order's `valid_to`, never the local `Expired` status; skip only
   genuinely-unsettleable (`valid_to` in the past) orders. Extracted the selection into a pure
   `select_orders_to_cancel(requests, now)` with unit tests (a locally-Expired-but-settleable order
   IS selected; unsigned / already-cancelled / past-valid_to / plain-Tx are skipped).

2. Demo runner redesign (HIGH+LOW, scripts/demo-agent.sh). The loop tracked deposits with a single
   high-water mark it ratcheted BOTH up (on deposit) and down (after a shield settled). That
   dual-direction scalar raced the settlement: a deposit landing in the ~2s window between a
   broadcast and the balance dropping got absorbed by the downward rebaseline and lost (and an
   unexpected shield reply orphaned a deposit). Replaced with a FIXED baseline set once at startup
   — work each poll is just (public - baseline), new proposals gated on "nothing in flight", and the
   daemon's deterministic request_id is the idempotency authority (re-propose returns the same
   record as-is). No ratchet, no race, less code. Aligned docs/build/32-agent-loop-prompt.md to the
   same model.

3. Amber-honesty (MEDIUM, the feed). A lapsed card mapped to `Decided{approved:false}`, so the feed
   tinted it amber "you acted" / "not approved" even though NOBODY acted. Split out a new additive
   `ActivityLifecycle::Expired` (roundtrip-tested): a human denial / STOP revoke stay
   `Decided{false}` (a human DID act → amber is honest); a lapsed window renders neutral (muted
   "expired" label + a neutral dash glyph, never amber). The two-signal "amber = a human acted"
   promise now holds.

Verified: cargo fmt --check, just check (clippy -D warnings, default + tray), cargo test --workspace
(272 passed, 0 failed), shellcheck + bash -n on demo-agent.sh.

* fix: the 5 confirmed bugs /code-review found in this branch's new code

A high-recall /code-review pass (7 angles → verify) over our authored #60/#61 + fixes diff surfaced
five real, reachable bugs — every one in code added this session. All fixed, with regression tests.

1. Runner wedges forever (HIGH, scripts/demo-agent.sh). try_execute() only dropped a saved id on
   broadcast / STOP / user_denied; an `expired` (120s approval-TTL lapse) fell through to "keep
   waiting", so the id stuck in PENDING_IDS — and the redesign's `pending_count == 0` gate then
   blocked EVERY future shield. Same for a re-proposed user_denied card (propose path treated it as
   transient → infinite "retry" spam). Fix: classify the daemon's terminal verdicts
   (mcp_is_terminal_refusal = expired | a human denied | user_denied) in BOTH try_execute and the
   propose-failure handler — drop the id AND advance the baseline past the refused funds (recorded
   per-id in a new PENDING_BASE array), so the loop gives up on dead funds and keeps watching for
   new ones. Aligned docs/build/32-agent-loop-prompt.md (it had the same expired-handling gap).

2. `--once` smoke shields the whole wallet (MED, justfile). Pinning baseline=0 against the prefunded
   demo wallet (anvil acct 0 holds ~10 ETH on the fork) treated the whole balance as surplus → over
   the per-tx cap → no broadcast → exit 1. Added `just demo-smoke`: captures the PRE-deposit
   balance, deposits, then runs --once pinned to that baseline so it shields exactly the deposit.
   The bare --once no longer defaults baseline to 0 (reads live balance = safe no-op).

3. Approved-then-lapsed erased the amber signal (MED, daemon.rs). A human-approved over-cap card
   that lapsed before execute mapped to ActivityLifecycle::Expired (neutral) — erasing that a human
   acted. Fix: activity_lifecycle now maps `Expired if req.approved` → Decided{approved:true}.

4. STOP-revoke of an auto-allow showed muted, not amber (MED, activity_view.rs). The amber tint
   keyed on bare `!auto_allowed`, but lock() leaves auto_allowed=true on a STOP-revoked row. A STOP
   is a human action → must read amber. Extracted a tested `human_acted` helper (any
   Decided{approved:false} OR !auto_allowed) and keyed the tint on it.

5. Stale activity_reviewing → dropped refusal (MED, shell.rs). resolve_activity_target preferred a
   set `activity_reviewing` even after a poll settled that record and the render fell through to the
   feed, so the first `x` resolved a stale off-screen id (a silent no-op) instead of the highlighted
   row. Fix: honor `activity_reviewing` only while its id is still in the pending subset, else fall
   back to the highlighted row.

Verified: fmt --check, just check (clippy -D, default + tray), cargo test --workspace (274 pass, 0
fail — +2 regression tests), shellcheck + bash -n.

* fix: bugs the fix-verification pass found IN the review fixes (incl. a blind-approve regression)

A second adversarial pass over the prior fix commit (5734602) caught that two of those fixes
introduced NEW bugs — exactly why fixes get the same scrutiny as features.

1. BLIND-APPROVE regression (HIGH, shell.rs). Fix #5's filter fallback turned a safe no-op into a
   real wrong-row resolve: when a review for card X is open and a background poll expires X, the
   render falls through to the feed (it's &self, can't clear activity_reviewing) with a DIFFERENT
   over-cap card Y highlighted. A one-key ⌘Enter then approved Y — whose clear-signing card was
   never shown — violating "no blind-approve of a spend" (the agent would then execute Y's overage).
   Real fix: clear `activity_reviewing` IN refresh_activity, atomically with the snapshot swap, when
   its id leaves the pending set. Now approve sees None → re-opens the highlighted row's review
   (no blind-approve), and deny targets the visible highlighted row (fail-safe, not the old no-op).

2. False-positive `expired` match (HIGH, demo-agent.sh). mcp_is_terminal_refusal grepped a BARE
   "expired", which also matches transient RPC errors ("connection expired", "session expired")
   embedded in broadcast_failed/signer_error prose — mis-classifying a transient failure as terminal
   and abandoning a still-valid card's funds. Anchored to the exact daemon copy ("this request
   expired", "a human denied this request"). Verified: terminal copies match, RPC errors don't.

3. Two-signal mismatch (MED, activity_view.rs). The amber LABEL was re-keyed to `human_acted` but
   the "→ You" actor-chain link still gated on bare `!auto_allowed`, so a STOP-revoked auto-allow
   showed the amber label with NO human in the chain. Re-keyed `needed_human` to `human_acted` too.

4. Propose-path baseline clarity (demo-agent.sh). `BASELINE_WEI=public_wei` is numerically the
   just-now propose-time balance (symmetric with try_execute's PENDING_BASE), but read as "absorb
   the whole wallet". Rewrote as `wei_add BASELINE_WEI surplus` (identical value) so the intent —
   "skip exactly the funds the daemon just refused" — is plain.

Verified: fmt --check, just check (clippy -D, default + tray), cargo test --workspace (274 pass, 0
fail), shellcheck + bash -n, and a matcher-precision simulation.

* fix: make a blind-approve structurally impossible (3rd entry point the verify loop found)

Round-3 verification found a THIRD path to the same blind-approve hazard the round-2 fix only
partially closed: `review_activity_row` (the proposed-row click handler) set `activity_reviewing`
UNCONDITIONALLY, so a click landing one frame after a background poll settled that row left a stale
review id with no refresh before the next ⌘Enter — and `approve_activity`'s `.or_else(highlighted)`
fallback then resolved a DIFFERENT highlighted over-cap row whose clear-signing card was never shown.

Whack-a-mole on entry points won't converge, so this is the DEEP fix: approve now resolves ONLY the
actively-reviewed, STILL-PENDING record — it has NO highlighted-row fallback. The decision is a pure
`approve_target(reviewing, pending)` helper (returns the id to resolve, or None = "open a review
first, resolve nothing"), so a blind-approve of an unreviewed spend is structurally impossible no
matter how `activity_reviewing` went stale (poll settle, raced click, anything). Deny keeps its
one-key highlighted-row fallback (it only REFUSES — the fail-safe direction).

Also hardened `review_activity_row` to set `activity_reviewing` only when the clicked row is still
pending (no phantom review for a just-settled row). The round-2 refresh-time clear stays as state
hygiene but is no longer load-bearing for the invariant.

New unit test `approve_target_never_falls_back_to_the_highlighted_row` pins the guarantee: a
reviewed id that has left the pending set resolves NOTHING (never the other highlighted row).

Verified: fmt --check, just check (clippy -D, default + tray), cargo test --workspace (275 pass, 0
fail), shellcheck.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant