From 7c16f96d7a4cb365ff6de00f732916a8ddd63deb Mon Sep 17 00:00:00 2001 From: camelludo Date: Fri, 7 Aug 2026 13:47:30 +0300 Subject: [PATCH 1/2] feat: add native decision cards Signed-off-by: camelludo --- crates/buzz-core/src/decision_card.rs | 147 ++++++++++ crates/buzz-core/src/kind.rs | 6 + crates/buzz-core/src/lib.rs | 2 + crates/buzz-relay/src/handlers/ingest.rs | 15 +- crates/buzz-sdk/src/builders.rs | 90 +++++- crates/buzz-sdk/tests/decision_cards.rs | 169 +++++++++++ .../tests/e2e_decision_card.rs | 122 ++++++++ desktop/playwright.config.ts | 1 + .../decision-cards/lib/decisionCards.test.mjs | 105 +++++++ .../decision-cards/lib/decisionCards.ts | 187 ++++++++++++ .../decision-cards/ui/DecisionCard.tsx | 266 ++++++++++++++++++ .../messages/lib/formatTimelineMessages.ts | 4 + .../src/features/messages/ui/MessageRow.tsx | 10 + desktop/src/shared/constants/kinds.ts | 6 + desktop/src/testing/e2eBridge.ts | 2 + desktop/tests/e2e/decision-card.spec.ts | 107 +++++++ 16 files changed, 1233 insertions(+), 6 deletions(-) create mode 100644 crates/buzz-core/src/decision_card.rs create mode 100644 crates/buzz-sdk/tests/decision_cards.rs create mode 100644 crates/buzz-test-client/tests/e2e_decision_card.rs create mode 100644 desktop/src/features/decision-cards/lib/decisionCards.test.mjs create mode 100644 desktop/src/features/decision-cards/lib/decisionCards.ts create mode 100644 desktop/src/features/decision-cards/ui/DecisionCard.tsx create mode 100644 desktop/tests/e2e/decision-card.spec.ts diff --git a/crates/buzz-core/src/decision_card.rs b/crates/buzz-core/src/decision_card.rs new file mode 100644 index 00000000000..c6e74f20e25 --- /dev/null +++ b/crates/buzz-core/src/decision_card.rs @@ -0,0 +1,147 @@ +//! Typed payloads for channel-native decision cards and their durable responses. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +/// Current wire schema for decision-card payloads. +pub const DECISION_CARD_SCHEMA_VERSION: u8 = 1; + +/// A human choice exposed by a decision card. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DecisionCardChoice { + /// Accept the proposed action. + Approve, + /// Ask for a revised proposal. + Redraft, + /// Route the case to a higher-authority reviewer. + Escalate, + /// Reject the proposed action. + Reject, +} + +/// Structured data carried by a `kind:40009` decision-card event. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DecisionCardPayload { + /// Payload schema version. + pub schema_version: u8, + /// Stable business-level card identifier. + pub card_id: Uuid, + /// Short decision title. + pub title: String, + /// Concise current situation. + pub situation: String, + /// Recommended choice and why. + pub recommendation: String, + /// Exact action that the decision would authorize as intent. + pub proposed_action: String, + /// Material risk or consequence. + pub risk: String, + /// Optional authoritative-record URL. + pub record_url: Option, + /// Ordered choices shown to the human. + pub choices: Vec, + /// Optional Unix-seconds expiry. + pub expires_at: Option, + /// Whether this card is explicitly non-production. + pub shadow: bool, +} + +impl DecisionCardPayload { + /// Validate the bounded wire contract. + pub fn validate(&self) -> Result<(), &'static str> { + if self.schema_version != DECISION_CARD_SCHEMA_VERSION { + return Err("unsupported decision card schema version"); + } + if self.title.trim().is_empty() + || self.situation.trim().is_empty() + || self.recommendation.trim().is_empty() + || self.proposed_action.trim().is_empty() + || self.risk.trim().is_empty() + { + return Err("decision card text fields must not be empty"); + } + if self.title.len() > 160 + || self.situation.len() > 2_000 + || self.recommendation.len() > 2_000 + || self.proposed_action.len() > 2_000 + || self.risk.len() > 2_000 + { + return Err("decision card text field exceeds its size limit"); + } + if self + .record_url + .as_ref() + .is_some_and(|record_url| record_url.len() > 2_048) + { + return Err("decision card record URL exceeds its size limit"); + } + if let Some(record_url) = &self.record_url { + let parsed = url::Url::parse(record_url) + .map_err(|_| "decision card record URL must be an absolute HTTP(S) URL")?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err("decision card record URL must be an absolute HTTP(S) URL"); + } + } + if self.choices.is_empty() || self.choices.len() > 4 { + return Err("decision card must expose between one and four choices"); + } + let unique: std::collections::HashSet<_> = self.choices.iter().collect(); + if unique.len() != self.choices.len() { + return Err("decision card choices must be unique"); + } + Ok(()) + } + + /// Serialize the payload in its canonical field order. + pub fn canonical_json(&self) -> Result { + serde_json::to_string(self) + } + + /// SHA-256 digest of the canonical structured payload. + pub fn payload_hash(&self) -> Result { + let encoded = self.canonical_json()?; + Ok(hex::encode(Sha256::digest(encoded.as_bytes()))) + } +} + +/// Structured data carried by a `kind:40010` decision-response event. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DecisionResponsePayload { + /// Payload schema version. + pub schema_version: u8, + /// Idempotency identifier for this response intent. + pub action_id: Uuid, + /// Stable business-level card identifier. + pub card_id: Uuid, + /// Human choice. + pub decision: DecisionCardChoice, + /// Digest of the exact card payload the human saw. + pub payload_hash: String, + /// Optional human note. + pub note: Option, + /// Whether this response is explicitly non-production. + pub shadow: bool, +} + +impl DecisionResponsePayload { + /// Validate the bounded response contract. + pub fn validate(&self) -> Result<(), &'static str> { + if self.schema_version != DECISION_CARD_SCHEMA_VERSION { + return Err("unsupported decision response schema version"); + } + if self.payload_hash.len() != 64 + || !self + .payload_hash + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err("decision response payload hash must be 64 hexadecimal characters"); + } + if self.note.as_ref().is_some_and(|note| note.len() > 2_000) { + return Err("decision response note exceeds its size limit"); + } + Ok(()) + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913d..be224bc1259 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -491,6 +491,10 @@ pub const KIND_STREAM_MESSAGE_SCHEDULED: u32 = 40006; pub const KIND_STREAM_REMINDER: u32 = 40007; /// A diff/patch message showing file changes (unified diff format). pub const KIND_STREAM_MESSAGE_DIFF: u32 = 40008; +/// A channel-native structured decision card with a Markdown fallback. +pub const KIND_STREAM_DECISION_CARD: u32 = 40009; +/// A signed response to a channel-native decision card. +pub const KIND_STREAM_DECISION_RESPONSE: u32 = 40010; /// Canvas (shared document) for a channel. pub const KIND_CANVAS: u32 = 40100; /// System message for channel state changes (join, leave, rename, etc.). @@ -707,6 +711,8 @@ pub const ALL_KINDS: &[u32] = &[ KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_REMINDER, KIND_STREAM_MESSAGE_DIFF, + KIND_STREAM_DECISION_CARD, + KIND_STREAM_DECISION_RESPONSE, KIND_CANVAS, KIND_SYSTEM_MESSAGE, KIND_CHANNEL_SUMMARY, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 7424915c83e..be90aeaf264 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,8 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +/// Typed channel-native decision cards and durable responses. +pub mod decision_card; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, /// body parse/serialize, envelope build/validate, head selection. pub mod engram; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index cd9f20b5f4a..f02341fe4ae 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -29,11 +29,12 @@ use buzz_core::kind::{ KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, - KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, - KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, - RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_STREAM_DECISION_CARD, KIND_STREAM_DECISION_RESPONSE, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, + KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, + RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -302,6 +303,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), @@ -534,6 +537,8 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_STREAM_MESSAGE_SCHEDULED | KIND_STREAM_REMINDER | KIND_STREAM_MESSAGE_DIFF + | KIND_STREAM_DECISION_CARD + | KIND_STREAM_DECISION_RESPONSE | KIND_CANVAS | KIND_FORUM_POST | KIND_FORUM_VOTE diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index c3b4432101b..6b68af01e20 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -4,6 +4,7 @@ //! The caller signs: `builder.sign_with_keys(&keys)?`. use buzz_core::{ + decision_card::{DecisionCardPayload, DecisionResponsePayload}, kind::{ KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH, @@ -12,7 +13,8 @@ use buzz_core::{ KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_PROJECT, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_STREAM_DECISION_CARD, KIND_STREAM_DECISION_RESPONSE, KIND_USER_STATUS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -242,6 +244,92 @@ pub fn build_message( Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } +/// Build a channel-native decision card (kind 40009). +/// +/// `fallback_markdown` remains readable in clients that do not understand the +/// structured `decision_card` tag. The payload hash binds future responses to +/// the exact structured proposal the human saw. +pub fn build_decision_card( + channel_id: Uuid, + payload: &DecisionCardPayload, + fallback_markdown: &str, + thread_ref: Option<&ThreadRef>, +) -> Result { + payload + .validate() + .map_err(|error| SdkError::InvalidInput(error.into()))?; + check_content(fallback_markdown, 64 * 1024)?; + if fallback_markdown.trim().is_empty() { + return Err(SdkError::InvalidInput( + "decision card Markdown fallback must not be empty".into(), + )); + } + + let encoded = payload + .canonical_json() + .map_err(|error| SdkError::InvalidInput(error.to_string()))?; + check_content(&encoded, 16 * 1024)?; + let payload_hash = payload + .payload_hash() + .map_err(|error| SdkError::InvalidInput(error.to_string()))?; + let mut tags = vec![ + tag(&["h", &channel_id.to_string()])?, + tag(&["decision_card", &encoded])?, + tag(&["payload_hash", &payload_hash])?, + tag(&["shadow", if payload.shadow { "1" } else { "0" }])?, + ]; + if let Some(expires_at) = payload.expires_at { + tags.push(tag(&["expiration", &expires_at.to_string()])?); + } + if let Some(thread_ref) = thread_ref { + thread_tags(thread_ref, &mut tags)?; + } + + Ok(EventBuilder::new( + Kind::Custom(KIND_STREAM_DECISION_CARD as u16), + fallback_markdown, + ) + .tags(tags)) +} + +/// Build a durable response to a decision card (kind 40010). +/// +/// The NIP-10 reference keeps the receipt in the card's originating thread; +/// `fallback_markdown` makes the outcome explicit in older clients. +pub fn build_decision_response( + channel_id: Uuid, + payload: &DecisionResponsePayload, + fallback_markdown: &str, + thread_ref: &ThreadRef, +) -> Result { + payload + .validate() + .map_err(|error| SdkError::InvalidInput(error.into()))?; + check_content(fallback_markdown, 64 * 1024)?; + if fallback_markdown.trim().is_empty() { + return Err(SdkError::InvalidInput( + "decision response Markdown fallback must not be empty".into(), + )); + } + + let encoded = serde_json::to_string(payload) + .map_err(|error| SdkError::InvalidInput(error.to_string()))?; + check_content(&encoded, 8 * 1024)?; + let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; + thread_tags(thread_ref, &mut tags)?; + tags.extend([ + tag(&["decision_response", &encoded])?, + tag(&["payload_hash", &payload.payload_hash])?, + tag(&["shadow", if payload.shadow { "1" } else { "0" }])?, + ]); + + Ok(EventBuilder::new( + Kind::Custom(KIND_STREAM_DECISION_RESPONSE as u16), + fallback_markdown, + ) + .tags(tags)) +} + /// Build an encrypted agent observer frame (kind 24200). /// /// `recipient_pubkey` is the cleartext `p` tag used by the relay for owner-only diff --git a/crates/buzz-sdk/tests/decision_cards.rs b/crates/buzz-sdk/tests/decision_cards.rs new file mode 100644 index 00000000000..36fc43d9c2a --- /dev/null +++ b/crates/buzz-sdk/tests/decision_cards.rs @@ -0,0 +1,169 @@ +use buzz_core::decision_card::{DecisionCardChoice, DecisionCardPayload, DecisionResponsePayload}; +use buzz_core::kind::{KIND_STREAM_DECISION_CARD, KIND_STREAM_DECISION_RESPONSE}; +use buzz_sdk::{build_decision_card, build_decision_response, ThreadRef}; +use nostr::{EventBuilder, EventId, Keys}; +use uuid::Uuid; + +fn sign(builder: EventBuilder) -> nostr::Event { + builder + .sign_with_keys(&Keys::generate()) + .expect("test event should sign") +} + +fn tag_value<'a>(event: &'a nostr::Event, name: &str) -> Option<&'a str> { + event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some(name)) + .then(|| parts.get(1).map(String::as_str)) + .flatten() + }) +} + +#[test] +fn decision_card_keeps_markdown_fallback_and_signed_structured_payload() { + let channel_id = Uuid::new_v4(); + let payload = DecisionCardPayload { + schema_version: 1, + card_id: Uuid::new_v4(), + title: "Approve corrected redraft".into(), + situation: "Case #625 has a wording correction ready.".into(), + recommendation: "Approve the corrected wording.".into(), + proposed_action: "Record a shadow approval only.".into(), + risk: "No external send and no production write.".into(), + record_url: Some("https://stomaton.example/cases/625".into()), + choices: vec![ + DecisionCardChoice::Approve, + DecisionCardChoice::Redraft, + DecisionCardChoice::Escalate, + DecisionCardChoice::Reject, + ], + expires_at: Some(2_000_000_000), + shadow: true, + }; + let fallback = "## Decision needed\nApprove corrected redraft\n\n**SHADOW — NOT DELIVERED**"; + + let event = sign( + build_decision_card(channel_id, &payload, fallback, None) + .expect("valid decision card should build"), + ); + + assert_eq!(event.kind.as_u16(), KIND_STREAM_DECISION_CARD as u16); + assert_eq!(event.content, fallback); + assert_eq!( + tag_value(&event, "h"), + Some(channel_id.to_string().as_str()) + ); + assert_eq!(tag_value(&event, "shadow"), Some("1")); + assert_eq!(tag_value(&event, "expiration"), Some("2000000000")); + + let encoded = tag_value(&event, "decision_card").expect("decision_card tag"); + let decoded: DecisionCardPayload = serde_json::from_str(encoded).expect("valid payload JSON"); + assert_eq!(decoded, payload); + + let payload_hash = tag_value(&event, "payload_hash").expect("payload_hash tag"); + assert_eq!(payload_hash, payload.payload_hash().expect("payload hash")); + assert_eq!(payload_hash.len(), 64); +} + +#[test] +fn decision_response_references_card_and_preserves_originating_thread() { + let channel_id = Uuid::new_v4(); + let root_id = EventId::from_hex(&"11".repeat(32)).expect("root event id"); + let card_event_id = EventId::from_hex(&"22".repeat(32)).expect("card event id"); + let action_id = Uuid::new_v4(); + let payload_hash = "ab".repeat(32); + let payload = DecisionResponsePayload { + schema_version: 1, + action_id, + card_id: Uuid::new_v4(), + decision: DecisionCardChoice::Approve, + payload_hash: payload_hash.clone(), + note: Some("Proceed with the shadow receipt.".into()), + shadow: true, + }; + let fallback = "✅ Approved — SHADOW / NOT DELIVERED"; + let thread_ref = ThreadRef { + root_event_id: root_id, + parent_event_id: card_event_id, + }; + + let event = sign( + build_decision_response(channel_id, &payload, fallback, &thread_ref) + .expect("valid response should build"), + ); + + assert_eq!(event.kind.as_u16(), KIND_STREAM_DECISION_RESPONSE as u16); + assert_eq!(event.content, fallback); + assert_eq!( + tag_value(&event, "h"), + Some(channel_id.to_string().as_str()) + ); + assert_eq!( + tag_value(&event, "payload_hash"), + Some(payload_hash.as_str()) + ); + assert_eq!(tag_value(&event, "shadow"), Some("1")); + + let encoded = tag_value(&event, "decision_response").expect("decision_response tag"); + let decoded: DecisionResponsePayload = + serde_json::from_str(encoded).expect("valid response JSON"); + assert_eq!(decoded, payload); + + let e_tags: Vec> = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("e")) + .map(|tag| tag.as_slice().to_vec()) + .collect(); + assert_eq!( + e_tags[0], + vec!["e".into(), root_id.to_hex(), "".into(), "root".into()] + ); + assert_eq!( + e_tags[1], + vec![ + "e".into(), + card_event_id.to_hex(), + "".into(), + "reply".into(), + ] + ); +} + +#[test] +fn decision_card_rejects_empty_choices() { + let payload = DecisionCardPayload { + schema_version: 1, + card_id: Uuid::new_v4(), + title: "Choose".into(), + situation: "A decision is required.".into(), + recommendation: "Approve.".into(), + proposed_action: "Record shadow intent.".into(), + risk: "None.".into(), + record_url: None, + choices: vec![], + expires_at: None, + shadow: true, + }; + + assert!(build_decision_card(Uuid::new_v4(), &payload, "fallback", None).is_err()); +} + +#[test] +fn decision_card_rejects_non_http_record_url() { + let payload = DecisionCardPayload { + schema_version: 1, + card_id: Uuid::new_v4(), + title: "Choose".into(), + situation: "A decision is required.".into(), + recommendation: "Approve.".into(), + proposed_action: "Record shadow intent.".into(), + risk: "None.".into(), + record_url: Some("javascript:alert(1)".into()), + choices: vec![DecisionCardChoice::Approve], + expires_at: None, + shadow: true, + }; + + assert!(build_decision_card(Uuid::new_v4(), &payload, "fallback", None).is_err()); +} diff --git a/crates/buzz-test-client/tests/e2e_decision_card.rs b/crates/buzz-test-client/tests/e2e_decision_card.rs new file mode 100644 index 00000000000..fc9ace56bac --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_decision_card.rs @@ -0,0 +1,122 @@ +//! Non-production relay canary for native decision cards and durable responses. + +use std::time::Duration; + +use buzz_core::decision_card::{DecisionCardChoice, DecisionCardPayload, DecisionResponsePayload}; +use buzz_sdk::{build_decision_card, build_decision_response, ThreadRef}; +use buzz_test_client::BuzzTestClient; +use nostr::{Filter, Keys}; +use uuid::Uuid; + +const TYLER_TEST_SECRET: &str = "3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03"; +const GENERAL_CHANNEL_ID: &str = "9f28288a-d724-587a-9709-92dc7f967110"; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +#[tokio::test] +#[ignore = "requires a seeded non-production relay"] +async fn signed_shadow_decision_round_trips_with_durable_receipt() { + let keys = Keys::parse(TYLER_TEST_SECRET).expect("fixture key"); + let channel_id = Uuid::parse_str(GENERAL_CHANNEL_ID).expect("fixture channel"); + let payload = DecisionCardPayload { + schema_version: 1, + card_id: Uuid::new_v4(), + title: "Approve corrected redraft".into(), + situation: "Historical case #625 has a corrected shadow draft.".into(), + recommendation: "Record approval intent in shadow mode.".into(), + proposed_action: "Create a durable Buzz receipt only.".into(), + risk: "No external send and no production write.".into(), + record_url: Some("https://stomaton.example/cases/625".into()), + choices: vec![ + DecisionCardChoice::Approve, + DecisionCardChoice::Redraft, + DecisionCardChoice::Escalate, + DecisionCardChoice::Reject, + ], + expires_at: Some(2_100_000_000), + shadow: true, + }; + let payload_hash = payload.payload_hash().expect("payload hash"); + let card = build_decision_card( + channel_id, + &payload, + "## Decision needed\nApprove corrected redraft\n\n**SHADOW — NOT DELIVERED**", + None, + ) + .expect("card builder") + .sign_with_keys(&keys) + .expect("signed card"); + + let mut client = BuzzTestClient::connect(&relay_url(), &keys) + .await + .expect("authenticated relay connection"); + let accepted = client.send_event(card.clone()).await.expect("publish card"); + assert!( + accepted.accepted, + "relay rejected card: {}", + accepted.message + ); + + client + .subscribe("decision-card", vec![Filter::new().id(card.id)]) + .await + .expect("query card"); + let stored_cards = client + .collect_until_eose("decision-card", Duration::from_secs(5)) + .await + .expect("stored card query"); + assert_eq!(stored_cards, vec![card.clone()]); + + let response_payload = DecisionResponsePayload { + schema_version: 1, + action_id: Uuid::new_v4(), + card_id: payload.card_id, + decision: DecisionCardChoice::Approve, + payload_hash: payload_hash.clone(), + note: Some("Non-production relay canary.".into()), + shadow: true, + }; + let response = build_decision_response( + channel_id, + &response_payload, + "✅ Approved — SHADOW / NOT DELIVERED", + &ThreadRef { + root_event_id: card.id, + parent_event_id: card.id, + }, + ) + .expect("response builder") + .sign_with_keys(&keys) + .expect("signed response"); + let accepted = client + .send_event(response.clone()) + .await + .expect("publish response"); + assert!( + accepted.accepted, + "relay rejected response: {}", + accepted.message + ); + + client + .subscribe("decision-response", vec![Filter::new().id(response.id)]) + .await + .expect("query response"); + let stored_responses = client + .collect_until_eose("decision-response", Duration::from_secs(5)) + .await + .expect("stored response query"); + assert_eq!(stored_responses, vec![response]); + assert!(stored_responses[0] + .tags + .iter() + .any(|tag| tag.as_slice() == ["payload_hash", payload_hash.as_str()])); + assert!(stored_responses[0] + .tags + .iter() + .any(|tag| tag.as_slice() == ["shadow", "1"])); + + client.disconnect().await.expect("clean disconnect"); +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b9..64cdc8dd21a 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ "**/invites-settings-screenshots.spec.ts", "**/messaging.spec.ts", "**/message-feedback-snapshots.spec.ts", + "**/decision-card.spec.ts", "**/custom-emoji.spec.ts", "**/profile-custom-emoji-status.spec.ts", "**/custom-emoji-ui.spec.ts", diff --git a/desktop/src/features/decision-cards/lib/decisionCards.test.mjs b/desktop/src/features/decision-cards/lib/decisionCards.test.mjs new file mode 100644 index 00000000000..21c93d100d3 --- /dev/null +++ b/desktop/src/features/decision-cards/lib/decisionCards.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex } from "@noble/hashes/utils.js"; + +import { + buildDecisionResponseContent, + buildDecisionResponseTags, + parseDecisionCard, + parseDecisionResponse, +} from "./decisionCards.ts"; + +const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; +const CARD_EVENT_ID = "a".repeat(64); +const ROOT_EVENT_ID = "b".repeat(64); + +const card = { + schema_version: 1, + card_id: "550e8400-e29b-41d4-a716-446655440000", + title: "Approve corrected redraft", + situation: "Case #625 has a corrected draft.", + recommendation: "Approve it.", + proposed_action: "Record shadow intent only.", + risk: "No external send.", + record_url: "https://stomaton.example/cases/625", + choices: ["approve", "redraft", "escalate", "reject"], + expires_at: 2_000_000_000, + shadow: true, +}; +const ENCODED_CARD = JSON.stringify(card); +const PAYLOAD_HASH = bytesToHex(sha256(new TextEncoder().encode(ENCODED_CARD))); + +function cardTags(payload) { + const encoded = JSON.stringify(payload); + const hash = bytesToHex(sha256(new TextEncoder().encode(encoded))); + return [ + ["decision_card", encoded], + ["payload_hash", hash], + ]; +} + +test("parses a versioned decision card tag without consuming its Markdown fallback", () => { + const parsed = parseDecisionCard([ + ["h", CHANNEL_ID], + ["decision_card", ENCODED_CARD], + ["payload_hash", PAYLOAD_HASH], + ["shadow", "1"], + ]); + + assert.deepEqual(parsed, { payload: card, payloadHash: PAYLOAD_HASH }); +}); + +test("rejects a card whose payload hash does not match what is rendered", () => { + assert.equal( + parseDecisionCard([ + ["decision_card", ENCODED_CARD], + ["payload_hash", "c".repeat(64)], + ]), + null, + ); +}); + +test("fails closed for malformed cards and unsupported choices", () => { + assert.equal(parseDecisionCard([["decision_card", "not-json"]]), null); + assert.equal( + parseDecisionCard(cardTags({ ...card, choices: ["launch"] })), + null, + ); + assert.equal( + parseDecisionCard(cardTags({ ...card, record_url: "javascript:alert(1)" })), + null, + ); +}); + +test("builds a thread-preserving signed response envelope", () => { + const tags = buildDecisionResponseTags({ + actionId: "4f34cd24-9d97-4e94-998d-c7d933542dbc", + cardEventId: CARD_EVENT_ID, + cardId: card.card_id, + channelId: CHANNEL_ID, + decision: "approve", + note: "Shadow only.", + payloadHash: PAYLOAD_HASH, + rootEventId: ROOT_EVENT_ID, + }); + + assert.deepEqual(tags.slice(0, 3), [ + ["h", CHANNEL_ID], + ["e", ROOT_EVENT_ID, "", "root"], + ["e", CARD_EVENT_ID, "", "reply"], + ]); + const parsed = parseDecisionResponse(tags); + assert.equal(parsed?.action_id, "4f34cd24-9d97-4e94-998d-c7d933542dbc"); + assert.equal(parsed?.decision, "approve"); + assert.equal(parsed?.payload_hash, PAYLOAD_HASH); + assert.equal(parsed?.shadow, true); +}); + +test("response fallback is explicit about shadow delivery state", () => { + assert.equal( + buildDecisionResponseContent("approve", "Shadow only."), + "✅ Approved — SHADOW / NOT DELIVERED\n\n> Shadow only.", + ); +}); diff --git a/desktop/src/features/decision-cards/lib/decisionCards.ts b/desktop/src/features/decision-cards/lib/decisionCards.ts new file mode 100644 index 00000000000..fa623ecc440 --- /dev/null +++ b/desktop/src/features/decision-cards/lib/decisionCards.ts @@ -0,0 +1,187 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import { z } from "zod"; + +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_STREAM_DECISION_RESPONSE } from "@/shared/constants/kinds"; + +export const DECISION_CARD_CHOICES = [ + "approve", + "redraft", + "escalate", + "reject", +] as const; + +export type DecisionCardChoice = (typeof DECISION_CARD_CHOICES)[number]; + +const decisionCardPayloadSchema = z.object({ + schema_version: z.literal(1), + card_id: z.string().uuid(), + title: z.string().trim().min(1).max(160), + situation: z.string().trim().min(1).max(2_000), + recommendation: z.string().trim().min(1).max(2_000), + proposed_action: z.string().trim().min(1).max(2_000), + risk: z.string().trim().min(1).max(2_000), + record_url: z + .string() + .max(2_048) + .refine((recordUrl) => { + try { + return ["http:", "https:"].includes(new URL(recordUrl).protocol); + } catch { + return false; + } + }) + .optional(), + choices: z.array(z.enum(DECISION_CARD_CHOICES)).min(1).max(4), + expires_at: z.number().int().optional(), + shadow: z.boolean(), +}); + +const decisionResponsePayloadSchema = z.object({ + schema_version: z.literal(1), + action_id: z.string().uuid(), + card_id: z.string().uuid(), + decision: z.enum(DECISION_CARD_CHOICES), + payload_hash: z.string().regex(/^[0-9a-f]{64}$/i), + note: z.string().max(2_000).optional(), + shadow: z.boolean(), +}); + +export type DecisionCardPayload = z.infer; +export type DecisionResponsePayload = z.infer< + typeof decisionResponsePayloadSchema +>; + +export type ParsedDecisionCard = { + payload: DecisionCardPayload; + payloadHash: string; +}; + +function findTag(tags: string[][], name: string): string | undefined { + return tags.find((tag) => tag[0] === name)?.[1]; +} + +export function parseDecisionCard(tags: string[][]): ParsedDecisionCard | null { + const encoded = findTag(tags, "decision_card"); + const payloadHash = findTag(tags, "payload_hash"); + if (!encoded || !payloadHash || !/^[0-9a-f]{64}$/i.test(payloadHash)) { + return null; + } + + try { + const payload = decisionCardPayloadSchema.parse(JSON.parse(encoded)); + if (new Set(payload.choices).size !== payload.choices.length) return null; + const encodedHash = bytesToHex(sha256(new TextEncoder().encode(encoded))); + if (encodedHash !== payloadHash.toLowerCase()) return null; + return { payload, payloadHash: encodedHash }; + } catch { + return null; + } +} + +export function parseDecisionResponse( + tags: string[][], +): DecisionResponsePayload | null { + const encoded = findTag(tags, "decision_response"); + if (!encoded) return null; + + try { + return decisionResponsePayloadSchema.parse(JSON.parse(encoded)); + } catch { + return null; + } +} + +export function buildDecisionResponseContent( + decision: DecisionCardChoice, + note?: string, +): string { + const labels: Record = { + approve: "✅ Approved", + redraft: "✏️ Redraft requested", + escalate: "↗️ Escalated", + reject: "⛔ Rejected", + }; + const base = `${labels[decision]} — SHADOW / NOT DELIVERED`; + return note?.trim() ? `${base}\n\n> ${note.trim()}` : base; +} + +export function buildDecisionResponseTags(input: { + actionId: string; + cardEventId: string; + cardId: string; + channelId: string; + decision: DecisionCardChoice; + note?: string; + payloadHash: string; + rootEventId?: string | null; +}): string[][] { + const payload: DecisionResponsePayload = { + schema_version: 1, + action_id: input.actionId, + card_id: input.cardId, + decision: input.decision, + payload_hash: input.payloadHash.toLowerCase(), + note: input.note?.trim() || undefined, + shadow: true, + }; + decisionResponsePayloadSchema.parse(payload); + + const tags: string[][] = [["h", input.channelId]]; + if (input.rootEventId && input.rootEventId !== input.cardEventId) { + tags.push(["e", input.rootEventId, "", "root"]); + } + tags.push(["e", input.cardEventId, "", "reply"]); + tags.push(["decision_response", JSON.stringify(payload)]); + tags.push(["payload_hash", payload.payload_hash]); + tags.push(["shadow", "1"]); + return tags; +} + +export async function publishDecisionResponse(input: { + cardEventId: string; + cardId: string; + channelId: string; + decision: DecisionCardChoice; + note?: string; + payloadHash: string; + rootEventId?: string | null; +}): Promise { + const event = await signRelayEvent({ + kind: KIND_STREAM_DECISION_RESPONSE, + content: buildDecisionResponseContent(input.decision, input.note), + tags: buildDecisionResponseTags({ + ...input, + actionId: crypto.randomUUID(), + }), + }); + return relayClient.publishEvent( + event, + "Timed out while recording the decision.", + "Failed to record the decision.", + ); +} + +export function selectDecisionResponse( + events: RelayEvent[], + cardId: string, + payloadHash: string, +): RelayEvent | null { + return ( + events + .filter((event) => { + const payload = parseDecisionResponse(event.tags); + return ( + payload?.card_id === cardId && + payload.payload_hash.toLowerCase() === payloadHash.toLowerCase() + ); + }) + .sort( + (left, right) => + left.created_at - right.created_at || left.id.localeCompare(right.id), + )[0] ?? null + ); +} diff --git a/desktop/src/features/decision-cards/ui/DecisionCard.tsx b/desktop/src/features/decision-cards/ui/DecisionCard.tsx new file mode 100644 index 00000000000..95261a23e3e --- /dev/null +++ b/desktop/src/features/decision-cards/ui/DecisionCard.tsx @@ -0,0 +1,266 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ArrowUpRight, + Check, + CircleAlert, + FilePenLine, + ShieldCheck, + X, +} from "lucide-react"; +import * as React from "react"; + +import { + type DecisionCardChoice, + parseDecisionCard, + parseDecisionResponse, + publishDecisionResponse, + selectDecisionResponse, +} from "@/features/decision-cards/lib/decisionCards"; +import type { TimelineMessage } from "@/features/messages/types"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_STREAM_DECISION_RESPONSE } from "@/shared/constants/kinds"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { Textarea } from "@/shared/ui/textarea"; + +const choicePresentation = { + approve: { + label: "Approve", + outcome: "Approved", + icon: Check, + variant: "default", + }, + redraft: { + label: "Redraft", + outcome: "Redraft requested", + icon: FilePenLine, + variant: "outline", + }, + escalate: { + label: "Escalate", + outcome: "Escalated", + icon: ArrowUpRight, + variant: "secondary", + }, + reject: { + label: "Reject", + outcome: "Rejected", + icon: X, + variant: "destructive", + }, +} as const; + +function responseQueryKey(cardEventId: string) { + return ["decision-card-response", cardEventId] as const; +} + +export function DecisionCard({ + channelId, + message, +}: { + channelId: string | null; + message: TimelineMessage; +}) { + const parsed = React.useMemo( + () => parseDecisionCard(message.tags ?? []), + [message.tags], + ); + const queryClient = useQueryClient(); + const [note, setNote] = React.useState(""); + const responseQuery = useQuery({ + enabled: parsed !== null, + queryKey: responseQueryKey(message.id), + queryFn: () => + relayClient.fetchEvents({ + kinds: [KIND_STREAM_DECISION_RESPONSE], + limit: 20, + "#e": [message.id], + }), + staleTime: 10_000, + }); + const responseEvent = parsed + ? selectDecisionResponse( + responseQuery.data ?? [], + parsed.payload.card_id, + parsed.payloadHash, + ) + : null; + const response = responseEvent + ? parseDecisionResponse(responseEvent.tags) + : null; + const responseMutation = useMutation({ + mutationFn: (decision: DecisionCardChoice) => { + if (!parsed || !channelId) { + throw new Error("Decision card is missing its channel context."); + } + return publishDecisionResponse({ + cardEventId: message.id, + cardId: parsed.payload.card_id, + channelId, + decision, + note, + payloadHash: parsed.payloadHash, + rootEventId: message.rootId, + }); + }, + onSuccess: (event) => { + queryClient.setQueryData(responseQueryKey(message.id), [ + event, + ]); + }, + }); + + if (!parsed) { + return

Invalid decision card.

; + } + + const { payload } = parsed; + const expired = + payload.expires_at !== undefined && + payload.expires_at <= Math.floor(Date.now() / 1_000); + + return ( +
+
+
+ Decision + {payload.shadow ? Shadow : null} + + {payload.card_id.slice(0, 8)} + +
+

+ {payload.title} +

+

+ {payload.situation} +

+
+ +
+
+
+ Recommendation +
+

{payload.recommendation}

+
+
+
+ Risk +
+

{payload.risk}

+
+
+ +
+

+ Exact proposed action +

+

+ {payload.proposed_action} +

+ {payload.record_url ? ( + + Open authoritative record + + ) : null} +
+ +
+ {response ? ( + + ) : expired ? ( +

+ This decision card has expired. +

+ ) : responseQuery.isError ? ( +

+ Prior decisions could not be verified. Actions are disabled. +

+ ) : ( + <> +