Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions crates/buzz-core/src/decision_card.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// Ordered choices shown to the human.
pub choices: Vec<DecisionCardChoice>,
/// Optional Unix-seconds expiry.
pub expires_at: Option<i64>,
/// 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<String, serde_json::Error> {
serde_json::to_string(self)
}

/// SHA-256 digest of the canonical structured payload.
pub fn payload_hash(&self) -> Result<String, serde_json::Error> {
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<String>,
/// 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(())
}
}
6 changes: 6 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.).
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions crates/buzz-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 10 additions & 6 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +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_PRIVATE_MANAGED_AGENT, 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_READ_STATE, KIND_REPORT, 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;
Expand Down Expand Up @@ -303,6 +303,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
| KIND_STREAM_MESSAGE_SCHEDULED
| KIND_STREAM_REMINDER
| KIND_STREAM_MESSAGE_DIFF
| KIND_STREAM_DECISION_CARD
| KIND_STREAM_DECISION_RESPONSE
| KIND_FORUM_POST
| KIND_FORUM_VOTE
| KIND_FORUM_COMMENT => Ok(Scope::MessagesWrite),
Expand Down Expand Up @@ -536,6 +538,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
Expand Down
90 changes: 89 additions & 1 deletion crates/buzz-sdk/src/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<EventBuilder, SdkError> {
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<EventBuilder, SdkError> {
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
Expand Down
Loading