diff --git a/crates/deckard-app/src/activity_view.rs b/crates/deckard-app/src/activity_view.rs index 2cb2350..1bb14f0 100644 --- a/crates/deckard-app/src/activity_view.rs +++ b/crates/deckard-app/src/activity_view.rs @@ -8,11 +8,14 @@ //! allowed within-cap shield executes immediately and never enters the queue, so the daemon must //! record what the agent *did*"). //! -//! **Inbox + log split.** Still-proposed rows (the things waiting on you) live ONLY in the NEEDS -//! YOU band — the inbox you triage — and never duplicate into the log. Everything settled (auto- -//! allowed, approved, denied, executed) falls to the day-grouped LOG. A pending row is selectable -//! and inline-approvable (select + Enter opens the clear-signing review, ⌘Enter approves, `x` -//! denies, all scoped to this surface's `key_context("Activity")`). +//! **Inbox + log split (#216).** The two halves read TWO sources. The NEEDS YOU band — the inbox +//! you triage — reads the daemon's **uncapped `PendingList`** (`Shell::inbox`), so an old still- +//! `Pending` approval shed from the 200-capped feed stays counted, selectable and approvable here. +//! The settled LOG (auto-allowed, approved, denied, executed) reads the capped `ActivityFeed` +//! (`Shell::activity`), correctly bounded for a log; `activity_settled` filters the feed's own +//! still-proposed rows out so a pending row never duplicates across the two. A pending row is +//! selectable and inline-approvable (select + Enter opens the clear-signing review, ⌘Enter +//! approves, `x` denies, all scoped to this surface's `key_context("Activity")`). //! //! **Two-signal fidelity (DESIGN §The actor model).** State is a small circular **glyph** that //! carries the color (green check = approved/executed, red x = denied/revoked); the outcome @@ -29,9 +32,10 @@ //! daily, never a hardcoded cite). A header STOP control is the always-reachable panic brake. //! //! Render is `&self`; mutation flows through the `cx.listener` closures (open review / approve / -//! deny / STOP arm+confirm). The surface reads `self.activity`, `self.activity_selected`, -//! `self.activity_reviewing`, `self.activity_loading`, `self.activity_error`, -//! `self.activity_stop_arming`, `self.activity_stopped`, and `self.mask`. +//! deny / STOP arm+confirm). The surface reads `self.inbox` (the NEEDS YOU source), `self.activity` +//! (the settled LOG source), `self.activity_selected`, `self.activity_reviewing`, +//! `self.activity_loading`, `self.activity_error`, `self.activity_stop_arming`, +//! `self.activity_stopped`, and `self.mask`. use std::time::{SystemTime, UNIX_EPOCH}; @@ -48,9 +52,9 @@ use gpui_component::{ }; use deckard_contract::{ - ActivityLifecycle, ActivityRecord, ApprovalRisk, BreachedLimit, Intent, IntentKind, - MessageSigningRisk, PendingPayloadView, ProposalOrigin, RequestId, SignMessage, - SignMessageKind, + ActivityLifecycle, ActivityRecord, ApprovalRisk, ApprovalStatus, BreachedLimit, Intent, + IntentKind, MessageSigningRisk, PendingPayloadView, PendingRecord, ProposalOrigin, RequestId, + SignMessage, SignMessageKind, }; use crate::money::money; @@ -287,10 +291,17 @@ fn is_proposed(record: &ActivityRecord) -> bool { matches!(record.lifecycle, ActivityLifecycle::Proposed) } -/// **The approvable subset** of the feed (the NEEDS YOU inbox), in feed (newest-first) order. -/// `activity_selected` indexes into this; only these rows are selectable/approvable. -pub(crate) fn activity_pending(records: &[ActivityRecord]) -> Vec<&ActivityRecord> { - records.iter().filter(|r| is_proposed(r)).collect() +/// **The approvable inbox** (the NEEDS YOU band), in `PendingList` order. Reads the daemon's +/// UNCAPPED pending snapshot (`Shell::inbox`), so an old still-`Pending` approval shed from the +/// 200-capped feed stays reachable here (#216); `activity_selected` indexes into this, and only +/// these rows are selectable/approvable. The daemon expires stale rows before listing, so a +/// `Pending` here is genuinely inside its approval window — the same guarantee `pending_waiting` +/// (the off-surface cue) relies on. +pub(crate) fn inbox_pending(records: &[PendingRecord]) -> Vec<&PendingRecord> { + records + .iter() + .filter(|r| matches!(r.status, ApprovalStatus::Pending)) + .collect() } /// The record id that **approve** (⌘Enter) is allowed to resolve: ONLY a still-pending, @@ -303,14 +314,15 @@ pub(crate) fn activity_pending(records: &[ActivityRecord]) -> Vec<&ActivityRecor /// refuses, the fail-safe direction). pub(crate) fn approve_target( reviewing: Option, - pending: &[&ActivityRecord], + pending: &[&PendingRecord], ) -> Option { reviewing.filter(|id| pending.iter().any(|r| r.request_id == *id)) } /// **The settled subset** of the feed (the LOG), in feed (newest-first) order — every row that is -/// no longer waiting on a human (auto-allowed, approved, denied, executed). A proposed row lives -/// ONLY in `activity_pending`, so the two subsets partition the feed with no duplication. +/// no longer waiting on a human (auto-allowed, approved, denied, executed). This drops the feed's +/// own still-proposed rows, which are triaged in the NEEDS YOU band from the uncapped inbox +/// ([`inbox_pending`], #216) — so a proposed row is never duplicated into the log. fn activity_settled(records: &[ActivityRecord]) -> Vec<&ActivityRecord> { records.iter().filter(|r| !is_proposed(r)).collect() } @@ -334,14 +346,14 @@ fn activity_feed_groups<'a>( } impl Shell { - /// The Activity surface: dispatch to the inline clear-signing review when one is open (and - /// its record is still proposed in the latest snapshot), otherwise the feed. + /// The Activity surface: dispatch to the inline clear-signing review when one is open (and its + /// record is still `Pending` in the latest INBOX snapshot, #216), otherwise the feed. pub fn render_activity(&self, cx: &mut Context) -> impl IntoElement { if let Some(reviewing) = self.activity_reviewing { if let Some(record) = self - .activity + .inbox .iter() - .find(|r| r.request_id == reviewing && is_proposed(r)) + .find(|r| r.request_id == reviewing && matches!(r.status, ApprovalStatus::Pending)) { return self.render_activity_review(record, cx).into_any_element(); } @@ -373,7 +385,7 @@ impl Shell { format!("Can't reach the signer. {err}"), ) } else { - let pending = activity_pending(&self.activity); + let pending = inbox_pending(&self.inbox); let settled = activity_settled(&self.activity); if pending.is_empty() && settled.is_empty() { // Empty: one calm muted line, no illustration (DESIGN §Required states → Empty). @@ -390,7 +402,7 @@ impl Shell { // selectable/inline-approvable. Its row index IS the pending-subset index, so the // selected lift and the keyboard `activity_selected` line up. if !pending.is_empty() { - list = list.child(self.activity_needs_you(&pending, selected_id, now, cx)); + list = list.child(self.activity_needs_you(&pending, selected_id, cx)); } else { // Nothing waiting, but there is history: a quiet "you're caught up" line above // the log (no Needs-you band). @@ -489,21 +501,22 @@ impl Shell { } } - /// The **NEEDS YOU** band: the triage inbox of still-proposed rows over a quiet uppercase - /// header. The row index IS the pending-subset index, so the selected lift agrees with the - /// keyboard `activity_selected`; `selected_id` is the highlighted pending row. + /// The **NEEDS YOU** band: the triage inbox of still-`Pending` rows from the uncapped inbox + /// (#216) over a quiet uppercase header. The row index IS the inbox-subset index, so the + /// selected lift agrees with the keyboard `activity_selected`; `selected_id` is the highlighted + /// pending row. Renders each `&PendingRecord` via [`Self::activity_pending_row`] (never the + /// feed's `activity_row`, which is the LOG's settled-`ActivityRecord` renderer). fn activity_needs_you( &self, - rows: &[&ActivityRecord], + rows: &[&PendingRecord], selected_id: Option, - now: u64, cx: &mut Context, ) -> impl IntoElement { let amber = theme::amber(cx.theme().is_dark()); let mut band = v_flex().w_full(); for record in rows.iter() { - band = band.child(self.activity_row("needs-you-row", record, selected_id, now, cx)); + band = band.child(self.activity_pending_row(record, selected_id, cx)); } // The "Needs you" band label reads in AMBER — the human-attention signal (DESIGN §the @@ -522,6 +535,172 @@ impl Shell { .child(band) } + /// One **NEEDS YOU** inbox row — a still-`Pending` request from the uncapped inbox (#216), + /// rendered as the proposed two-actor chain: the actor mark + subject + payload summary, the + /// "→ You waiting" human link (for an agent/dapp request), then the trailing over-cap cite + /// chip (the ACTUAL breached fence, never hardcoded) with the `⌘⏎ approve · x deny` hint on the + /// SELECTED row plus a hover-only "Review →" for the mouse. A pending row is ALWAYS the waiting + /// state, so it reads only `.payload`/`.origin`/`.reason` — never a lifecycle/tx_hash/auto- + /// allowed (those live on the settled LOG's `ActivityRecord`). Click opens the inline review; + /// the keyboard j/k/Enter also reach it. This mirrors the proposed path of the log's + /// `activity_row` pixel-for-pixel, just sourced from a `&PendingRecord`. + fn activity_pending_row( + &self, + record: &PendingRecord, + selected_id: Option, + cx: &mut Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let lift = theme.secondary; + let hairline = theme.border; + let is_dark = theme.is_dark(); + let agent = theme::agent(is_dark); + let agent_tint = theme::agent_tint(is_dark); + let amber = theme::amber(is_dark); + + let is_agent = record.origin == ProposalOrigin::Agent; + // An agent OR a dapp (#198) initiated — the "→ You waiting" human link applies to both: + // when a card is raised, YOU are the second actor in the chain regardless of who asked. + let third_party = !matches!(record.origin, ProposalOrigin::App); + let agent_handle = self.agent_handle(); + let selected = selected_id == Some(record.request_id); + let summary = payload_summary(&record.payload, self.mask); + + // The lead glyph: the cyan agent mark (handle-seeded) for an agent, a neutral identity + // square for an app action — both static. + let lead = if is_agent { + agent_mark( + &agent_handle, + crate::tokens::MARK_MD, + crate::tokens::RADIUS_ROW, + agent, + agent_tint, + ) + } else { + div() + .size(crate::tokens::MARK_MD) + .rounded(crate::tokens::RADIUS_ROW) + .bg(theme::identity_square(is_dark)) + .into_any_element() + }; + + let mut chain = h_flex() + .items_center() + .gap_2p5() + .min_w_0() + .flex_1() + .child(lead) + .child( + // A dapp subject is a verbatim origin string (#198) and can be long — let it + // shrink + ellipsize so it can never push the trailing rail off the pane. + div() + .min_w_0() + .truncate() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(fg) + .child(origin_subject(&record.origin, &agent_handle).to_string()), + ) + .child(div().flex_shrink_0().text_sm().text_color(muted).child("·")) + // The verb + object grows and clamps: it takes the flex space and ellipsizes so a long + // summary can never push the trailing rail off the pane. + .child( + div() + .min_w_0() + .flex_1() + .truncate() + .text_sm() + .text_color(fg) + .child(summary), + ); + + // A pending row is by definition waiting on you, so a third-party request always shows the + // "→ You waiting" link (an App-origin pending request stays neutral — no human-link claim). + if third_party { + chain = chain + .child(div().flex_shrink_0().text_sm().text_color(muted).child("→")) + .child( + div() + .flex_shrink_0() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(fg) + .child("You"), + ) + .child( + div() + .flex_shrink_0() + .text_sm() + .text_color(muted) + .child("waiting"), + ); + } + + // The trailing outcome cluster: the amber breached-fence cite (the real cap, never + // hardcoded) + the `⌘⏎ · x` hint on the SELECTED row (the others stay quiet), matching the + // log's `activity_outcome` Proposed arm exactly. + let cite = cite_phrase(record.reason).unwrap_or("held for approval"); + let mut outcome = h_flex().items_center().gap_2p5().flex_shrink_0().child( + h_flex() + .items_center() + .gap_1() + .child(Icon::new(IconName::TriangleAlert).text_color(amber).small()) + .child(div().text_xs().text_color(fg).child(cite)), + ); + if selected { + outcome = outcome.child( + div() + .text_xs() + .text_color(muted) + .child("⌘⏎ approve · x deny"), + ); + } + + // The trailing side: the outcome cluster, plus a hover-revealed "Review →" so a MOUSE user + // discovers the review card (the keyboard path is unchanged). Key the row element id + + // hover group on the request_id (unique), so rows never collide. + let group = format!("needs-you-row-{:x}", record.request_id); + let trailing = h_flex() + .items_center() + .gap_3() + .flex_shrink_0() + .child(outcome) + .child( + div() + .text_xs() + .text_color(muted) + .opacity(0.0) + .group_hover(group.clone(), |s| s.opacity(1.0)) + .child("Review →"), + ); + + // Editorial row: hairline-separated and dense (DESIGN §Visual language). A click opens its + // inline review (the queue's keyboard-first j/k/Enter also reach it); the selected row lifts + // with a subtle fill. + let id = record.request_id; + let mut row = h_flex() + .id(gpui::SharedString::from(group.clone())) + .group(group) + .w_full() + .items_center() + .justify_between() + .gap_3() + .px_1() + .py_2p5() + .border_b_1() + .border_color(hairline) + .child(chain) + .child(trailing) + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| this.review_activity_row(id, cx))); + if selected { + row = row.bg(lift).rounded(crate::tokens::RADIUS_ROW); + } + row + } + /// One day-band of the settled LOG: a quiet uppercase header over its dense (passive) rows. /// Log rows are never proposed, so `selected_id` is always `None` here. fn activity_group( @@ -837,7 +1016,7 @@ impl Shell { } } - /// The clear-signing review for a single proposed feed row — the ONE shared Review (DESIGN + /// The clear-signing review for a single still-`Pending` inbox row (#216) — the ONE shared Review (DESIGN /// §Clear-signing), NOT a divergent boxed card. Only the request-origin rail changes: an agent /// proposal shows the cyan ` proposes`, a dapp message its neutral ` requests`, /// otherwise `You are …`. A Tx renders the same transaction-as-hero (amount + full `To`) as the @@ -849,7 +1028,7 @@ impl Shell { /// the still-pending record this card renders. pub fn render_activity_review( &self, - record: &ActivityRecord, + record: &PendingRecord, cx: &mut Context, ) -> impl IntoElement { let theme = cx.theme(); @@ -1010,7 +1189,7 @@ impl Shell { /// fence (the real cap from the record). fn activity_review_detail_rows( &self, - record: &ActivityRecord, + record: &PendingRecord, cx: &mut Context, ) -> Vec { let theme = cx.theme(); @@ -1521,12 +1700,13 @@ fn activity_shell(inner: gpui::AnyElement) -> impl IntoElement { impl Shell { /// The Activity rail's title + body, contextual to what's in focus: the selected pending /// request's compact clear-signing, else the most-recent broadcast transaction's receipt, else a - /// quiet empty state. `activity_selected` indexes the pending subset ([`activity_pending`]), so a - /// highlighted row is always the one detailed — never "Nothing selected." while a row is selected. + /// quiet empty state. `activity_selected` indexes the uncapped inbox subset ([`inbox_pending`], + /// #216), so a highlighted row is always the one detailed — never "Nothing selected." while a + /// row is selected, and never a phantom miss for a request shed from the 200-capped feed. pub(crate) fn activity_rail(&self, cx: &mut Context) -> (&'static str, AnyElement) { let theme = cx.theme(); let handle = self.agent_handle(); - let pending = activity_pending(&self.activity); + let pending = inbox_pending(&self.inbox); // The rail matches the on-screen review: when one is open (`activity_reviewing`) and its // record is still pending, detail THAT request; otherwise the highlighted pending row. So an // open review of X never shows a different row's clear-signing beside it. @@ -1584,9 +1764,10 @@ fn action_word(payload: &PendingPayloadView) -> &'static str { /// The selected pending request as **compact clear-signing** (DESIGN §The request-origin model): the /// origin header (agent = cyan, you = amber) + the verb/amount + the fence that's holding it + the /// irreversible caution. A read-only echo of the feed's review — the arm-delay confirm + resolve -/// stay on the feed, so the rail can never blind-approve. +/// stay on the feed, so the rail can never blind-approve. Reads a `&PendingRecord` from the uncapped +/// inbox (#216): only `.origin`/`.payload`/`.reason`, all present on a pending row. fn request_rail_body( - rec: &ActivityRecord, + rec: &PendingRecord, agent_handle: &str, mask: bool, theme: &Theme, @@ -1838,6 +2019,20 @@ mod tests { }) } + /// An inbox `PendingRecord` (the NEEDS YOU source, #216) — the sibling of `record` for the + /// approvable-subset tests. A pending row carries no lifecycle/tx_hash/auto_allowed. + fn pending_rec(id: u8, status: ApprovalStatus) -> PendingRecord { + PendingRecord { + request_id: B256::repeat_byte(id), + status, + payload: tx_payload(), + remaining_ms: 90_000, + origin: ProposalOrigin::Agent, + reason: BreachedLimit::None, + timestamp_ms: 1_700_000_000_000, + } + } + /// #198 trust invariant: a Dapp-origin Tx renders the origin string VERBATIM on the review /// rail — never the "You are sending" treatment, and no prettifying (the bridge's literal /// `unknown-origin` fallback reads as exactly that). @@ -1962,31 +2157,25 @@ mod tests { } #[test] - fn activity_pending_keeps_only_proposed() { + fn inbox_pending_keeps_only_pending() { + // The NEEDS YOU band reads the uncapped inbox (#216): only still-`Pending` rows are + // approvable; an `Allowed`/`Denied` row has left the human's hands and drops out. let records = vec![ - record( - 0x01, - ActivityLifecycle::Proposed, - BreachedLimit::PerTxCap, - false, - ), - record(0x02, ActivityLifecycle::Executed, BreachedLimit::None, true), - record( + pending_rec(0x01, ApprovalStatus::Pending), + pending_rec(0x02, ApprovalStatus::Allowed), + pending_rec( 0x03, - ActivityLifecycle::Decided { approved: true }, - BreachedLimit::None, - true, - ), - record( - 0x04, - ActivityLifecycle::Proposed, - BreachedLimit::DailyCap, - false, + ApprovalStatus::Denied { + reason: "user_denied".into(), + }, ), + pending_rec(0x04, ApprovalStatus::Pending), ]; - let pending = activity_pending(&records); - assert_eq!(pending.len(), 2, "only proposed rows are approvable"); - assert!(pending.iter().all(|r| is_proposed(r))); + let inbox = inbox_pending(&records); + assert_eq!(inbox.len(), 2, "only still-Pending rows are approvable"); + assert!(inbox + .iter() + .all(|r| matches!(r.status, ApprovalStatus::Pending))); } #[test] @@ -2073,20 +2262,11 @@ mod tests { #[test] fn approve_target_never_falls_back_to_the_highlighted_row() { - // The no-blind-approve guarantee: approve may resolve ONLY a still-pending reviewed record. - let a = record( - 0x0A, - ActivityLifecycle::Proposed, - BreachedLimit::PerTxCap, - false, - ); - let b = record( - 0x0B, - ActivityLifecycle::Proposed, - BreachedLimit::PerTxCap, - false, - ); - let both: Vec<&ActivityRecord> = vec![&a, &b]; + // The no-blind-approve guarantee: approve may resolve ONLY a still-Pending reviewed record. + // The subset is now the uncapped inbox (#216), but the invariant is verbatim. + let a = pending_rec(0x0A, ApprovalStatus::Pending); + let b = pending_rec(0x0B, ApprovalStatus::Pending); + let both: Vec<&PendingRecord> = vec![&a, &b]; // Reviewing a still-pending row → resolve exactly it. assert_eq!( @@ -2097,7 +2277,7 @@ mod tests { // Reviewing a row that has LEFT the pending set (settled/expired, or a stale click) → None. // It must NOT fall back to the other highlighted pending row B — that would blind-approve a // spend whose clear-signing card was never shown. - let only_b: Vec<&ActivityRecord> = vec![&b]; + let only_b: Vec<&PendingRecord> = vec![&b]; assert_eq!( approve_target(Some(a.request_id), &only_b), None, @@ -2111,30 +2291,20 @@ mod tests { #[test] fn routed_agent_review_still_cannot_blind_approve_a_settled_record() { // #185 regression: the agent-approval now renders through the ONE shared Review, but the - // no-blind-approve guard is UNCHANGED — approve resolves ONLY a still-pending reviewed + // no-blind-approve guard is UNCHANGED — approve resolves ONLY a still-Pending reviewed // record. If the reviewed agent Tx settles under a background poll while its review is open, - // it leaves the pending set, so `approve_target` returns None and ⌘Enter re-opens a review - // rather than resolving blind. `record()` builds Agent-origin Tx records — exactly the - // payload now routed through the shared review. + // it leaves the inbox's Pending set, so `approve_target` returns None and ⌘Enter re-opens a + // review rather than resolving blind. `pending_rec()` builds Agent-origin Tx inbox records + // — exactly the payload now routed through the shared review (#216). let reviewed_id = B256::repeat_byte(0x0A); let other_id = B256::repeat_byte(0x0B); - // The feed after a poll settled the reviewed row: 0x0A still EXISTS but as a settled row, - // while a different row 0x0B is still pending. - let feed = vec![ - record( - 0x0A, - ActivityLifecycle::Decided { approved: true }, - BreachedLimit::PerTxCap, - true, - ), - record( - 0x0B, - ActivityLifecycle::Proposed, - BreachedLimit::DailyCap, - false, - ), + // The inbox after a poll settled the reviewed row: 0x0A still EXISTS but as an Allowed + // (settled) record that `inbox_pending` drops, while a different row 0x0B is still Pending. + let inbox = vec![ + pending_rec(0x0A, ApprovalStatus::Allowed), + pending_rec(0x0B, ApprovalStatus::Pending), ]; - let pending = activity_pending(&feed); + let pending = inbox_pending(&inbox); // Reviewing the now-settled 0x0A → resolve NOTHING, and never the still-pending 0x0B. assert_eq!( approve_target(Some(reviewed_id), &pending), @@ -2210,8 +2380,10 @@ mod tests { } #[test] - fn pending_and_settled_partition_the_feed() { - // A proposed row appears ONLY in NEEDS YOU (pending); never duplicated into the LOG. + fn settled_excludes_proposed_from_the_log() { + // The feed (the LOG source) still RETAINS proposed rows daemon-side (capped at 200), so + // `activity_settled` must exclude them: a proposed row is triaged in the NEEDS YOU band, + // sourced from the UNCAPPED inbox (#216), and never duplicated into the day-grouped log. let records = vec![ record( 0x01, @@ -2227,17 +2399,12 @@ mod tests { false, ), ]; - let pending = activity_pending(&records); let settled = activity_settled(&records); - assert_eq!(pending.len(), 1, "only the proposed row needs you"); - assert_eq!(settled.len(), 2, "the rest fall to the log"); - // No request_id appears in both subsets. - for p in &pending { - assert!( - !settled.iter().any(|s| s.request_id == p.request_id), - "a pending row must never duplicate into the log" - ); - } + assert_eq!(settled.len(), 2, "the proposed row is not in the log"); + assert!( + settled.iter().all(|r| !is_proposed(r)), + "a proposed feed row must never fall into the settled log" + ); } #[test] diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index 27d3fb5..3dde1ad 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -205,10 +205,17 @@ pub struct Shell { // --- activity feed (#60: the see-and-stop ledger) --- /// The latest `ActivityFeed` snapshot — every tracked action (auto-allowed, pending, denied, /// executed), newest-first, with `tx_hash`/`timestamp_ms`/breached-cap. The Activity surface - /// renders this; refreshed by `refresh_activity` + the poller. It is the feed's sole source — - /// the still-proposed rows form the "NEEDS YOU" band (the inline triage queue), and the feed - /// also retains executed/auto-allowed rows the pending-only view would drop. + /// renders this as the settled LOG; refreshed by `refresh_activity` + the poller. The feed + /// retains executed/auto-allowed rows a pending-only view would drop — but it is capped at the + /// newest 200 rows daemon-side, so it is NO LONGER the source for the "NEEDS YOU" band (that is + /// the uncapped `inbox` below, #216); `activity_settled` filters the still-proposed rows out. pub activity: Vec, + /// The latest uncapped `PendingList` snapshot — the sibling of `activity`, fetched in the SAME + /// `refresh_activity` cycle. This is the SOLE source for the "NEEDS YOU" triage band (#216): the + /// still-`Pending` rows here form the inbox the operator approves/denies, and the on-Activity + /// `waiting_count` counts them. Uncapped, so an old approval shed from the 200-capped feed stays + /// counted, selectable, and approvable on the very surface built to triage it. + pub inbox: Vec, /// The highlighted feed row, 0-based into the APPROVABLE (still-proposed) subset of /// `activity` — clamped on every refresh so it never points past a now-shorter list. j/k move /// it; the feed renders all rows but only proposed ones are selectable/approvable. @@ -700,6 +707,7 @@ impl Shell { mask, agent_policy: None, activity: Vec::new(), + inbox: Vec::new(), activity_selected: 0, activity_reviewing: None, activity_loading: false, @@ -967,10 +975,10 @@ impl Shell { self.pending_poll_task = None; self.polled_pending = None; self.pending_poll_inflight = false; - // Supersede any in-flight `ActivityFeed` reply: without this, a fetch started just before - // the lock could land afterwards and re-install a dead session's feed snapshot into - // `self.activity` — which off-surface `waiting_count` falls back to while `polled_pending` - // is None, resurrecting the count this lock just cleared. + // Supersede any in-flight `refresh_activity` reply: without this, a fetch started just + // before the lock could land afterwards and re-install a dead session's snapshot into + // `self.activity` / `self.inbox` (#216) — and off-surface `waiting_count` falls back to the + // inbox while `polled_pending` is None, resurrecting the count this lock just cleared. self.activity_epoch = self.activity_epoch.wrapping_add(1); // Dropping the handle closes its channel → the sync worker thread exits. self.shielded = None; @@ -996,14 +1004,16 @@ impl Shell { self.activity_stopped = false; self.activity_stop_arming = false; self.activity_reviewing = None; - // Drop the feed snapshot too (#200): off-Activity `waiting_count` falls back to this list - // when `polled_pending` is None (the window before a session's first background poll). If - // it survived the lock, re-unlocking a wallet that had pending rows last session would - // flash a phantom amber "N waiting for you" until the first poll lands. Cleared here, the - // fallback reads 0 (caught up) — the safe direction. This path never renders the feed (a - // plain lock returns to the unlock gate; the STOP feed goes through `stop_revoke_all`, not - // `lock`), so clearing it has no visible downside. + // Drop BOTH snapshots too (#200, #216): the LOG feed AND the uncapped inbox, which is now + // the source off-Activity `waiting_count` falls back to when `polled_pending` is None (the + // window before a session's first background poll). If the inbox survived the lock, re- + // unlocking a wallet that had pending rows last session would flash a phantom amber "N + // waiting for you" until the first poll lands. Cleared here, the fallback reads 0 (caught + // up) — the safe direction. This path never renders the feed (a plain lock returns to the + // unlock gate; the STOP feed goes through `stop_revoke_all`, not `lock`), so clearing has no + // visible downside. self.activity = Vec::new(); + self.inbox = Vec::new(); self.activity_selected = 0; self.auth = AuthStep::Unlock; self.palette_open = false; @@ -2646,49 +2656,59 @@ impl Shell { self.start_activity_poller(cx); } - /// Fetch the latest `ActivityFeed` off the UI thread and fold it into `self.activity`, - /// epoch-guarded (a slow reply for a superseded fetch can't clobber a newer snapshot) and - /// clamping `activity_selected` to the new approvable-row count. This is the feed's sole - /// source — the "NEEDS YOU" band derives its rows from `activity`, never a separate list. + /// Fetch the settled `ActivityFeed` (the LOG) **and** the uncapped `PendingList` (the NEEDS YOU + /// inbox) off the UI thread in ONE cycle, folding them into `self.activity` and `self.inbox` + /// under the same epoch guard (a slow reply for a superseded fetch can't clobber a newer + /// snapshot) and clamping `activity_selected` to the new approvable-row count. The two sources + /// split cleanly (#216): the LOG reads the 200-capped feed (a log is bounded, correctly), the + /// INBOX reads the uncapped pending list — so an old still-`Pending` approval shed from the feed + /// stays counted, selectable, and approvable on the very surface built to triage it. pub fn refresh_activity(&mut self, cx: &mut Context) { self.activity_epoch = self.activity_epoch.wrapping_add(1); let epoch = self.activity_epoch; self.activity_loading = true; cx.notify(); let client = self.signer.client(); - let task = cx.background_spawn(async move { client.activity_feed_blocking() }); + // One background round-trip fetches BOTH the settled feed (LOG) and the uncapped pending + // list (INBOX) — sequential on the same client — so the band + log always install together. + let task = cx.background_spawn(async move { + let feed = client.activity_feed_blocking(); + let inbox = client.pending_list_blocking(); + (feed, inbox) + }); cx.spawn(async move |this, cx| { - let res = task.await; + let (feed_res, inbox_res) = task.await; this.update(cx, |this, cx| { if this.activity_epoch != epoch { return; } this.activity_loading = false; - match res { - Ok(records) => { + match (feed_res, inbox_res) { + (Ok(feed_records), Ok(pending_records)) => { this.activity_error = None; // Preserve the highlight by request_id, NOT by raw index. The ~2s poller - // can insert/remove proposed rows between renders, so a stale numeric index + // can insert/remove pending rows between renders, so a stale numeric index // could point at a DIFFERENT record than the operator sees selected — and // `x`/deny + the palette deny-selected resolve the SELECTED record. Capture - // the id from the old snapshot, then re-key to it in the new pending subset; - // if it's gone (settled/expired), clamp. This closes a wrong-deny race. - let selected_id = crate::activity_view::activity_pending(&this.activity) + // the id from the OLD inbox snapshot, then re-key to it in the new pending + // subset; if it's gone (settled/expired), clamp. This closes a wrong-deny + // race. The selected/reviewed row lives in the INBOX now (#216), so the + // reconcile keys against `inbox_pending(&self.inbox)`, not the feed. + let selected_id = crate::activity_view::inbox_pending(&this.inbox) .get(this.activity_selected) .map(|r| r.request_id); - this.activity = records; - let pending = crate::activity_view::activity_pending(&this.activity); - // NOTE (#200): deliberately do NOT reconcile `polled_pending` from this - // feed. The daemon caps `ActivityFeed` at the newest 200 rows while - // `PendingList` is uncapped, so a pending row older than 200 newer records - // is absent here — writing `pending.len()` could undercount to 0 and hide a - // real waiting request (a wrong-direction cue). `polled_pending` therefore - // has ONE source: the uncapped `PendingList` in `kick_pending_refresh`. - // Freshness on leaving the feed (so an approve/deny here doesn't leave a - // stale home cue) comes from a `kick_pending_refresh` fired on the - // Activity→elsewhere nav transition (see `open`/`select`), not from here. - // On the Activity surface `waiting_count` reads this same feed count, so the - // badge still equals the NEEDS YOU band beside it (both inherit the cap). + this.activity = feed_records; + this.inbox = pending_records; + let pending = crate::activity_view::inbox_pending(&this.inbox); + // NOTE (#216): the NEEDS YOU band, the selection, and the on-Activity + // `waiting_count` input all read the UNCAPPED inbox — so an old still- + // `Pending` approval shed from the 200-capped feed stays counted AND + // approvable on this surface. The feed is the LOG only (settled rows). The + // off-surface cue keeps its own honest source: `polled_pending` is still + // sourced ONLY from the uncapped `PendingList` in `kick_pending_refresh`, + // never reconciled from this feed. Freshness on leaving the feed (so an + // approve/deny here doesn't leave a stale home cue) comes from a + // `kick_pending_refresh` fired on the Activity→elsewhere nav transition. this.activity_selected = selected_id .and_then(|id| pending.iter().position(|r| r.request_id == id)) .unwrap_or_else(|| { @@ -2707,7 +2727,9 @@ impl Shell { } } } - Err(e) => this.activity_error = Some(short_err(e)), + // Either half failing fails the surface loud (never a silent empty inbox/log); + // the previous `activity`/`inbox` snapshots are left intact for continuity. + (Err(e), _) | (_, Err(e)) => this.activity_error = Some(short_err(e)), } cx.notify(); }) @@ -2719,7 +2741,7 @@ impl Shell { /// Move the feed highlight down one approvable (still-proposed) row, clamped (no wrap, no /// panic on an all-terminal feed). j / ↓ route here. pub fn activity_select_next(&mut self) { - let len = crate::activity_view::activity_pending(&self.activity).len(); + let len = crate::activity_view::inbox_pending(&self.inbox).len(); if len > 0 { self.activity_selected = (self.activity_selected + 1).min(len - 1); } @@ -2733,7 +2755,7 @@ impl Shell { /// Open the inline clear-signing review for the highlighted approvable feed row. No-op when /// there are no proposed rows / the selection points past them (guarded via `.get`). pub fn open_selected_activity_review(&mut self, cx: &mut Context) { - let pending = crate::activity_view::activity_pending(&self.activity); + let pending = crate::activity_view::inbox_pending(&self.inbox); if let Some(rec) = pending.get(self.activity_selected) { self.activity_reviewing = Some(rec.request_id); cx.notify(); @@ -2750,7 +2772,7 @@ impl Shell { request_id: deckard_contract::RequestId, cx: &mut Context, ) { - if let Some(i) = crate::activity_view::activity_pending(&self.activity) + if let Some(i) = crate::activity_view::inbox_pending(&self.inbox) .iter() .position(|r| r.request_id == request_id) { @@ -2775,7 +2797,7 @@ impl Shell { /// came to be stale. The agent executes its own write once the record flips to `Allowed` — the /// app never broadcasts. pub fn approve_activity(&mut self, cx: &mut Context) { - let pending = crate::activity_view::activity_pending(&self.activity); + let pending = crate::activity_view::inbox_pending(&self.inbox); match crate::activity_view::approve_target(self.activity_reviewing, &pending) { // The reviewed record is still pending → its clear-signing card is exactly what the // render shows (it keys the same id), so approving it is never blind. @@ -2793,7 +2815,7 @@ impl Shell { /// proposed row. Unlike approve, deny is one-key by design (it only REFUSES — the fail-safe /// direction), so it MAY fall back to the highlighted, on-screen row. pub fn deny_activity(&mut self, cx: &mut Context) { - let pending = crate::activity_view::activity_pending(&self.activity); + let pending = crate::activity_view::inbox_pending(&self.inbox); let target = self .activity_reviewing .filter(|id| pending.iter().any(|r| r.request_id == *id)) @@ -2967,9 +2989,10 @@ impl Shell { /// Home/Settings/a wallet view can't silently expire unseen (approval TTL ~120s). Mirrors /// `start_balance_poll`: stored in `pending_poll_task` (dropped on lock cancels it) and /// epoch-fenced against a fast re-unlock. Each tick is decided by the pure - /// [`pending_poll_tick`]: while Activity is open its own ~2s feed poller already refreshes - /// (and `refresh_activity` reconciles `polled_pending`), so this loop skips the wire call - /// there — the two pollers never double-fetch. + /// [`pending_poll_tick`]: while Activity is open its own ~2s poller (`refresh_activity`) + /// already refreshes the uncapped `inbox` the on-Activity count reads, so this loop skips the + /// wire call there — the two pollers never double-fetch. (`refresh_activity` deliberately does + /// NOT write `polled_pending`; that field has one source — this loop.) fn start_pending_poll(&mut self, cx: &mut Context) { let epoch = self.auth_epoch; // Fetch once immediately: a request parked before this unlock should light the cue the @@ -3048,15 +3071,16 @@ impl Shell { } /// The "waiting on you" count behind the everywhere-cues — the home waiting strip and the - /// sidebar Activity badge (#200). Dispatch is the pure [`waiting_count`] (unit-tested): on - /// the Activity surface the feed itself is the source, so the badge always equals the NEEDS - /// YOU band it sits beside; everywhere else the uncapped `PendingList` snapshot wins (the - /// feed only refreshes while Activity is open, so off-surface it can be stale in either - /// direction), falling back to the last feed snapshot until the first poll lands. + /// sidebar Activity badge (#200). Dispatch is the pure [`waiting_count`] (unit-tested): on the + /// Activity surface the UNCAPPED inbox is the source (#216), so the badge always equals the + /// NEEDS YOU band it sits beside AND never undercounts a request shed from the 200-capped feed; + /// everywhere else the uncapped `PendingList` snapshot wins (the inbox only refreshes while + /// Activity is open, so off-surface it can be stale in either direction), falling back to the + /// last inbox snapshot until the first poll lands. pub(crate) fn waiting_count(&self) -> usize { waiting_count( self.surface == Surface::Activity, - crate::activity_view::activity_pending(&self.activity).len(), + crate::activity_view::inbox_pending(&self.inbox).len(), self.polled_pending, ) } @@ -3574,16 +3598,17 @@ fn pending_waiting(records: &[PendingRecord]) -> usize { } /// Which source feeds the "waiting on you" cues (the home strip + the sidebar Activity badge). -/// On the Activity surface the feed count wins — the badge sits beside the NEEDS YOU band and -/// must equal it exactly (the ~2s feed poller keeps it fresh). Everywhere else the background -/// `PendingList` snapshot wins — off-surface the feed is stale in either direction (missing a -/// new arrival, or still showing a row that has since settled) — with the last feed snapshot as -/// the fallback until the first poll lands. -fn waiting_count(on_activity: bool, feed_pending: usize, polled: Option) -> usize { +/// On the Activity surface the on-screen count wins — the caller now supplies the UNCAPPED inbox +/// count (#216), so the badge sits beside the NEEDS YOU band, equals it exactly (the ~2s refresh +/// keeps it fresh), and can't undercount a request shed from the 200-capped feed. Everywhere else +/// the background `PendingList` snapshot wins — off-surface the inbox is stale in either direction +/// (missing a new arrival, or still showing a row that has since settled) — with the last on-screen +/// snapshot as the fallback until the first poll lands. +fn waiting_count(on_activity: bool, on_screen_pending: usize, polled: Option) -> usize { if on_activity { - feed_pending + on_screen_pending } else { - polled.unwrap_or(feed_pending) + polled.unwrap_or(on_screen_pending) } } @@ -3601,7 +3626,7 @@ fn should_kick_pending_on_nav(from: Surface, to: Surface) -> bool { mod pending_poll_tests { use super::*; use alloy_primitives::{Address, Bytes, B256, U256}; - use deckard_contract::{IntentKind, PendingPayloadView, ProposalOrigin}; + use deckard_contract::{BreachedLimit, IntentKind, PendingPayloadView, ProposalOrigin}; fn rec(id: u8, status: ApprovalStatus) -> PendingRecord { PendingRecord { @@ -3617,6 +3642,8 @@ mod pending_poll_tests { }), remaining_ms: 90_000, origin: ProposalOrigin::Agent, + reason: BreachedLimit::None, + timestamp_ms: 0, } } @@ -3650,11 +3677,12 @@ mod pending_poll_tests { assert_eq!(pending_waiting(&[]), 0); } - /// On the Activity surface the badge must equal the NEEDS YOU band beside it — the feed - /// count wins even when the background snapshot disagrees (it can lag one interval), and - /// approving the last row hides the badge in the same refresh. + /// On the Activity surface the badge must equal the NEEDS YOU band beside it — the UNCAPPED + /// inbox count wins (#216) even when the background snapshot disagrees (it can lag one + /// interval), and approving the last row hides the badge in the same refresh. The first arg is + /// the inbox count now, so a request shed from the 200-capped feed can never undercount it. #[test] - fn badge_matches_feed_on_activity_surface() { + fn badge_matches_inbox_on_activity_surface() { assert_eq!(waiting_count(true, 2, Some(5)), 2); assert_eq!(waiting_count(true, 0, Some(3)), 0); assert_eq!(waiting_count(true, 1, None), 1); diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs index 2324400..2efdde2 100644 --- a/crates/deckard-contract/src/lib.rs +++ b/crates/deckard-contract/src/lib.rs @@ -524,6 +524,9 @@ mod roundtrip_tests { payload: PendingPayloadView::Order(sample_swap_order()), remaining_ms: 119_000, origin: ProposalOrigin::Agent, + // Non-default reason + non-zero timestamp exercise the two new fields end-to-end. + reason: BreachedLimit::PerTxCap, + timestamp_ms: 1_720_000_000_000, }, PendingRecord { request_id: B256::repeat_byte(0x02), @@ -531,6 +534,8 @@ mod roundtrip_tests { payload: PendingPayloadView::Tx(sample_intent(IntentKind::Send)), remaining_ms: 0, origin: ProposalOrigin::App, + reason: BreachedLimit::None, + timestamp_ms: 0, }, PendingRecord { request_id: B256::repeat_byte(0x03), @@ -538,6 +543,8 @@ mod roundtrip_tests { payload: PendingPayloadView::Message(sample_message()), remaining_ms: 60_000, origin: ProposalOrigin::Agent, + reason: BreachedLimit::DailyCap, + timestamp_ms: 1_720_000_060_000, }, ])); } @@ -656,6 +663,8 @@ mod roundtrip_tests { origin: ProposalOrigin::Dapp { origin: "https://app.example.org".into(), }, + reason: BreachedLimit::OffAllowlist, + timestamp_ms: 1_720_000_123_000, }); roundtrip(&ActivityRecord { request_id: B256::repeat_byte(0x04), @@ -706,6 +715,8 @@ mod roundtrip_tests { payload: PendingPayloadView::Order(sample_swap_order()), remaining_ms: 60_000, origin: ProposalOrigin::Agent, + reason: BreachedLimit::PerTxCap, + timestamp_ms: 1_720_000_200_000, }); roundtrip(&PendingRecord { request_id: B256::repeat_byte(0x02), @@ -719,6 +730,9 @@ mod roundtrip_tests { // u64::MAX exercises the wire's full width for the new field. remaining_ms: u64::MAX, origin: ProposalOrigin::App, + // u64::MAX exercises the timestamp field's full wire width too. + reason: BreachedLimit::None, + timestamp_ms: u64::MAX, }); } diff --git a/crates/deckard-contract/src/rpc.rs b/crates/deckard-contract/src/rpc.rs index bc27e63..095683f 100644 --- a/crates/deckard-contract/src/rpc.rs +++ b/crates/deckard-contract/src/rpc.rs @@ -264,6 +264,14 @@ pub struct PendingRecord { pub remaining_ms: u64, /// Who proposed this — drives the agent band + Activity two-actor chain. pub origin: ProposalOrigin, + /// The breached fence for this proposal (display-only; `None` for a within-cap card). + /// Mirrors `ActivityRecord::reason` so the approval INBOX can render the over-cap chip + /// without re-fetching the feed. `#[serde(default)]` keeps an older producer decoding. + #[serde(default)] + pub reason: BreachedLimit, + /// Unix epoch **millis**, daemon-stamped at propose time (mirrors `ActivityRecord::timestamp_ms`). + #[serde(default)] + pub timestamp_ms: u64, } /// Rich per-request status for the agent's poll loop (the `deckard_status` MCP tool). diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs index af1198f..2a39e12 100644 --- a/crates/deckard-signerd/src/daemon.rs +++ b/crates/deckard-signerd/src/daemon.rs @@ -1522,6 +1522,11 @@ impl Daemon { payload: payload_view(&req.payload), remaining_ms, origin: req.origin.clone(), + // Same sources `activity_feed` uses for `ActivityRecord.reason`/`.timestamp_ms`, + // so the uncapped approval inbox (#216) can render the over-cap chip + timestamp + // without re-fetching the (200-capped) feed. + reason: req.breached, + timestamp_ms: req.created_ms, } }) .collect() @@ -2015,3 +2020,191 @@ mod stop_selection_tests { assert!(select_orders_to_cancel(&reqs, now).is_empty()); } } + +#[cfg(test)] +mod pending_list_uncap_tests { + //! Regression guard for issue #216: the app's "needs you" inbox now reads the UNCAPPED + //! `pending_list()` instead of the 200-capped `activity_feed()`. This pins the property that + //! fix depends on — a waiting (`Pending`) row must survive `pending_list()` even when the + //! request table holds more than [`ACTIVITY_FEED_CAP`] records. If a future change ever added + //! the feed's `take(ACTIVITY_FEED_CAP)` (newest-first) here, the oldest waiting row would be + //! dropped and this test would fail — the inbox must never be capped. Drives a REAL `Daemon` + //! (route (a)) so it exercises the actual `pending_list` mapping — including the `expire_stale` + //! call it runs first — no socket, no chain, no async. + use super::*; + + /// A hermetic, `Locked` daemon. `config_dir` points at a temp path that holds no + /// `policy.json` / spend counter, so both loaders fall back to their safe defaults; `Daemon::new` + /// never writes, and neither does `pending_list`/`expire_stale`, so nothing touches disk beyond + /// two absent-file reads. `chain_id` is anvil's (a testnet/dev id) but is irrelevant here — the + /// inbox mapping never consults the guardrail. + fn test_daemon() -> Daemon { + let config_dir = std::env::temp_dir().join(format!( + "deckard-signerd-pending-list-uncap-test-{}", + std::process::id() + )); + let cfg = Config { + rpc_url: "http://127.0.0.1:8545".to_string(), + chain_id: 31_337, + config_dir, + socket_path: std::path::PathBuf::from("/tmp/deckard-signerd-pending-list-uncap.sock"), + autonomy_override: false, + }; + Daemon::new(cfg) + } + + /// A distinct [`RequestId`] per `n` (the intent is identical, so `request_id_for` would collide — + /// we key the map by a synthetic id instead). + fn id_for(n: u64) -> RequestId { + let mut bytes = [0u8; 32]; + bytes[24..].copy_from_slice(&n.to_be_bytes()); + B256::from(bytes) + } + + fn tx_intent() -> Intent { + Intent { + chain_id: 31_337, + to: Address::ZERO, + token: None, + value: U256::ZERO, + calldata: Bytes::new(), + kind: IntentKind::ContractCall, + } + } + + /// One record for the request table. `expires_at`/`broadcast` are the two knobs that decide + /// whether `expire_stale` (which `pending_list` runs first) touches it. + fn record( + seq: u64, + status: ApprovalStatus, + expires_at: Instant, + broadcast: Option, + breached: BreachedLimit, + created_ms: u64, + ) -> PendingReq { + PendingReq { + payload: PendingPayload::Tx(tx_intent()), + status, + expires_at, + broadcast, + signature: None, + approved: false, + origin: ProposalOrigin::App, + created_ms, + seq, + breached, + cancelled: false, + auto_allowed: false, + } + } + + #[test] + fn pending_list_surfaces_low_seq_waiting_row_past_feed_cap() { + let mut daemon = test_daemon(); + let future = Instant::now() + Duration::from_secs(3600); + + // The waiting row gets the LOWEST seq (the OLDEST). A newest-first cap (like `activity_feed`) + // would drop exactly this one — so its survival is what proves the inbox is uncapped. Its + // `expires_at` is safely in the FUTURE so `expire_stale` does NOT flip it to `Expired`. + let waiting_id = id_for(0); + daemon.requests.insert( + waiting_id, + record( + 0, + ApprovalStatus::Pending, + future, + None, + BreachedLimit::None, + 0, + ), + ); + + // ACTIVITY_FEED_CAP + 5 higher-seq SETTLED rows: `Allowed` + already broadcast (executed). + // `broadcast.is_some()` keeps `expire_stale` from ever touching them (its guard is + // `broadcast.is_none()`), so `expires_at` is irrelevant for them and total records = + // 206 > 200, all higher-seq than the waiting row. + let settled = ACTIVITY_FEED_CAP + 5; + for n in 1..=settled as u64 { + daemon.requests.insert( + id_for(n), + record( + n, + ApprovalStatus::Allowed, + future, + Some(B256::repeat_byte(0xbb)), + BreachedLimit::None, + 0, + ), + ); + } + + let list = daemon.pending_list(); + + // Uncapped: every record comes back, not just the newest ACTIVITY_FEED_CAP. + assert_eq!( + list.len(), + settled + 1, + "issue #216: pending_list must return ALL {} records uncapped, got {}", + settled + 1, + list.len() + ); + assert!( + list.len() > ACTIVITY_FEED_CAP, + "test must exercise the >{ACTIVITY_FEED_CAP} (past-cap) regime" + ); + + match list.iter().find(|r| r.request_id == waiting_id) { + Some(row) => { + assert!( + matches!(row.status, ApprovalStatus::Pending), + "the waiting row must still read as Pending (expire_stale must not mask it)" + ); + assert!( + row.remaining_ms > 0, + "the waiting row's approval TTL is in the future — remaining_ms must be > 0" + ); + } + None => panic!( + "issue #216: the lowest-seq waiting row was dropped from pending_list — \ + the approval inbox must NEVER be capped like the activity feed" + ), + } + } + + #[test] + fn pending_list_carries_reason_and_timestamp() { + let mut daemon = test_daemon(); + let future = Instant::now() + Duration::from_secs(3600); + + // A breached, still-pending over-cap card: its `breached`/`created_ms` must round-trip into + // the new `PendingRecord.reason`/`.timestamp_ms` fields (mirrors `activity_feed`). + let id = id_for(42); + daemon.requests.insert( + id, + record( + 7, + ApprovalStatus::Pending, + future, + None, + BreachedLimit::PerTxCap, + 123, + ), + ); + + let list = daemon.pending_list(); + match list.iter().find(|r| r.request_id == id) { + Some(row) => { + assert_eq!( + row.reason, + BreachedLimit::PerTxCap, + "pending_list must surface the breached fence for the over-cap chip" + ); + assert_eq!( + row.timestamp_ms, 123, + "pending_list must carry the propose-time timestamp" + ); + } + None => panic!("the breached pending record must appear in pending_list"), + } + } +}