From afe57e5cb44022a3ee42473fc0eec356af8ff0f3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 27 Aug 2026 17:46:06 -0400 Subject: [PATCH 01/33] Wake agents from verified workflow mentions Signed-off-by: Logan Johnson Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- Cargo.lock | 1 + crates/buzz-acp/Cargo.toml | 1 + crates/buzz-acp/src/lib.rs | 51 ++++ crates/buzz-acp/src/relay.rs | 150 ++++++++-- crates/buzz-acp/src/workflow_wake.rs | 347 ++++++++++++++++++++++++ crates/buzz-core/src/kind.rs | 4 + crates/buzz-core/src/lib.rs | 2 + crates/buzz-core/src/workflow_wake.rs | 327 ++++++++++++++++++++++ crates/buzz-relay/src/api/workflows.rs | 90 ++++++ crates/buzz-relay/src/handlers/event.rs | 22 ++ crates/buzz-relay/src/handlers/req.rs | 25 +- crates/buzz-relay/src/router.rs | 4 + crates/buzz-relay/src/workflow_sink.rs | 178 +++++++++++- crates/buzz-workflow/src/action_sink.rs | 24 +- crates/buzz-workflow/src/executor.rs | 8 +- 15 files changed, 1190 insertions(+), 44 deletions(-) create mode 100644 crates/buzz-acp/src/workflow_wake.rs create mode 100644 crates/buzz-core/src/workflow_wake.rs diff --git a/Cargo.lock b/Cargo.lock index 9544a63b899..b9ed077f4b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -846,6 +846,7 @@ dependencies = [ "rustls", "serde", "serde_json", + "serde_yaml", "sha2 0.11.0", "thiserror 2.0.18", "tokio", diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806f..6dffb85bc19 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -41,6 +41,7 @@ reqwest = { workspace = true } # Serialization serde = { workspace = true } serde_json = { workspace = true } +serde_yaml = { workspace = true } # IDs uuid = { workspace = true } diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index af504a11768..c5600619fd8 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -14,6 +14,7 @@ mod relay; mod scope; mod setup_mode; mod usage; +mod workflow_wake; pub use usage::TurnUsage; @@ -2510,6 +2511,12 @@ async fn tokio_main() -> Result<()> { tracing::warn!("failed to set startup watermark: {e}"); } + let workflow_relay_pubkey = relay + .rest_client() + .relay_signing_pubkey() + .await + .map_err(|e| anyhow::anyhow!("relay signing identity error: {e}"))?; + tracing::info!("connected to relay at {}", config.relay_url); let relay_rest_client = relay.rest_client(); @@ -2605,6 +2612,7 @@ async fn tokio_main() -> Result<()> { kinds: config.kinds_override.clone().unwrap_or_else(|| { vec![ KIND_STREAM_MESSAGE, + buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE, KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_STREAM_REMINDER, ] @@ -3136,6 +3144,49 @@ async fn tokio_main() -> Result<()> { Some(buzz_event) => { let kind_u32 = buzz_event.event.kind.as_u16() as u32; + let (buzz_event, admission_author_override) = if kind_u32 + == buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE + { + let Some(wake) = buzz_core::workflow_wake::WorkflowMentionWake::parse( + &buzz_event.event, + ) + .ok() + else { + continue; + }; + let authority = match ctx + .rest_client + .workflow_wake_authority(wake.run_id(), &wake.message_event_id()) + .await + { + Ok(authority) => authority, + Err(error) => { + tracing::warn!(%error, "workflow wake authority unavailable"); + continue; + } + }; + let Some((message, signed_author)) = workflow_wake::verify( + &buzz_event.event, + authority, + workflow_relay_pubkey, + config.keys.public_key(), + buzz_event.channel_id, + ) else { + tracing::warn!("workflow wake authority verification failed"); + continue; + }; + ( + relay::BuzzEvent { + channel_id: buzz_event.channel_id, + event: message, + }, + Some(signed_author), + ) + } else { + (buzz_event, None) + }; + let kind_u32 = buzz_event.event.kind.as_u16() as u32; + if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION || kind_u32 == KIND_MEMBER_REMOVED_NOTIFICATION { diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index e4e41b4660d..0e2224bbb3f 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -479,6 +479,55 @@ impl RestClient { .await } + /// Fetch the relay's advertised signing identity from NIP-11 `/info`. + pub async fn relay_signing_pubkey(&self) -> Result { + let url = format!("{}/info", self.base_url.trim_end_matches('/')); + let value: Value = self + .http + .get(&url) + .header("Accept", "application/nostr+json") + .send() + .await + .map_err(|error| RelayError::Http(error.to_string()))? + .json() + .await + .map_err(|error| RelayError::Http(error.to_string()))?; + let relay_self = value + .get("self") + .and_then(Value::as_str) + .ok_or_else(|| RelayError::Http("relay did not advertise a signing identity".into()))?; + nostr::PublicKey::from_hex(relay_self) + .map_err(|error| RelayError::Http(format!("invalid relay signing identity: {error}"))) + } + + async fn bridge_get(&self, path: &str) -> Result { + let url = format!("{}{}", self.base_url, path); + let auth_tag_header = self.auth_tag_json.clone(); + self.request_with_retry("GET", path, || { + let auth = self.nip98_header("GET", &url, None).unwrap_or_default(); + let mut request = self.http.get(&url).header("Authorization", auth); + if let Some(ref tag) = auth_tag_header { + request = request.header("x-auth-tag", tag); + } + request.send() + }) + .await + } + + /// Fetch one exact workflow-wake authority bundle. + pub async fn workflow_wake_authority( + &self, + run_id: uuid::Uuid, + message_id: &nostr::EventId, + ) -> Result { + let path = format!("/workflow-wakes/{run_id}/{}", message_id.to_hex()); + self.bridge_get(&path) + .await? + .json() + .await + .map_err(|error| RelayError::Http(error.to_string())) + } + /// Query events via the HTTP bridge: `POST /query` with NIP-98 auth. /// /// Accepts a slice of `nostr::Filter` (serialized as JSON array). @@ -3384,33 +3433,68 @@ async fn wait_for_reconnect( /// history. On reconnect (`since` is `Some`) subtracts [`SINCE_SKEW_SECS`]. /// /// Returns `true` if the REQ was successfully written to the WebSocket. -async fn send_subscribe( - ws: &mut WsStream, - _state: &BgState, +fn build_channel_req( + sub_id: &str, channel_id: Uuid, agent_pubkey_hex: &str, - since: Option, + since_ts: u64, filter: &ChannelFilter, -) -> bool { - let sub_id = channel_sub_id(channel_id); - +) -> Value { let mut req_filter = serde_json::Map::new(); - // kinds — omit entirely for wildcard subscriptions. - if let Some(ref kinds) = filter.kinds { + // The recipient-gated wake kind always gets its own exact #p filter. This + // preserves `--no-mention-filter` for ordinary channel events without + // weakening wake recipient gating or causing the relay to reject the mixed + // subscription. + let wake_kind = buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE; + let includes_wake = filter + .kinds + .as_ref() + .is_some_and(|kinds| kinds.contains(&wake_kind)); + let normal_kinds = filter.kinds.as_ref().map(|kinds| { + kinds + .iter() + .copied() + .filter(|kind| *kind != wake_kind) + .collect::>() + }); + + if let Some(kinds) = normal_kinds.as_ref().filter(|kinds| !kinds.is_empty()) { req_filter.insert("kinds".into(), json!(kinds)); } - - // #h — always present (channel scope). req_filter.insert("#h".into(), json!([channel_id.to_string()])); - - // #p — only when require_mention is true. if filter.require_mention { req_filter.insert("#p".into(), json!([agent_pubkey_hex])); } + req_filter.insert("since".into(), json!(since_ts)); + + let mut req_filters = Vec::new(); + if normal_kinds.as_ref().is_none_or(|kinds| !kinds.is_empty()) { + req_filters.push(Value::Object(req_filter)); + } + if includes_wake { + let mut wake_filter = serde_json::Map::new(); + wake_filter.insert("kinds".into(), json!([wake_kind])); + wake_filter.insert("#h".into(), json!([channel_id.to_string()])); + wake_filter.insert("#p".into(), json!([agent_pubkey_hex])); + wake_filter.insert("since".into(), json!(since_ts)); + req_filters.push(Value::Object(wake_filter)); + } - // since — on first subscribe use current time to skip history; on reconnect - // subtract skew buffer to catch events missed during the disconnect window. + let mut req = vec![json!("REQ"), json!(sub_id)]; + req.extend(req_filters); + Value::Array(req) +} + +async fn send_subscribe( + ws: &mut WsStream, + _state: &BgState, + channel_id: Uuid, + agent_pubkey_hex: &str, + since: Option, + filter: &ChannelFilter, +) -> bool { + let sub_id = channel_sub_id(channel_id); let since_ts = match since { Some(ts) => ts.saturating_sub(SINCE_SKEW_SECS), None => std::time::SystemTime::now() @@ -3418,9 +3502,7 @@ async fn send_subscribe( .unwrap_or_default() .as_secs(), }; - req_filter.insert("since".into(), json!(since_ts)); - - let req = json!(["REQ", sub_id, Value::Object(req_filter)]); + let req = build_channel_req(&sub_id, channel_id, agent_pubkey_hex, since_ts, filter); match serde_json::to_string(&req) { Ok(text) => { @@ -4363,6 +4445,38 @@ mod tests { server.abort(); } + #[test] + fn workflow_wake_uses_exact_recipient_filter_when_mentions_are_disabled() { + let channel = Uuid::new_v4(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let req = build_channel_req( + "sub", + channel, + &agent, + 123, + &ChannelFilter { + kinds: Some(vec![ + buzz_core::kind::KIND_STREAM_MESSAGE, + buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE, + ]), + require_mention: false, + }, + ); + let filters = req.as_array().expect("REQ array"); + assert_eq!(filters.len(), 4); + assert_eq!( + filters[2]["kinds"], + json!([buzz_core::kind::KIND_STREAM_MESSAGE]) + ); + assert!(filters[2].get("#p").is_none()); + assert_eq!( + filters[3]["kinds"], + json!([buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE]) + ); + assert_eq!(filters[3]["#p"], json!([agent])); + assert_eq!(filters[3]["#h"], json!([channel.to_string()])); + } + #[test] fn relay_ws_to_http_plain() { assert_eq!( diff --git a/crates/buzz-acp/src/workflow_wake.rs b/crates/buzz-acp/src/workflow_wake.rs new file mode 100644 index 00000000000..4c6f4ec925c --- /dev/null +++ b/crates/buzz-acp/src/workflow_wake.rs @@ -0,0 +1,347 @@ +//! Fail-closed verification of relay-signed workflow mention wakes. + +use buzz_core::kind::{KIND_STREAM_MESSAGE, KIND_WORKFLOW_DEF}; +use buzz_core::workflow_wake::WorkflowMentionWake; +use nostr::{Event, PublicKey}; +use serde::Deserialize; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +struct WorkflowAuthority { + steps: Vec, +} + +#[derive(Debug, Deserialize)] +struct WorkflowAuthorityStep { + id: String, + action: String, + #[serde(default)] + channel: Option, +} + +/// Exact public authority bundle returned by the authenticated relay read. +#[derive(Debug, Deserialize)] +pub struct WorkflowWakeAuthority { + /// Exact run ID. + pub run_id: Uuid, + /// Exact workflow channel. + pub channel_id: Uuid, + /// Workflow ID named by the signed definition. + pub workflow_id: Uuid, + /// Exact signed definition revision ID. + pub definition_event_id: String, + /// Workflow owner authenticated against relay workflow state. + pub workflow_owner: String, + /// Owner-signed workflow definition. + pub definition: Event, + /// Relay-signed visible message. + pub message: Event, +} + +/// Verify every authority edge and return the visible message plus its signed author principal. +pub fn verify( + wake_event: &Event, + authority: WorkflowWakeAuthority, + relay_pubkey: PublicKey, + agent_pubkey: PublicKey, + subscription_channel: Uuid, +) -> Option<(Event, String)> { + if wake_event.pubkey != relay_pubkey || wake_event.verify().is_err() { + return None; + } + let wake = WorkflowMentionWake::parse(wake_event).ok()?; + if wake.recipient() != agent_pubkey + || authority.workflow_owner != authority.definition.pubkey.to_hex() + || wake.run_id() != authority.run_id + || wake.channel_id() != subscription_channel + || wake.channel_id() != authority.channel_id + || wake.definition_event_id().to_hex() != authority.definition_event_id + || wake.message_event_id() != authority.message.id + { + return None; + } + + let definition = authority.definition; + if definition.verify().is_err() + || definition.kind.as_u16() as u32 != KIND_WORKFLOW_DEF + || definition.id != wake.definition_event_id() + || !exact_tag(&definition, "d", &authority.workflow_id.to_string()) + { + return None; + } + let channel = single_tag(&definition, "h")?; + if channel != authority.channel_id.to_string() { + return None; + } + let message = authority.message; + if message.verify().is_err() + || message.pubkey != relay_pubkey + || message.kind.as_u16() as u32 != KIND_STREAM_MESSAGE + || !exact_tag(&message, "h", channel) + || !contains_tag(&message, "p", &agent_pubkey.to_hex()) + || !exact_tag(&message, "workflow-run", &authority.run_id.to_string()) + || !exact_tag(&message, "workflow-definition", &definition.id.to_hex()) + { + return None; + } + let step_id = single_tag(&message, "workflow-step")?; + let workflow: WorkflowAuthority = serde_yaml::from_str(&definition.content).ok()?; + let step = workflow.steps.iter().find(|step| step.id == step_id)?; + if step.action != "send_message" { + return None; + } + if step + .channel + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some_and(|value| value != channel) + { + return None; + } + Some((message, definition.pubkey.to_hex())) +} + +fn single_tag<'a>(event: &'a Event, name: &str) -> Option<&'a str> { + let mut matches = event.tags.iter().filter_map(|tag| { + let values = tag.as_slice(); + (values.len() == 2 && values[0] == name).then(|| values[1].as_str()) + }); + let value = matches.next()?; + matches.next().is_none().then_some(value) +} + +fn exact_tag(event: &Event, name: &str, value: &str) -> bool { + single_tag(event, name).is_some_and(|actual| actual.eq_ignore_ascii_case(value)) +} + +fn contains_tag(event: &Event, name: &str, value: &str) -> bool { + event.tags.iter().any(|tag| { + let values = tag.as_slice(); + values.len() == 2 && values[0] == name && values[1].eq_ignore_ascii_case(value) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + struct Fixture { + relay: Keys, + agent: Keys, + owner: Keys, + channel: Uuid, + run: Uuid, + workflow: Uuid, + definition: Event, + message: Event, + wake: Event, + } + + impl Fixture { + fn new(definition_content: &str, target_channel: Option) -> Self { + let relay = Keys::generate(); + let agent = Keys::generate(); + let owner = Keys::generate(); + let channel = Uuid::new_v4(); + let run = Uuid::new_v4(); + let workflow = Uuid::new_v4(); + let definition = EventBuilder::new( + Kind::Custom(KIND_WORKFLOW_DEF as u16), + definition_content + .replace("$CHANNEL", &target_channel.unwrap_or(channel).to_string()), + ) + .tags([ + Tag::parse(["d", &workflow.to_string()]).expect("d tag"), + Tag::parse(["h", &channel.to_string()]).expect("h tag"), + ]) + .sign_with_keys(&owner) + .expect("definition"); + let message = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "do work") + .tags([ + Tag::parse(["h", &channel.to_string()]).expect("h tag"), + Tag::parse(["p", &agent.public_key().to_hex()]).expect("p tag"), + Tag::parse(["workflow-run", &run.to_string()]).expect("run tag"), + Tag::parse(["workflow-definition", &definition.id.to_hex()]) + .expect("definition tag"), + Tag::parse(["workflow-step", "notify"]).expect("step tag"), + ]) + .sign_with_keys(&relay) + .expect("message"); + let wake = WorkflowMentionWake::new( + agent.public_key(), + channel, + run, + definition.id, + message.id, + ) + .sign(&relay) + .expect("wake"); + Self { + relay, + agent, + owner, + channel, + run, + workflow, + definition, + message, + wake, + } + } + + fn valid() -> Self { + Self::new( + "name: wake\ntrigger:\n on: message_posted\nsteps:\n - id: notify\n action: send_message\n text: do work\n channel: $CHANNEL\n", + None, + ) + } + + fn authority(&self) -> WorkflowWakeAuthority { + WorkflowWakeAuthority { + run_id: self.run, + channel_id: self.channel, + workflow_id: self.workflow, + definition_event_id: self.definition.id.to_hex(), + workflow_owner: self.owner.public_key().to_hex(), + definition: self.definition.clone(), + message: self.message.clone(), + } + } + + fn verify(&self, authority: WorkflowWakeAuthority) -> Option<(Event, String)> { + super::verify( + &self.wake, + authority, + self.relay.public_key(), + self.agent.public_key(), + self.channel, + ) + } + } + + #[test] + fn accepts_exact_authority_and_returns_signed_owner() { + let fixture = Fixture::valid(); + let (message, author) = fixture.verify(fixture.authority()).expect("verified"); + assert_eq!(message.id, fixture.message.id); + assert_eq!(author, fixture.owner.public_key().to_hex()); + } + + #[test] + fn rejects_wrong_wake_signer_or_recipient() { + let fixture = Fixture::valid(); + assert!(super::verify( + &fixture.wake, + fixture.authority(), + Keys::generate().public_key(), + fixture.agent.public_key(), + fixture.channel, + ) + .is_none()); + assert!(super::verify( + &fixture.wake, + fixture.authority(), + fixture.relay.public_key(), + Keys::generate().public_key(), + fixture.channel, + ) + .is_none()); + } + + #[test] + fn rejects_mismatched_run_revision_message_channel_and_owner() { + let fixture = Fixture::valid(); + let mut authority = fixture.authority(); + authority.run_id = Uuid::new_v4(); + assert!(fixture.verify(authority).is_none()); + + let mut authority = fixture.authority(); + authority.definition_event_id = EventBuilder::text_note("other") + .sign_with_keys(&Keys::generate()) + .expect("event") + .id + .to_hex(); + assert!(fixture.verify(authority).is_none()); + + let mut authority = fixture.authority(); + authority.message = EventBuilder::text_note("other") + .sign_with_keys(&fixture.relay) + .expect("event"); + assert!(fixture.verify(authority).is_none()); + + let mut authority = fixture.authority(); + authority.channel_id = Uuid::new_v4(); + assert!(fixture.verify(authority).is_none()); + + let mut authority = fixture.authority(); + authority.workflow_owner = Keys::generate().public_key().to_hex(); + assert!(fixture.verify(authority).is_none()); + } + + #[test] + fn rejects_malformed_or_non_send_message_instruction() { + let malformed = Fixture::new("not: [valid", None); + assert!(malformed.verify(malformed.authority()).is_none()); + + let other_action = Fixture::new( + "name: wake\ntrigger:\n on: message_posted\nsteps:\n - id: notify\n action: add_reaction\n emoji: thumbsup\n", + None, + ); + assert!(other_action.verify(other_action.authority()).is_none()); + } + + #[test] + fn rejects_wrong_step_or_target_channel() { + let fixture = Fixture::valid(); + let wrong_step_message = + EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "do work") + .tags([ + Tag::parse(["h", &fixture.channel.to_string()]).expect("h tag"), + Tag::parse(["p", &fixture.agent.public_key().to_hex()]).expect("p tag"), + Tag::parse(["workflow-run", &fixture.run.to_string()]).expect("run tag"), + Tag::parse(["workflow-definition", &fixture.definition.id.to_hex()]) + .expect("definition tag"), + Tag::parse(["workflow-step", "missing"]).expect("step tag"), + ]) + .sign_with_keys(&fixture.relay) + .expect("message"); + let wrong_step_wake = WorkflowMentionWake::new( + fixture.agent.public_key(), + fixture.channel, + fixture.run, + fixture.definition.id, + wrong_step_message.id, + ) + .sign(&fixture.relay) + .expect("wake"); + let mut authority = fixture.authority(); + authority.message = wrong_step_message; + assert!(super::verify( + &wrong_step_wake, + authority, + fixture.relay.public_key(), + fixture.agent.public_key(), + fixture.channel, + ) + .is_none()); + + let wrong_target = Fixture::new( + "name: wake\ntrigger:\n on: message_posted\nsteps:\n - id: notify\n action: send_message\n text: do work\n channel: $CHANNEL\n", + Some(Uuid::new_v4()), + ); + assert!(wrong_target.verify(wrong_target.authority()).is_none()); + } + + #[test] + fn wake_kind_remains_identifier_only() { + let fixture = Fixture::valid(); + assert_eq!( + fixture.wake.kind.as_u16() as u32, + KIND_WORKFLOW_MENTION_WAKE + ); + assert!(fixture.wake.content.is_empty()); + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913d..474f3864b6b 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -158,6 +158,7 @@ pub const RESULT_GATED_KINDS: &[u32] = &[KIND_DM_VISIBILITY, KIND_AGENT_TURN_MET /// storage-layer search defense does not apply to them. pub const P_GATED_KINDS: &[u32] = &[ KIND_AGENT_OBSERVER_FRAME, + KIND_WORKFLOW_MENTION_WAKE, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_GIFT_WRAP, @@ -467,6 +468,8 @@ pub const KIND_PAIRING: u32 = 24134; pub const KIND_TYPING_INDICATOR: u32 = 20002; /// Ephemeral: owner-scoped encrypted agent observer telemetry and control frame. pub const KIND_AGENT_OBSERVER_FRAME: u32 = 24200; +/// Ephemeral: relay-signed identifier-only workflow mention wake. +pub const KIND_WORKFLOW_MENTION_WAKE: u32 = 24620; /// Ephemeral: huddle emoji reaction burst. Channel-scoped to the ephemeral /// huddle channel with an `h` tag; never stored in the timeline. pub const KIND_HUDDLE_REACTION: u32 = 24810; @@ -698,6 +701,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_BLOSSOM_AUTH, KIND_PAIRING, KIND_AGENT_OBSERVER_FRAME, + KIND_WORKFLOW_MENTION_WAKE, KIND_HTTP_AUTH, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 36dc772da3b..574abc9e889 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -42,6 +42,8 @@ pub mod relay; pub mod tenant; /// Schnorr signature and event ID verification. pub mod verification; +/// Identifier-only workflow mention wake hints. +pub mod workflow_wake; pub use error::VerificationError; pub use event::StoredEvent; diff --git a/crates/buzz-core/src/workflow_wake.rs b/crates/buzz-core/src/workflow_wake.rs new file mode 100644 index 00000000000..b5d60ab2005 --- /dev/null +++ b/crates/buzz-core/src/workflow_wake.rs @@ -0,0 +1,327 @@ +//! Identifier-only wake hints for verified workflow mentions. +//! +//! A wake grants no instruction authority. Receivers must authenticate the +//! relay and fetch the exact run-bound workflow definition and visible message +//! before dispatching anything. + +use nostr::{Event, EventBuilder, EventId, Keys, Kind, PublicKey, Tag}; +use thiserror::Error; +use uuid::Uuid; + +use crate::kind::KIND_WORKFLOW_MENTION_WAKE; + +/// A relay-signed, ephemeral hint that a workflow message mentioned one agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WorkflowMentionWake { + recipient: PublicKey, + channel_id: Uuid, + run_id: Uuid, + definition_event_id: EventId, + message_event_id: EventId, +} + +impl WorkflowMentionWake { + /// Construct an identifier-only wake. + pub const fn new( + recipient: PublicKey, + channel_id: Uuid, + run_id: Uuid, + definition_event_id: EventId, + message_event_id: EventId, + ) -> Self { + Self { + recipient, + channel_id, + run_id, + definition_event_id, + message_event_id, + } + } + + /// Sign the canonical empty-content event with the relay identity. + pub fn sign(self, relay_keys: &Keys) -> Result { + EventBuilder::new(Kind::Custom(KIND_WORKFLOW_MENTION_WAKE as u16), "") + .tags(self.canonical_tags()?) + .sign_with_keys(relay_keys) + .map_err(|error| WorkflowMentionWakeError::Signing(error.to_string())) + } + + /// Parse the exact canonical wire shape. Unknown, duplicate, or malformed + /// identity tags are rejected rather than ignored. + pub fn parse(event: &Event) -> Result { + if event.kind.as_u16() as u32 != KIND_WORKFLOW_MENTION_WAKE { + return Err(WorkflowMentionWakeError::WrongKind(event.kind.as_u16())); + } + if !event.content.is_empty() { + return Err(WorkflowMentionWakeError::NonEmptyContent); + } + if event.tags.len() != 5 { + return Err(WorkflowMentionWakeError::WrongTagCount(event.tags.len())); + } + + let tags: Vec<&[String]> = event.tags.iter().map(|tag| tag.as_slice()).collect(); + let recipient = parse_single(&tags, "p", PublicKey::from_hex)?; + let channel_id = parse_single(&tags, "h", |value| value.parse::())?; + let run_id = parse_single(&tags, "run", |value| { + value + .parse::() + .ok() + .filter(|id| !id.is_nil()) + .ok_or(()) + })?; + let definition_event_id = parse_single(&tags, "definition", EventId::from_hex)?; + let message_event_id = parse_single(&tags, "message", EventId::from_hex)?; + + let wake = Self::new( + recipient, + channel_id, + run_id, + definition_event_id, + message_event_id, + ); + let canonical = [ + vec!["p".to_string(), wake.recipient.to_hex()], + vec!["h".to_string(), wake.channel_id.to_string()], + vec!["run".to_string(), wake.run_id.to_string()], + vec!["definition".to_string(), wake.definition_event_id.to_hex()], + vec!["message".to_string(), wake.message_event_id.to_hex()], + ]; + if tags + .iter() + .zip(canonical.iter()) + .any(|(actual, expected)| *actual != expected.as_slice()) + { + return Err(WorkflowMentionWakeError::NonCanonicalTags); + } + Ok(wake) + } + + /// Intended recipient. + pub const fn recipient(self) -> PublicKey { + self.recipient + } + + /// Workflow channel carrying the visible generated message. + pub const fn channel_id(self) -> Uuid { + self.channel_id + } + + /// Exact workflow run. + pub const fn run_id(self) -> Uuid { + self.run_id + } + + /// Exact signed workflow-definition revision selected by the run. + pub const fn definition_event_id(self) -> EventId { + self.definition_event_id + } + + /// Exact visible workflow message to dispatch after verification. + pub const fn message_event_id(self) -> EventId { + self.message_event_id + } + + fn canonical_tags(self) -> Result, WorkflowMentionWakeError> { + [ + vec!["p".to_string(), self.recipient.to_hex()], + vec!["h".to_string(), self.channel_id.to_string()], + vec!["run".to_string(), self.run_id.to_string()], + vec!["definition".to_string(), self.definition_event_id.to_hex()], + vec!["message".to_string(), self.message_event_id.to_hex()], + ] + .into_iter() + .map(|values| { + Tag::parse(values).map_err(|error| WorkflowMentionWakeError::Tag(error.to_string())) + }) + .collect() + } +} + +fn parse_single( + tags: &[&[String]], + name: &'static str, + parse: impl FnOnce(&str) -> Result, +) -> Result { + let matches: Vec<_> = tags + .iter() + .filter(|tag| tag.first().map(String::as_str) == Some(name)) + .collect(); + if matches.len() != 1 || matches[0].len() != 2 { + return Err(WorkflowMentionWakeError::InvalidTag(name)); + } + parse(&matches[0][1]).map_err(|_| WorkflowMentionWakeError::InvalidTag(name)) +} + +/// Invalid workflow mention wake. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum WorkflowMentionWakeError { + /// Event kind is not the workflow mention wake kind. + #[error("wrong workflow mention wake kind: {0}")] + WrongKind(u16), + /// Wake content must be empty. + #[error("workflow mention wake content must be empty")] + NonEmptyContent, + /// Wake must contain exactly the five canonical identity tags. + #[error("wrong workflow mention wake tag count: {0}")] + WrongTagCount(usize), + /// A required identity tag is missing, duplicated, malformed, or has extra fields. + #[error("invalid workflow mention wake {0} tag")] + InvalidTag(&'static str), + /// Tags are not in canonical order or contain a non-canonical representation. + #[error("workflow mention wake tags are not canonical")] + NonCanonicalTags, + /// Canonical tag construction failed. + #[error("workflow mention wake tag construction failed: {0}")] + Tag(String), + /// Event signing failed. + #[error("workflow mention wake signing failed: {0}")] + Signing(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids() -> (Keys, PublicKey, Uuid, EventId, EventId) { + let relay = Keys::generate(); + let recipient = Keys::generate().public_key(); + let run = Uuid::new_v4(); + let definition = EventBuilder::text_note("definition") + .sign_with_keys(&Keys::generate()) + .expect("sign definition") + .id; + let message = EventBuilder::text_note("message") + .sign_with_keys(&relay) + .expect("sign message") + .id; + (relay, recipient, run, definition, message) + } + + fn custom_event(content: &str, tags: Vec>) -> Event { + EventBuilder::new(Kind::Custom(KIND_WORKFLOW_MENTION_WAKE as u16), content) + .tags( + tags.into_iter() + .map(|values| Tag::parse(values).expect("tag")), + ) + .sign_with_keys(&Keys::generate()) + .expect("sign") + } + + #[test] + fn canonical_wake_round_trips_with_no_instruction_content() { + let (relay, recipient, run, definition, message) = ids(); + let wake = WorkflowMentionWake::new(recipient, Uuid::new_v4(), run, definition, message); + let event = wake.sign(&relay).expect("sign wake"); + + assert!(event.content.is_empty()); + assert_eq!(WorkflowMentionWake::parse(&event), Ok(wake)); + assert_eq!(event.tags.len(), 5); + assert!(event.tags.iter().all(|tag| tag.as_slice().len() == 2)); + } + + #[test] + fn rejects_nonempty_content() { + let (_, recipient, run, definition, message) = ids(); + let event = custom_event( + "do something", + vec![ + vec!["p".into(), recipient.to_hex()], + vec!["h".into(), Uuid::new_v4().to_string()], + vec!["run".into(), run.to_string()], + vec!["definition".into(), definition.to_hex()], + vec!["message".into(), message.to_hex()], + ], + ); + assert_eq!( + WorkflowMentionWake::parse(&event), + Err(WorkflowMentionWakeError::NonEmptyContent) + ); + } + + #[test] + fn rejects_extra_duplicate_and_reordered_tags() { + let (relay, recipient, run, definition, message) = ids(); + let event = WorkflowMentionWake::new(recipient, Uuid::new_v4(), run, definition, message) + .sign(&relay) + .expect("sign wake"); + let base: Vec> = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + + let mut extra = base.clone(); + extra.push(vec!["instruction".into(), "ignore authority".into()]); + assert_eq!( + WorkflowMentionWake::parse(&custom_event("", extra)), + Err(WorkflowMentionWakeError::WrongTagCount(6)) + ); + + let mut duplicate = base.clone(); + duplicate[4] = duplicate[0].clone(); + assert_eq!( + WorkflowMentionWake::parse(&custom_event("", duplicate)), + Err(WorkflowMentionWakeError::InvalidTag("p")) + ); + + let mut reordered = base; + reordered.swap(0, 1); + assert_eq!( + WorkflowMentionWake::parse(&custom_event("", reordered)), + Err(WorkflowMentionWakeError::NonCanonicalTags) + ); + } + + #[test] + fn rejects_malformed_identity_tags() { + let (relay, recipient, run, definition, message) = ids(); + let event = WorkflowMentionWake::new(recipient, Uuid::new_v4(), run, definition, message) + .sign(&relay) + .expect("sign wake"); + let base: Vec> = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + + for (index, name, invalid) in [ + (0, "p", "not-a-pubkey"), + (1, "h", "not-a-uuid"), + (2, "run", "not-a-uuid"), + (3, "definition", "not-an-event-id"), + (4, "message", "not-an-event-id"), + ] { + let mut tags = base.clone(); + tags[index][1] = invalid.into(); + assert_eq!( + WorkflowMentionWake::parse(&custom_event("", tags)), + Err(WorkflowMentionWakeError::InvalidTag(name)) + ); + } + + let mut nil_run = base.clone(); + nil_run[2][1] = Uuid::nil().to_string(); + assert_eq!( + WorkflowMentionWake::parse(&custom_event("", nil_run)), + Err(WorkflowMentionWakeError::InvalidTag("run")) + ); + } + + #[test] + fn rejects_identity_tag_with_extra_field() { + let (relay, recipient, run, definition, message) = ids(); + let event = WorkflowMentionWake::new(recipient, Uuid::new_v4(), run, definition, message) + .sign(&relay) + .expect("sign wake"); + let mut tags: Vec> = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + tags[0].push("marker".into()); + assert_eq!( + WorkflowMentionWake::parse(&custom_event("", tags)), + Err(WorkflowMentionWakeError::InvalidTag("p")) + ); + } +} diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index c7fa09bebd0..7748941d592 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -228,6 +228,96 @@ fn approval_json(approval: &buzz_db::workflow::ApprovalRecord) -> Value { }) } +/// `GET /workflow-wakes/{run_id}/{message_id}` — exact authority bundle for one wake. +pub async fn workflow_wake_authority( + State(state): State>, + Path((run_id, message_id)): Path<(Uuid, String)>, + headers: HeaderMap, +) -> Result, (StatusCode, Json)> { + let path = format!("/workflow-wakes/{run_id}/{message_id}"); + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path); + let (recipient, auth_event_id) = + bridge::verify_bridge_auth(&headers, "GET", &url, None, state.config.require_auth_token)?; + bridge::enforce_http_admission(&state, &tenant, &recipient).await?; + bridge::check_nip98_replay(&state, &tenant, auth_event_id).await?; + + let run = state + .db + .get_workflow_run(tenant.community(), run_id) + .await + .map_err(|_| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; + let workflow = state + .db + .get_workflow(tenant.community(), run.workflow_id) + .await + .map_err(|_| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; + let definition_id = run + .definition_event_id + .as_deref() + .filter(|id| id.len() == 32) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; + let message_id = nostr::EventId::from_hex(&message_id) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid message id"))?; + let definition = state + .db + .get_event_by_id(tenant.community(), definition_id) + .await + .map_err(|error| internal_error(&format!("workflow definition lookup: {error}")))? + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; + let message = state + .db + .get_event_by_id(tenant.community(), message_id.as_bytes()) + .await + .map_err(|error| internal_error(&format!("workflow message lookup: {error}")))? + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; + + let recipient_hex = recipient.to_hex(); + let exact_tag = |event: &nostr::Event, name: &str, value: &str| { + event.tags.iter().any(|tag| { + let values = tag.as_slice(); + values.len() == 2 && values[0] == name && values[1].eq_ignore_ascii_case(value) + }) + }; + let message_channel = message + .channel_id + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; + if workflow.owner_pubkey != definition.event.pubkey.to_bytes() + || workflow.channel_id != Some(message_channel) + || !exact_tag(&definition.event, "h", &message_channel.to_string()) + || !exact_tag(&definition.event, "d", &run.workflow_id.to_string()) + || !exact_tag(&message.event, "h", &message_channel.to_string()) + || !message.event.tags.iter().any(|tag| { + let values = tag.as_slice(); + values.len() == 2 && values[0] == "p" && values[1].eq_ignore_ascii_case(&recipient_hex) + }) + || !exact_tag(&message.event, "workflow-run", &run_id.to_string()) + || !exact_tag( + &message.event, + "workflow-definition", + &definition.event.id.to_hex(), + ) + { + return Err(api_error(StatusCode::NOT_FOUND, "workflow wake not found")); + } + + Ok(Json(serde_json::json!({ + "run_id": run.id, + "channel_id": message_channel, + "workflow_id": run.workflow_id, + "definition_event_id": definition.event.id.to_hex(), + "workflow_owner": hex::encode(&workflow.owner_pubkey), + "definition": definition.event, + "message": message.event, + }))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ccba40f3282..ab74060d605 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -337,6 +337,28 @@ pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pub } } +/// Fan out one relay-generated ephemeral event without passing through client admission. +/// +/// The event is never stored. Global routing plus `P_GATED_KINDS` ensures only a +/// subscription authenticated as the exact `p` recipient can receive a wake. +pub(crate) async fn dispatch_ephemeral_event( + tenant: &TenantContext, + state: &Arc, + event: Event, + channel_id: Option, +) { + state.mark_local_event(tenant.community(), &event.id); + let topic = channel_id.map_or(EventTopic::Global, EventTopic::Channel); + if let Err(error) = state.pubsub.publish_event(tenant, topic, &event).await { + state + .local_event_ids + .invalidate(&(tenant.community(), event.id.to_bytes())); + warn!(event_id = %event.id, %error, "relay-generated ephemeral publish failed"); + } + let stored = StoredEvent::new(event, channel_id); + fan_out_event_to_local_subscribers(state, tenant.community(), &stored).await; +} + /// Schedule post-commit delivery/side effects for a stored event. /// /// This intentionally returns after only the bounded audit enqueue has completed: diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index d299cc045fa..90b8716aab8 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -213,18 +213,21 @@ pub async fn handle_req( // kinds) to harvest indexed-but-globally-stored sensitive events. Search // hits are looked up by event id and returned without the per-filter // post-check the historical-delivery branch applies, so the gate must run - // here, up front. Only applies to GLOBAL subscriptions (channel_id = None): - // channel-scoped subs can never receive globally-stored events because of - // the fan_out() invariant in subscription.rs. + // here, up front. P-gated authorization applies to every subscription; + // the other global-event gates below remain global-only. + // P-gated events require an exact authenticated recipient even when they + // are channel-scoped. Channel membership alone is not authority to observe + // another recipient's ephemeral workflow wake. + let authed_pubkey_hex = hex::encode(&pubkey_bytes); + if !p_gated_filters_authorized(&filters, &authed_pubkey_hex) { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: p-gated events require #p matching your pubkey", + )); + return; + } + if channel_id.is_none() { - let authed_pubkey_hex = hex::encode(&pubkey_bytes); - if !p_gated_filters_authorized(&filters, &authed_pubkey_hex) { - conn.send(RelayMessage::closed( - &sub_id, - "restricted: p-gated events require #p matching your pubkey", - )); - return; - } if !engram_filters_authorized(&filters, &authed_pubkey_hex) { conn.send(RelayMessage::closed( &sub_id, diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index dd0fde6fdcd..8e8d4c56646 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -75,6 +75,10 @@ pub fn build_router(state: Arc) -> Router { // Relay-owned third-party GIF metadata proxy (NIP-98 auth). .route(api::gifs::SEARCH_PATH, post(api::gifs::search)) .route(api::gifs::SHARE_PATH, post(api::gifs::share)) + .route( + "/workflow-wakes/{run_id}/{message_id}", + get(api::workflows::workflow_wake_authority), + ) .route( "/workflows/{workflow_id}/runs", get(api::workflows::workflow_runs), diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 9b86985d7d5..6a137160e1c 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -9,8 +9,8 @@ use std::pin::Pin; use std::sync::{Arc, Weak}; use buzz_core::kind::KIND_STREAM_MESSAGE; -use buzz_core::tenant::CommunityId; -use buzz_workflow::action_sink::{ActionSink, ActionSinkError}; +use buzz_core::workflow_wake::WorkflowMentionWake; +use buzz_workflow::action_sink::{ActionSink, ActionSinkError, WorkflowMessageContext}; use chrono::Utc; use nostr::{EventBuilder, Kind, Tag}; use tracing::info; @@ -205,13 +205,19 @@ impl RelayActionSink { impl ActionSink for RelayActionSink { fn send_message( &self, - community_id: CommunityId, + context: WorkflowMessageContext, channel_id: &str, text: &str, authored_text: &str, author_pubkey: &str, reply_to: Option<&str>, ) -> Pin> + Send + '_>> { + let WorkflowMessageContext { + community_id, + run_id, + step_id, + definition_event_id, + } = context; let channel_id = channel_id.to_owned(); let text = text.to_owned(); let authored_text = authored_text.to_owned(); @@ -387,6 +393,37 @@ impl ActionSink for RelayActionSink { &named_members, &author_pubkey_hex, )?; + let mentioned_pubkeys = tags.iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz:workflow-mention")) + .filter_map(|tag| tag.as_slice().get(1)) + .filter(|pk| *pk != &author_pubkey_hex) + .map(|pk| nostr::PublicKey::from_hex(pk).map_err(|e| ActionSinkError::EventBuild(format!("mention pubkey: {e}")))) + .collect::, _>>()?; + + let definition_event_id = definition_event_id + .as_deref() + .map(nostr::EventId::from_slice) + .transpose() + .map_err(|e| { + ActionSinkError::InvalidInput(format!("invalid definition event id: {e}")) + })?; + if let Some(definition_event_id) = definition_event_id { + tags.push( + Tag::parse(["workflow-run", &run_id.to_string()]).map_err(|e| { + ActionSinkError::EventBuild(format!("workflow run tag: {e}")) + })?, + ); + tags.push( + Tag::parse(["workflow-definition", &definition_event_id.to_hex()]).map_err( + |e| ActionSinkError::EventBuild(format!("workflow definition tag: {e}")), + )?, + ); + tags.push( + Tag::parse(["workflow-step", &step_id]).map_err(|e| { + ActionSinkError::EventBuild(format!("workflow step tag: {e}")) + })?, + ); + } let kind = Kind::from(KIND_STREAM_MESSAGE as u16); let event = EventBuilder::new(kind, &text) @@ -455,6 +492,23 @@ impl ActionSink for RelayActionSink { ) .await; + for wake in build_workflow_wakes( + &state.relay_keypair, + channel_uuid, + run_id, + definition_event_id, + event.id, + mentioned_pubkeys, + )? { + crate::handlers::event::dispatch_ephemeral_event( + &tenant, + &state, + wake, + Some(channel_uuid), + ) + .await; + } + // A threaded reply changed its thread's counters — push a fresh // relay-signed kind:39005 so subscribed clients update badge // counts without refetching the head window, exactly as the @@ -475,6 +529,33 @@ impl ActionSink for RelayActionSink { } } +fn build_workflow_wakes( + relay_keys: &nostr::Keys, + channel_id: Uuid, + run_id: Uuid, + definition_event_id: Option, + message_event_id: nostr::EventId, + recipients: Vec, +) -> Result, ActionSinkError> { + let Some(definition_event_id) = definition_event_id else { + return Ok(Vec::new()); + }; + recipients + .into_iter() + .map(|recipient| { + WorkflowMentionWake::new( + recipient, + channel_id, + run_id, + definition_event_id, + message_event_id, + ) + .sign(relay_keys) + .map_err(|error| ActionSinkError::EventBuild(format!("workflow wake: {error}"))) + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -488,6 +569,62 @@ mod tests { std::iter::repeat_n(nibble, 64).collect() } + #[test] + fn legacy_message_without_revision_emits_no_wake() { + let relay = nostr::Keys::generate(); + let recipient = nostr::Keys::generate().public_key(); + let message = nostr::EventBuilder::text_note("message") + .sign_with_keys(&relay) + .expect("message"); + let wakes = build_workflow_wakes( + &relay, + Uuid::new_v4(), + Uuid::new_v4(), + None, + message.id, + vec![recipient], + ) + .expect("legacy wake build"); + assert!(wakes.is_empty()); + } + + #[test] + fn revision_bound_message_emits_one_identifier_wake_per_recipient() { + let relay = nostr::Keys::generate(); + let recipients = [ + nostr::Keys::generate().public_key(), + nostr::Keys::generate().public_key(), + ]; + let channel = Uuid::new_v4(); + let run = Uuid::new_v4(); + let definition = nostr::EventBuilder::text_note("definition") + .sign_with_keys(&nostr::Keys::generate()) + .expect("definition"); + let message = nostr::EventBuilder::text_note("message") + .sign_with_keys(&relay) + .expect("message"); + let wakes = build_workflow_wakes( + &relay, + channel, + run, + Some(definition.id), + message.id, + recipients.to_vec(), + ) + .expect("wake build"); + assert_eq!(wakes.len(), recipients.len()); + for (event, recipient) in wakes.iter().zip(recipients) { + let wake = WorkflowMentionWake::parse(event).expect("canonical wake"); + assert!(event.content.is_empty()); + assert_eq!(event.pubkey, relay.public_key()); + assert_eq!(wake.recipient(), recipient); + assert_eq!(wake.channel_id(), channel); + assert_eq!(wake.run_id(), run); + assert_eq!(wake.definition_event_id(), definition.id); + assert_eq!(wake.message_event_id(), message.id); + } + } + #[test] fn resolves_exact_member_name() { let members = vec![m("Robby", &pk('a'))]; @@ -1086,7 +1223,12 @@ mod postgres_tests { // 1. A top-level workflow message becomes the thread root. let root_hex = sink .send_message( - community, + WorkflowMessageContext { + community_id: community, + run_id: Uuid::new_v4(), + step_id: "test-step".into(), + definition_event_id: None, + }, &channel.id.to_string(), "root message", "root message", @@ -1099,7 +1241,12 @@ mod postgres_tests { // 2. A reply_in_thread message threads onto it. let reply_hex = sink .send_message( - community, + WorkflowMessageContext { + community_id: community, + run_id: Uuid::new_v4(), + step_id: "test-step".into(), + definition_event_id: None, + }, &channel.id.to_string(), "threaded reply", "threaded reply", @@ -1242,7 +1389,12 @@ mod postgres_tests { // A workflow reply onto the metadata-less nested parent. let reply_hex = RelayActionSink::new(&state) .send_message( - community, + WorkflowMessageContext { + community_id: community, + run_id: Uuid::new_v4(), + step_id: "test-step".into(), + definition_event_id: None, + }, &channel_hex, "workflow reply", "workflow reply", @@ -1324,7 +1476,12 @@ mod postgres_tests { let root_only_reply_hex = RelayActionSink::new(&state) .send_message( - community, + WorkflowMessageContext { + community_id: community, + run_id: Uuid::new_v4(), + step_id: "test-step".into(), + definition_event_id: None, + }, &channel_hex, "workflow reply to root-only parent", "workflow reply to root-only parent", @@ -1387,7 +1544,12 @@ mod postgres_tests { let unknown = nostr::Keys::generate().public_key().to_hex(); let err = RelayActionSink::new(&state) .send_message( - community, + WorkflowMessageContext { + community_id: community, + run_id: Uuid::new_v4(), + step_id: "test-step".into(), + definition_event_id: None, + }, &channel.id.to_string(), "orphan reply", "orphan reply", diff --git a/crates/buzz-workflow/src/action_sink.rs b/crates/buzz-workflow/src/action_sink.rs index b8c7f4dd809..4f136626153 100644 --- a/crates/buzz-workflow/src/action_sink.rs +++ b/crates/buzz-workflow/src/action_sink.rs @@ -7,6 +7,21 @@ use std::future::Future; use std::pin::Pin; use buzz_core::tenant::CommunityId; +use uuid::Uuid; + +/// Workflow authority carried into a message side effect. +#[derive(Debug, Clone)] +pub struct WorkflowMessageContext { + /// Server-resolved community that owns the workflow run. + pub community_id: CommunityId, + /// Exact workflow run driving this side effect. + pub run_id: Uuid, + /// Exact signed-definition step being executed. + pub step_id: String, + /// Exact signed definition selected when the run was created, or `None` + /// for a legacy run that must not emit an automatic wake. + pub definition_event_id: Option>, +} /// Errors from action sink operations. #[derive(Debug, thiserror::Error)] @@ -48,11 +63,8 @@ impl From for crate::WorkflowError { pub trait ActionSink: Send + Sync { /// Post a message to a channel on behalf of a workflow owner. /// - /// - `community_id`: the server-resolved community that owns the workflow - /// run driving this side effect. The relay-signed message is published - /// under *this* community, never the deployment/default tenant — the run - /// carries its owning community so a workflow in community B posts into B - /// even though the side effect has no inbound connection to bind. + /// - `context`: exact workflow authority driving this side effect, including + /// the server-resolved community and optional signed-definition revision /// - `channel_id`: UUID string of the target channel /// - `text`: rendered message body (must not be empty/whitespace-only) /// - `authored_text`: the workflow owner's stored, unrendered step template; @@ -67,7 +79,7 @@ pub trait ActionSink: Send + Sync { /// Returns the event ID hex string on success. fn send_message( &self, - community_id: CommunityId, + context: WorkflowMessageContext, channel_id: &str, text: &str, authored_text: &str, diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index 90a6a02e020..288ec875ea8 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -18,6 +18,7 @@ use serde_json::Value as JsonValue; use tracing::{debug, info, warn}; use uuid::Uuid; +use crate::action_sink::WorkflowMessageContext; use crate::error::WorkflowError; use crate::schema::{ActionDef, Step, WorkflowDef}; use crate::WorkflowEngine; @@ -636,7 +637,12 @@ pub async fn dispatch_action( let event_id = engine .action_sink()? .send_message( - community_id, + WorkflowMessageContext { + community_id, + run_id, + step_id: step_id.to_owned(), + definition_event_id: wf_run.definition_event_id.clone(), + }, &channel_id, text, authored_text, From 3b4890ab7413c6286f7c891d22d57f7c2ee0d9c4 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 27 Aug 2026 17:52:52 -0400 Subject: [PATCH 02/33] Dispatch workflow mentions only after verification Signed-off-by: Logan Johnson Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-acp/src/lib.rs | 6 +++++ crates/buzz-acp/src/workflow_wake.rs | 35 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index c5600619fd8..f1b164bf6c1 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3143,6 +3143,12 @@ async fn tokio_main() -> Result<()> { match buzz_event { Some(buzz_event) => { let kind_u32 = buzz_event.event.kind.as_u16() as u32; + if workflow_wake::requires_verified_wake( + &buzz_event.event, + workflow_relay_pubkey, + ) { + continue; + } let (buzz_event, admission_author_override) = if kind_u32 == buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE diff --git a/crates/buzz-acp/src/workflow_wake.rs b/crates/buzz-acp/src/workflow_wake.rs index 4c6f4ec925c..dafbfe772b8 100644 --- a/crates/buzz-acp/src/workflow_wake.rs +++ b/crates/buzz-acp/src/workflow_wake.rs @@ -38,6 +38,16 @@ pub struct WorkflowWakeAuthority { pub message: Event, } +/// Return whether a relay-signed workflow message must be dispatched only +/// through its separately verified wake. +pub fn requires_verified_wake(event: &Event, relay_pubkey: PublicKey) -> bool { + event.pubkey == relay_pubkey + && event.kind.as_u16() as u32 == KIND_STREAM_MESSAGE + && single_tag(event, "workflow-run").is_some() + && single_tag(event, "workflow-definition").is_some() + && single_tag(event, "workflow-step").is_some() +} + /// Verify every authority edge and return the visible message plus its signed author principal. pub fn verify( wake_event: &Event, @@ -222,6 +232,31 @@ mod tests { } } + #[test] + fn workflow_message_is_ineligible_for_direct_dispatch() { + let fixture = Fixture::valid(); + assert!(requires_verified_wake( + &fixture.message, + fixture.relay.public_key() + )); + assert!(!requires_verified_wake( + &fixture.message, + Keys::generate().public_key() + )); + + let ordinary = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "ordinary") + .tags([ + Tag::parse(["h", &fixture.channel.to_string()]).expect("h tag"), + Tag::parse(["p", &fixture.agent.public_key().to_hex()]).expect("p tag"), + ]) + .sign_with_keys(&fixture.relay) + .expect("ordinary message"); + assert!(!requires_verified_wake( + &ordinary, + fixture.relay.public_key() + )); + } + #[test] fn accepts_exact_authority_and_returns_signed_owner() { let fixture = Fixture::valid(); From 4056e28f765816d58831f75161747e5224f44761 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 27 Aug 2026 17:56:58 -0400 Subject: [PATCH 03/33] Enforce current access for workflow wakes Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-relay/src/api/workflows.rs | 77 ++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 10 deletions(-) diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index 7748941d592..9368f539c0c 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -40,6 +40,45 @@ fn request_path(path: &str, raw_query: Option<&str>) -> String { } } +fn ensure_channel_access( + accessible: &[Uuid], + channel_id: Uuid, + error: &'static str, +) -> Result<(), (StatusCode, Json)> { + if accessible.contains(&channel_id) { + Ok(()) + } else { + Err(api_error(StatusCode::FORBIDDEN, error)) + } +} + +async fn enforce_current_channel_read( + state: &Arc, + tenant: &TenantContext, + headers: &HeaderMap, + pubkey: &nostr::PublicKey, + channel_id: Uuid, + error: &'static str, +) -> Result<(), (StatusCode, Json)> { + let pubkey_bytes = pubkey.to_bytes().to_vec(); + let auth_tag = headers + .get("x-auth-tag") + .and_then(|value| value.to_str().ok()); + super::relay_members::enforce_relay_membership( + state, + tenant.community(), + &pubkey_bytes, + auth_tag, + ) + .await?; + + let accessible = state + .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) + .await + .map_err(|error| internal_error(&format!("workflow channel access lookup: {error}")))?; + ensure_channel_access(&accessible, channel_id, error) +} + async fn authorize_workflow_read( state: &Arc, headers: &HeaderMap, @@ -94,16 +133,15 @@ async fn authorize_workflow_read( let channel_id = workflow .channel_id .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "workflow is not channel-scoped"))?; - let accessible = state - .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) - .await - .map_err(|error| internal_error(&format!("workflow channel access lookup: {error}")))?; - if !accessible.contains(&channel_id) { - return Err(api_error( - StatusCode::FORBIDDEN, - "workflow is not accessible", - )); - } + enforce_current_channel_read( + state, + &tenant, + headers, + &pubkey, + channel_id, + "workflow is not accessible", + ) + .await?; Ok(tenant) } @@ -288,6 +326,15 @@ pub async fn workflow_wake_authority( let message_channel = message .channel_id .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; + enforce_current_channel_read( + &state, + &tenant, + &headers, + &recipient, + message_channel, + "workflow wake not accessible", + ) + .await?; if workflow.owner_pubkey != definition.event.pubkey.to_bytes() || workflow.channel_id != Some(message_channel) || !exact_tag(&definition.event, "h", &message_channel.to_string()) @@ -334,6 +381,16 @@ mod tests { ); } + #[test] + fn channel_access_is_required_at_authority_read_time() { + let channel = Uuid::new_v4(); + assert!(ensure_channel_access(&[channel], channel, "revoked").is_ok()); + + let (status, _) = ensure_channel_access(&[], channel, "revoked") + .expect_err("removed member must not retain authority read access"); + assert_eq!(status, StatusCode::FORBIDDEN); + } + #[test] fn approval_wire_does_not_expose_hash_as_token() { let approval = buzz_db::workflow::ApprovalRecord { From 625ad3bb93c42eee891c4df309e5365e17c40cb1 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 27 Aug 2026 18:32:42 -0400 Subject: [PATCH 04/33] Allow safe kindless channel search Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-relay/src/handlers/req.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 90b8716aab8..39279f61bbc 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -1185,10 +1185,18 @@ fn extract_channel_id_from_filters(filters: &[Filter]) -> Option { pub(crate) fn p_gated_filters_authorized(filters: &[Filter], authed_pubkey_hex: &str) -> bool { let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); filters.iter().all(|filter| { - let can_match_p_gated = filter.kinds.as_ref().is_none_or(|ks| { - ks.iter() - .any(|kind| P_GATED_KINDS.contains(&(kind.as_u16() as u32))) - }); + // A kindless full-text search cannot surface a p-gated event: persistent + // p-gated kinds have a NULL search vector and ephemeral kinds are never + // stored. Keep explicit p-gated kind searches subject to the recipient + // check, but do not close ordinary channel searches merely because their + // omitted kind set is theoretically broad. + let can_match_p_gated = filter.kinds.as_ref().map_or_else( + || filter.search.is_none(), + |ks| { + ks.iter() + .any(|kind| P_GATED_KINDS.contains(&(kind.as_u16() as u32))) + }, + ); if !can_match_p_gated { return true; } @@ -2294,6 +2302,13 @@ mod tests { assert!(engram_filters_authorized(&[f], &agent)); } + #[test] + fn p_gate_allows_kindless_search_because_p_gated_rows_are_unsearchable() { + let (agent, _, _) = three_pubkeys(); + let f = Filter::new().search("ordinary-channel-search"); + assert!(p_gated_filters_authorized(&[f], &agent)); + } + #[test] fn p_gate_rejects_bare_kind_search_filter_for_gift_wrap() { // P-gated kinds (observer frames, member notifications) are indexed From e93fe7629bf9ab9a4b9d01f860ad01fe454bc23b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 27 Aug 2026 21:43:48 -0400 Subject: [PATCH 05/33] Recover workflow wakes across reconnects Persist recipient-gated identifier wakes, include them in default ACP mention subscriptions, and preserve safe channel wildcards. Co-authored-by: Larry Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-acp/src/config.rs | 36 ++++++- crates/buzz-acp/src/relay.rs | 66 ++++++++++++ crates/buzz-core/src/filter.rs | 13 ++- crates/buzz-core/src/kind.rs | 10 +- crates/buzz-core/src/workflow_wake.rs | 2 +- crates/buzz-relay/src/handlers/count.rs | 5 +- crates/buzz-relay/src/handlers/event.rs | 129 ++++++++++++++---------- crates/buzz-relay/src/handlers/req.rs | 44 +++++--- crates/buzz-relay/src/workflow_sink.rs | 43 +++++--- 9 files changed, 248 insertions(+), 100 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 3d4e67d0f55..5670ff9dee5 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -1324,6 +1324,7 @@ pub fn resolve_channel_filters( ) -> HashMap { use buzz_core::kind::{ KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, + KIND_WORKFLOW_MENTION_WAKE, }; let target_channels: Vec = if let Some(ref overrides) = config.channels_override { @@ -1343,6 +1344,7 @@ pub fn resolve_channel_filters( let kinds = config.kinds_override.clone().unwrap_or_else(|| { vec![ KIND_STREAM_MESSAGE, + KIND_WORKFLOW_MENTION_WAKE, KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_STREAM_REMINDER, ] @@ -1426,6 +1428,7 @@ pub fn resolve_dynamic_channel_filter( ) -> Option { use buzz_core::kind::{ KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, + KIND_WORKFLOW_MENTION_WAKE, }; // In Mentions/All mode, if the operator explicitly constrained channels @@ -1448,6 +1451,7 @@ pub fn resolve_dynamic_channel_filter( kinds: Some(config.kinds_override.clone().unwrap_or_else(|| { vec![ KIND_STREAM_MESSAGE, + KIND_WORKFLOW_MENTION_WAKE, KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_STREAM_REMINDER, ] @@ -1596,13 +1600,37 @@ mod tests { for ch in &channels { let f = result.get(ch).expect("channel should be present"); assert!(f.require_mention, "mentions mode requires mention"); - let kinds = f.kinds.as_ref().expect("should have kinds"); - assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_MESSAGE)); - assert!(kinds.contains(&buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED)); - assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_REMINDER)); + assert_eq!( + f.kinds, + Some(vec![ + buzz_core::kind::KIND_STREAM_MESSAGE, + buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE, + buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED, + buzz_core::kind::KIND_STREAM_REMINDER, + ]) + ); } } + #[test] + fn test_mentions_mode_dynamic_default_kinds_include_workflow_wake() { + let config = test_config(SubscribeMode::Mentions); + let channel = Uuid::new_v4(); + let filter = resolve_dynamic_channel_filter(&config, channel, &[]) + .expect("dynamic channel should be subscribed"); + + assert!(filter.require_mention); + assert_eq!( + filter.kinds, + Some(vec![ + buzz_core::kind::KIND_STREAM_MESSAGE, + buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE, + buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED, + buzz_core::kind::KIND_STREAM_REMINDER, + ]) + ); + } + #[test] fn test_mentions_mode_custom_kinds() { let mut config = test_config(SubscribeMode::Mentions); diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 0e2224bbb3f..c6e50052a2b 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -4445,6 +4445,72 @@ mod tests { server.abort(); } + #[test] + fn default_mentions_builds_complete_recipient_gated_subscription_shape() { + let channel = Uuid::new_v4(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let req = build_channel_req( + "sub", + channel, + &agent, + 123, + &ChannelFilter { + kinds: Some(vec![ + buzz_core::kind::KIND_STREAM_MESSAGE, + buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE, + buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED, + buzz_core::kind::KIND_STREAM_REMINDER, + ]), + require_mention: true, + }, + ); + let req = req.as_array().expect("REQ array"); + + assert_eq!(req.len(), 4); + assert_eq!( + req[2]["kinds"], + json!([ + buzz_core::kind::KIND_STREAM_MESSAGE, + buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED, + buzz_core::kind::KIND_STREAM_REMINDER, + ]) + ); + assert_eq!(req[2]["#p"], json!([agent])); + assert_eq!(req[2]["#h"], json!([channel.to_string()])); + assert_eq!( + req[3]["kinds"], + json!([buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE]) + ); + assert_eq!(req[3]["#p"], json!([agent])); + assert_eq!(req[3]["#h"], json!([channel.to_string()])); + } + + #[test] + fn durable_workflow_wake_is_requested_on_reconnect() { + let channel = Uuid::new_v4(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let req = build_channel_req( + "sub", + channel, + &agent, + 456, + &ChannelFilter { + kinds: Some(vec![buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE]), + require_mention: true, + }, + ); + let req = req.as_array().expect("REQ array"); + + assert_eq!(req.len(), 3); + assert_eq!( + req[2]["kinds"], + json!([buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE]) + ); + assert_eq!(req[2]["#p"], json!([agent])); + assert_eq!(req[2]["#h"], json!([channel.to_string()])); + assert_eq!(req[2]["since"], json!(456)); + } + #[test] fn workflow_wake_uses_exact_recipient_filter_when_mentions_are_disabled() { let channel = Uuid::new_v4(); diff --git a/crates/buzz-core/src/filter.rs b/crates/buzz-core/src/filter.rs index 32e3a7ad16b..8333fdb4acf 100644 --- a/crates/buzz-core/src/filter.rs +++ b/crates/buzz-core/src/filter.rs @@ -11,10 +11,10 @@ pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool { filters.iter().any(|f| filter_match_one(f, event)) } -/// Result-level read authorization for relay-signed events whose content is -/// private to a single viewer. Currently gates `KIND_DM_VISIBILITY` and -/// `KIND_AGENT_TURN_METRIC`: the reader MUST equal the event's `#p` tag -/// (owner). Returns `true` for every other kind. +/// Result-level read authorization for events whose content or envelope is +/// private to a single viewer. Currently gates `KIND_DM_VISIBILITY`, +/// `KIND_AGENT_TURN_METRIC`, and `KIND_WORKFLOW_MENTION_WAKE`: the reader MUST +/// equal the event's `#p` tag (owner/recipient). Returns `true` for every other kind. /// /// This guards every delivery surface — WS historical pull (`req.rs`), HTTP /// bridge (`bridge.rs`), and live fan-out (`event.rs`) — so a query that @@ -22,7 +22,10 @@ pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool { /// a known event id) still cannot read another user's private event. pub fn reader_authorized_for_event(event: &nostr::Event, reader_pubkey_hex: &str) -> bool { let kind = crate::kind::event_kind_u32(event); - if kind != crate::kind::KIND_DM_VISIBILITY && kind != crate::kind::KIND_AGENT_TURN_METRIC { + if kind != crate::kind::KIND_DM_VISIBILITY + && kind != crate::kind::KIND_AGENT_TURN_METRIC + && kind != crate::kind::KIND_WORKFLOW_MENTION_WAKE + { return true; } let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 474f3864b6b..0136d312d77 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -139,7 +139,11 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ /// /// Used by `filter_can_match_result_gated_kinds` to force the per-event /// fallback path in COUNT rather than the fast SQL `count_events()`. -pub const RESULT_GATED_KINDS: &[u32] = &[KIND_DM_VISIBILITY, KIND_AGENT_TURN_METRIC]; +pub const RESULT_GATED_KINDS: &[u32] = &[ + KIND_DM_VISIBILITY, + KIND_AGENT_TURN_METRIC, + KIND_WORKFLOW_MENTION_WAKE, +]; /// Kinds whose stored events have `#p`-bound read access — readable only by /// subscribers whose pubkey appears in the event's `#p` tag. @@ -468,8 +472,8 @@ pub const KIND_PAIRING: u32 = 24134; pub const KIND_TYPING_INDICATOR: u32 = 20002; /// Ephemeral: owner-scoped encrypted agent observer telemetry and control frame. pub const KIND_AGENT_OBSERVER_FRAME: u32 = 24200; -/// Ephemeral: relay-signed identifier-only workflow mention wake. -pub const KIND_WORKFLOW_MENTION_WAKE: u32 = 24620; +/// Durable relay-signed identifier-only workflow mention wake. +pub const KIND_WORKFLOW_MENTION_WAKE: u32 = 44620; /// Ephemeral: huddle emoji reaction burst. Channel-scoped to the ephemeral /// huddle channel with an `h` tag; never stored in the timeline. pub const KIND_HUDDLE_REACTION: u32 = 24810; diff --git a/crates/buzz-core/src/workflow_wake.rs b/crates/buzz-core/src/workflow_wake.rs index b5d60ab2005..ace3bd8714b 100644 --- a/crates/buzz-core/src/workflow_wake.rs +++ b/crates/buzz-core/src/workflow_wake.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::kind::KIND_WORKFLOW_MENTION_WAKE; -/// A relay-signed, ephemeral hint that a workflow message mentioned one agent. +/// A relay-signed, durable hint that a workflow message mentioned one agent. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct WorkflowMentionWake { recipient: PublicKey, diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 938674301e7..18fecfc19f8 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -38,8 +38,9 @@ pub async fn handle_count( } }; - // P-gated kinds (gift wraps, member notifications, observer frames) require - // the caller's own pubkey in the #p tag — same enforcement as WS REQ handler. + // Result-gated kinds (DM visibility, agent metrics, and workflow wakes) + // require the caller's own pubkey in the #p tag for explicit-kind filters. + // Kindless filters remain valid and are restricted by per-event gates. let authed_pubkey_hex = hex::encode(&pubkey_bytes); if !super::req::p_gated_filters_authorized(&filters, &authed_pubkey_hex) { conn.send(RelayMessage::closed( diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ab74060d605..f57e7142ffe 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -174,6 +174,29 @@ pub async fn filter_fanout_by_access( matches }; + let owner_only_kind = event_kind_u32(&stored_event.event); + // Result-gated delivery (DM visibility, agent metrics, and workflow wakes) + // must reach only the exact #p owner/recipient, including kindless channel + // wildcard subscriptions. + let matches = if buzz_core::kind::RESULT_GATED_KINDS.contains(&owner_only_kind) { + matches + .into_iter() + .filter(|(conn_id, _)| { + state + .conn_manager + .pubkey_for_conn(*conn_id) + .is_some_and(|pk| { + buzz_core::filter::reader_authorized_for_event( + &stored_event.event, + &hex::encode(pk), + ) + }) + }) + .collect() + } else { + matches + }; + let Some(channel_id) = stored_event.channel_id else { return matches; }; @@ -337,28 +360,6 @@ pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pub } } -/// Fan out one relay-generated ephemeral event without passing through client admission. -/// -/// The event is never stored. Global routing plus `P_GATED_KINDS` ensures only a -/// subscription authenticated as the exact `p` recipient can receive a wake. -pub(crate) async fn dispatch_ephemeral_event( - tenant: &TenantContext, - state: &Arc, - event: Event, - channel_id: Option, -) { - state.mark_local_event(tenant.community(), &event.id); - let topic = channel_id.map_or(EventTopic::Global, EventTopic::Channel); - if let Err(error) = state.pubsub.publish_event(tenant, topic, &event).await { - state - .local_event_ids - .invalidate(&(tenant.community(), event.id.to_bytes())); - warn!(event_id = %event.id, %error, "relay-generated ephemeral publish failed"); - } - let stored = StoredEvent::new(event, channel_id); - fan_out_event_to_local_subscribers(state, tenant.community(), &stored).await; -} - /// Schedule post-commit delivery/side effects for a stored event. /// /// This intentionally returns after only the bounded audit enqueue has completed: @@ -476,40 +477,13 @@ async fn dispatch_persistent_event_inner( return 0; } }; - // For viewer-private events (kind:30622 DM visibility, kind:44200 agent turn - // metrics), live fan-out must reach only the owner — a kindless `ids:[…]` - // subscription can otherwise match it. Pull paths (HTTP /query, WS historical) - // are gated separately by reader_authorized_for_event. - let owner_only_kind = kind_u32 == buzz_core::kind::KIND_DM_VISIBILITY - || kind_u32 == buzz_core::kind::KIND_AGENT_TURN_METRIC; - let private_event_owner: Option = owner_only_kind - .then(|| { - let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); - stored_event - .event - .tags - .filter(nostr::TagKind::SingleLetter(p)) - .find_map(|t| t.content().map(|s| s.to_string())) - }) - .flatten(); - // Author-only delivery gating (NIP-ER reminders) is enforced centrally in - // filter_fanout_by_access, applied to `matches` above before this loop. The - // DM visibility owner gate is an additional delivery fence, so build shared - // frames only after applying it to the already access-filtered recipient set. + // Result-gated delivery (DM visibility, agent metrics, and workflow wakes) + // is enforced centrally in filter_fanout_by_access, applied to `matches` + // above before this loop. Build shared frames only after every recipient + // has passed that chokepoint. let recipients: Vec<_> = matches .iter() - .filter_map(|(target_conn_id, sub_id)| { - if let Some(ref owner_hex) = private_event_owner { - let is_owner = state - .conn_manager - .pubkey_for(*target_conn_id) - .is_some_and(|pk| hex::encode(pk) == *owner_hex); - if !is_owner { - return None; - } - } - Some((*target_conn_id, sub_id.as_str())) - }) + .map(|(target_conn_id, sub_id)| (*target_conn_id, sub_id.as_str())) .collect(); let frames = fanout_frame_cache(recipients.iter().map(|(_, sub_id)| *sub_id), &event_json); let drop_count = send_fanout_frames(state, recipients, &frames); @@ -2017,6 +1991,7 @@ mod tests { use std::sync::atomic::AtomicU8; use std::sync::Arc; + use buzz_core::workflow_wake::WorkflowMentionWake; use buzz_core::StoredEvent; use nostr::{EventBuilder, Keys, Kind}; use tokio::sync::{mpsc, Mutex}; @@ -2189,6 +2164,52 @@ mod tests { assert_eq!(out, matches); } + #[tokio::test] + async fn workflow_wake_delivers_only_to_exact_authenticated_recipient() { + let state = test_state().await; + let community_id = buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()); + let channel_id = Uuid::new_v4(); + state + .channel_visibility_cache + .insert((community_id, channel_id), "open".to_string()); + + let recipient_keys = Keys::generate(); + let other_keys = Keys::generate(); + let relay_keys = Keys::generate(); + let definition = EventBuilder::text_note("definition") + .sign_with_keys(&Keys::generate()) + .expect("sign definition"); + let message = EventBuilder::text_note("message") + .sign_with_keys(&relay_keys) + .expect("sign message"); + let wake = WorkflowMentionWake::new( + recipient_keys.public_key(), + channel_id, + Uuid::new_v4(), + definition.id, + message.id, + ) + .sign(&relay_keys) + .expect("sign wake"); + let stored = StoredEvent::new(wake, Some(channel_id)); + + let recipient = register_conn( + &state, + Some(recipient_keys.public_key().to_bytes().to_vec()), + ); + let other = register_conn(&state, Some(other_keys.public_key().to_bytes().to_vec())); + let unauthed = register_conn(&state, None); + let matches = vec![ + (recipient, "recipient".to_string()), + (other, "other".to_string()), + (unauthed, "unauthed".to_string()), + ]; + + let out = filter_fanout_by_access(&state, community_id, &stored, matches, None).await; + + assert_eq!(out, vec![(recipient, "recipient".to_string())]); + } + #[tokio::test] async fn private_channel_keeps_member_drops_non_member_and_unknown() { let state = test_state().await; diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 39279f61bbc..7429f1c9f92 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -208,16 +208,10 @@ pub async fn handle_req( return; } - // Applied BEFORE the NIP-50 search branch so that an authenticated member - // cannot use `{"search":"...","kinds":[30174]}` (or similar for p-gated - // kinds) to harvest indexed-but-globally-stored sensitive events. Search - // hits are looked up by event id and returned without the per-filter - // post-check the historical-delivery branch applies, so the gate must run - // here, up front. P-gated authorization applies to every subscription; - // the other global-event gates below remain global-only. - // P-gated events require an exact authenticated recipient even when they - // are channel-scoped. Channel membership alone is not authority to observe - // another recipient's ephemeral workflow wake. + // Applied BEFORE the NIP-50 search branch so an explicit sensitive-kind + // filter cannot harvest indexed private events. Kindless channel filters + // remain valid NIP-01 wildcards; every returned event is independently + // checked at the shared result gate, and live fan-out uses the same check. let authed_pubkey_hex = hex::encode(&pubkey_bytes); if !p_gated_filters_authorized(&filters, &authed_pubkey_hex) { conn.send(RelayMessage::closed( @@ -1185,13 +1179,17 @@ fn extract_channel_id_from_filters(filters: &[Filter]) -> Option { pub(crate) fn p_gated_filters_authorized(filters: &[Filter], authed_pubkey_hex: &str) -> bool { let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); filters.iter().all(|filter| { - // A kindless full-text search cannot surface a p-gated event: persistent - // p-gated kinds have a NULL search vector and ephemeral kinds are never - // stored. Keep explicit p-gated kind searches subject to the recipient - // check, but do not close ordinary channel searches merely because their - // omitted kind set is theoretically broad. + // Kindless full-text searches cannot surface p-gated rows, and a + // kindless channel filter is safe to register: global p-gated events + // cannot enter its channel index, while channel-scoped workflow wakes + // are removed by the shared per-event recipient gate. Preserve the + // stricter rule for global wildcards and explicit p-gated kinds. + let has_channel_scope = filter + .generic_tags + .get(&nostr::SingleLetterTag::lowercase(nostr::Alphabet::H)) + .is_some_and(|values| !values.is_empty()); let can_match_p_gated = filter.kinds.as_ref().map_or_else( - || filter.search.is_none(), + || filter.search.is_none() && !has_channel_scope, |ks| { ks.iter() .any(|kind| P_GATED_KINDS.contains(&(kind.as_u16() as u32))) @@ -1881,6 +1879,20 @@ mod tests { assert_eq!(extract_channel_id_from_filters(&filters), Some(channel_id)); } + /// A channel-scoped kindless wildcard remains admissible. P-gated global + /// kinds cannot enter the channel subscription index, and the only + /// channel-scoped p-gated kind (workflow wake) is result-gated per recipient. + #[test] + fn channel_wildcard_preserves_all_mode_without_weakening_wakes() { + let authed = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let channel = uuid::Uuid::new_v4(); + assert!(p_gated_filters_authorized( + &[filter_with_channel(channel)], + authed + )); + assert!(!p_gated_filters_authorized(&[Filter::new()], authed)); + } + #[test] fn test_search_filter_detection() { let search_filter = Filter::new().search("hello world"); diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 6a137160e1c..67751a305c0 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -8,7 +8,7 @@ use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Weak}; -use buzz_core::kind::KIND_STREAM_MESSAGE; +use buzz_core::kind::{KIND_STREAM_MESSAGE, KIND_WORKFLOW_MENTION_WAKE}; use buzz_core::workflow_wake::WorkflowMentionWake; use buzz_workflow::action_sink::{ActionSink, ActionSinkError, WorkflowMessageContext}; use chrono::Utc; @@ -479,8 +479,7 @@ impl ActionSink for RelayActionSink { .await .map_err(|e| ActionSinkError::Database(e.to_string()))?; - // 5. Post-persist side effects (fan-out, search, audit) - // Only if actually inserted (idempotency guard). + // 5. Post-persist side effects (fan-out, search, audit). if was_inserted { let _ = dispatch_persistent_event( &tenant, @@ -491,25 +490,39 @@ impl ActionSink for RelayActionSink { None, ) .await; + } - for wake in build_workflow_wakes( - &state.relay_keypair, - channel_uuid, - run_id, - definition_event_id, - event.id, - mentioned_pubkeys, - )? { - crate::handlers::event::dispatch_ephemeral_event( + // Persist one recipient-gated identifier wake for every mentioned + // agent. This also runs when the message insert was an idempotent + // duplicate, so a retry repairs a crash/failure between message and + // wake persistence instead of permanently losing the notification. + for wake in build_workflow_wakes( + &state.relay_keypair, + channel_uuid, + run_id, + definition_event_id, + event.id, + mentioned_pubkeys, + )? { + let (stored_wake, wake_inserted) = state + .db + .insert_event(tenant.community(), &wake, Some(channel_uuid)) + .await + .map_err(|error| ActionSinkError::Database(error.to_string()))?; + if wake_inserted { + let _ = dispatch_persistent_event( &tenant, &state, - wake, - Some(channel_uuid), + &stored_wake, + KIND_WORKFLOW_MENTION_WAKE, + &author_pubkey_hex, + None, ) .await; } + } - // A threaded reply changed its thread's counters — push a fresh + if was_inserted { // relay-signed kind:39005 so subscribed clients update badge // counts without refetching the head window, exactly as the // ingest path does after a reply insert. Fan-out-only and From 356c3e47275fdcf55d7e3189a388798a6f53a298 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 10:16:06 -0400 Subject: [PATCH 06/33] Harden durable workflow wake admission Reject client-authored wakes, authenticate relay signatures before authority lookup, and preserve the storage-level FTS exclusion on brownfield databases. Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-acp/src/lib.rs | 8 +-- crates/buzz-acp/src/workflow_wake.rs | 30 ++++++-- crates/buzz-core/src/kind.rs | 6 +- crates/buzz-db/src/runtime/migration.rs | 9 ++- .../tests/postgres_fts_integration.rs | 26 +++++-- crates/buzz-test-client/tests/e2e_relay.rs | 70 +++++++++++-------- migrations/0041_workflow_mention_wake_fts.sql | 32 +++++++++ schema/schema.sql | 4 +- 8 files changed, 138 insertions(+), 47 deletions(-) create mode 100644 migrations/0041_workflow_mention_wake_fts.sql diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index f1b164bf6c1..ff17bae89b5 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3153,11 +3153,11 @@ async fn tokio_main() -> Result<()> { let (buzz_event, admission_author_override) = if kind_u32 == buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE { - let Some(wake) = buzz_core::workflow_wake::WorkflowMentionWake::parse( + let Some(wake) = workflow_wake::authenticate( &buzz_event.event, - ) - .ok() - else { + workflow_relay_pubkey, + ) else { + tracing::warn!("workflow wake authentication failed"); continue; }; let authority = match ctx diff --git a/crates/buzz-acp/src/workflow_wake.rs b/crates/buzz-acp/src/workflow_wake.rs index dafbfe772b8..b25caaeb740 100644 --- a/crates/buzz-acp/src/workflow_wake.rs +++ b/crates/buzz-acp/src/workflow_wake.rs @@ -48,6 +48,14 @@ pub fn requires_verified_wake(event: &Event, relay_pubkey: PublicKey) -> bool { && single_tag(event, "workflow-step").is_some() } +/// Authenticate and parse a relay-signed workflow wake before any authority lookup. +pub fn authenticate(wake_event: &Event, relay_pubkey: PublicKey) -> Option { + if wake_event.pubkey != relay_pubkey || wake_event.verify().is_err() { + return None; + } + WorkflowMentionWake::parse(wake_event).ok() +} + /// Verify every authority edge and return the visible message plus its signed author principal. pub fn verify( wake_event: &Event, @@ -56,10 +64,7 @@ pub fn verify( agent_pubkey: PublicKey, subscription_channel: Uuid, ) -> Option<(Event, String)> { - if wake_event.pubkey != relay_pubkey || wake_event.verify().is_err() { - return None; - } - let wake = WorkflowMentionWake::parse(wake_event).ok()?; + let wake = authenticate(wake_event, relay_pubkey)?; if wake.recipient() != agent_pubkey || authority.workflow_owner != authority.definition.pubkey.to_hex() || wake.run_id() != authority.run_id @@ -257,6 +262,23 @@ mod tests { )); } + #[test] + fn rejects_forged_wake_before_authority_lookup() { + let fixture = Fixture::valid(); + let forged = WorkflowMentionWake::new( + fixture.agent.public_key(), + fixture.channel, + fixture.run, + fixture.definition.id, + fixture.message.id, + ) + .sign(&Keys::generate()) + .expect("forged wake"); + + assert!(authenticate(&forged, fixture.relay.public_key()).is_none()); + assert!(authenticate(&fixture.wake, fixture.relay.public_key()).is_some()); + } + #[test] fn accepts_exact_authority_and_returns_signed_owner() { let fixture = Fixture::valid(); diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 0136d312d77..e40552accf1 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -153,7 +153,7 @@ pub const RESULT_GATED_KINDS: &[u32] = &[ /// `#p` values exactly equal the authenticated reader's pubkey. For stored /// (non-ephemeral) kinds in this set, the storage layer additionally writes a /// NULL `search_tsv` so the event is unsearchable through NIP-50 FTS -/// (`schema/schema.sql` and `migrations/0001_initial_schema.sql` — drift +/// (`schema/schema.sql` and the forward FTS migrations — drift /// caught by `p_gated_persistent_kinds_have_storage_null_tsvector` in /// `crates/buzz-search/tests/fts_integration.rs`). /// @@ -844,6 +844,7 @@ pub const fn is_relay_only_kind(kind: u32) -> bool { | KIND_DM_VISIBILITY | KIND_THREAD_SUMMARY | KIND_WINDOW_BOUNDS + | KIND_WORKFLOW_MENTION_WAKE ) } @@ -916,8 +917,9 @@ mod tests { } #[test] - fn nip43_membership_snapshot_is_relay_only() { + fn relay_generated_kinds_are_relay_only() { assert!(is_relay_only_kind(KIND_NIP43_MEMBERSHIP_LIST)); + assert!(is_relay_only_kind(KIND_WORKFLOW_MENTION_WAKE)); assert!(!is_relay_only_kind(KIND_NIP43_LEAVE_REQUEST)); } diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index dc3d29b10a6..47722673300 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -908,7 +908,7 @@ mod postgres_tests { assert!(migrations[32].sql.as_str().contains("search_tsv")); assert!(!migrations[0].sql.as_str().contains("30179")); assert!(include_str!("../../../../schema/schema.sql") - .contains("kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200)")); + .contains("kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200, 44620)")); // Public push-gateway authority is intentionally deployment-global and // durable: immediate revocation and hostile-relay admission cannot be @@ -1191,6 +1191,13 @@ mod postgres_tests { 2 ); + // Durable workflow wakes are recipient-gated and must remain outside + // full-text search on both fresh and brownfield databases. + assert_eq!(migrations[35].version, 36); + let workflow_wake_fts = migrations[35].sql.as_str(); + assert!(workflow_wake_fts.contains("kind = 44620")); + assert!(desired_schema.contains("44200, 44620")); + // pgschema intentionally reconciles DDL, not seed DML or table storage // parameters. Its post-apply reconciliation must restore and verify // both parts of the live heartbeat contract for fresh bootstraps. diff --git a/crates/buzz-search/tests/postgres_fts_integration.rs b/crates/buzz-search/tests/postgres_fts_integration.rs index 175a01aaaa3..4ff5e039dc7 100644 --- a/crates/buzz-search/tests/postgres_fts_integration.rs +++ b/crates/buzz-search/tests/postgres_fts_integration.rs @@ -30,8 +30,14 @@ const MIGRATION_0008_SQL: &str = const MIGRATION_0014_SQL: &str = include_str!("../../../migrations/0014_push_lease_fts.sql"); const MIGRATION_0033_SQL: &str = include_str!("../../../migrations/0033_private_managed_agent_fts.sql"); +const MIGRATION_0036_SQL: &str = + include_str!("../../../migrations/0036_workflow_mention_wake_fts.sql"); async fn setup() -> (PgPool, String) { + setup_with_search_policy(true).await +} + +async fn setup_with_search_policy(apply_fresh_allowlist: bool) -> (PgPool, String) { let url = std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); let schema = format!("fts_test_{}", Uuid::new_v4().simple()); // Connect to the default schema first to create the test schema. @@ -77,15 +83,20 @@ async fn setup() -> (PgPool, String) { pool.execute(MIGRATION_0007_SQL) .await .expect("apply 0007 migration"); - pool.execute(MIGRATION_0008_SQL) - .await - .expect("apply 0008 migration"); + if apply_fresh_allowlist { + pool.execute(MIGRATION_0008_SQL) + .await + .expect("apply 0008 migration"); + } pool.execute(MIGRATION_0014_SQL) .await .expect("apply 0014 migration"); pool.execute(MIGRATION_0033_SQL) .await .expect("apply 0033 migration"); + pool.execute(MIGRATION_0036_SQL) + .await + .expect("apply 0036 migration"); (pool, schema) } @@ -1414,8 +1425,8 @@ async fn author_only_kinds_are_storage_level_unsearchable() { /// search entry point could surface tokenized content from these kinds. The /// L1 NULL tsvector is the unbreakable backstop: `@@` mathematically cannot /// match NULL. This test catches the drift where someone adds a persistent -/// kind to `P_GATED_KINDS` without the matching `schema/schema.sql` + -/// `migrations/0001_initial_schema.sql` skip-set update. +/// kind to `P_GATED_KINDS` without the matching desired schema and forward +/// migration exclusion. /// /// Ephemeral kinds (20000–29999) are skipped: they are never stored, so the /// storage-layer defense does not apply to them regardless of the schema @@ -1428,7 +1439,10 @@ async fn author_only_kinds_are_storage_level_unsearchable() { #[tokio::test] #[ignore = "requires Postgres"] async fn p_gated_persistent_kinds_have_storage_null_tsvector() { - let (pool, schema) = setup().await; + // Exercise the brownfield negative skip-set. The fresh-install positive + // allowlist would make every unknown kind unsearchable and let a missing + // per-kind migration pass vacuously. + let (pool, schema) = setup_with_search_policy(false).await; let c = mk_community(&pool, "p-gated-tripwire.example").await; let token = "pgated_tripwire_marker_qwerty"; diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index b119d267740..d1f6f0db9a0 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -222,40 +222,54 @@ async fn test_connect_and_authenticate() { #[tokio::test] #[ignore] -async fn test_client_submitted_nip43_membership_snapshots_are_rejected() { +async fn test_client_submitted_relay_only_events_are_rejected() { let url = relay_url(); let keys = Keys::generate(); - // Prove this actor can submit a normal event so the rejection below is + // Prove this actor can submit a normal event so the rejections below are // specifically the relay-only invariant, not a broader authorization failure. - create_test_channel(&keys).await; - let forged = EventBuilder::new(Kind::Custom(13_534), "") - .tags([Tag::parse(["member", &keys.public_key().to_hex(), "owner"]).unwrap()]) - .sign_with_keys(&keys) - .expect("sign forged membership snapshot"); + let channel_id = create_test_channel(&keys).await; + let forged_events = [ + EventBuilder::new(Kind::Custom(13_534), "") + .tags([Tag::parse(["member", &keys.public_key().to_hex(), "owner"]).unwrap()]) + .sign_with_keys(&keys) + .expect("sign forged membership snapshot"), + EventBuilder::new(Kind::Custom(44_620), "") + .tags([ + Tag::parse(["p", &keys.public_key().to_hex()]).unwrap(), + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["run", &Uuid::new_v4().to_string()]).unwrap(), + Tag::parse(["definition", &"11".repeat(32)]).unwrap(), + Tag::parse(["message", &"22".repeat(32)]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign forged workflow wake"), + ]; let mut ws = BuzzTestClient::connect(&url, &keys).await.expect("connect"); - let ok = ws - .send_event(forged.clone()) - .await - .expect("submit forged snapshot via websocket"); - assert!(!ok.accepted, "forged WebSocket snapshot must be rejected"); - assert_eq!(ok.message, "restricted: relay-only kind"); + for forged in forged_events { + let ok = ws + .send_event(forged.clone()) + .await + .expect("submit forged relay-only event via websocket"); + assert!(!ok.accepted, "forged WebSocket event must be rejected"); + assert_eq!(ok.message, "restricted: relay-only kind"); + + let response = reqwest::Client::new() + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&forged).unwrap()) + .send() + .await + .expect("submit forged relay-only event via HTTP"); + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + let body = response.text().await.expect("read HTTP rejection"); + assert!( + body.contains("restricted: relay-only kind"), + "unexpected HTTP rejection: {body}" + ); + } ws.disconnect().await.expect("disconnect"); - - let response = reqwest::Client::new() - .post(format!("{}/events", relay_http_url())) - .header("X-Pubkey", keys.public_key().to_hex()) - .header("Content-Type", "application/json") - .body(serde_json::to_string(&forged).unwrap()) - .send() - .await - .expect("submit forged snapshot via HTTP"); - assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); - let body = response.text().await.expect("read HTTP rejection"); - assert!( - body.contains("restricted: relay-only kind"), - "unexpected HTTP rejection: {body}" - ); } #[tokio::test] diff --git a/migrations/0041_workflow_mention_wake_fts.sql b/migrations/0041_workflow_mention_wake_fts.sql new file mode 100644 index 00000000000..a0c42c95edc --- /dev/null +++ b/migrations/0041_workflow_mention_wake_fts.sql @@ -0,0 +1,32 @@ +-- Kind:44620 is a durable, recipient-gated workflow mention wake. Its canonical +-- content is empty, but keep the storage-level full-text-search backstop aligned +-- with every persistent P_GATED_KINDS member, including on brownfield databases +-- that retain the legacy negative skip-set. +-- +-- Preserve the database's existing search policy for every other kind. As with +-- 0014 and 0033, replacing this generated column rewrites the events table and +-- rebuilds the GIN index under an ACCESS EXCLUSIVE lock. +DO $$ +DECLARE + existing_expression TEXT; +BEGIN + SELECT pg_get_expr(d.adbin, d.adrelid) + INTO existing_expression + FROM pg_attrdef d + JOIN pg_attribute a + ON a.attrelid = d.adrelid + AND a.attnum = d.adnum + WHERE d.adrelid = 'events'::regclass + AND a.attname = 'search_tsv'; + + IF existing_expression IS NULL THEN + RAISE EXCEPTION 'events.search_tsv generated expression not found'; + END IF; + + ALTER TABLE events DROP COLUMN search_tsv; + EXECUTE format( + 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind = 44620 THEN NULL::tsvector ELSE (%s) END) STORED', + existing_expression + ); + CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +END $$; diff --git a/schema/schema.sql b/schema/schema.sql index af5dcfe4ebf..2f0dd2338e1 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -219,9 +219,9 @@ CREATE TABLE events ( -- Privacy: encrypted/private routing wrappers and p-gated membership notices -- must never be discoverable through NIP-50 full-text search. NULL tsvector -- never matches `@@`. - -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033). + -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033 + 0036). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200, 44620) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED, From 99ff9d584704b1f6f003ac84f37d0bd1ce49d4f5 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 13:31:43 -0400 Subject: [PATCH 07/33] Repair workflow wake lifecycle boundaries Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-acp/src/lib.rs | 27 +- crates/buzz-acp/src/relay.rs | 182 +++++++- crates/buzz-acp/src/workflow_wake.rs | 87 +++- crates/buzz-db/src/store/event.rs | 30 +- crates/buzz-db/src/store/mod.rs | 2 + crates/buzz-db/src/store/replaceable.rs | 3 + crates/buzz-db/src/store/workflow_delivery.rs | 57 +++ crates/buzz-relay/src/api/bridge.rs | 55 ++- crates/buzz-relay/src/api/workflows.rs | 13 +- crates/buzz-relay/src/handlers/count.rs | 18 +- crates/buzz-relay/src/handlers/event.rs | 23 +- crates/buzz-relay/src/handlers/req.rs | 48 ++- .../buzz-relay/src/workflow_delivery_tests.rs | 390 ++++++++++++++++++ crates/buzz-relay/src/workflow_sink.rs | 72 ++-- .../0037_workflow_superseded_authority.sql | 3 + 15 files changed, 941 insertions(+), 69 deletions(-) create mode 100644 crates/buzz-db/src/store/workflow_delivery.rs create mode 100644 crates/buzz-relay/src/workflow_delivery_tests.rs create mode 100644 migrations/0037_workflow_superseded_authority.sql diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index ff17bae89b5..d110866056c 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3166,8 +3166,33 @@ async fn tokio_main() -> Result<()> { .await { Ok(authority) => authority, + Err(error) if error.is_transient() => { + // The authority request exhausted bounded 500ms/1s/2s + // retry. Transport dedup has recorded this relay-signed + // wake, but it has not reached dispatch; re-admit it for + // filtered relay replay rather than losing it or bypassing + // verification. Each failed cycle is paced by that bounded + // request retry budget before another replay is scheduled. + if let Err(replay_error) = relay + .replay_event( + buzz_event.channel_id, + buzz_event.event.id.to_hex(), + buzz_event.event.created_at.as_secs(), + ) + .await + { + tracing::warn!( + %replay_error, + "failed to arrange workflow wake authority replay" + ); + } + tracing::warn!(%error, "workflow wake authority unavailable; replay queued"); + continue; + } Err(error) => { - tracing::warn!(%error, "workflow wake authority unavailable"); + // 403/404 and malformed authority bundles are terminal: + // replays cannot make a rejected or invalid authority safe. + tracing::warn!(%error, "workflow wake authority rejected"); continue; } }; diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index c6e50052a2b..249d19798dd 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -428,7 +428,7 @@ impl RestClient { Ok(resp) if is_retriable_status(resp.status()) => { let status = resp.status(); tracing::warn!("{method} {path} returned retriable HTTP {status}"); - last_err = Some(RelayError::Http(format!( + last_err = Some(RelayError::TransientHttp(format!( "{method} {path} returned HTTP {status}" ))); } @@ -441,14 +441,15 @@ impl RestClient { } Err(e) if e.is_timeout() || e.is_connect() => { tracing::warn!("{method} {path} network error: {e}"); - last_err = Some(RelayError::Http(e.to_string())); + last_err = Some(RelayError::TransientHttp(e.to_string())); } Err(e) => return Err(RelayError::Http(e.to_string())), } } - Err(last_err - .unwrap_or_else(|| RelayError::Http(format!("{method} {path} failed after retries")))) + Err(last_err.unwrap_or_else(|| { + RelayError::TransientHttp(format!("{method} {path} failed after retries")) + })) } /// POST with NIP-98 auth and retry. Re-signs on each attempt. @@ -667,10 +668,24 @@ pub enum RelayError { #[error("HTTP error: {0}")] Http(String), + /// A request exhausted its bounded retry budget after only transient + /// failures. Callers may safely schedule delayed recovery; all other HTTP + /// errors, including 403/404 and malformed bodies, are terminal. + #[error("transient HTTP error: {0}")] + TransientHttp(String), + #[error("Unexpected message: {0}")] UnexpectedMessage(String), } +impl RelayError { + /// Whether retry exhaustion, rather than an authority denial or malformed + /// response, caused this failure. + pub fn is_transient(&self) -> bool { + matches!(self, Self::TransientHttp(_)) + } +} + impl From for RelayError { fn from(e: nostr::event::builder::Error) -> Self { RelayError::AuthFailed(e.to_string()) @@ -731,6 +746,15 @@ enum RelayCommand { PublishEvent { event: Box }, /// Floor `since` for membership notification replay; events before startup are never re-delivered. SetStartupWatermark { ts: u64 }, + /// Re-admit an event which reached the harness but could not be safely + /// processed. Removing its transport dedup entry and replaying its channel + /// lets a transient harness-side dependency failure recover without + /// dispatching an unverified event. + ReplayEvent { + channel_id: Uuid, + event_id: String, + created_at: u64, + }, } type WsStream = WebSocketStream>; @@ -1024,6 +1048,27 @@ impl HarnessRelay { self.event_rx.recv().await.flatten() } + /// Arrange replay of an event that failed harness-side admission. + /// + /// This is intentionally narrower than general event retry: it preserves + /// the subscription's exact filter and reuses transport dedup/replay rather + /// than manufacturing a local event or bypassing verification. + pub async fn replay_event( + &self, + channel_id: Uuid, + event_id: String, + created_at: u64, + ) -> Result<(), RelayError> { + self.cmd_tx + .send(RelayCommand::ReplayEvent { + channel_id, + event_id, + created_at, + }) + .await + .map_err(|_| RelayError::ConnectionClosed) + } + /// Publish a signed event to the relay via the background WebSocket task. /// /// Blocks until the command channel has capacity. For ephemeral events @@ -1343,6 +1388,19 @@ impl BgState { } } + /// Undo transport admission for an event whose harness-side verification + /// dependency failed. The event was never dispatched, so it must become + /// eligible for the existing replay path. The timestamp is retained as a + /// replay floor even though `last_seen` already advanced when it arrived. + fn replay_event(&mut self, channel_id: Uuid, event_id: String, created_at: u64) { + self.seen_ids.remove(&event_id); + self.channel_dropped_since + .entry(channel_id) + .and_modify(|since| *since = (*since).min(created_at)) + .or_insert(created_at); + self.proactive_resubscribe_needed = true; + } + /// Clear all per-channel state for a channel that is being unsubscribed. /// Prevents stale replay on re-subscribe and avoids unbounded state growth /// for channels that are removed and never re-added. @@ -1527,6 +1585,11 @@ fn apply_command_to_state(state: &mut BgState, cmd: RelayCommand) { state.membership_last_seen = Some(ts); } } + RelayCommand::ReplayEvent { + channel_id, + event_id, + created_at, + } => state.replay_event(channel_id, event_id, created_at), // Observer telemetry frames are durable: park them (bounded, visible // overflow) so they are delivered by the post-reconnect drain. Other // ephemeral publishes (typing indicators) are meaningless while @@ -1765,6 +1828,14 @@ async fn execute_connected_command( debug!("startup watermark set to {ts}"); true } + RelayCommand::ReplayEvent { + channel_id, + event_id, + created_at, + } => { + state.replay_event(channel_id, event_id, created_at); + true + } // Control-flow commands — callers handle these before dispatching. RelayCommand::Shutdown | RelayCommand::Reconnect => { debug_assert!( @@ -3966,6 +4037,7 @@ pub(crate) fn parse_relay_message(text: &str) -> Result bool { match err { RelayError::Http(_) | RelayError::Json(_) | RelayError::UnexpectedMessage(_) => true, + RelayError::TransientHttp(_) => false, RelayError::WebSocket(e) => is_terminal_ws_error(e.as_ref()), RelayError::AuthFailed(message) => is_terminal_auth_failure(message), RelayError::NoAuthChallenge | RelayError::ConnectionClosed | RelayError::Timeout => false, @@ -4897,6 +4969,76 @@ mod tests { assert!(result.is_err()); } + #[tokio::test(start_paused = true)] + async fn workflow_wake_authority_retries_transient_failure_then_recovers() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let keys = Keys::generate(); + let relay = Keys::generate(); + let run_id = Uuid::new_v4(); + let channel_id = Uuid::new_v4(); + let workflow_id = Uuid::new_v4(); + let definition = EventBuilder::text_note("definition") + .sign_with_keys(&keys) + .expect("definition"); + let message = EventBuilder::text_note("message") + .sign_with_keys(&relay) + .expect("message"); + let authority = serde_json::json!({ + "run_id": run_id, + "channel_id": channel_id, + "workflow_id": workflow_id, + "definition_event_id": definition.id.to_hex(), + "workflow_owner": keys.public_key().to_hex(), + "definition": definition, + "message": message, + }); + let body = authority.to_string(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind authority server"); + let address = listener.local_addr().expect("authority server address"); + let server = tokio::spawn(async move { + for (status, response_body) in [(503, String::new()), (200, body)] { + let (mut stream, _) = listener.accept().await.expect("accept authority request"); + let mut request = [0u8; 4096]; + let _ = stream + .read(&mut request) + .await + .expect("read authority request"); + stream + .write_all( + format!( + "HTTP/1.1 {status} test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}", + response_body.len() + ) + .as_bytes(), + ) + .await + .expect("write authority response"); + } + }); + let client = RestClient { + http: reqwest::Client::new(), + base_url: format!("http://{address}"), + keys, + auth_tag_json: None, + }; + + let fetch = tokio::spawn(async move { + client + .workflow_wake_authority(run_id, &message.id) + .await + .expect("transient authority failure should recover") + }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(5)).await; + let received = fetch.await.expect("join authority fetch"); + server.await.expect("join authority server"); + assert_eq!(received.run_id, run_id); + assert_eq!(received.message.id, message.id); + } + #[test] fn subscription_id_starts_with_ch_prefix() { let uuid = Uuid::new_v4(); @@ -5333,6 +5475,33 @@ mod tests { ); } + #[test] + fn workflow_wake_authority_failure_reopens_transport_dedup_for_replay() { + let mut state = BgState::new(); + let channel_id = Uuid::new_v4(); + let keys = nostr::Keys::generate(); + let wake = make_test_event(&keys, 1_000); + let event_id = wake.id.to_hex(); + + // Normal transport delivery claims the ID and advances its watermark. + assert!(state.record_event(channel_id, &wake)); + assert!(!state.record_event(channel_id, &wake)); + + // A failure before authenticated authority verification must not lose + // the wake: make its exact ID eligible and replay from its timestamp. + state.replay_event(channel_id, event_id.clone(), wake.created_at.as_secs()); + assert!(!state.seen_ids.contains(&event_id)); + assert_eq!( + state.channel_since(&channel_id), + Some(wake.created_at.as_secs()) + ); + assert!(state.proactive_resubscribe_needed); + assert!(state.record_event(channel_id, &wake)); + + // Once replayed, ordinary dedup resumes; this does not admit duplicates. + assert!(!state.record_event(channel_id, &wake)); + } + /// Test 8: channel_dropped_since records the OLDEST dropped timestamp. /// /// Simulates the backpressure path directly on BgState: @@ -5671,6 +5840,11 @@ mod tests { let cases: Vec<(&str, RelayError, bool)> = vec![ // ── outer RelayError variants ── ("Http: bad URL", RelayError::Http("bad url".into()), true), + ( + "TransientHttp: exhausted retry budget", + RelayError::TransientHttp("timeout".into()), + false, + ), ( "Json: malformed relay frame", RelayError::Json(serde_json::from_str::<()>("not json").unwrap_err()), diff --git a/crates/buzz-acp/src/workflow_wake.rs b/crates/buzz-acp/src/workflow_wake.rs index b25caaeb740..9908e33b804 100644 --- a/crates/buzz-acp/src/workflow_wake.rs +++ b/crates/buzz-acp/src/workflow_wake.rs @@ -105,14 +105,24 @@ pub fn verify( if step.action != "send_message" { return None; } - if step + if let Some(target) = step .channel .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .is_some_and(|value| value != channel) { - return None; + // The endpoint's relay-signed message is the authority for a resolved + // template target. A definition stores templates, while its execution + // resolves them from per-run state unavailable to ACP; comparing raw + // text would reject valid targets. Literal targets remain an independent + // constraint and are compared as UUIDs so noncanonical spelling works. + if !target.contains("{{") { + let target = Uuid::parse_str(target).ok()?; + let message_channel = Uuid::parse_str(channel).ok()?; + if target != message_channel { + return None; + } + } } Some((message, definition.pubkey.to_hex())) } @@ -392,6 +402,77 @@ mod tests { assert!(wrong_target.verify(wrong_target.authority()).is_none()); } + #[test] + fn accepts_template_target_using_relay_signed_resolved_message_channel() { + let fixture = Fixture::new( + "name: wake\ntrigger:\n on: message_posted\nsteps:\n - id: notify\n action: send_message\n text: do work\n channel: '{{trigger.channel_id}}'\n", + None, + ); + assert!(fixture.verify(fixture.authority()).is_some()); + } + + #[test] + fn accepts_noncanonical_literal_uuid_target() { + let fixture = Fixture::new( + "name: wake\ntrigger:\n on: message_posted\nsteps:\n - id: notify\n action: send_message\n text: do work\n channel: $CHANNEL\n", + None, + ); + let noncanonical = fixture.channel.simple().to_string(); + let definition = EventBuilder::new( + Kind::Custom(KIND_WORKFLOW_DEF as u16), + format!("name: wake\ntrigger:\n on: message_posted\nsteps:\n - id: notify\n action: send_message\n text: do work\n channel: {noncanonical}\n"), + ) + .tags([ + Tag::parse(["d", &fixture.workflow.to_string()]).expect("d tag"), + Tag::parse(["h", &fixture.channel.to_string()]).expect("h tag"), + ]) + .sign_with_keys(&fixture.owner) + .expect("definition"); + let message = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "do work") + .tags([ + Tag::parse(["h", &fixture.channel.to_string()]).expect("h tag"), + Tag::parse(["p", &fixture.agent.public_key().to_hex()]).expect("p tag"), + Tag::parse(["workflow-run", &fixture.run.to_string()]).expect("run tag"), + Tag::parse(["workflow-definition", &definition.id.to_hex()]) + .expect("definition tag"), + Tag::parse(["workflow-step", "notify"]).expect("step tag"), + ]) + .sign_with_keys(&fixture.relay) + .expect("message"); + let wake = WorkflowMentionWake::new( + fixture.agent.public_key(), + fixture.channel, + fixture.run, + definition.id, + message.id, + ) + .sign(&fixture.relay) + .expect("wake"); + let authority = WorkflowWakeAuthority { + definition_event_id: definition.id.to_hex(), + definition, + message, + ..fixture.authority() + }; + assert!(super::verify( + &wake, + authority, + fixture.relay.public_key(), + fixture.agent.public_key(), + fixture.channel, + ) + .is_some()); + } + + #[test] + fn malformed_literal_target_remains_rejected() { + let fixture = Fixture::new( + "name: wake\ntrigger:\n on: message_posted\nsteps:\n - id: notify\n action: send_message\n text: do work\n channel: not-a-channel\n", + None, + ); + assert!(fixture.verify(fixture.authority()).is_none()); + } + #[test] fn wake_kind_remains_identifier_only() { let fixture = Fixture::valid(); diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index d685e44485e..cd78c46dc26 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -824,7 +824,8 @@ pub async fn soft_delete_event( event_id: &[u8], ) -> Result { let result = sqlx::query( - "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + "UPDATE events SET deleted_at = COALESCE(deleted_at, NOW()), workflow_revision_superseded = false \ + WHERE community_id = $1 AND id = $2 AND (deleted_at IS NULL OR workflow_revision_superseded)", ) .bind(community_id.as_uuid()) .bind(event_id) @@ -904,7 +905,8 @@ pub async fn soft_delete_event_and_update_thread( let mut tx = pool.begin().await?; let result = sqlx::query( - "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + "UPDATE events SET deleted_at = COALESCE(deleted_at, NOW()), workflow_revision_superseded = false \ + WHERE community_id = $1 AND id = $2 AND (deleted_at IS NULL OR workflow_revision_superseded)", ) .bind(community_id.as_uuid()) .bind(event_id) @@ -1026,6 +1028,30 @@ pub async fn get_event_by_id( } } +/// Fetch a captured workflow revision, including positively identified supersession. +/// Explicit deletion and legacy rows with unknown deletion reasons remain revoked. +/// This does not authorize access; callers must verify the run and current membership. +pub async fn get_workflow_revision( + pool: &PgPool, + community_id: CommunityId, + id_bytes: &[u8], +) -> Result> { + let row = sqlx::query( + "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ + FROM events WHERE community_id = $1 AND id = $2 AND kind = $3 \ + AND (deleted_at IS NULL OR workflow_revision_superseded) ORDER BY created_at DESC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(id_bytes) + .bind(buzz_core::kind::KIND_WORKFLOW_DEF as i32) + .fetch_optional(pool) + .await?; + match row { + Some(row) => row_to_stored_event(row), + None => Ok(None), + } +} + /// Fetches the latest global (non-channel, `channel_id IS NULL`) replaceable event /// for a (kind, pubkey) pair. /// diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 1fa1273eb0f..a130f3bf00d 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -54,3 +54,5 @@ pub mod usage; pub mod user; /// Workflow, run, and approval persistence. pub mod workflow; + +mod workflow_delivery; diff --git a/crates/buzz-db/src/store/replaceable.rs b/crates/buzz-db/src/store/replaceable.rs index 19f0d2d008e..723d5de2c59 100644 --- a/crates/buzz-db/src/store/replaceable.rs +++ b/crates/buzz-db/src/store/replaceable.rs @@ -270,6 +270,9 @@ async fn replace_parameterized_event_in_transaction_impl( let statement = if hard_delete_superseded { "DELETE FROM events \ WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL" + } else if kind_i32 == buzz_core::kind::KIND_WORKFLOW_DEF as i32 { + "UPDATE events SET deleted_at = NOW(), workflow_revision_superseded = true \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL" } else { "UPDATE events SET deleted_at = NOW() \ WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL" diff --git a/crates/buzz-db/src/store/workflow_delivery.rs b/crates/buzz-db/src/store/workflow_delivery.rs new file mode 100644 index 00000000000..71beb62b2ea --- /dev/null +++ b/crates/buzz-db/src/store/workflow_delivery.rs @@ -0,0 +1,57 @@ +//! Atomic workflow output persistence and captured-revision reads. +use crate::{event, insert_mentions_in_transaction, Db, Result}; +use buzz_core::{tenant::CommunityId, StoredEvent}; +use uuid::Uuid; + +impl Db { + /// Atomically persist a visible event, its thread metadata/mentions, and all + /// required notifications. No caller may publish any row until this commits. + /// Cancellation or any insert failure rolls the entire bundle back. + pub async fn insert_event_with_notifications( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Uuid, + thread_meta: Option>, + notifications: &[nostr::Event], + ) -> Result> { + let mut tx = self.begin_transaction().await?; + self.deletion_store() + .guard_transaction(&mut tx, community_id) + .await?; + let mut stored = Vec::with_capacity(1 + notifications.len()); + let message = event::insert_event_with_thread_metadata_tx( + &mut tx, + community_id, + event, + Some(channel_id), + thread_meta, + ) + .await?; + insert_mentions_in_transaction(&mut tx, community_id, event, Some(channel_id)).await?; + stored.push(message); + for notification in notifications { + let row = event::insert_event_in_transaction( + &mut tx, + community_id, + notification, + Some(channel_id), + ) + .await?; + insert_mentions_in_transaction(&mut tx, community_id, notification, Some(channel_id)) + .await?; + stored.push(row); + } + tx.commit().await?; + Ok(stored) + } + + /// Read a captured workflow definition without reviving explicitly deleted revisions. + pub async fn get_workflow_revision( + &self, + community_id: CommunityId, + id_bytes: &[u8], + ) -> Result> { + event::get_workflow_revision(&self.pool, community_id, id_bytes).await + } +} diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 09174cfd17c..998d165ecef 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1284,7 +1284,14 @@ async fn query_events_authed( // Defense-in-depth: never deliver a result-gated event (e.g. kind:44200 // or kind:30622) to a non-owner via the feed path, even though feed SQL // kind allowlists already exclude these kinds. - if !buzz_core::filter::reader_authorized_for_event(&se.event, &authed_pubkey_hex) { + if !crate::handlers::req::event_visible_to_reader( + &state, + tenant.community(), + &se.event, + &pubkey_bytes, + ) + .await + { continue; } if let Ok(v) = serde_json::to_value(&se.event) { @@ -1352,7 +1359,14 @@ async fn query_events_authed( // Defense-in-depth: never deliver a result-gated event (e.g. kind:44200 // or kind:30622) to a non-owner via the thread path, even though // requires_h_channel_scope already excludes these kinds from thread metadata. - if !buzz_core::filter::reader_authorized_for_event(&se.event, &authed_pubkey_hex) { + if !crate::handlers::req::event_visible_to_reader( + &state, + tenant.community(), + &se.event, + &pubkey_bytes, + ) + .await + { continue; } thread_row_ids.push(se.event.id.to_hex()); @@ -1377,10 +1391,13 @@ async fn query_events_authed( for se in aux_events { if !seen_aux.insert(se.event.id) || !event_in_accessible_channel(&se, &accessible_channels) - || !buzz_core::filter::reader_authorized_for_event( + || !crate::handlers::req::event_visible_to_reader( + &state, + tenant.community(), &se.event, - &authed_pubkey_hex, + &pubkey_bytes, ) + .await { continue; } @@ -1497,7 +1514,14 @@ async fn query_events_authed( // Also enforces author-only kinds (30300/30350) and the persona // shared-gate (kind:30175 without ["shared","true"]). Single call // covers all three gated event classes. - if !crate::handlers::req::event_visible_to_reader(&se.event, &pubkey_bytes) { + if !crate::handlers::req::event_visible_to_reader( + &state, + tenant.community(), + &se.event, + &pubkey_bytes, + ) + .await + { continue; } if let Ok(v) = serde_json::to_value(&se.event) { @@ -1783,9 +1807,13 @@ async fn count_events_authed( continue; } if !crate::handlers::req::event_visible_to_reader( + &state, + tenant.community(), &se.event, &pubkey_bytes, - ) { + ) + .await + { continue; } total += 1; @@ -1853,9 +1881,13 @@ async fn count_events_authed( continue; } if !crate::handlers::req::event_visible_to_reader( + &state, + tenant.community(), &se.event, &pubkey_bytes, - ) { + ) + .await + { continue; } total += 1; @@ -2036,7 +2068,14 @@ async fn handle_bridge_search( // branch cannot currently return unshared persona content — but the // check here ensures that a future FTS allowlist change cannot silently // reopen the bypass. - if !crate::handlers::req::event_visible_to_reader(&stored.event, pubkey_bytes) { + if !crate::handlers::req::event_visible_to_reader( + state, + tenant.community(), + &stored.event, + pubkey_bytes, + ) + .await + { continue; } // Dedup across filters. diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index 9368f539c0c..38e90d60c92 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -305,7 +305,7 @@ pub async fn workflow_wake_authority( .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid message id"))?; let definition = state .db - .get_event_by_id(tenant.community(), definition_id) + .get_workflow_revision(tenant.community(), definition_id) .await .map_err(|error| internal_error(&format!("workflow definition lookup: {error}")))? .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; @@ -335,6 +335,17 @@ pub async fn workflow_wake_authority( "workflow wake not accessible", ) .await?; + if !state + .db + .is_member(tenant.community(), message_channel, &recipient.to_bytes()) + .await + .map_err(|error| internal_error(&format!("workflow wake membership: {error}")))? + { + return Err(api_error( + StatusCode::FORBIDDEN, + "workflow wake not accessible", + )); + } if workflow.owner_pubkey != definition.event.pubkey.to_bytes() || workflow.channel_id != Some(message_channel) || !exact_tag(&definition.event, "h", &message_channel.to_string()) diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 18fecfc19f8..bd9e606b5ec 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -227,7 +227,14 @@ pub async fn handle_count( { continue; } - if !event_visible_to_reader(&se.event, &pubkey_bytes) { + if !event_visible_to_reader( + &state, + conn.tenant.community(), + &se.event, + &pubkey_bytes, + ) + .await + { continue; } total += 1; @@ -300,7 +307,14 @@ pub async fn handle_count( { continue; } - if !event_visible_to_reader(&se.event, &pubkey_bytes) { + if !event_visible_to_reader( + &state, + conn.tenant.community(), + &se.event, + &pubkey_bytes, + ) + .await + { continue; } total += 1; diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index f57e7142ffe..c2a446ef32c 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -197,6 +197,25 @@ pub async fn filter_fanout_by_access( matches }; + if owner_only_kind == buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE { + let mut allowed = Vec::with_capacity(matches.len()); + for (conn_id, sub_id) in matches { + let Some(pubkey) = state.conn_manager.pubkey_for_conn(conn_id) else { + continue; + }; + if super::req::event_visible_to_reader( + state, + community_id, + &stored_event.event, + &pubkey, + ) + .await + { + allowed.push((conn_id, sub_id)); + } + } + return allowed; + } let Some(channel_id) = stored_event.channel_id else { return matches; }; @@ -2165,7 +2184,7 @@ mod tests { } #[tokio::test] - async fn workflow_wake_delivers_only_to_exact_authenticated_recipient() { + async fn workflow_wake_fails_closed_when_membership_cannot_be_established() { let state = test_state().await; let community_id = buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()); let channel_id = Uuid::new_v4(); @@ -2207,7 +2226,7 @@ mod tests { let out = filter_fanout_by_access(&state, community_id, &stored, matches, None).await; - assert_eq!(out, vec![(recipient, "recipient".to_string())]); + assert!(out.is_empty()); } #[tokio::test] diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 7429f1c9f92..99c208aa313 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -445,7 +445,14 @@ pub async fn handle_req( // Also enforces author-only kinds (30300/30350) and the persona // shared-gate (kind:30175 without ["shared","true"]). Single call // covers all three gated event classes. - if !event_visible_to_reader(&stored.event, &pubkey_bytes) { + if !event_visible_to_reader( + &state, + conn.tenant.community(), + &stored.event, + &pubkey_bytes, + ) + .await + { continue; } @@ -778,7 +785,14 @@ async fn handle_search_req( } // Result-level gate: covers author-only, persona shared-gate, // and result-gated kinds in one call. - if !event_visible_to_reader(&stored.event, reader_pubkey_bytes) { + if !event_visible_to_reader( + state, + tenant.community(), + &stored.event, + reader_pubkey_bytes, + ) + .await + { continue; } // Dedup AFTER acceptance — an event that fails filter A's constraints @@ -1338,6 +1352,14 @@ pub(crate) fn result_gated_count_safe_for_pushdown( filter: &Filter, authed_pubkey_hex: &str, ) -> bool { + // Recipient pinning alone cannot prove current channel membership for wakes. + if filter.kinds.as_ref().is_none_or(|kinds| { + kinds + .iter() + .any(|kind| u32::from(kind.as_u16()) == buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE) + }) { + return false; + } let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); filter .generic_tags @@ -1374,7 +1396,27 @@ pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: /// Call this from every read surface — both WS (REQ/COUNT/fan-out) and HTTP /// (NIP-98 `/query`, `/count`, FTS search) — instead of inlining the three /// individual predicates at each site. -pub(crate) fn event_visible_to_reader(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { +pub(crate) async fn event_visible_to_reader( + state: &AppState, + community: buzz_core::tenant::CommunityId, + event: &nostr::Event, + requester_pubkey_bytes: &[u8], +) -> bool { + if u32::from(event.kind.as_u16()) == buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE { + let Ok(wake) = buzz_core::workflow_wake::WorkflowMentionWake::parse(event) else { + return false; + }; + // Open-channel readability is not wake authority. Read the writer, + // not a cached membership snapshot, at every delivery/count boundary. + if !state + .db + .is_member(community, wake.channel_id(), requester_pubkey_bytes) + .await + .unwrap_or(false) + { + return false; + } + } if is_author_only_event(event, requester_pubkey_bytes) { return false; } diff --git a/crates/buzz-relay/src/workflow_delivery_tests.rs b/crates/buzz-relay/src/workflow_delivery_tests.rs new file mode 100644 index 00000000000..7d1c4de3e88 --- /dev/null +++ b/crates/buzz-relay/src/workflow_delivery_tests.rs @@ -0,0 +1,390 @@ +//! Real-storage regressions for workflow wake lifecycle boundaries. +use super::integration_tests::test_state; +use super::*; +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, +}; +use buzz_core::{ + channel::{ChannelType, ChannelVisibility, MemberRole}, + tenant::CommunityId, +}; +use buzz_db::CreateCommunityWithOwnerResult; +use nostr::{Event, Keys, Timestamp}; + +struct Fixture { + state: Arc, + community: CommunityId, + host: String, + channel: Uuid, + owner: Keys, + agent: Keys, + workflow: Uuid, +} +impl Fixture { + async fn new() -> Self { + let mut state = test_state().await; + Arc::get_mut(&mut state) + .expect("unique state") + .config + .require_auth_token = false; + let owner = Keys::generate(); + let agent = Keys::generate(); + let host = format!("wake-{}.example", Uuid::new_v4().simple()); + let community = match state + .db + .create_community_with_owner(&host, &owner.public_key().to_hex()) + .await + .expect("community") + { + CreateCommunityWithOwnerResult::Created(record) => record.id, + other => panic!("unexpected {other:?}"), + }; + let channel = state + .db + .create_channel( + community, + "wake", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner.public_key().to_bytes(), + None, + ) + .await + .expect("channel") + .id; + state + .db + .ensure_user(community, &agent.public_key().to_bytes()) + .await + .expect("agent"); + state + .db + .update_user_profile( + community, + &agent.public_key().to_bytes(), + Some("Worker"), + None, + None, + None, + ) + .await + .expect("name"); + state + .db + .add_member( + community, + channel, + &agent.public_key().to_bytes(), + MemberRole::Bot, + Some(&owner.public_key().to_bytes()), + ) + .await + .expect("member"); + Self { + state, + community, + host, + channel, + owner, + agent, + workflow: Uuid::new_v4(), + } + } + fn headers(&self) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("host", self.host.parse().expect("host")); + headers.insert( + "x-pubkey", + self.agent.public_key().to_hex().parse().expect("pubkey"), + ); + headers + } + async fn revision(&self, timestamp: u64) -> Event { + let definition = "name: wake\ntrigger:\n on: message_posted\nsteps:\n - id: notify\n action: send_message\n text: '@Worker work'\n"; + let event = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_WORKFLOW_DEF as u16), + definition, + ) + .custom_created_at(Timestamp::from(timestamp)) + .tags([ + Tag::parse(["d", &self.workflow.to_string()]).expect("d"), + Tag::parse(["h", &self.channel.to_string()]).expect("h"), + ]) + .sign_with_keys(&self.owner) + .expect("definition"); + let mut tx = self.state.db.begin_transaction().await.expect("tx"); + self.state + .db + .replace_parameterized_event_in_transaction( + &mut tx, + self.community, + &event, + &self.workflow.to_string(), + Some(self.channel), + buzz_db::replaceable::ParameterizedReplacePrecondition::Unconditional, + ) + .await + .expect("replace"); + self.state + .db + .upsert_workflow( + &mut tx, + self.community, + self.workflow, + Some(self.channel), + &self.owner.public_key().to_bytes(), + "wake", + "{}", + &[0; 32], + event.id.as_bytes(), + ) + .await + .expect("materialize"); + tx.commit().await.expect("commit"); + event + } + async fn authority( + &self, + run: Uuid, + message: &str, + ) -> Result, (StatusCode, axum::Json)> { + crate::api::workflows::workflow_wake_authority( + State(self.state.clone()), + Path((run, message.to_owned())), + self.headers(), + ) + .await + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn captured_revision_survives_replacement_but_not_revocation() { + let f = Fixture::new().await; + let a = f.revision(Timestamp::now().as_secs()).await; + let run = f + .state + .db + .create_workflow_run(f.community, f.workflow, Some(a.id.as_bytes()), None, None) + .await + .expect("run"); + let message = RelayActionSink::new(&f.state) + .send_message( + WorkflowMessageContext { + community_id: f.community, + run_id: run, + step_id: "notify".into(), + definition_event_id: Some(a.id.as_bytes().to_vec()), + }, + &f.channel.to_string(), + "@Worker work", + &f.owner.public_key().to_hex(), + None, + ) + .await + .expect("message"); + f.revision(a.created_at.as_secs() + 1).await; + assert!(f + .state + .db + .get_event_by_id(f.community, a.id.as_bytes()) + .await + .expect("live read") + .is_none()); + let authority = f + .authority(run, &message) + .await + .expect("captured authority"); + assert_eq!(authority.0["definition"]["id"], a.id.to_hex()); + f.state + .db + .soft_delete_event_and_update_thread(f.community, a.id.as_bytes(), None, None) + .await + .expect("explicit revoke superseded revision"); + assert_eq!( + f.authority(run, &message).await.expect_err("revoked").0, + StatusCode::NOT_FOUND + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn removed_open_channel_member_cannot_read_or_count_wakes() { + let f = Fixture::new().await; + let a = f.revision(Timestamp::now().as_secs()).await; + let run = f + .state + .db + .create_workflow_run(f.community, f.workflow, Some(a.id.as_bytes()), None, None) + .await + .expect("run"); + let message = RelayActionSink::new(&f.state) + .send_message( + WorkflowMessageContext { + community_id: f.community, + run_id: run, + step_id: "notify".into(), + definition_event_id: Some(a.id.as_bytes().to_vec()), + }, + &f.channel.to_string(), + "@Worker work", + &f.owner.public_key().to_hex(), + None, + ) + .await + .expect("message"); + f.authority(run, &message).await.expect("member authority"); + let filter = serde_json::json!({"kinds":[buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE], "#p":[f.agent.public_key().to_hex()], "#h":[f.channel.to_string()]}); + let body = axum::body::Bytes::from( + serde_json::to_vec(&serde_json::json!({"filters":[filter]})).expect("body"), + ); + let before = + crate::api::bridge::query_events(State(f.state.clone()), f.headers(), body.clone()) + .await + .expect("query"); + assert_eq!(before.0.as_array().expect("events").len(), 1); + f.state + .db + .remove_member( + f.community, + f.channel, + &f.agent.public_key().to_bytes(), + &f.owner.public_key().to_bytes(), + ) + .await + .expect("remove"); + assert!(f + .state + .db + .get_accessible_channel_ids(f.community, &f.agent.public_key().to_bytes()) + .await + .expect("open readability") + .contains(&f.channel)); + assert_eq!( + f.authority(run, &message) + .await + .expect_err("not membership") + .0, + StatusCode::FORBIDDEN + ); + let after = crate::api::bridge::query_events(State(f.state.clone()), f.headers(), body.clone()) + .await + .expect("query after removal"); + assert!(after.0.as_array().expect("events").is_empty()); + let count = crate::api::bridge::count_events(State(f.state.clone()), f.headers(), body) + .await + .expect("count after removal"); + assert_eq!(count.0["count"], 0); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn notification_failure_rolls_back_message_mentions_and_thread_metadata() { + let f = Fixture::new().await; + let message = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "@Worker work") + .tags([ + Tag::parse(["h", &f.channel.to_string()]).expect("h"), + Tag::public_key(f.agent.public_key()), + ]) + .sign_with_keys(&f.state.relay_keypair) + .expect("message"); + let wake = WorkflowMentionWake::new( + f.agent.public_key(), + f.channel, + Uuid::new_v4(), + message.id, + message.id, + ) + .sign(&f.state.relay_keypair) + .expect("wake"); + // The second notification fails inside the transaction, after the message, + // metadata, mentions and first recipient have been written. + let rejected = EventBuilder::new(Kind::Custom(22242), "auth cannot persist") + .sign_with_keys(&f.state.relay_keypair) + .expect("rejected event"); + let meta = || buzz_db::event::ThreadMetadataParams { + event_id: message.id.as_bytes(), + event_created_at: chrono::DateTime::from_timestamp(message.created_at.as_secs() as i64, 0) + .expect("ts"), + channel_id: f.channel, + parent_event_id: None, + parent_event_created_at: None, + root_event_id: None, + root_event_created_at: None, + depth: 0, + broadcast: false, + }; + assert!(f + .state + .db + .insert_event_with_notifications( + f.community, + &message, + f.channel, + Some(meta()), + &[wake.clone(), rejected] + ) + .await + .is_err()); + for event in [&message, &wake] { + assert!(f + .state + .db + .get_event_by_id(f.community, event.id.as_bytes()) + .await + .expect("rollback read") + .is_none()); + } + assert!(f + .state + .db + .get_thread_metadata_by_event(f.community, message.id.as_bytes()) + .await + .expect("metadata rollback") + .is_none()); + let mut tx = f.state.db.begin_transaction().await.expect("read mentions"); + let mentions: i64 = sqlx::query_scalar( + "SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2", + ) + .bind(f.community.as_uuid()) + .bind(message.id.as_bytes().as_slice()) + .fetch_one(&mut *tx) + .await + .expect("mentions"); + assert_eq!(mentions, 0); + tx.rollback().await.expect("read rollback"); + // Commit without any fan-out: a new historical read still recovers all rows. + let second = WorkflowMentionWake::new( + f.owner.public_key(), + f.channel, + Uuid::new_v4(), + message.id, + message.id, + ) + .sign(&f.state.relay_keypair) + .expect("second wake"); + let rows = f + .state + .db + .insert_event_with_notifications( + f.community, + &message, + f.channel, + Some(meta()), + &[wake.clone(), second.clone()], + ) + .await + .expect("commit bundle"); + assert_eq!(rows.len(), 3); + for event in [&message, &wake, &second] { + assert!(f + .state + .db + .get_event_by_id(f.community, event.id.as_bytes()) + .await + .expect("replay read") + .is_some()); + } +} diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 67751a305c0..4768f6b1fd1 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -8,7 +8,7 @@ use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Weak}; -use buzz_core::kind::{KIND_STREAM_MESSAGE, KIND_WORKFLOW_MENTION_WAKE}; +use buzz_core::kind::KIND_STREAM_MESSAGE; use buzz_core::workflow_wake::WorkflowMentionWake; use buzz_workflow::action_sink::{ActionSink, ActionSinkError, WorkflowMessageContext}; use chrono::Utc; @@ -366,8 +366,7 @@ impl ActionSink for RelayActionSink { // The stored author-written template independently supplies the // authority-bearing workflow-mention tags. A trigger may therefore // render an `@Name` into visible output, but it cannot borrow the - // workflow owner's authority to wake that agent. A resolution failure - // must not drop the message, so log and proceed with the base tags. + // workflow owner's authority to wake that agent. Fail before persistence if resolution cannot establish recipients. let members = state .db .get_members(tenant.community(), channel_uuid) @@ -468,53 +467,36 @@ impl ActionSink for RelayActionSink { }, }); - let (stored_event, was_inserted) = state - .db - .insert_event_with_thread_metadata( - tenant.community(), - &event, - Some(channel_uuid), - thread_meta, - ) - .await - .map_err(|e| ActionSinkError::Database(e.to_string()))?; - - // 5. Post-persist side effects (fan-out, search, audit). - if was_inserted { - let _ = dispatch_persistent_event( - &tenant, - &state, - &stored_event, - kind_u32, - &author_pubkey_hex, - None, - ) - .await; - } - - // Persist one recipient-gated identifier wake for every mentioned - // agent. This also runs when the message insert was an idempotent - // duplicate, so a retry repairs a crash/failure between message and - // wake persistence instead of permanently losing the notification. - for wake in build_workflow_wakes( + // Build every wake before writing. The message, thread counters, + // mentions, and all recipients commit together; replay can recover + // committed wakes even if the relay dies before publishing them. + let wakes = build_workflow_wakes( &state.relay_keypair, channel_uuid, run_id, definition_event_id, event.id, mentioned_pubkeys, - )? { - let (stored_wake, wake_inserted) = state - .db - .insert_event(tenant.community(), &wake, Some(channel_uuid)) - .await - .map_err(|error| ActionSinkError::Database(error.to_string()))?; - if wake_inserted { + )?; + let stored = state + .db + .insert_event_with_notifications( + tenant.community(), + &event, + channel_uuid, + thread_meta, + &wakes, + ) + .await + .map_err(|error| ActionSinkError::Database(error.to_string()))?; + let was_inserted = stored.first().is_some_and(|(_, inserted)| *inserted); + for (stored_event, inserted) in &stored { + if *inserted { let _ = dispatch_persistent_event( &tenant, &state, - &stored_wake, - KIND_WORKFLOW_MENTION_WAKE, + stored_event, + u32::from(stored_event.event.kind.as_u16()), &author_pubkey_hex, None, ) @@ -922,7 +904,7 @@ mod tests { } #[cfg(test)] -mod postgres_tests { +pub(crate) mod postgres_tests { //! Regression test for `e3661764` / `7899c1a8`: a workflow `send_message` //! that mentions a channel member by name (`@Name`) in its author-written //! step template must emit both the legacy `p` tag and authenticated @@ -937,7 +919,7 @@ mod postgres_tests { use std::sync::Arc; /// Real-PG state mirroring `handlers::event::tests::test_state_with_redis_url`. - async fn test_state() -> Arc { + pub(crate) async fn test_state() -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); @@ -1577,3 +1559,7 @@ mod postgres_tests { ); } } + +#[cfg(test)] +#[path = "workflow_delivery_tests.rs"] +mod workflow_delivery_tests; diff --git a/migrations/0037_workflow_superseded_authority.sql b/migrations/0037_workflow_superseded_authority.sql new file mode 100644 index 00000000000..808ccf7ad72 --- /dev/null +++ b/migrations/0037_workflow_superseded_authority.sql @@ -0,0 +1,3 @@ +-- Supersession retains captured workflow authority; explicit deletion revokes it. +-- Do not infer a deletion reason for historical rows: unknown stays fail-closed. +ALTER TABLE events ADD COLUMN workflow_revision_superseded BOOLEAN NOT NULL DEFAULT false; From 662af242d38010d1d41b011f5d560115423251b2 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 13:33:39 -0400 Subject: [PATCH 08/33] Reconcile wake migrations with current foundation Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-db/src/runtime/migration.rs | 2 +- ...ded_authority.sql => 0042_workflow_superseded_authority.sql} | 0 schema/schema.sql | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) rename migrations/{0037_workflow_superseded_authority.sql => 0042_workflow_superseded_authority.sql} (100%) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 47722673300..2fa4e81056a 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -699,7 +699,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 43); + assert_eq!(migrations.len(), 45); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] diff --git a/migrations/0037_workflow_superseded_authority.sql b/migrations/0042_workflow_superseded_authority.sql similarity index 100% rename from migrations/0037_workflow_superseded_authority.sql rename to migrations/0042_workflow_superseded_authority.sql diff --git a/schema/schema.sql b/schema/schema.sql index 2f0dd2338e1..062fedd62dd 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -229,6 +229,7 @@ CREATE TABLE events ( received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), channel_id UUID, deleted_at TIMESTAMPTZ, + workflow_revision_superseded BOOLEAN NOT NULL DEFAULT false, d_tag TEXT, not_before BIGINT, delivered_at BIGINT, From d6f4c78961d75de5812d1179c5e351cfdd2098f7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 13:36:32 -0400 Subject: [PATCH 09/33] Configure lifecycle fixtures before state construction Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-relay/src/workflow_delivery_tests.rs | 6 +----- crates/buzz-relay/src/workflow_sink.rs | 1 + 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/buzz-relay/src/workflow_delivery_tests.rs b/crates/buzz-relay/src/workflow_delivery_tests.rs index 7d1c4de3e88..4bb419996c1 100644 --- a/crates/buzz-relay/src/workflow_delivery_tests.rs +++ b/crates/buzz-relay/src/workflow_delivery_tests.rs @@ -23,11 +23,7 @@ struct Fixture { } impl Fixture { async fn new() -> Self { - let mut state = test_state().await; - Arc::get_mut(&mut state) - .expect("unique state") - .config - .require_auth_token = false; + let state = test_state().await; let owner = Keys::generate(); let agent = Keys::generate(); let host = format!("wake-{}.example", Uuid::new_v4().simple()); diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 4768f6b1fd1..b7ede38059e 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -922,6 +922,7 @@ pub(crate) mod postgres_tests { pub(crate) async fn test_state() -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; + config.require_auth_token = false; config.redis_url = "redis://127.0.0.1:1".to_string(); let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); let db = buzz_db::Db::from_pool(pool.clone()); From 0372b25102f879327d9d29fa45934908f42597f4 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 13:40:39 -0400 Subject: [PATCH 10/33] Keep lifecycle regressions in backend integration gate Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-db/src/runtime/migration.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 2fa4e81056a..e30f245ebdd 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -1193,8 +1193,8 @@ mod postgres_tests { // Durable workflow wakes are recipient-gated and must remain outside // full-text search on both fresh and brownfield databases. - assert_eq!(migrations[35].version, 36); - let workflow_wake_fts = migrations[35].sql.as_str(); + assert_eq!(migrations[40].version, 41); + let workflow_wake_fts = migrations[40].sql.as_str(); assert!(workflow_wake_fts.contains("kind = 44620")); assert!(desired_schema.contains("44200, 44620")); From 0ef5041125b5b64f9863295287a57fb7cb8ebc07 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 13:42:56 -0400 Subject: [PATCH 11/33] Create workflow fixture owner in tenant user table Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-relay/src/workflow_delivery_tests.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/buzz-relay/src/workflow_delivery_tests.rs b/crates/buzz-relay/src/workflow_delivery_tests.rs index 4bb419996c1..316f0d45fa0 100644 --- a/crates/buzz-relay/src/workflow_delivery_tests.rs +++ b/crates/buzz-relay/src/workflow_delivery_tests.rs @@ -36,6 +36,11 @@ impl Fixture { CreateCommunityWithOwnerResult::Created(record) => record.id, other => panic!("unexpected {other:?}"), }; + state + .db + .ensure_user(community, &owner.public_key().to_bytes()) + .await + .expect("owner user"); let channel = state .db .create_channel( From 819ed22b70c94cb90430c4bfae75703f4b0a33ed Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 13:44:37 -0400 Subject: [PATCH 12/33] Exercise authority admission with real Redis in lifecycle tests Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-relay/src/workflow_delivery_tests.rs | 13 ++++++++----- crates/buzz-relay/src/workflow_sink.rs | 6 +++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/buzz-relay/src/workflow_delivery_tests.rs b/crates/buzz-relay/src/workflow_delivery_tests.rs index 316f0d45fa0..0b124f7d96c 100644 --- a/crates/buzz-relay/src/workflow_delivery_tests.rs +++ b/crates/buzz-relay/src/workflow_delivery_tests.rs @@ -1,5 +1,5 @@ //! Real-storage regressions for workflow wake lifecycle boundaries. -use super::integration_tests::test_state; +use super::integration_tests::test_state_with_redis; use super::*; use axum::{ extract::{Path, State}, @@ -23,7 +23,10 @@ struct Fixture { } impl Fixture { async fn new() -> Self { - let state = test_state().await; + let state = test_state_with_redis( + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".into()), + ) + .await; let owner = Keys::generate(); let agent = Keys::generate(); let host = format!("wake-{}.example", Uuid::new_v4().simple()); @@ -161,7 +164,7 @@ impl Fixture { } #[tokio::test] -#[ignore = "requires Postgres"] +#[ignore = "requires Postgres and Redis"] async fn captured_revision_survives_replacement_but_not_revocation() { let f = Fixture::new().await; let a = f.revision(Timestamp::now().as_secs()).await; @@ -211,7 +214,7 @@ async fn captured_revision_survives_replacement_but_not_revocation() { } #[tokio::test] -#[ignore = "requires Postgres"] +#[ignore = "requires Postgres and Redis"] async fn removed_open_channel_member_cannot_read_or_count_wakes() { let f = Fixture::new().await; let a = f.revision(Timestamp::now().as_secs()).await; @@ -281,7 +284,7 @@ async fn removed_open_channel_member_cannot_read_or_count_wakes() { } #[tokio::test] -#[ignore = "requires Postgres"] +#[ignore = "requires Postgres and Redis"] async fn notification_failure_rolls_back_message_mentions_and_thread_metadata() { let f = Fixture::new().await; let message = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "@Worker work") diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index b7ede38059e..52fbb8dd652 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -920,10 +920,14 @@ pub(crate) mod postgres_tests { /// Real-PG state mirroring `handlers::event::tests::test_state_with_redis_url`. pub(crate) async fn test_state() -> Arc { + test_state_with_redis("redis://127.0.0.1:1".to_string()).await + } + + pub(crate) async fn test_state_with_redis(redis_url: String) -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; config.require_auth_token = false; - config.redis_url = "redis://127.0.0.1:1".to_string(); + config.redis_url = redis_url; let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) From 470285dcaa3ce568ddd3f04f422cdd1941b03dd0 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 13:49:36 -0400 Subject: [PATCH 13/33] Use bridge filter array in wake removal regression Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-relay/src/workflow_delivery_tests.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/buzz-relay/src/workflow_delivery_tests.rs b/crates/buzz-relay/src/workflow_delivery_tests.rs index 0b124f7d96c..34167f05130 100644 --- a/crates/buzz-relay/src/workflow_delivery_tests.rs +++ b/crates/buzz-relay/src/workflow_delivery_tests.rs @@ -241,9 +241,8 @@ async fn removed_open_channel_member_cannot_read_or_count_wakes() { .expect("message"); f.authority(run, &message).await.expect("member authority"); let filter = serde_json::json!({"kinds":[buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE], "#p":[f.agent.public_key().to_hex()], "#h":[f.channel.to_string()]}); - let body = axum::body::Bytes::from( - serde_json::to_vec(&serde_json::json!({"filters":[filter]})).expect("body"), - ); + let body = + axum::body::Bytes::from(serde_json::to_vec(&serde_json::json!([filter])).expect("body")); let before = crate::api::bridge::query_events(State(f.state.clone()), f.headers(), body.clone()) .await From 23b9faa6c5c8933e80ad63a595384c886cb468b7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 13:52:41 -0400 Subject: [PATCH 14/33] Exercise wake revocation across WebSocket read and fanout paths Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../buzz-relay/src/workflow_delivery_tests.rs | 99 ++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/workflow_delivery_tests.rs b/crates/buzz-relay/src/workflow_delivery_tests.rs index 34167f05130..2407e47839b 100644 --- a/crates/buzz-relay/src/workflow_delivery_tests.rs +++ b/crates/buzz-relay/src/workflow_delivery_tests.rs @@ -96,6 +96,51 @@ impl Fixture { workflow: Uuid::new_v4(), } } + fn connection( + &self, + ) -> ( + Arc, + tokio::sync::mpsc::Receiver, + ) { + use crate::connection::{AuthState, ConnectionState}; + use std::{collections::HashMap, sync::atomic::AtomicU8}; + use tokio::sync::{mpsc, Mutex, RwLock}; + let (send_tx, rx) = mpsc::channel(16); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(4); + let conn = Arc::new(ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::TenantContext::resolved(self.community, &self.host), + remote_addr: "127.0.0.1:1234".parse().expect("address"), + auth_state: RwLock::new(AuthState::Authenticated(buzz_auth::AuthContext { + pubkey: self.agent.public_key(), + scopes: vec![], + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: None, + })), + subscriptions: Arc::new(Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + cancel: tokio_util::sync::CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }); + self.state.conn_manager.register( + conn.conn_id, + conn.send_tx.clone(), + conn.ctrl_tx.clone(), + None, + conn.cancel.clone(), + self.community, + conn.backpressure_count.clone(), + conn.subscriptions.clone(), + conn.grace_limit, + ); + self.state + .conn_manager + .set_authenticated_pubkey(conn.conn_id, self.agent.public_key().to_bytes().to_vec()); + (conn, rx) + } fn headers(&self) -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert("host", self.host.parse().expect("host")); @@ -163,6 +208,15 @@ impl Fixture { } } +fn next_frame( + rx: &mut tokio::sync::mpsc::Receiver, +) -> serde_json::Value { + let axum::extract::ws::Message::Text(text) = rx.try_recv().expect("frame") else { + panic!("expected text frame"); + }; + serde_json::from_str(&text).expect("frame JSON") +} + #[tokio::test] #[ignore = "requires Postgres and Redis"] async fn captured_revision_survives_replacement_but_not_revocation() { @@ -239,7 +293,7 @@ async fn removed_open_channel_member_cannot_read_or_count_wakes() { ) .await .expect("message"); - f.authority(run, &message).await.expect("member authority"); + let _ = f.authority(run, &message).await.expect("member authority"); let filter = serde_json::json!({"kinds":[buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE], "#p":[f.agent.public_key().to_hex()], "#h":[f.channel.to_string()]}); let body = axum::body::Bytes::from(serde_json::to_vec(&serde_json::json!([filter])).expect("body")); @@ -248,6 +302,32 @@ async fn removed_open_channel_member_cannot_read_or_count_wakes() { .await .expect("query"); assert_eq!(before.0.as_array().expect("events").len(), 1); + // Exercise the actual WS handlers and live send path, retaining the same + // connection/subscription across removal to expose stale access state. + let (conn, mut frames) = f.connection(); + let filters: Vec = serde_json::from_slice(&body).expect("filters"); + crate::handlers::req::handle_req( + "wakes".into(), + filters.clone(), + conn.clone(), + f.state.clone(), + ) + .await; + assert_eq!(next_frame(&mut frames)[0], "EVENT"); + assert_eq!(next_frame(&mut frames)[0], "EOSE"); + crate::handlers::count::handle_count( + "count".into(), + filters.clone(), + conn.clone(), + f.state.clone(), + ) + .await; + assert_eq!(next_frame(&mut frames)[2]["count"], 1); + let wake: Event = serde_json::from_value(before.0[0].clone()).expect("wake"); + let stored = buzz_core::StoredEvent::new(wake, Some(f.channel)); + crate::handlers::event::fan_out_event_to_local_subscribers(&f.state, f.community, &stored) + .await; + assert_eq!(next_frame(&mut frames)[0], "EVENT"); f.state .db .remove_member( @@ -280,6 +360,23 @@ async fn removed_open_channel_member_cannot_read_or_count_wakes() { .await .expect("count after removal"); assert_eq!(count.0["count"], 0); + crate::handlers::event::fan_out_event_to_local_subscribers(&f.state, f.community, &stored) + .await; + assert!( + frames.try_recv().is_err(), + "stale subscription must not deliver" + ); + crate::handlers::req::handle_req( + "wakes".into(), + filters.clone(), + conn.clone(), + f.state.clone(), + ) + .await; + assert_eq!(next_frame(&mut frames)[0], "EOSE", "no historical EVENT"); + crate::handlers::count::handle_count("count".into(), filters, conn, f.state.clone()).await; + assert_eq!(next_frame(&mut frames)[2]["count"], 0); + assert!(frames.try_recv().is_err()); } #[tokio::test] From 6473304bae93a78de1558a5bee592ad9eaa2b094 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 13:57:45 -0400 Subject: [PATCH 15/33] Distinguish unavailable wake authority from revocation Preserve transient storage failures for bounded ACP recovery. Keep absent and malformed authority terminal. Correct the renamed FTS migration fixture include. Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-relay/src/api/workflows.rs | 88 ++++++++++++++++--- .../tests/postgres_fts_integration.rs | 6 +- 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index 38e90d60c92..657e65306fc 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -266,6 +266,32 @@ fn approval_json(approval: &buzz_db::workflow::ApprovalRecord) -> Value { }) } +// A missing/revoked authority is terminal; unavailable storage is not. Keep +// this distinction at the endpoint so ACP's bounded transport retries can work. +fn wake_lookup_error(error: buzz_db::DbError) -> (StatusCode, Json) { + use buzz_db::DbError; + let status = match &error { + DbError::NotFound(_) => StatusCode::NOT_FOUND, + DbError::Sqlx(sqlx::Error::Io(_) | sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed) => { + StatusCode::SERVICE_UNAVAILABLE + } + DbError::Sqlx(sqlx::Error::Database(error)) + if error.code().is_some_and(|code| { + code.starts_with("08") + || matches!( + code.as_ref(), + "40001" | "40P01" | "53300" | "57P01" | "57P02" | "57P03" + ) + }) => + { + StatusCode::SERVICE_UNAVAILABLE + } + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + tracing::warn!(%error, %status, "workflow wake authority lookup failed"); + api_error(status, "workflow wake authority unavailable") +} + /// `GET /workflow-wakes/{run_id}/{message_id}` — exact authority bundle for one wake. pub async fn workflow_wake_authority( State(state): State>, @@ -290,12 +316,12 @@ pub async fn workflow_wake_authority( .db .get_workflow_run(tenant.community(), run_id) .await - .map_err(|_| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; + .map_err(wake_lookup_error)?; let workflow = state .db .get_workflow(tenant.community(), run.workflow_id) .await - .map_err(|_| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; + .map_err(wake_lookup_error)?; let definition_id = run .definition_event_id .as_deref() @@ -307,13 +333,13 @@ pub async fn workflow_wake_authority( .db .get_workflow_revision(tenant.community(), definition_id) .await - .map_err(|error| internal_error(&format!("workflow definition lookup: {error}")))? + .map_err(wake_lookup_error)? .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; let message = state .db .get_event_by_id(tenant.community(), message_id.as_bytes()) .await - .map_err(|error| internal_error(&format!("workflow message lookup: {error}")))? + .map_err(wake_lookup_error)? .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; let recipient_hex = recipient.to_hex(); @@ -326,20 +352,31 @@ pub async fn workflow_wake_authority( let message_channel = message .channel_id .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; - enforce_current_channel_read( + let auth_tag = headers + .get("x-auth-tag") + .and_then(|value| value.to_str().ok()); + super::relay_members::enforce_relay_membership( &state, - &tenant, - &headers, - &recipient, - message_channel, - "workflow wake not accessible", + tenant.community(), + &recipient.to_bytes(), + auth_tag, ) - .await?; + .await + .map_err(|(status, body)| { + // This shared boundary exposes database lookup failures as 500, while + // explicit roster/authentication denials retain their terminal status. + let status = if status == StatusCode::INTERNAL_SERVER_ERROR { + StatusCode::SERVICE_UNAVAILABLE + } else { + status + }; + (status, body) + })?; if !state .db .is_member(tenant.community(), message_channel, &recipient.to_bytes()) .await - .map_err(|error| internal_error(&format!("workflow wake membership: {error}")))? + .map_err(wake_lookup_error)? { return Err(api_error( StatusCode::FORBIDDEN, @@ -392,6 +429,33 @@ mod tests { ); } + #[test] + fn wake_lookup_failure_is_not_authority_revocation() { + use buzz_db::DbError; + for error in [ + sqlx::Error::PoolClosed, + sqlx::Error::PoolTimedOut, + sqlx::Error::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset)), + ] { + assert_eq!( + wake_lookup_error(DbError::Sqlx(error)).0, + StatusCode::SERVICE_UNAVAILABLE + ); + } + assert_eq!( + wake_lookup_error(DbError::NotFound("run".into())).0, + StatusCode::NOT_FOUND + ); + assert_eq!( + wake_lookup_error(DbError::InvalidData("bad row".into())).0, + StatusCode::INTERNAL_SERVER_ERROR + ); + assert_eq!( + wake_lookup_error(DbError::Sqlx(sqlx::Error::ColumnNotFound("bad".into()))).0, + StatusCode::INTERNAL_SERVER_ERROR + ); + } + #[test] fn channel_access_is_required_at_authority_read_time() { let channel = Uuid::new_v4(); diff --git a/crates/buzz-search/tests/postgres_fts_integration.rs b/crates/buzz-search/tests/postgres_fts_integration.rs index 4ff5e039dc7..8ca027ebee1 100644 --- a/crates/buzz-search/tests/postgres_fts_integration.rs +++ b/crates/buzz-search/tests/postgres_fts_integration.rs @@ -30,8 +30,8 @@ const MIGRATION_0008_SQL: &str = const MIGRATION_0014_SQL: &str = include_str!("../../../migrations/0014_push_lease_fts.sql"); const MIGRATION_0033_SQL: &str = include_str!("../../../migrations/0033_private_managed_agent_fts.sql"); -const MIGRATION_0036_SQL: &str = - include_str!("../../../migrations/0036_workflow_mention_wake_fts.sql"); +const MIGRATION_0041_SQL: &str = + include_str!("../../../migrations/0041_workflow_mention_wake_fts.sql"); async fn setup() -> (PgPool, String) { setup_with_search_policy(true).await @@ -94,7 +94,7 @@ async fn setup_with_search_policy(apply_fresh_allowlist: bool) -> (PgPool, Strin pool.execute(MIGRATION_0033_SQL) .await .expect("apply 0033 migration"); - pool.execute(MIGRATION_0036_SQL) + pool.execute(MIGRATION_0041_SQL) .await .expect("apply 0036 migration"); (pool, schema) From 0a6889bd043e7d7a34a3d2ea1da4a416c64b532d Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 13:59:15 -0400 Subject: [PATCH 16/33] Pin PostgreSQL timeout recovery and clear bridge lint Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-relay/src/api/bridge.rs | 12 +++---- crates/buzz-relay/src/api/workflows.rs | 11 +++++-- .../buzz-relay/src/workflow_delivery_tests.rs | 32 +++++++++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 998d165ecef..05e06e58610 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1285,7 +1285,7 @@ async fn query_events_authed( // or kind:30622) to a non-owner via the feed path, even though feed SQL // kind allowlists already exclude these kinds. if !crate::handlers::req::event_visible_to_reader( - &state, + state, tenant.community(), &se.event, &pubkey_bytes, @@ -1360,7 +1360,7 @@ async fn query_events_authed( // or kind:30622) to a non-owner via the thread path, even though // requires_h_channel_scope already excludes these kinds from thread metadata. if !crate::handlers::req::event_visible_to_reader( - &state, + state, tenant.community(), &se.event, &pubkey_bytes, @@ -1392,7 +1392,7 @@ async fn query_events_authed( if !seen_aux.insert(se.event.id) || !event_in_accessible_channel(&se, &accessible_channels) || !crate::handlers::req::event_visible_to_reader( - &state, + state, tenant.community(), &se.event, &pubkey_bytes, @@ -1515,7 +1515,7 @@ async fn query_events_authed( // shared-gate (kind:30175 without ["shared","true"]). Single call // covers all three gated event classes. if !crate::handlers::req::event_visible_to_reader( - &state, + state, tenant.community(), &se.event, &pubkey_bytes, @@ -1807,7 +1807,7 @@ async fn count_events_authed( continue; } if !crate::handlers::req::event_visible_to_reader( - &state, + state, tenant.community(), &se.event, &pubkey_bytes, @@ -1881,7 +1881,7 @@ async fn count_events_authed( continue; } if !crate::handlers::req::event_visible_to_reader( - &state, + state, tenant.community(), &se.event, &pubkey_bytes, diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index 657e65306fc..1bd1f371a59 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -268,7 +268,7 @@ fn approval_json(approval: &buzz_db::workflow::ApprovalRecord) -> Value { // A missing/revoked authority is terminal; unavailable storage is not. Keep // this distinction at the endpoint so ACP's bounded transport retries can work. -fn wake_lookup_error(error: buzz_db::DbError) -> (StatusCode, Json) { +pub(crate) fn wake_lookup_error(error: buzz_db::DbError) -> (StatusCode, Json) { use buzz_db::DbError; let status = match &error { DbError::NotFound(_) => StatusCode::NOT_FOUND, @@ -280,7 +280,14 @@ fn wake_lookup_error(error: buzz_db::DbError) -> (StatusCode, Json) { code.starts_with("08") || matches!( code.as_ref(), - "40001" | "40P01" | "53300" | "57P01" | "57P02" | "57P03" + "40001" + | "40P01" + | "53300" + | "55P03" + | "57014" + | "57P01" + | "57P02" + | "57P03" ) }) => { diff --git a/crates/buzz-relay/src/workflow_delivery_tests.rs b/crates/buzz-relay/src/workflow_delivery_tests.rs index 2407e47839b..3b9c0dd0d8e 100644 --- a/crates/buzz-relay/src/workflow_delivery_tests.rs +++ b/crates/buzz-relay/src/workflow_delivery_tests.rs @@ -488,3 +488,35 @@ async fn notification_failure_rolls_back_message_mentions_and_thread_metadata() .is_some()); } } + +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn storage_timeout_is_retryable_but_missing_authority_is_terminal() { + use crate::api::workflows::wake_lookup_error; + let f = Fixture::new().await; + let mut tx = f.state.db.begin_transaction().await.expect("transaction"); + sqlx::query("SET LOCAL statement_timeout = '10ms'") + .execute(&mut *tx) + .await + .expect("set timeout"); + let error = sqlx::query("SELECT pg_sleep(1)") + .execute(&mut *tx) + .await + .expect_err("statement timeout"); + assert_eq!( + error.as_database_error().and_then(|e| e.code()).as_deref(), + Some("57014") + ); + assert_eq!( + wake_lookup_error(error.into()).0, + StatusCode::SERVICE_UNAVAILABLE + ); + tx.rollback().await.expect("rollback"); + let error = f + .state + .db + .get_workflow_run(f.community, Uuid::new_v4()) + .await + .expect_err("missing run"); + assert_eq!(wake_lookup_error(error).0, StatusCode::NOT_FOUND); +} From 0938253aed576349c71a6147e3f74dc702d9d713 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 14:02:11 -0400 Subject: [PATCH 17/33] Exercise exhausted authority recovery through transport replay Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-acp/src/relay.rs | 70 +------- .../src/workflow_wake_recovery_tests.rs | 168 ++++++++++++++++++ 2 files changed, 170 insertions(+), 68 deletions(-) create mode 100644 crates/buzz-acp/src/workflow_wake_recovery_tests.rs diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 249d19798dd..d75bbaace3e 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -4969,74 +4969,8 @@ mod tests { assert!(result.is_err()); } - #[tokio::test(start_paused = true)] - async fn workflow_wake_authority_retries_transient_failure_then_recovers() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - let keys = Keys::generate(); - let relay = Keys::generate(); - let run_id = Uuid::new_v4(); - let channel_id = Uuid::new_v4(); - let workflow_id = Uuid::new_v4(); - let definition = EventBuilder::text_note("definition") - .sign_with_keys(&keys) - .expect("definition"); - let message = EventBuilder::text_note("message") - .sign_with_keys(&relay) - .expect("message"); - let authority = serde_json::json!({ - "run_id": run_id, - "channel_id": channel_id, - "workflow_id": workflow_id, - "definition_event_id": definition.id.to_hex(), - "workflow_owner": keys.public_key().to_hex(), - "definition": definition, - "message": message, - }); - let body = authority.to_string(); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind authority server"); - let address = listener.local_addr().expect("authority server address"); - let server = tokio::spawn(async move { - for (status, response_body) in [(503, String::new()), (200, body)] { - let (mut stream, _) = listener.accept().await.expect("accept authority request"); - let mut request = [0u8; 4096]; - let _ = stream - .read(&mut request) - .await - .expect("read authority request"); - stream - .write_all( - format!( - "HTTP/1.1 {status} test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}", - response_body.len() - ) - .as_bytes(), - ) - .await - .expect("write authority response"); - } - }); - let client = RestClient { - http: reqwest::Client::new(), - base_url: format!("http://{address}"), - keys, - auth_tag_json: None, - }; - - let fetch = tokio::spawn(async move { - client - .workflow_wake_authority(run_id, &message.id) - .await - .expect("transient authority failure should recover") - }); - tokio::task::yield_now().await; - tokio::time::advance(Duration::from_secs(5)).await; - let received = fetch.await.expect("join authority fetch"); - server.await.expect("join authority server"); - assert_eq!(received.run_id, run_id); - assert_eq!(received.message.id, message.id); + mod workflow_wake_recovery_tests { + include!("workflow_wake_recovery_tests.rs"); } #[test] diff --git a/crates/buzz-acp/src/workflow_wake_recovery_tests.rs b/crates/buzz-acp/src/workflow_wake_recovery_tests.rs new file mode 100644 index 00000000000..d64d1efb9ad --- /dev/null +++ b/crates/buzz-acp/src/workflow_wake_recovery_tests.rs @@ -0,0 +1,168 @@ +// Exhausted HTTP authority retry through the real relay command/replay loop. +use super::*; +use buzz_core::kind::{KIND_STREAM_MESSAGE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_MENTION_WAKE}; +use buzz_core::workflow_wake::WorkflowMentionWake; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[tokio::test] +async fn exhausted_authority_failure_replays_exact_wake_and_verifies_before_dispatch() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let relay_key = Keys::generate(); + let channel = Uuid::new_v4(); + let run = Uuid::new_v4(); + let workflow = Uuid::new_v4(); + let definition = EventBuilder::new(Kind::Custom(KIND_WORKFLOW_DEF as u16), + "name: wake\ntrigger:\n on: message_posted\nsteps:\n - id: notify\n action: send_message\n text: work\n") + .tags([Tag::parse(["d", &workflow.to_string()]).unwrap(), + Tag::parse(["h", &channel.to_string()]).unwrap()]) + .sign_with_keys(&owner).unwrap(); + let message = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "work") + .tags([ + Tag::parse(["h", &channel.to_string()]).unwrap(), + Tag::public_key(agent.public_key()), + Tag::parse(["workflow-run", &run.to_string()]).unwrap(), + Tag::parse(["workflow-definition", &definition.id.to_hex()]).unwrap(), + Tag::parse(["workflow-step", "notify"]).unwrap(), + ]) + .sign_with_keys(&relay_key) + .unwrap(); + let wake = + WorkflowMentionWake::new(agent.public_key(), channel, run, definition.id, message.id) + .sign(&relay_key) + .unwrap(); + let body = json!({"run_id":run, "channel_id":channel, "workflow_id":workflow, + "definition_event_id":definition.id.to_hex(), "workflow_owner":owner.public_key().to_hex(), + "definition":definition, "message":message}) + .to_string(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + // Four failures exhaust the entire bounded request budget; the fifth + // request is possible only after the transport has replayed the wake. + let http_server = tokio::spawn(async move { + for index in 0..5 { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 4096]; + let len = stream.read(&mut request).await.unwrap(); + let request = String::from_utf8_lossy(&request[..len]); + assert!(request.starts_with(&format!("GET /workflow-wakes/{run}/"))); + let (status, response) = if index < 4 { + (503, "") + } else { + (200, body.as_str()) + }; + stream.write_all(format!("HTTP/1.1 {status} test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).as_bytes()).await.unwrap(); + } + }); + let http = reqwest::Client::new(); + let rest = RestClient { + http: http.clone(), + base_url: format!("http://{address}"), + keys: agent.clone(), + auth_tag_json: None, + }; + let (ws, mut server) = test_ws_pair().await; + let (event_tx, event_rx) = mpsc::channel(16); + let (observer_control_tx, observer_control_rx) = mpsc::channel(16); + let (cmd_tx, cmd_rx) = mpsc::channel(16); + let bg = tokio::spawn(run_background_task( + ws, + VecDeque::new(), + event_tx, + observer_control_tx, + cmd_rx, + agent.clone(), + "ws://unused".into(), + agent.public_key().to_hex(), + None, + )); + let mut harness = HarnessRelay { + event_rx, + observer_control_rx: Some(observer_control_rx), + cmd_tx, + http, + relay_url: "ws://unused".into(), + keys: agent.clone(), + auth_tag: None, + bg_handle: Some(bg), + }; + harness + .subscribe_channel_from( + channel, + ChannelFilter { + kinds: Some(vec![KIND_WORKFLOW_MENTION_WAKE]), + require_mention: false, + }, + Some(wake.created_at.as_secs()), + ) + .await + .unwrap(); + let initial = next_test_frame(&mut server).await; + let frame = json!(["EVENT", channel_sub_id(channel), wake]).to_string(); + server + .send(Message::Text(frame.clone().into())) + .await + .unwrap(); + let received = timeout(Duration::from_secs(2), harness.next_event()) + .await + .unwrap() + .unwrap(); + let authenticated = + crate::workflow_wake::authenticate(&received.event, relay_key.public_key()).unwrap(); + let error = rest + .workflow_wake_authority(authenticated.run_id(), &authenticated.message_event_id()) + .await + .expect_err("all four requests fail"); + assert!(error.is_transient()); + assert!( + harness.event_rx.try_recv().is_err(), + "no fabricated event on lookup failure" + ); + harness + .replay_event( + channel, + received.event.id.to_hex(), + received.event.created_at.as_secs(), + ) + .await + .unwrap(); + let replay = next_test_frame(&mut server).await; + assert_eq!(replay[0], "REQ"); + assert_eq!(replay[1], initial[1]); + assert_eq!(replay[2]["kinds"], json!([KIND_WORKFLOW_MENTION_WAKE])); + assert_eq!(replay[2]["#h"], json!([channel.to_string()])); + assert_eq!(replay[2]["#p"], json!([agent.public_key().to_hex()])); + assert!(replay[2]["since"].as_u64().unwrap() <= wake.created_at.as_secs()); + server + .send(Message::Text(frame.clone().into())) + .await + .unwrap(); + let replayed = timeout(Duration::from_secs(2), harness.next_event()) + .await + .unwrap() + .unwrap(); + assert_eq!(replayed.event.id, wake.id); + let authority = rest + .workflow_wake_authority(run, &message.id) + .await + .unwrap(); + let (verified, principal) = crate::workflow_wake::verify( + &replayed.event, + authority, + relay_key.public_key(), + agent.public_key(), + channel, + ) + .expect("full authority verified"); + assert_eq!(verified.id, message.id); + assert_eq!(principal, owner.public_key().to_hex()); + server.send(Message::Text(frame.into())).await.unwrap(); + assert!( + timeout(Duration::from_millis(100), harness.next_event()) + .await + .is_err(), + "normal dedup resumes" + ); + http_server.await.unwrap(); + harness.shutdown().await; +} From ae903c5aae6b241e11e48d6d31aa7bdd0f506ee9 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 14:03:47 -0400 Subject: [PATCH 18/33] Handle background heartbeat in wake replay fixture Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../src/workflow_wake_recovery_tests.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/buzz-acp/src/workflow_wake_recovery_tests.rs b/crates/buzz-acp/src/workflow_wake_recovery_tests.rs index d64d1efb9ad..07c736ef9d9 100644 --- a/crates/buzz-acp/src/workflow_wake_recovery_tests.rs +++ b/crates/buzz-acp/src/workflow_wake_recovery_tests.rs @@ -97,7 +97,7 @@ async fn exhausted_authority_failure_replays_exact_wake_and_verifies_before_disp ) .await .unwrap(); - let initial = next_test_frame(&mut server).await; + let initial = next_data_frame(&mut server).await; let frame = json!(["EVENT", channel_sub_id(channel), wake]).to_string(); server .send(Message::Text(frame.clone().into())) @@ -126,7 +126,7 @@ async fn exhausted_authority_failure_replays_exact_wake_and_verifies_before_disp ) .await .unwrap(); - let replay = next_test_frame(&mut server).await; + let replay = next_data_frame(&mut server).await; assert_eq!(replay[0], "REQ"); assert_eq!(replay[1], initial[1]); assert_eq!(replay[2]["kinds"], json!([KIND_WORKFLOW_MENTION_WAKE])); @@ -166,3 +166,17 @@ async fn exhausted_authority_failure_replays_exact_wake_and_verifies_before_disp http_server.await.unwrap(); harness.shutdown().await; } + +async fn next_data_frame(server: &mut WebSocketStream) -> Value { + timeout(Duration::from_secs(2), async { + loop { + match server.next().await.expect("websocket open").expect("frame") { + Message::Text(text) => return serde_json::from_str(&text).expect("JSON frame"), + Message::Ping(payload) => server.send(Message::Pong(payload)).await.expect("pong"), + other => panic!("unexpected frame {other:?}"), + } + } + }) + .await + .expect("data frame before timeout") +} From 5ec692937a42911dfb1e38bbd6f1e50354b611fc Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 14:13:25 -0400 Subject: [PATCH 19/33] Recover workflow wakes after interrupted authority bodies Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-acp/src/relay.rs | 20 +++- .../src/workflow_wake_recovery_tests.rs | 101 ++++++++++++++++-- 2 files changed, 106 insertions(+), 15 deletions(-) diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index d75bbaace3e..c5945dacff4 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -522,11 +522,21 @@ impl RestClient { message_id: &nostr::EventId, ) -> Result { let path = format!("/workflow-wakes/{run_id}/{}", message_id.to_hex()); - self.bridge_get(&path) - .await? - .json() - .await - .map_err(|error| RelayError::Http(error.to_string())) + // A successful status is not a complete authority response. Read the + // body separately so an interrupted transfer remains recoverable while + // a complete but malformed authority document stays terminal. + let response = self.bridge_get(&path).await?; + let body = match response.bytes().await { + Ok(body) => body, + Err(error) => { + // Replay retries this read through the normal verification path. + // Pace body failures too: headers may have arrived immediately, + // bypassing request_with_retry's backoff entirely. + tokio::time::sleep(jittered_duration(REST_RETRY_BASE_DELAYS[0])).await; + return Err(RelayError::TransientHttp(error.to_string())); + } + }; + serde_json::from_slice(&body).map_err(|error| RelayError::Http(error.to_string())) } /// Query events via the HTTP bridge: `POST /query` with NIP-98 auth. diff --git a/crates/buzz-acp/src/workflow_wake_recovery_tests.rs b/crates/buzz-acp/src/workflow_wake_recovery_tests.rs index 07c736ef9d9..9e07b11ef6a 100644 --- a/crates/buzz-acp/src/workflow_wake_recovery_tests.rs +++ b/crates/buzz-acp/src/workflow_wake_recovery_tests.rs @@ -6,6 +6,27 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[tokio::test] async fn exhausted_authority_failure_replays_exact_wake_and_verifies_before_dispatch() { + assert_authority_recovery(AuthorityFailure::Status).await; +} + +#[tokio::test] +async fn truncated_authority_body_replays_before_dispatch() { + assert_authority_recovery(AuthorityFailure::TruncatedBody).await; +} + +#[tokio::test] +async fn stalled_authority_body_replays_before_dispatch() { + assert_authority_recovery(AuthorityFailure::StalledBody).await; +} + +#[derive(Clone, Copy)] +enum AuthorityFailure { + Status, + TruncatedBody, + StalledBody, +} + +async fn assert_authority_recovery(failure: AuthorityFailure) { let agent = Keys::generate(); let owner = Keys::generate(); let relay_key = Keys::generate(); @@ -37,24 +58,44 @@ async fn exhausted_authority_failure_replays_exact_wake_and_verifies_before_disp .to_string(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); - // Four failures exhaust the entire bounded request budget; the fifth - // request is possible only after the transport has replayed the wake. + // Status failures exhaust the HTTP retry budget. Body failures arrive + // after successful headers and must independently reopen transport replay. + let failures = if matches!(failure, AuthorityFailure::Status) { + 4 + } else { + 1 + }; let http_server = tokio::spawn(async move { - for index in 0..5 { + for index in 0..=failures { let (mut stream, _) = listener.accept().await.unwrap(); let mut request = [0u8; 4096]; let len = stream.read(&mut request).await.unwrap(); let request = String::from_utf8_lossy(&request[..len]); assert!(request.starts_with(&format!("GET /workflow-wakes/{run}/"))); - let (status, response) = if index < 4 { - (503, "") + if index < failures { + match failure { + AuthorityFailure::Status => { + stream.write_all(b"HTTP/1.1 503 test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap(); + } + AuthorityFailure::TruncatedBody | AuthorityFailure::StalledBody => { + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\nConnection: close\r\n\r\n{").await.unwrap(); + if matches!(failure, AuthorityFailure::StalledBody) { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(5)).await; + drop(stream); + }); + } + } + } } else { - (200, body.as_str()) - }; - stream.write_all(format!("HTTP/1.1 {status} test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).as_bytes()).await.unwrap(); + stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + } } }); - let http = reqwest::Client::new(); + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(1)) + .build() + .unwrap(); let rest = RestClient { http: http.clone(), base_url: format!("http://{address}"), @@ -112,7 +153,7 @@ async fn exhausted_authority_failure_replays_exact_wake_and_verifies_before_disp let error = rest .workflow_wake_authority(authenticated.run_id(), &authenticated.message_event_id()) .await - .expect_err("all four requests fail"); + .expect_err("authority transfer fails"); assert!(error.is_transient()); assert!( harness.event_rx.try_recv().is_err(), @@ -180,3 +221,43 @@ async fn next_data_frame(server: &mut WebSocketStream) -> .await .expect("data frame before timeout") } + +#[tokio::test] +async fn complete_malformed_authority_body_is_terminal() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + for body in ["{", "{}"] { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 4096]; + stream.read(&mut request).await.unwrap(); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await + .unwrap(); + } + }); + let rest = RestClient { + http: reqwest::Client::new(), + base_url: format!("http://{address}"), + keys: Keys::generate(), + auth_tag_json: None, + }; + for _ in 0..2 { + let error = rest + .workflow_wake_authority(Uuid::new_v4(), &nostr::EventId::all_zeros()) + .await + .unwrap_err(); + assert!( + !error.is_transient(), + "complete malformed authority must not replay" + ); + } + server.await.unwrap(); +} From 71056bbc7463999e159249dceaf32b384afb4b22 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 14:14:49 -0400 Subject: [PATCH 20/33] Keep replay-guard outages distinct from authentication denials Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-relay/src/api/bridge.rs | 37 ++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 05e06e58610..910b46b1eda 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -187,7 +187,7 @@ async fn check_nip98_replay_with_guard( "NIP-98 replay guard failed; rejecting request fail-closed" ); Err(api_error( - StatusCode::UNAUTHORIZED, + StatusCode::SERVICE_UNAVAILABLE, "NIP-98: replay check unavailable", )) } @@ -2876,7 +2876,8 @@ mod postgres_tests { /// This test does not require Redis — it injects a guard that always /// returns `Err`, exercising the `Err =>` arm in /// `check_nip98_replay_with_guard` directly. Bites if the arm is changed - /// to admit (`Ok(())` / `Ok(true)`) instead of returning 401. + /// to admit (`Ok(())` / `Ok(true)`) instead of returning retryable 503. + /// A dependency outage is not a replay or invalid-credential verdict. #[tokio::test] async fn nip98_replay_check_fails_closed_when_guard_errors() { use buzz_auth::AuthError; @@ -2884,23 +2885,29 @@ mod postgres_tests { use std::future::Future; use std::pin::Pin; - struct AlwaysErrGuard; - impl Nip98ReplayGuard for AlwaysErrGuard { + struct TestGuard { + unavailable: bool, + } + impl Nip98ReplayGuard for TestGuard { fn try_mark_in_scope<'a>( &'a self, _scope: &'a str, _event_id: &'a EventId, _ttl_secs: u64, ) -> Pin> + Send + 'a>> { - Box::pin(async { - Err(AuthError::Internal( - "simulated Redis pool acquire failure".into(), - )) + Box::pin(async move { + if self.unavailable { + Err(AuthError::Internal( + "simulated Redis pool acquire failure".into(), + )) + } else { + Ok(false) + } }) } } - let guard = AlwaysErrGuard; + let guard = TestGuard { unavailable: true }; let tenant = fresh_tenant("relay-a.example"); let event_id_bytes = fresh_nip98_event_id_bytes(); @@ -2909,8 +2916,8 @@ mod postgres_tests { .expect_err("guard error MUST fail closed, never admit"); assert_eq!( status, - StatusCode::UNAUTHORIZED, - "fail-closed must return 401" + StatusCode::SERVICE_UNAVAILABLE, + "fail-closed dependency failure must remain retryable" ); let msg = body .get("error") @@ -2921,6 +2928,14 @@ mod postgres_tests { "fail-closed body must carry the unavailable signal so callers can \ distinguish unavailability from replay; got body = {body:?}" ); + let (status, _) = check_nip98_replay_with_guard( + &TestGuard { unavailable: false }, + &tenant, + event_id_bytes, + ) + .await + .expect_err("a real replay stays denied"); + assert_eq!(status, StatusCode::UNAUTHORIZED); } /// Build a signed NIP-98 event JSON string for `url` + `method`, mirroring From 4db89eb2721ea1f519691ae061dabdb3e4c2b6cf Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 14:17:28 -0400 Subject: [PATCH 21/33] Verify captured revision revocation through deletion ingress Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-acp/src/lib.rs | 11 ++++----- .../buzz-relay/src/workflow_delivery_tests.rs | 23 +++++++++++++++---- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index d110866056c..beaa4bbf39e 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3167,12 +3167,11 @@ async fn tokio_main() -> Result<()> { { Ok(authority) => authority, Err(error) if error.is_transient() => { - // The authority request exhausted bounded 500ms/1s/2s - // retry. Transport dedup has recorded this relay-signed - // wake, but it has not reached dispatch; re-admit it for - // filtered relay replay rather than losing it or bypassing - // verification. Each failed cycle is paced by that bounded - // request retry budget before another replay is scheduled. + // HTTP-status failures exhaust bounded retries; body + // interruptions also return transient after pacing. + // Transport dedup recorded this relay-signed wake, but + // dispatch has not occurred. Re-admit it for filtered + // replay rather than losing it or bypassing verification. if let Err(replay_error) = relay .replay_event( buzz_event.channel_id, diff --git a/crates/buzz-relay/src/workflow_delivery_tests.rs b/crates/buzz-relay/src/workflow_delivery_tests.rs index 3b9c0dd0d8e..e46c9a37086 100644 --- a/crates/buzz-relay/src/workflow_delivery_tests.rs +++ b/crates/buzz-relay/src/workflow_delivery_tests.rs @@ -256,11 +256,24 @@ async fn captured_revision_survives_replacement_but_not_revocation() { .await .expect("captured authority"); assert_eq!(authority.0["definition"]["id"], a.id.to_hex()); - f.state - .db - .soft_delete_event_and_update_thread(f.community, a.id.as_bytes(), None, None) - .await - .expect("explicit revoke superseded revision"); + let deletion = EventBuilder::new(Kind::EventDeletion, "revoke captured revision") + .tags([Tag::event(a.id)]) + .sign_with_keys(&f.owner) + .expect("signed deletion"); + let result = crate::handlers::ingest::ingest_event( + &f.state, + &buzz_core::TenantContext::resolved(f.community, &f.host), + deletion, + crate::handlers::ingest::IngestAuth::Nip42 { + pubkey: f.owner.public_key(), + scopes: vec![], + channel_ids: None, + conn_id: Uuid::new_v4(), + }, + ) + .await + .expect("explicit revocation through authenticated deletion ingress"); + assert!(result.accepted); assert_eq!( f.authority(run, &message).await.expect_err("revoked").0, StatusCode::NOT_FOUND From 929c69760fa9009926c0593313e0ae78a8873fce Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 14:21:28 -0400 Subject: [PATCH 22/33] Supply authenticated deletion scope and check fixture reads Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-acp/src/workflow_wake_recovery_tests.rs | 2 +- crates/buzz-relay/src/workflow_delivery_tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/buzz-acp/src/workflow_wake_recovery_tests.rs b/crates/buzz-acp/src/workflow_wake_recovery_tests.rs index 9e07b11ef6a..82a54215e15 100644 --- a/crates/buzz-acp/src/workflow_wake_recovery_tests.rs +++ b/crates/buzz-acp/src/workflow_wake_recovery_tests.rs @@ -230,7 +230,7 @@ async fn complete_malformed_authority_body_is_terminal() { for body in ["{", "{}"] { let (mut stream, _) = listener.accept().await.unwrap(); let mut request = [0u8; 4096]; - stream.read(&mut request).await.unwrap(); + assert!(stream.read(&mut request).await.unwrap() > 0); stream .write_all( format!( diff --git a/crates/buzz-relay/src/workflow_delivery_tests.rs b/crates/buzz-relay/src/workflow_delivery_tests.rs index e46c9a37086..3d045bc2191 100644 --- a/crates/buzz-relay/src/workflow_delivery_tests.rs +++ b/crates/buzz-relay/src/workflow_delivery_tests.rs @@ -266,7 +266,7 @@ async fn captured_revision_survives_replacement_but_not_revocation() { deletion, crate::handlers::ingest::IngestAuth::Nip42 { pubkey: f.owner.public_key(), - scopes: vec![], + scopes: vec![buzz_auth::Scope::MessagesWrite], channel_ids: None, conn_id: Uuid::new_v4(), }, From 8f56bc4503202e9f8095dc1f157785dcf1c63fc3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 17:01:25 -0400 Subject: [PATCH 23/33] Integrate workflow delivery with domain datastore tracing Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-db/src/store/workflow_delivery.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/buzz-db/src/store/workflow_delivery.rs b/crates/buzz-db/src/store/workflow_delivery.rs index 71beb62b2ea..8dda48ea2dc 100644 --- a/crates/buzz-db/src/store/workflow_delivery.rs +++ b/crates/buzz-db/src/store/workflow_delivery.rs @@ -1,12 +1,14 @@ //! Atomic workflow output persistence and captured-revision reads. use crate::{event, insert_mentions_in_transaction, Db, Result}; use buzz_core::{tenant::CommunityId, StoredEvent}; +use buzz_datastore_tracing::datastore_span; use uuid::Uuid; impl Db { /// Atomically persist a visible event, its thread metadata/mentions, and all /// required notifications. No caller may publish any row until this commits. /// Cancellation or any insert failure rolls the entire bundle back. + #[datastore_span(name = "insert_event_with_notifications", system = "postgresql")] pub async fn insert_event_with_notifications( &self, community_id: CommunityId, @@ -47,6 +49,7 @@ impl Db { } /// Read a captured workflow definition without reviving explicitly deleted revisions. + #[datastore_span(name = "get_workflow_revision", system = "postgresql")] pub async fn get_workflow_revision( &self, community_id: CommunityId, From dc0a020c97ce2b66a923a8e152b3883ee177d5e8 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 17:28:02 -0400 Subject: [PATCH 24/33] Place wake migrations after updated workflow foundation Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-db/src/runtime/migration.rs | 4 ++-- ...ention_wake_fts.sql => 0042_workflow_mention_wake_fts.sql} | 0 ...d_authority.sql => 0043_workflow_superseded_authority.sql} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename migrations/{0041_workflow_mention_wake_fts.sql => 0042_workflow_mention_wake_fts.sql} (100%) rename migrations/{0042_workflow_superseded_authority.sql => 0043_workflow_superseded_authority.sql} (100%) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index e30f245ebdd..48d9b83b2d5 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -1193,8 +1193,8 @@ mod postgres_tests { // Durable workflow wakes are recipient-gated and must remain outside // full-text search on both fresh and brownfield databases. - assert_eq!(migrations[40].version, 41); - let workflow_wake_fts = migrations[40].sql.as_str(); + assert_eq!(migrations[41].version, 42); + let workflow_wake_fts = migrations[41].sql.as_str(); assert!(workflow_wake_fts.contains("kind = 44620")); assert!(desired_schema.contains("44200, 44620")); diff --git a/migrations/0041_workflow_mention_wake_fts.sql b/migrations/0042_workflow_mention_wake_fts.sql similarity index 100% rename from migrations/0041_workflow_mention_wake_fts.sql rename to migrations/0042_workflow_mention_wake_fts.sql diff --git a/migrations/0042_workflow_superseded_authority.sql b/migrations/0043_workflow_superseded_authority.sql similarity index 100% rename from migrations/0042_workflow_superseded_authority.sql rename to migrations/0043_workflow_superseded_authority.sql From 0d19d131db10da7c65d64e96e9bd84bdd79f21c0 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 28 Aug 2026 17:33:15 -0400 Subject: [PATCH 25/33] Point FTS migration fixture at renumbered wake migration Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- crates/buzz-search/tests/postgres_fts_integration.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/buzz-search/tests/postgres_fts_integration.rs b/crates/buzz-search/tests/postgres_fts_integration.rs index 8ca027ebee1..fceba8a72b8 100644 --- a/crates/buzz-search/tests/postgres_fts_integration.rs +++ b/crates/buzz-search/tests/postgres_fts_integration.rs @@ -30,8 +30,8 @@ const MIGRATION_0008_SQL: &str = const MIGRATION_0014_SQL: &str = include_str!("../../../migrations/0014_push_lease_fts.sql"); const MIGRATION_0033_SQL: &str = include_str!("../../../migrations/0033_private_managed_agent_fts.sql"); -const MIGRATION_0041_SQL: &str = - include_str!("../../../migrations/0041_workflow_mention_wake_fts.sql"); +const MIGRATION_0042_SQL: &str = + include_str!("../../../migrations/0042_workflow_mention_wake_fts.sql"); async fn setup() -> (PgPool, String) { setup_with_search_policy(true).await @@ -94,7 +94,7 @@ async fn setup_with_search_policy(apply_fresh_allowlist: bool) -> (PgPool, Strin pool.execute(MIGRATION_0033_SQL) .await .expect("apply 0033 migration"); - pool.execute(MIGRATION_0041_SQL) + pool.execute(MIGRATION_0042_SQL) .await .expect("apply 0036 migration"); (pool, schema) From ac456ab255810d90932f1a070abb2025be16717c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 1 Sep 2026 12:25:23 -0400 Subject: [PATCH 26/33] test(search): assert raw NULL and negated-query privacy for workflow wakes Signed-off-by: Logan Johnson --- .../tests/postgres_fts_integration.rs | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/buzz-search/tests/postgres_fts_integration.rs b/crates/buzz-search/tests/postgres_fts_integration.rs index fceba8a72b8..855a591e338 100644 --- a/crates/buzz-search/tests/postgres_fts_integration.rs +++ b/crates/buzz-search/tests/postgres_fts_integration.rs @@ -96,7 +96,7 @@ async fn setup_with_search_policy(apply_fresh_allowlist: bool) -> (PgPool, Strin .expect("apply 0033 migration"); pool.execute(MIGRATION_0042_SQL) .await - .expect("apply 0036 migration"); + .expect("apply 0042 migration"); (pool, schema) } @@ -1442,7 +1442,17 @@ async fn p_gated_persistent_kinds_have_storage_null_tsvector() { // Exercise the brownfield negative skip-set. The fresh-install positive // allowlist would make every unknown kind unsearchable and let a missing // per-kind migration pass vacuously. - let (pool, schema) = setup_with_search_policy(false).await; + assert_p_gated_storage_null(false).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn fresh_p_gated_persistent_kinds_have_storage_null_tsvector() { + assert_p_gated_storage_null(true).await; +} + +async fn assert_p_gated_storage_null(apply_fresh_allowlist: bool) { + let (pool, schema) = setup_with_search_policy(apply_fresh_allowlist).await; let c = mk_community(&pool, "p-gated-tripwire.example").await; let token = "pgated_tripwire_marker_qwerty"; @@ -1482,8 +1492,46 @@ async fn p_gated_persistent_kinds_have_storage_null_tsvector() { 1_700_000_100 + i as i64, ) .await; + // Empty content is the canonical wake payload, but an empty vector is + // not NULL: it matches a NOT-only query. Exercise both payload shapes. + insert_event( + &pool, + c, + rand_bytes32(), + rand_bytes32(), + kind as i32, + "", + None, + 1_700_000_200 + i as i64, + ) + .await; } + let persistent_kinds: Vec = persistent.iter().map(|&kind| kind as i32).collect(); + let nonnull: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id = $1 AND kind = ANY($2) \ + AND search_tsv IS NOT NULL", + ) + .bind(c.as_uuid()) + .bind(&persistent_kinds) + .fetch_one(&pool) + .await + .expect("read raw vectors"); + assert_eq!(nonnull, 0, "private vectors must be SQL NULL, not empty"); + + // Deliberately bypass SearchService: a query-layer exclusion must not make + // this storage-contract test pass. The public control proves the negative + // query itself is capable of matching rows in this fixture. + let negative_kinds: Vec = sqlx::query_scalar( + "SELECT kind FROM events WHERE community_id = $1 \ + AND search_tsv @@ websearch_to_tsquery('simple', '-neverpresentqzx')", + ) + .bind(c.as_uuid()) + .fetch_all(&pool) + .await + .expect("NOT-only raw FTS query"); + assert_eq!(negative_kinds, vec![9], "only the public control may match"); + let svc = SearchService::new(pool.clone()); let result = svc .search(&SearchQuery { From 2a2f26326f1bbe090427ae0a94798cf69a4a6236 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 1 Sep 2026 12:41:30 -0400 Subject: [PATCH 27/33] fix(search): skip proven-safe wake FTS policies and preserve generated storage Signed-off-by: Logan Johnson --- .../tests/postgres_fts_integration.rs | 2 +- .../tests/postgres_workflow_wake_fts.rs | 137 ++++++++++++++++++ docs/workflow-wake-fts-rollout.md | 73 ++++++++++ migrations/0042_workflow_mention_wake_fts.sql | 32 ---- migrations/0044_workflow_mention_wake_fts.sql | 78 ++++++++++ ...=> 0045_workflow_superseded_authority.sql} | 0 6 files changed, 289 insertions(+), 33 deletions(-) create mode 100644 crates/buzz-search/tests/postgres_workflow_wake_fts.rs create mode 100644 docs/workflow-wake-fts-rollout.md delete mode 100644 migrations/0042_workflow_mention_wake_fts.sql create mode 100644 migrations/0044_workflow_mention_wake_fts.sql rename migrations/{0043_workflow_superseded_authority.sql => 0045_workflow_superseded_authority.sql} (100%) diff --git a/crates/buzz-search/tests/postgres_fts_integration.rs b/crates/buzz-search/tests/postgres_fts_integration.rs index 855a591e338..8cde7f80026 100644 --- a/crates/buzz-search/tests/postgres_fts_integration.rs +++ b/crates/buzz-search/tests/postgres_fts_integration.rs @@ -31,7 +31,7 @@ const MIGRATION_0014_SQL: &str = include_str!("../../../migrations/0014_push_lea const MIGRATION_0033_SQL: &str = include_str!("../../../migrations/0033_private_managed_agent_fts.sql"); const MIGRATION_0042_SQL: &str = - include_str!("../../../migrations/0042_workflow_mention_wake_fts.sql"); + include_str!("../../../migrations/0044_workflow_mention_wake_fts.sql"); async fn setup() -> (PgPool, String) { setup_with_search_policy(true).await diff --git a/crates/buzz-search/tests/postgres_workflow_wake_fts.rs b/crates/buzz-search/tests/postgres_workflow_wake_fts.rs new file mode 100644 index 00000000000..cfc05e882b6 --- /dev/null +++ b/crates/buzz-search/tests/postgres_workflow_wake_fts.rs @@ -0,0 +1,137 @@ +//! Storage/rollout contract for the workflow wake FTS migration on PostgreSQL 17. +use sqlx::{postgres::PgPoolOptions, Executor, PgPool}; +use uuid::Uuid; + +const MIGRATION: &str = include_str!("../../../migrations/0044_workflow_mention_wake_fts.sql"); +const ALLOWLIST: &str = "CASE WHEN kind IN (0,9,40002,45001,45003) THEN to_tsvector('simple',content) ELSE NULL::tsvector END"; +const DESIRED: &str = "CASE WHEN kind IN (1059,30179,30300,30350,30622,44100,44101,44200,44620) THEN NULL::tsvector ELSE to_tsvector('simple',content) END"; + +async fn fixture(expression: &str) -> (PgPool, String) { + let url = std::env::var("BUZZ_TEST_DATABASE_URL").expect("isolated PostgreSQL URL"); + let schema = format!("wake_fts_{}", Uuid::new_v4().simple()); + let pool = PgPoolOptions::new() + .max_connections(1) + .connect(&url) + .await + .expect("connect"); + pool.execute(sqlx::AssertSqlSafe(format!( + "CREATE SCHEMA {schema}; SET search_path = {schema}; + CREATE TABLE events(kind int, content text, created_at int, + search_tsv tsvector GENERATED ALWAYS AS ({expression}) STORED) + PARTITION BY RANGE(created_at); + CREATE TABLE events_old PARTITION OF events FOR VALUES FROM (0) TO (100); + CREATE INDEX custom_fts_index ON events USING gin(search_tsv) WITH (fastupdate=off); + INSERT INTO events(kind,content,created_at) VALUES + (9,'public control',1),(44620,'private payload',2),(44620,'',3); + CREATE TEMP TABLE original_nodes AS SELECT oid, relname, relfilenode FROM pg_class + WHERE relnamespace = '{schema}'::regnamespace; + CREATE TEMP TABLE original_indexes AS SELECT c.relname, pg_get_indexdef(c.oid) AS definition, c.reloptions + FROM pg_class c WHERE c.relnamespace = '{schema}'::regnamespace AND c.relkind IN ('i','I');" + ))) + .await + .expect("fixture schema"); + (pool, schema) +} + +async fn cleanup(pool: PgPool, schema: String) { + pool.execute(sqlx::AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE"))) + .await + .expect("cleanup"); + pool.close().await; +} + +async fn assert_null_and_future_partition(pool: &PgPool) { + pool.execute( + "CREATE TABLE events_future PARTITION OF events FOR VALUES FROM (100) TO (200); + INSERT INTO events_future(kind,content,created_at) VALUES (44620,'future private',101); + UPDATE events SET content='changed private' WHERE kind=44620; + INSERT INTO events(kind,content,created_at) VALUES (9,'was public',102); + UPDATE events SET kind=44620 WHERE created_at=102;", + ) + .await + .expect("parent and direct leaf writes"); + let nonnull: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE kind=44620 AND search_tsv IS NOT NULL", + ) + .fetch_one(pool) + .await + .expect("raw vectors"); + assert_eq!(nonnull, 0); + let matches: Vec = sqlx::query_scalar( + "SELECT kind FROM events WHERE search_tsv @@ websearch_to_tsquery('simple','-absentword')", + ) + .fetch_all(pool) + .await + .expect("NOT-only query"); + assert_eq!(matches, vec![9]); + assert!( + pool.execute("UPDATE events SET search_tsv=to_tsvector('private') WHERE kind=44620") + .await + .is_err(), + "generated column must reject direct vector assignments" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn safe_policies_preserve_heap_and_index_files() { + let migrated = format!("CASE WHEN kind=30179 THEN NULL::tsvector ELSE (CASE WHEN kind=30350 THEN NULL::tsvector ELSE ({ALLOWLIST}) END) END"); + for expression in [ALLOWLIST, &migrated, DESIRED] { + let (pool, schema) = fixture(expression).await; + pool.execute(MIGRATION).await.expect("safe migration"); + let changed: i64 = sqlx::query_scalar("SELECT count(*) FROM original_nodes b JOIN pg_class c USING(oid) WHERE b.relfilenode <> c.relfilenode") + .fetch_one(&pool).await.expect("physical files"); + assert_eq!(changed, 0, "safe policy must not rewrite heaps or indexes"); + assert_null_and_future_partition(&pool).await; + cleanup(pool, schema).await; + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn legacy_policy_preserves_column_dependencies_and_nonwake_values() { + let (pool, schema) = fixture("to_tsvector('simple',content)").await; + pool.execute("CREATE VIEW public_projection AS SELECT search_tsv FROM events WHERE kind=9; + CREATE TEMP TABLE original_projection AS SELECT created_at,content,search_tsv FROM events WHERE kind<>44620;") + .await.expect("dependent view"); + pool.execute(MIGRATION).await.expect("legacy migration"); + let differences: i64 = sqlx::query_scalar("SELECT count(*) FROM original_indexes b FULL JOIN (SELECT c.relname, pg_get_indexdef(c.oid) AS definition, c.reloptions FROM pg_class c WHERE c.relnamespace=current_schema()::regnamespace AND c.relkind IN ('i','I')) a USING(relname) WHERE (b.definition,b.reloptions) IS DISTINCT FROM (a.definition,a.reloptions)") + .fetch_one(&pool).await.expect("custom index definitions"); + assert_eq!( + differences, 0, + "custom index definitions and options must survive" + ); + let changed: i64 = sqlx::query_scalar("SELECT count(*) FROM original_nodes b JOIN pg_class c ON c.relname=b.relname AND c.relnamespace=current_schema()::regnamespace WHERE b.relfilenode <> c.relfilenode") + .fetch_one(&pool).await.expect("physical files"); + assert!(changed > 0, "legacy correction honestly requires a rewrite"); + let differences: i64 = sqlx::query_scalar("SELECT count(*) FROM original_projection o JOIN events e USING(created_at) WHERE (o.content,o.search_tsv) IS DISTINCT FROM (e.content,e.search_tsv)") + .fetch_one(&pool).await.expect("nonwake values"); + assert_eq!(differences, 0); + let visible: i64 = + sqlx::query_scalar("SELECT count(*) FROM public_projection WHERE search_tsv IS NOT NULL") + .fetch_one(&pool) + .await + .expect("dependent view still works"); + assert_eq!(visible, 1); + assert_null_and_future_partition(&pool).await; + cleanup(pool, schema).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn divergent_partition_policy_fails_without_mutation() { + let (pool, schema) = fixture(ALLOWLIST).await; + pool.execute("DROP INDEX custom_fts_index; ALTER TABLE events_old ALTER COLUMN search_tsv SET EXPRESSION AS (to_tsvector('simple',content));") + .await.expect("divergent leaf"); + let before: String = sqlx::query_scalar("SELECT pg_get_expr(adbin,adrelid) FROM pg_attrdef d JOIN pg_attribute a ON a.attrelid=d.adrelid AND a.attnum=d.adnum WHERE a.attrelid='events_old'::regclass AND a.attname='search_tsv'") + .fetch_one(&pool).await.expect("leaf expression"); + let error = pool + .execute(MIGRATION) + .await + .expect_err("reject divergent policy"); + assert!(error.to_string().contains("divergent search_tsv policy")); + let after: String = sqlx::query_scalar("SELECT pg_get_expr(adbin,adrelid) FROM pg_attrdef d JOIN pg_attribute a ON a.attrelid=d.adrelid AND a.attnum=d.adnum WHERE a.attrelid='events_old'::regclass AND a.attname='search_tsv'") + .fetch_one(&pool).await.expect("unchanged leaf expression"); + assert_eq!(before, after); + cleanup(pool, schema).await; +} diff --git a/docs/workflow-wake-fts-rollout.md b/docs/workflow-wake-fts-rollout.md new file mode 100644 index 00000000000..841f50d52d6 --- /dev/null +++ b/docs/workflow-wake-fts-rollout.md @@ -0,0 +1,73 @@ +# Workflow wake FTS rollout (PostgreSQL 17) + +Kind 44620 is durable workflow delivery, not searchable chat. Its `search_tsv` +must be SQL NULL even for empty or malformed private content. An empty vector +is not equivalent: it matches NOT-only queries. Neither query-layer filtering +nor the canonical empty wake payload replaces this storage contract. + +## Decision + +Migration 0044 retains **stored generated** search vectors. It inspects the +parent and every existing partition under a tree-wide lock. Exact PostgreSQL +catalog expression comparisons recognize the fresh positive allowlist, its +0014/0033-wrapped form, and the desired-schema policy including 44620. These +installations perform **no heap or index rewrite**. This is a conservative +recognizer, not an arbitrary SQL equivalence checker. + +Other uniform generated policies are wrapped with `CASE WHEN kind = 44620 THEN +NULL::tsvector ELSE existing_expression END` using PostgreSQL 17's `SET +EXPRESSION`. This preserves other kinds' search policy and the column, +dependent view and index definitions/options; it **does rewrite heaps and +indexes**. Index OIDs/physical files can change. This is a maintenance operation, +not an online migration. Unknown custom expressions are not assumed safe. +Divergent parent/partition expressions and non-generated columns fail before +mutation: operators must reconcile that drift before upgrading, not silently +lose a leaf's custom policy. + +A tested alternative, `DROP EXPRESSION` plus an ALWAYS write trigger, can retain +existing heap/index files and repair only historical wake vectors. We have not +chosen it: it adds permanent bootstrap, restoration and replication obligations, +and row-level repair fails for deletion-fenced historical wakes. There is no +assumption that pre-existing kind-44620 rows cannot exist. Generated-expression +recomputation repairs their unsigned projection without modifying signed event +fields, executing row UPDATE hooks, or granting a deletion-fence bypass. The +fence remains effective for ordinary writes. + +## Before upgrade + +- Inventory the generated expressions for `events.search_tsv` across + `pg_partition_tree('events')`; record heap/index/TOAST sizes, free disk, + replica lag and WAL retention. Do not infer the policy from an empty-content + probe or a substring match. The same PostgreSQL-normalized whole-expression + comparison used in the migration is authoritative for its skip path. +- Confirm PostgreSQL 17 and the repository's normal schema/destruction lock + discipline. Migration startup must not race tenant destruction. +- For an unrecognized policy, size and schedule a maintenance window. Budget + replacement heap/index storage plus WAL/replica headroom. No production + duration or throughput estimate is claimed by the small disposable tests. +- Lock acquisition is limited to five seconds; a busy table makes the migration + fail transactionally for a later controlled retry. Once acquired, an unsafe + policy holds ACCESS EXCLUSIVE for its rewrite. Existing operator + `statement_timeout` still applies. Even the safe skip path briefly blocks + readers/writers while inspecting the tree; it is not lock-free. + +Fresh desired-state bootstrap already includes 44620 in its generated policy; +no new reconciliation trigger or seed DML is needed. Future partitions inherit +that policy. Ordered migration bootstrap keeps the fresh positive allowlist. +These paths intentionally preserve their pre-existing search differences for +other kinds. Direct vector assignments remain rejected by PostgreSQL. + +## Evidence and limits + +`postgres_workflow_wake_fts` exercises safe-policy heap/index relfilenode +preservation, unsafe-policy correction with custom indexes and a dependent view, +raw NULL/NOT-only semantics, future partitions and direct leaf writes, +kind/content changes, rejected direct vector assignment, and divergent-policy +rollback. `postgres_fts_integration` also covers every persistent p-gated kind +under both fresh and legacy policies, with empty and nonempty payloads. + +A disposable current desired-schema test additionally established correction of +an existing wake in a genuinely deletion-fenced community, with executor bypass +settings cleared before migration and ordinary UPDATE still rejected afterward. +These are correctness checks, not a production benchmark or deployment approval. +No live database modification is part of this PR's validation. diff --git a/migrations/0042_workflow_mention_wake_fts.sql b/migrations/0042_workflow_mention_wake_fts.sql deleted file mode 100644 index a0c42c95edc..00000000000 --- a/migrations/0042_workflow_mention_wake_fts.sql +++ /dev/null @@ -1,32 +0,0 @@ --- Kind:44620 is a durable, recipient-gated workflow mention wake. Its canonical --- content is empty, but keep the storage-level full-text-search backstop aligned --- with every persistent P_GATED_KINDS member, including on brownfield databases --- that retain the legacy negative skip-set. --- --- Preserve the database's existing search policy for every other kind. As with --- 0014 and 0033, replacing this generated column rewrites the events table and --- rebuilds the GIN index under an ACCESS EXCLUSIVE lock. -DO $$ -DECLARE - existing_expression TEXT; -BEGIN - SELECT pg_get_expr(d.adbin, d.adrelid) - INTO existing_expression - FROM pg_attrdef d - JOIN pg_attribute a - ON a.attrelid = d.adrelid - AND a.attnum = d.adnum - WHERE d.adrelid = 'events'::regclass - AND a.attname = 'search_tsv'; - - IF existing_expression IS NULL THEN - RAISE EXCEPTION 'events.search_tsv generated expression not found'; - END IF; - - ALTER TABLE events DROP COLUMN search_tsv; - EXECUTE format( - 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind = 44620 THEN NULL::tsvector ELSE (%s) END) STORED', - existing_expression - ); - CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); -END $$; diff --git a/migrations/0044_workflow_mention_wake_fts.sql b/migrations/0044_workflow_mention_wake_fts.sql new file mode 100644 index 00000000000..6c50cd3fef3 --- /dev/null +++ b/migrations/0044_workflow_mention_wake_fts.sql @@ -0,0 +1,78 @@ +-- Kind:44620 must have a raw NULL search vector, including malformed payloads. +-- Keep generated storage: no trigger/replication/restore maintenance contract and +-- no row UPDATE that could cross a community deletion fence. PG17 SET EXPRESSION +-- preserves the column and its dependent indexes, but STILL rewrites heaps and +-- indexes. Only installations needing correction pay that cost. See +-- docs/workflow-wake-fts-rollout.md before upgrading a populated legacy database. +DO $$ +DECLARE + existing_expression TEXT; + partition_expression TEXT; + safe_expressions TEXT[]; + relation RECORD; + previous_lock_timeout TEXT := current_setting('lock_timeout'); +BEGIN + -- Bound lock acquisition, not the rewrite duration. Hold the entire tree + -- stable while inspecting it, including against partition attach/detach. + PERFORM set_config('lock_timeout', '5s', true); + LOCK TABLE events IN ACCESS EXCLUSIVE MODE; + + -- Ask PostgreSQL to canonicalize the known safe policies. Comparing whole + -- expressions is deliberate: a substring or an empty-content probe cannot + -- prove NULL for every possible private payload. Unknown policies are not + -- guessed safe. The temporary relation contains no event data. + CREATE TEMP TABLE workflow_wake_safe_fts ( + kind INT, + content TEXT, + allowlist TSVECTOR GENERATED ALWAYS AS ( + CASE WHEN kind IN (0, 9, 40002, 45001, 45003) + THEN to_tsvector('simple', content) ELSE NULL::tsvector END + ) STORED, + migrated_allowlist TSVECTOR GENERATED ALWAYS AS ( + CASE WHEN kind = 30179 THEN NULL::tsvector ELSE ( + CASE WHEN kind = 30350 THEN NULL::tsvector ELSE ( + CASE WHEN kind IN (0, 9, 40002, 45001, 45003) + THEN to_tsvector('simple', content) ELSE NULL::tsvector END + ) END + ) END + ) STORED, + desired_policy TSVECTOR GENERATED ALWAYS AS ( + CASE WHEN kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200, 44620) + THEN NULL::tsvector ELSE to_tsvector('simple', content) END + ) STORED + ) ON COMMIT DROP; + SELECT array_agg(pg_get_expr(adbin, adrelid)) INTO safe_expressions + FROM pg_attrdef WHERE adrelid = 'pg_temp.workflow_wake_safe_fts'::regclass; + + SELECT pg_get_expr(d.adbin, d.adrelid) INTO existing_expression + FROM pg_attribute a JOIN pg_attrdef d + ON d.adrelid = a.attrelid AND d.adnum = a.attnum + WHERE a.attrelid = 'events'::regclass AND a.attname = 'search_tsv' + AND a.attgenerated = 's'; + IF existing_expression IS NULL THEN + RAISE EXCEPTION 'events.search_tsv must be a stored generated column'; + END IF; + + FOR relation IN SELECT relid FROM pg_partition_tree('events'::regclass) LOOP + SELECT pg_get_expr(d.adbin, d.adrelid) INTO partition_expression + FROM pg_attribute a JOIN pg_attrdef d + ON d.adrelid = a.attrelid AND d.adnum = a.attnum + WHERE a.attrelid = relation.relid AND a.attname = 'search_tsv' + AND a.attgenerated = 's'; + IF partition_expression IS DISTINCT FROM existing_expression THEN + RAISE EXCEPTION 'divergent search_tsv policy on %; reconcile partition policy before upgrading', relation.relid::regclass; + END IF; + END LOOP; + + IF NOT (existing_expression = ANY(safe_expressions)) THEN + -- Preserve every non-wake kind's existing policy, signed event fields, + -- column identity, privileges and dependent objects. DDL recomputation + -- does not replay row UPDATE triggers or bypass their deletion fences. + EXECUTE format( + 'ALTER TABLE events ALTER COLUMN search_tsv SET EXPRESSION AS (CASE WHEN kind = 44620 THEN NULL::tsvector ELSE (%s) END)', + existing_expression + ); + END IF; + DROP TABLE pg_temp.workflow_wake_safe_fts; + PERFORM set_config('lock_timeout', previous_lock_timeout, true); +END $$; diff --git a/migrations/0043_workflow_superseded_authority.sql b/migrations/0045_workflow_superseded_authority.sql similarity index 100% rename from migrations/0043_workflow_superseded_authority.sql rename to migrations/0045_workflow_superseded_authority.sql From e707882fb05c0cea0fb577786d03de11d71a16c7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 1 Sep 2026 12:49:11 -0400 Subject: [PATCH 28/33] fix(workflow): bind wake admission and resumes to captured authority Signed-off-by: Logan Johnson --- crates/buzz-acp/src/lib.rs | 118 ++++-- crates/buzz-acp/src/workflow_wake.rs | 105 +++++- .../src/workflow_wake_recovery_tests.rs | 3 + crates/buzz-db/src/runtime/migration.rs | 4 +- crates/buzz-relay/src/api/workflows.rs | 31 +- .../src/handlers/command_executor.rs | 47 ++- .../src/handlers/workflow_approval_tests.rs | 340 ++++++++++++++++++ .../buzz-relay/src/workflow_delivery_tests.rs | 95 ++++- crates/buzz-relay/src/workflow_sink.rs | 15 +- crates/buzz-workflow/src/lib.rs | 60 +++- docs/workflow-wake-fts-rollout.md | 6 +- 11 files changed, 736 insertions(+), 88 deletions(-) create mode 100644 crates/buzz-relay/src/handlers/workflow_approval_tests.rs diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index beaa4bbf39e..aff04caba00 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -457,6 +457,27 @@ mod inbound_author_gate { self.relay_self.as_deref() } + /// Resolve the signing key through the same generation-fenced lifecycle + /// used by ordinary author admission. Durable wakes must not pin a + /// separate startup identity across relay reconnects. + pub(crate) async fn relay_identity_for_generation( + &mut self, + rest_client: &relay::RestClient, + event_generation: u64, + ) -> Option { + if refresh_needed(self.refreshed_generation, event_generation) { + let (relay_self, completed) = + refresh_relay_self(rest_client, self.relay_self.take(), "listener").await; + self.relay_self = relay_self; + if completed { + self.refreshed_generation = Some(event_generation); + } + } + self.relay_self + .as_deref() + .and_then(|key| nostr::PublicKey::from_hex(key).ok()) + } + /// Refresh relay identity, resolve channel trust, and apply trusted /// workflow attribution and author policy for one listener event. /// @@ -475,14 +496,8 @@ mod inbound_author_gate { // Retry failed startup discovery on generation 0 as well as failed // reconnect refreshes. Only an authoritative result completes the // generation; transient failure retains the last verified key. - if refresh_needed(self.refreshed_generation, buzz_event.connection_generation) { - let (relay_self, completed) = - refresh_relay_self(rest_client, self.relay_self.take(), "listener").await; - self.relay_self = relay_self; - if completed { - self.refreshed_generation = Some(buzz_event.connection_generation); - } - } + self.relay_identity_for_generation(rest_client, buzz_event.connection_generation) + .await; let is_dm = is_dm_channel(buzz_event.channel_id, channel_info).await; self.evaluate_with_channel_trust( &buzz_event.event, @@ -2511,12 +2526,6 @@ async fn tokio_main() -> Result<()> { tracing::warn!("failed to set startup watermark: {e}"); } - let workflow_relay_pubkey = relay - .rest_client() - .relay_signing_pubkey() - .await - .map_err(|e| anyhow::anyhow!("relay signing identity error: {e}"))?; - tracing::info!("connected to relay at {}", config.relay_url); let relay_rest_client = relay.rest_client(); @@ -3143,16 +3152,33 @@ async fn tokio_main() -> Result<()> { match buzz_event { Some(buzz_event) => { let kind_u32 = buzz_event.event.kind.as_u16() as u32; - if workflow_wake::requires_verified_wake( - &buzz_event.event, - workflow_relay_pubkey, - ) { + // Revision-labelled messages dispatch only through their + // durable wake, even while identity discovery is unavailable. + if workflow_wake::requires_verified_wake(&buzz_event.event) { continue; } - let (buzz_event, admission_author_override) = if kind_u32 + let buzz_event = if kind_u32 == buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE { + let Some(workflow_relay_pubkey) = author_gate_ctx + .relay_identity_for_generation( + &ctx.rest_client, + buzz_event.connection_generation, + ) + .await + else { + // Discovery can recover on the next delivery; reopen + // transport dedup just as for transient authority reads. + if let Err(error) = relay.replay_event( + buzz_event.channel_id, + buzz_event.event.id.to_hex(), + buzz_event.event.created_at.as_secs(), + ).await { + tracing::warn!(%error, "failed to arrange workflow identity replay"); + } + continue; + }; let Some(wake) = workflow_wake::authenticate( &buzz_event.event, workflow_relay_pubkey, @@ -3195,7 +3221,7 @@ async fn tokio_main() -> Result<()> { continue; } }; - let Some((message, signed_author)) = workflow_wake::verify( + let Some((message, _signed_author)) = workflow_wake::verify( &buzz_event.event, authority, workflow_relay_pubkey, @@ -3205,15 +3231,13 @@ async fn tokio_main() -> Result<()> { tracing::warn!("workflow wake authority verification failed"); continue; }; - ( - relay::BuzzEvent { - channel_id: buzz_event.channel_id, - event: message, - }, - Some(signed_author), - ) + relay::BuzzEvent { + channel_id: buzz_event.channel_id, + connection_generation: buzz_event.connection_generation, + event: message, + } } else { - (buzz_event, None) + buzz_event }; let kind_u32 = buzz_event.event.kind.as_u16() as u32; @@ -7089,6 +7113,44 @@ mod author_gate_tests { /// The first authorized event after reconnect must restore attribution /// through the same decision boundary both listeners use, without a /// separate identity-refresh call. + #[tokio::test] + async fn durable_wake_identity_tracks_listener_generation_rotation() { + let old = nostr::Keys::generate(); + let new = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let (rest, server) = nip11_scripted_server(std::collections::VecDeque::from([ + Ok(serde_json::json!({"self": old.public_key().to_hex()})), + Ok(serde_json::json!({"self": new.public_key().to_hex()})), + ])) + .await; + let mut gate = + InboundAuthorGate::connect(&rest, &agent.public_key().to_hex(), "test").await; + assert_eq!( + gate.relay_identity_for_generation(&rest, 0).await, + Some(old.public_key()) + ); + let channel = Uuid::new_v4(); + let wake = buzz_core::workflow_wake::WorkflowMentionWake::new( + agent.public_key(), + channel, + Uuid::new_v4(), + nostr::EventId::from_byte_array([1; 32]), + nostr::EventId::from_byte_array([2; 32]), + ) + .sign(&new) + .unwrap(); + let key = gate.relay_identity_for_generation(&rest, 1).await.unwrap(); + assert_eq!(key, new.public_key()); + assert!(workflow_wake::authenticate(&wake, key).is_some()); + assert!(workflow_wake::authenticate(&wake, old.public_key()).is_none()); + // The ordinary gate consumes that exact identity, not a second startup cache. + assert_eq!( + gate.relay_identity_for_test(), + Some(new.public_key().to_hex().as_str()) + ); + server.abort(); + } + #[tokio::test] async fn test_gate_refresh_arms_attribution_after_reconnect() { let relay_keys = nostr::Keys::generate(); diff --git a/crates/buzz-acp/src/workflow_wake.rs b/crates/buzz-acp/src/workflow_wake.rs index 9908e33b804..2902e429a1b 100644 --- a/crates/buzz-acp/src/workflow_wake.rs +++ b/crates/buzz-acp/src/workflow_wake.rs @@ -38,11 +38,10 @@ pub struct WorkflowWakeAuthority { pub message: Event, } -/// Return whether a relay-signed workflow message must be dispatched only -/// through its separately verified wake. -pub fn requires_verified_wake(event: &Event, relay_pubkey: PublicKey) -> bool { - event.pubkey == relay_pubkey - && event.kind.as_u16() as u32 == KIND_STREAM_MESSAGE +/// Return whether a revision-labelled workflow message must dispatch only +/// through its separately verified wake, regardless of relay key rotation. +pub fn requires_verified_wake(event: &Event) -> bool { + event.kind.as_u16() as u32 == KIND_STREAM_MESSAGE && single_tag(event, "workflow-run").is_some() && single_tag(event, "workflow-definition").is_some() && single_tag(event, "workflow-step").is_some() @@ -89,6 +88,17 @@ pub fn verify( return None; } let message = authority.message; + // Use the same authored-mention boundary as ordinary listener admission. + // A legacy `p` tag can come entirely from trigger-controlled rendered text; + // even a signed wake must not turn it into the definition owner's authority. + let attributed_owner = crate::verified_workflow_owner( + &message, + Some(&relay_pubkey.to_hex()), + &agent_pubkey.to_hex(), + )?; + if attributed_owner != definition.pubkey.to_hex() { + return None; + } if message.verify().is_err() || message.pubkey != relay_pubkey || message.kind.as_u16() as u32 != KIND_STREAM_MESSAGE @@ -188,6 +198,11 @@ mod tests { .tags([ Tag::parse(["h", &channel.to_string()]).expect("h tag"), Tag::parse(["p", &agent.public_key().to_hex()]).expect("p tag"), + Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + Tag::parse(["buzz:workflow-owner", &owner.public_key().to_hex()]) + .expect("owner tag"), + Tag::parse(["buzz:workflow-mention", &agent.public_key().to_hex()]) + .expect("authored mention"), Tag::parse(["workflow-run", &run.to_string()]).expect("run tag"), Tag::parse(["workflow-definition", &definition.id.to_hex()]) .expect("definition tag"), @@ -250,26 +265,24 @@ mod tests { #[test] fn workflow_message_is_ineligible_for_direct_dispatch() { let fixture = Fixture::valid(); - assert!(requires_verified_wake( - &fixture.message, - fixture.relay.public_key() - )); - assert!(!requires_verified_wake( - &fixture.message, - Keys::generate().public_key() - )); + assert!(requires_verified_wake(&fixture.message)); let ordinary = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "ordinary") .tags([ Tag::parse(["h", &fixture.channel.to_string()]).expect("h tag"), Tag::parse(["p", &fixture.agent.public_key().to_hex()]).expect("p tag"), + Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + Tag::parse(["buzz:workflow-owner", &fixture.owner.public_key().to_hex()]) + .expect("owner tag"), + Tag::parse([ + "buzz:workflow-mention", + &fixture.agent.public_key().to_hex(), + ]) + .expect("authored mention"), ]) .sign_with_keys(&fixture.relay) .expect("ordinary message"); - assert!(!requires_verified_wake( - &ordinary, - fixture.relay.public_key() - )); + assert!(!requires_verified_wake(&ordinary)); } #[test] @@ -297,6 +310,48 @@ mod tests { assert_eq!(author, fixture.owner.public_key().to_hex()); } + #[test] + fn signed_wake_cannot_promote_rendered_only_mentions_to_owner_authority() { + let fixture = Fixture::valid(); + // Re-sign both objects so every existing signature, recipient and exact + // provenance edge is valid. Only the authored-mention boundary differs. + for replacement in [None, Some(Keys::generate().public_key().to_hex())] { + let mut tags: Vec = fixture + .message + .tags + .iter() + .filter(|tag| tag.as_slice()[0] != "buzz:workflow-mention") + .cloned() + .collect(); + if let Some(other) = replacement { + tags.push(Tag::parse(["buzz:workflow-mention", &other]).expect("other mention")); + } + let message = EventBuilder::new(fixture.message.kind, "@Agent injected by trigger") + .tags(tags) + .sign_with_keys(&fixture.relay) + .expect("signed message"); + let wake = WorkflowMentionWake::new( + fixture.agent.public_key(), + fixture.channel, + fixture.run, + fixture.definition.id, + message.id, + ) + .sign(&fixture.relay) + .expect("signed wake"); + let mut authority = fixture.authority(); + authority.message = message; + assert!(super::verify( + &wake, + authority, + fixture.relay.public_key(), + fixture.agent.public_key(), + fixture.channel + ) + .is_none()); + } + } + #[test] fn rejects_wrong_wake_signer_or_recipient() { let fixture = Fixture::valid(); @@ -368,6 +423,14 @@ mod tests { .tags([ Tag::parse(["h", &fixture.channel.to_string()]).expect("h tag"), Tag::parse(["p", &fixture.agent.public_key().to_hex()]).expect("p tag"), + Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + Tag::parse(["buzz:workflow-owner", &fixture.owner.public_key().to_hex()]) + .expect("owner tag"), + Tag::parse([ + "buzz:workflow-mention", + &fixture.agent.public_key().to_hex(), + ]) + .expect("authored mention"), Tag::parse(["workflow-run", &fixture.run.to_string()]).expect("run tag"), Tag::parse(["workflow-definition", &fixture.definition.id.to_hex()]) .expect("definition tag"), @@ -432,6 +495,14 @@ mod tests { .tags([ Tag::parse(["h", &fixture.channel.to_string()]).expect("h tag"), Tag::parse(["p", &fixture.agent.public_key().to_hex()]).expect("p tag"), + Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + Tag::parse(["buzz:workflow-owner", &fixture.owner.public_key().to_hex()]) + .expect("owner tag"), + Tag::parse([ + "buzz:workflow-mention", + &fixture.agent.public_key().to_hex(), + ]) + .expect("authored mention"), Tag::parse(["workflow-run", &fixture.run.to_string()]).expect("run tag"), Tag::parse(["workflow-definition", &definition.id.to_hex()]) .expect("definition tag"), diff --git a/crates/buzz-acp/src/workflow_wake_recovery_tests.rs b/crates/buzz-acp/src/workflow_wake_recovery_tests.rs index 82a54215e15..1b290397c20 100644 --- a/crates/buzz-acp/src/workflow_wake_recovery_tests.rs +++ b/crates/buzz-acp/src/workflow_wake_recovery_tests.rs @@ -42,6 +42,9 @@ async fn assert_authority_recovery(failure: AuthorityFailure) { .tags([ Tag::parse(["h", &channel.to_string()]).unwrap(), Tag::public_key(agent.public_key()), + Tag::parse(["buzz:workflow", "true"]).unwrap(), + Tag::parse(["buzz:workflow-owner", &owner.public_key().to_hex()]).unwrap(), + Tag::parse(["buzz:workflow-mention", &agent.public_key().to_hex()]).unwrap(), Tag::parse(["workflow-run", &run.to_string()]).unwrap(), Tag::parse(["workflow-definition", &definition.id.to_hex()]).unwrap(), Tag::parse(["workflow-step", "notify"]).unwrap(), diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 48d9b83b2d5..c28b5facc8e 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -1193,8 +1193,8 @@ mod postgres_tests { // Durable workflow wakes are recipient-gated and must remain outside // full-text search on both fresh and brownfield databases. - assert_eq!(migrations[41].version, 42); - let workflow_wake_fts = migrations[41].sql.as_str(); + assert_eq!(migrations[43].version, 44); + let workflow_wake_fts = migrations[43].sql.as_str(); assert!(workflow_wake_fts.contains("kind = 44620")); assert!(desired_schema.contains("44200, 44620")); diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index 1bd1f371a59..bfc6cdbad74 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -55,23 +55,11 @@ fn ensure_channel_access( async fn enforce_current_channel_read( state: &Arc, tenant: &TenantContext, - headers: &HeaderMap, pubkey: &nostr::PublicKey, channel_id: Uuid, error: &'static str, ) -> Result<(), (StatusCode, Json)> { let pubkey_bytes = pubkey.to_bytes().to_vec(); - let auth_tag = headers - .get("x-auth-tag") - .and_then(|value| value.to_str().ok()); - super::relay_members::enforce_relay_membership( - state, - tenant.community(), - &pubkey_bytes, - auth_tag, - ) - .await?; - let accessible = state .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) .await @@ -136,7 +124,6 @@ async fn authorize_workflow_read( enforce_current_channel_read( state, &tenant, - headers, &pubkey, channel_id, "workflow is not accessible", @@ -314,8 +301,11 @@ pub async fn workflow_wake_authority( .await .map_err(|_| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path); - let (recipient, auth_event_id) = - bridge::verify_bridge_auth(&headers, "GET", &url, None, state.config.require_auth_token)?; + let bridge::VerifiedBridgeAuth { + pubkey: recipient, + event_id_bytes: auth_event_id, + signed_created_at, + } = bridge::verify_bridge_auth(&headers, "GET", &url, None, state.config.require_auth_token)?; bridge::enforce_http_admission(&state, &tenant, &recipient).await?; bridge::check_nip98_replay(&state, &tenant, auth_event_id).await?; @@ -359,14 +349,13 @@ pub async fn workflow_wake_authority( let message_channel = message .channel_id .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; - let auth_tag = headers - .get("x-auth-tag") - .and_then(|value| value.to_str().ok()); + let auth_tag = super::relay_members::extract_auth_tag_header(&headers); super::relay_members::enforce_relay_membership( &state, tenant.community(), &recipient.to_bytes(), auth_tag, + signed_created_at, ) .await .map_err(|(status, body)| { @@ -399,6 +388,12 @@ pub async fn workflow_wake_authority( let values = tag.as_slice(); values.len() == 2 && values[0] == "p" && values[1].eq_ignore_ascii_case(&recipient_hex) }) + || !exact_tag(&message.event, "buzz:workflow-mention", &recipient_hex) + || !exact_tag( + &message.event, + "buzz:workflow-owner", + &definition.event.pubkey.to_hex(), + ) || !exact_tag(&message.event, "workflow-run", &run_id.to_string()) || !exact_tag( &message.event, diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 4f8946d160b..723fb0ee007 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -1295,19 +1295,12 @@ async fn resume_workflow_after_approval( return; } - let workflow = match db.get_workflow(community_id, workflow_id).await { - Ok(w) => w, + let (run, def) = match engine.load_run_definition(community_id, run_id).await { + Ok(loaded) => loaded, Err(e) => { - tracing::error!("resume_workflow: failed to fetch workflow {workflow_id}: {e}"); - return; - } - }; - - let def: buzz_workflow::WorkflowDef = match serde_json::from_value(workflow.definition.clone()) - { - Ok(d) => d, - Err(e) => { - tracing::error!("resume_workflow: failed to parse workflow definition: {e}"); + tracing::error!( + "resume_workflow: failed to load signed definition for run {run_id}: {e}" + ); if let Err(db_err) = db .update_workflow_run( community_id, @@ -1317,7 +1310,7 @@ async fn resume_workflow_after_approval( &run.execution_trace, Some(buzz_db::workflow::WorkflowRunFailure { code: "invalid_definition", - message: &format!("definition parse error: {e}"), + message: &format!("signed run definition unavailable: {e}"), }), ) .await @@ -1328,6 +1321,30 @@ async fn resume_workflow_after_approval( } }; + if run.workflow_id != workflow_id { + tracing::error!( + "resume_workflow: approval workflow {workflow_id} does not match run workflow {}", + run.workflow_id + ); + if let Err(e) = db + .update_workflow_run( + community_id, + run_id, + RunStatus::Failed, + run.current_step, + &run.execution_trace, + Some(buzz_db::workflow::WorkflowRunFailure { + code: "approval_binding_mismatch", + message: "approval does not belong to the workflow run", + }), + ) + .await + { + tracing::error!("resume_workflow: failed to mark mismatched run as failed: {e}"); + } + return; + } + // Reconstruct step_outputs from execution trace for template resolution let mut initial_outputs: std::collections::HashMap = std::collections::HashMap::new(); @@ -1632,3 +1649,7 @@ mod postgres_tests { )); } } + +#[cfg(test)] +#[path = "workflow_approval_tests.rs"] +mod approval_postgres_tests; diff --git a/crates/buzz-relay/src/handlers/workflow_approval_tests.rs b/crates/buzz-relay/src/handlers/workflow_approval_tests.rs new file mode 100644 index 00000000000..03f805e2815 --- /dev/null +++ b/crates/buzz-relay/src/handlers/workflow_approval_tests.rs @@ -0,0 +1,340 @@ +//! Approval resumes must not relabel current content as a captured revision. +use super::*; +use nostr::{EventBuilder, Keys, Kind, Tag}; + +#[derive(Default)] +struct RecordingActionSink { + messages: std::sync::Mutex>, +} + +impl buzz_workflow::ActionSink for RecordingActionSink { + fn send_message( + &self, + _community_id: CommunityId, + _channel_id: &str, + text: &str, + _authored_text: &str, + _author_pubkey: &str, + _reply_to: Option<&str>, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + '_, + >, + > { + self.messages + .lock() + .expect("recording action sink lock") + .push(text.to_string()); + Box::pin(async { Ok("recorded-event".to_string()) }) + } +} + +async fn manual_trigger_test_context() -> (Arc, TenantContext, Keys, Keys, Uuid, Event) { + use buzz_core::channel::{ChannelType, ChannelVisibility}; + + let url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + let setup_pool = sqlx::PgPool::connect(&url) + .await + .expect("connect workflow trigger setup database"); + // The harness prepares the schema (CI uses pgschema, not SQLx history). + let setup_db = buzz_db::Db::from_pool(setup_pool.clone()); + + let host = format!("workflow-trigger-{}.example", Uuid::new_v4().simple()); + let community = setup_db + .ensure_configured_community(&host) + .await + .expect("create workflow trigger test community") + .id; + let tenant = TenantContext::resolved(community, host.clone()); + let human = Keys::generate(); + let agent = Keys::generate(); + let human_bytes = human.public_key().to_bytes(); + let agent_bytes = agent.public_key().to_bytes(); + setup_db + .ensure_user(community, &human_bytes) + .await + .expect("ensure human owner"); + setup_db + .ensure_user(community, &agent_bytes) + .await + .expect("ensure managed agent"); + assert!(setup_db + .set_agent_owner(community, &agent_bytes, &human_bytes) + .await + .expect("set immutable agent owner")); + let channel = setup_db + .create_channel( + community, + "manual-trigger-pool", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &agent_bytes, + None, + ) + .await + .expect("create workflow channel"); + let workflow_id = Uuid::new_v4(); + let definition = EventBuilder::new( + Kind::Custom(KIND_WORKFLOW_DEF as u16), + concat!( + "name: manual-trigger-pool\n", + "trigger:\n on: message_posted\n", + "steps:\n", + " - id: approval\n action: request_approval\n from: '@owner'\n message: approve\n", + " - id: send\n action: send_message\n text: done\n", + ), + ) + .tags(vec![ + Tag::parse(["d", workflow_id.to_string().as_str()]).expect("d tag"), + Tag::parse(["h", channel.id.to_string().as_str()]).expect("h tag"), + ]) + .sign_with_keys(&agent) + .expect("sign workflow definition"); + let (_, definition_json) = buzz_workflow::WorkflowEngine::parse_yaml(&definition.content) + .expect("parse signed workflow definition"); + let definition_hash = compute_definition_hash(&definition_json); + let mut tx = setup_db + .begin_transaction() + .await + .expect("begin workflow seed"); + buzz_db::event::insert_event_in_transaction(&mut tx, community, &definition, Some(channel.id)) + .await + .expect("persist signed workflow definition"); + setup_db + .upsert_workflow( + &mut tx, + community, + workflow_id, + Some(channel.id), + &agent_bytes, + "manual-trigger-pool", + &definition_json, + &definition_hash, + definition.id.as_bytes(), + ) + .await + .expect("materialize signed workflow"); + tx.commit().await.expect("commit signed workflow"); + + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(1)) + .connect(&url) + .await + .expect("connect one-connection workflow trigger pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let mut config = crate::config::Config::from_env().expect("config from env"); + config.database_url = url; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.relay_url = format!("wss://{host}"); + config.require_relay_membership = false; + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool config"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + setup_pool.close().await; + ( + Arc::new(state), + tenant, + human, + agent, + workflow_id, + definition, + ) +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn approval_resume_executes_the_run_bound_signed_revision() { + let (state, tenant, _human, agent, workflow_id, revision_a) = + manual_trigger_test_context().await; + let community_id = tenant.community(); + let db = state.db.clone(); + let trigger_context = serde_json::to_value(TriggerContext { + channel_id: exact_tag_value(&revision_a, "h") + .unwrap_or_default() + .to_string(), + ..TriggerContext::default() + }) + .expect("serialize trigger context"); + let run_id = db + .create_workflow_run( + community_id, + workflow_id, + Some(revision_a.id.as_bytes()), + None, + Some(&trigger_context), + ) + .await + .expect("create revision A run"); + db.update_workflow_run( + community_id, + run_id, + RunStatus::WaitingApproval, + 0, + &serde_json::json!([{ + "step_id": "approval", + "output": {"approved": true} + }]), + None, + ) + .await + .expect("suspend revision A run for approval"); + + let channel_id = Uuid::parse_str(exact_tag_value(&revision_a, "h").expect("channel tag")) + .expect("channel UUID"); + let revision_b = EventBuilder::new( + Kind::Custom(KIND_WORKFLOW_DEF as u16), + concat!( + "name: revision-b\n", + "trigger:\n on: message_posted\n", + "steps:\n", + " - id: approval\n action: request_approval\n from: '@owner'\n message: approve\n", + " - id: after\n action: send_message\n text: revision B\n", + ), + ) + .tags(vec![ + Tag::parse(["d", workflow_id.to_string().as_str()]).expect("d tag"), + Tag::parse(["h", channel_id.to_string().as_str()]).expect("h tag"), + ]) + .sign_with_keys(&agent) + .expect("sign revision B"); + let (_, definition_b_json) = + buzz_workflow::WorkflowEngine::parse_yaml(&revision_b.content).expect("parse revision B"); + let definition_b_hash = compute_definition_hash(&definition_b_json); + let mut tx = db + .begin_transaction() + .await + .expect("begin revision B update"); + buzz_db::event::insert_event_in_transaction( + &mut tx, + community_id, + &revision_b, + Some(channel_id), + ) + .await + .expect("persist revision B"); + db.upsert_workflow( + &mut tx, + community_id, + workflow_id, + Some(channel_id), + &agent.public_key().to_bytes(), + "revision-b", + &definition_b_json, + &definition_b_hash, + revision_b.id.as_bytes(), + ) + .await + .expect("materialize revision B"); + tx.commit().await.expect("commit revision B update"); + + let sink = Arc::new(RecordingActionSink::default()); + state.workflow_engine.set_action_sink(sink.clone()); + resume_workflow_after_approval( + Arc::clone(&state.workflow_engine), + db.clone(), + community_id, + run_id, + workflow_id, + 1, + ) + .await; + + let resumed = db + .get_workflow_run(community_id, run_id) + .await + .expect("load resumed run"); + assert_eq!(resumed.status, RunStatus::Completed); + assert_eq!( + sink.messages + .lock() + .expect("recorded messages lock") + .as_slice(), + ["done"], + "approval resume must execute revision A, never current revision B" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn approval_resume_fails_closed_without_a_signed_run_revision() { + let (state, tenant, _human, _agent, workflow_id, _revision) = + manual_trigger_test_context().await; + let community_id = tenant.community(); + let db = state.db.clone(); + let run_id = db + .create_workflow_run(community_id, workflow_id, None, None, None) + .await + .expect("create legacy revisionless run"); + db.update_workflow_run( + community_id, + run_id, + RunStatus::WaitingApproval, + 0, + &serde_json::json!([]), + None, + ) + .await + .expect("suspend revisionless run"); + + resume_workflow_after_approval( + Arc::clone(&state.workflow_engine), + db.clone(), + community_id, + run_id, + workflow_id, + 1, + ) + .await; + + let failed = db + .get_workflow_run(community_id, run_id) + .await + .expect("load failed run"); + assert_eq!(failed.status, RunStatus::Failed); + assert_eq!(failed.error_code.as_deref(), Some("invalid_definition")); + assert!(failed + .error_message + .as_deref() + .is_some_and(|message| message.contains("no owner-signed definition revision"))); +} + +fn exact_tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> { + let mut values = event.tags.iter().filter_map(|tag| { + (tag.kind().to_string() == name) + .then(|| tag.content()) + .flatten() + }); + let value = values.next()?; + values.next().is_none().then_some(value) +} diff --git a/crates/buzz-relay/src/workflow_delivery_tests.rs b/crates/buzz-relay/src/workflow_delivery_tests.rs index 3d045bc2191..1cf217ee8db 100644 --- a/crates/buzz-relay/src/workflow_delivery_tests.rs +++ b/crates/buzz-relay/src/workflow_delivery_tests.rs @@ -1,5 +1,5 @@ //! Real-storage regressions for workflow wake lifecycle boundaries. -use super::integration_tests::test_state_with_redis; +use super::postgres_tests::test_state_with_redis; use super::*; use axum::{ extract::{Path, State}, @@ -151,7 +151,10 @@ impl Fixture { headers } async fn revision(&self, timestamp: u64) -> Event { - let definition = "name: wake\ntrigger:\n on: message_posted\nsteps:\n - id: notify\n action: send_message\n text: '@Worker work'\n"; + self.revision_with_text(timestamp, "@Worker work").await + } + async fn revision_with_text(&self, timestamp: u64, text: &str) -> Event { + let definition = format!("name: wake\ntrigger:\n on: message_posted\nsteps:\n - id: notify\n action: send_message\n text: '{text}'\n"); let event = EventBuilder::new( Kind::Custom(buzz_core::kind::KIND_WORKFLOW_DEF as u16), definition, @@ -185,7 +188,11 @@ impl Fixture { Some(self.channel), &self.owner.public_key().to_bytes(), "wake", - "{}", + &serde_json::to_string( + &serde_yaml::from_str::(&event.content) + .expect("definition"), + ) + .expect("serialize definition"), &[0; 32], event.id.as_bytes(), ) @@ -238,6 +245,7 @@ async fn captured_revision_survives_replacement_but_not_revocation() { }, &f.channel.to_string(), "@Worker work", + "@Worker work", &f.owner.public_key().to_hex(), None, ) @@ -301,6 +309,7 @@ async fn removed_open_channel_member_cannot_read_or_count_wakes() { }, &f.channel.to_string(), "@Worker work", + "@Worker work", &f.owner.public_key().to_hex(), None, ) @@ -533,3 +542,83 @@ async fn storage_timeout_is_retryable_but_missing_authority_is_terminal() { .expect_err("missing run"); assert_eq!(wake_lookup_error(error).0, StatusCode::NOT_FOUND); } + +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn rendered_trigger_mentions_never_create_durable_wakes_or_authority() { + for (template, expected_wakes) in [ + ("echo: {{trigger.text}}", 0_i64), + ("@Worker {{trigger.text}}", 1), + ] { + let f = Fixture::new().await; + let revision = f + .revision_with_text(Timestamp::now().as_secs(), template) + .await; + let trigger = buzz_workflow::executor::TriggerContext { + text: "@Worker injected instruction".into(), + channel_id: f.channel.to_string(), + ..Default::default() + }; + let run = f + .state + .db + .create_workflow_run( + f.community, + f.workflow, + Some(revision.id.as_bytes()), + None, + Some(&serde_json::to_value(&trigger).expect("trigger")), + ) + .await + .expect("run"); + f.state + .workflow_engine + .set_action_sink(Arc::new(RelayActionSink::new(&f.state))); + let stored = f + .state + .db + .get_workflow(f.community, f.workflow) + .await + .expect("stored definition"); + let definition = + serde_json::from_value(stored.definition).expect("parse stored definition"); + let result = buzz_workflow::executor::execute_run( + &f.state.workflow_engine, + f.community, + run, + &definition, + &trigger, + ) + .await + .expect("execute run"); + let message = result.step_outputs["notify"]["event_id"] + .as_str() + .expect("message id"); + let mut tx = f + .state + .db + .begin_transaction() + .await + .expect("read transaction"); + let count: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND kind=$2") + .bind(f.community.as_uuid()) + .bind(buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE as i32) + .fetch_one(&mut *tx) + .await + .expect("durable wakes"); + tx.rollback().await.expect("read rollback"); + assert_eq!(count, expected_wakes); + let authority = f.authority(run, message).await; + if expected_wakes == 0 { + assert_eq!( + authority + .expect_err("rendered-only recipient cannot fetch authority") + .0, + StatusCode::NOT_FOUND + ); + } else { + let _ = authority.expect("authored recipient can fetch authority"); + } + } +} diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 52fbb8dd652..517a7ef3411 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -392,11 +392,17 @@ impl ActionSink for RelayActionSink { &named_members, &author_pubkey_hex, )?; - let mentioned_pubkeys = tags.iter() - .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz:workflow-mention")) + let mentioned_pubkeys = tags + .iter() + .filter(|tag| { + tag.as_slice().first().map(String::as_str) == Some("buzz:workflow-mention") + }) .filter_map(|tag| tag.as_slice().get(1)) .filter(|pk| *pk != &author_pubkey_hex) - .map(|pk| nostr::PublicKey::from_hex(pk).map_err(|e| ActionSinkError::EventBuild(format!("mention pubkey: {e}")))) + .map(|pk| { + nostr::PublicKey::from_hex(pk) + .map_err(|e| ActionSinkError::EventBuild(format!("mention pubkey: {e}"))) + }) .collect::, _>>()?; let definition_event_id = definition_event_id @@ -915,6 +921,7 @@ pub(crate) mod postgres_tests { //! `cargo test -p buzz-relay --lib workflow_sink -- --ignored` use super::*; use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole}; + use buzz_core::CommunityId; use buzz_db::CreateCommunityWithOwnerResult; use std::sync::Arc; @@ -1567,4 +1574,4 @@ pub(crate) mod postgres_tests { #[cfg(test)] #[path = "workflow_delivery_tests.rs"] -mod workflow_delivery_tests; +mod workflow_delivery_postgres_tests; diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 390b9b14ac3..fb44b6d5686 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -44,7 +44,9 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::OnceLock; -use buzz_core::kind::{event_kind_u32, is_workflow_execution_kind, KIND_REACTION}; +use buzz_core::kind::{ + event_kind_u32, is_workflow_execution_kind, KIND_REACTION, KIND_WORKFLOW_DEF, +}; use buzz_core::tenant::CommunityId; use buzz_db::workflow::RunStatus; use buzz_db::Db; @@ -122,6 +124,62 @@ impl WorkflowEngine { } } + /// Load and verify the exact owner-signed definition bound to a run. + /// + /// The mutable `workflows` row supplies only immutable identity/channel + /// binding. Definition content always comes from the run's signed event; + /// legacy runs without a revision fail closed. + pub async fn load_run_definition( + &self, + community_id: CommunityId, + run_id: Uuid, + ) -> Result<(buzz_db::workflow::WorkflowRunRecord, WorkflowDef), WorkflowError> { + let run = self.db.get_workflow_run(community_id, run_id).await?; + let revision = run.definition_event_id.as_deref().ok_or_else(|| { + WorkflowError::InvalidDefinition( + "workflow run has no owner-signed definition revision".into(), + ) + })?; + let workflow = self.db.get_workflow(community_id, run.workflow_id).await?; + let stored = self + .db + .get_event_by_id_including_deleted(community_id, revision) + .await? + .ok_or_else(|| { + WorkflowError::InvalidDefinition( + "workflow run definition event is unavailable".into(), + ) + })?; + let event = &stored.event; + let workflow_id = run.workflow_id.to_string(); + let channel_id = workflow.channel_id.map(|id| id.to_string()); + let exact_tag = |name: &str| { + let mut values = event.tags.iter().filter_map(|tag| { + (tag.kind().to_string() == name) + .then(|| tag.content()) + .flatten() + }); + let value = values.next(); + value.filter(|_| values.next().is_none()) + }; + if event.id.as_bytes() != revision + || !event.verify_id() + || !event.verify_signature() + || event_kind_u32(event) != KIND_WORKFLOW_DEF + || event.pubkey.to_bytes().as_slice() != workflow.owner_pubkey + || exact_tag("d") != Some(workflow_id.as_str()) + || channel_id.is_none() + || exact_tag("h") != channel_id.as_deref() + || stored.channel_id != workflow.channel_id + { + return Err(WorkflowError::InvalidDefinition( + "workflow run definition event binding mismatch".into(), + )); + } + let (definition, _) = Self::parse_yaml(&event.content)?; + Ok((run, definition)) + } + /// Drop the cached enabled-workflow list for a channel. /// /// Must be called after any write to a workflow's trigger eligibility or diff --git a/docs/workflow-wake-fts-rollout.md b/docs/workflow-wake-fts-rollout.md index 841f50d52d6..a00d6b92422 100644 --- a/docs/workflow-wake-fts-rollout.md +++ b/docs/workflow-wake-fts-rollout.md @@ -47,8 +47,10 @@ fence remains effective for ordinary writes. duration or throughput estimate is claimed by the small disposable tests. - Lock acquisition is limited to five seconds; a busy table makes the migration fail transactionally for a later controlled retry. Once acquired, an unsafe - policy holds ACCESS EXCLUSIVE for its rewrite. Existing operator - `statement_timeout` still applies. Even the safe skip path briefly blocks + policy holds ACCESS EXCLUSIVE for its rewrite. Normal relay startup explicitly + sets `statement_timeout = 0` on its migration connection, so it imposes no + rewrite-duration bound. A manually controlled SQL session may set its own + statement timeout, with transactional rollback on expiration. Even the safe skip path briefly blocks readers/writers while inspecting the tree; it is not lock-free. Fresh desired-state bootstrap already includes 44620 in its generated policy; From db1e8c2b531e628aebf6bffdd0467be33595f08b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 1 Sep 2026 12:52:14 -0400 Subject: [PATCH 29/33] fix(workflow): retain deletion revocation on approval resume Signed-off-by: Logan Johnson --- crates/buzz-acp/src/relay.rs | 35 ------------------- .../src/handlers/workflow_approval_tests.rs | 2 +- crates/buzz-workflow/src/lib.rs | 2 +- 3 files changed, 2 insertions(+), 37 deletions(-) diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index c5945dacff4..09271c64a3e 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -480,41 +480,6 @@ impl RestClient { .await } - /// Fetch the relay's advertised signing identity from NIP-11 `/info`. - pub async fn relay_signing_pubkey(&self) -> Result { - let url = format!("{}/info", self.base_url.trim_end_matches('/')); - let value: Value = self - .http - .get(&url) - .header("Accept", "application/nostr+json") - .send() - .await - .map_err(|error| RelayError::Http(error.to_string()))? - .json() - .await - .map_err(|error| RelayError::Http(error.to_string()))?; - let relay_self = value - .get("self") - .and_then(Value::as_str) - .ok_or_else(|| RelayError::Http("relay did not advertise a signing identity".into()))?; - nostr::PublicKey::from_hex(relay_self) - .map_err(|error| RelayError::Http(format!("invalid relay signing identity: {error}"))) - } - - async fn bridge_get(&self, path: &str) -> Result { - let url = format!("{}{}", self.base_url, path); - let auth_tag_header = self.auth_tag_json.clone(); - self.request_with_retry("GET", path, || { - let auth = self.nip98_header("GET", &url, None).unwrap_or_default(); - let mut request = self.http.get(&url).header("Authorization", auth); - if let Some(ref tag) = auth_tag_header { - request = request.header("x-auth-tag", tag); - } - request.send() - }) - .await - } - /// Fetch one exact workflow-wake authority bundle. pub async fn workflow_wake_authority( &self, diff --git a/crates/buzz-relay/src/handlers/workflow_approval_tests.rs b/crates/buzz-relay/src/handlers/workflow_approval_tests.rs index 03f805e2815..53a927fe525 100644 --- a/crates/buzz-relay/src/handlers/workflow_approval_tests.rs +++ b/crates/buzz-relay/src/handlers/workflow_approval_tests.rs @@ -10,7 +10,7 @@ struct RecordingActionSink { impl buzz_workflow::ActionSink for RecordingActionSink { fn send_message( &self, - _community_id: CommunityId, + _context: buzz_workflow::action_sink::WorkflowMessageContext, _channel_id: &str, text: &str, _authored_text: &str, diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index fb44b6d5686..d8a9eec180e 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -143,7 +143,7 @@ impl WorkflowEngine { let workflow = self.db.get_workflow(community_id, run.workflow_id).await?; let stored = self .db - .get_event_by_id_including_deleted(community_id, revision) + .get_workflow_revision(community_id, revision) .await? .ok_or_else(|| { WorkflowError::InvalidDefinition( From 520989f3c98b5d39e8e8063ab1dad289b6e9cbc7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 1 Sep 2026 13:04:39 -0400 Subject: [PATCH 30/33] fix(acp): distinguish pending and absent wake signing identities Signed-off-by: Logan Johnson --- crates/buzz-acp/src/lib.rs | 124 ++++++++++++++---- crates/buzz-acp/src/relay.rs | 14 ++ crates/buzz-acp/src/workflow_wake.rs | 31 +++++ .../src/workflow_wake_recovery_tests.rs | 88 +++++++++++++ 4 files changed, 234 insertions(+), 23 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index aff04caba00..712924fbf50 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -415,6 +415,15 @@ mod inbound_author_gate { .await } + /// Durable wakes require a current-generation identity, unlike ordinary + /// admission's availability-oriented retained-key policy. + #[derive(Debug, PartialEq, Eq)] + pub(crate) enum WakeIdentity { + Ready(nostr::PublicKey), + Unavailable, + Retry, + } + pub(crate) struct InboundAuthorGate { agent_pubkey_hex: String, relay_self: Option, @@ -478,6 +487,26 @@ mod inbound_author_gate { .and_then(|key| nostr::PublicKey::from_hex(key).ok()) } + /// Resolve durable-wake identity without consuming a wake against a + /// stale key. Missing identity in a complete document is terminal for + /// this generation. Transient failures remain replayable, paced even + /// when discovery fails immediately (for example HTTP 500). + pub(crate) async fn wake_identity_for_generation( + &mut self, + rest_client: &relay::RestClient, + event_generation: u64, + ) -> WakeIdentity { + let key = self + .relay_identity_for_generation(rest_client, event_generation) + .await; + if refresh_needed(self.refreshed_generation, event_generation) { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + WakeIdentity::Retry + } else { + key.map_or(WakeIdentity::Unavailable, WakeIdentity::Ready) + } + } + /// Refresh relay identity, resolve channel trust, and apply trusted /// workflow attribution and author policy for one listener event. /// @@ -3161,29 +3190,9 @@ async fn tokio_main() -> Result<()> { let buzz_event = if kind_u32 == buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE { - let Some(workflow_relay_pubkey) = author_gate_ctx - .relay_identity_for_generation( - &ctx.rest_client, - buzz_event.connection_generation, - ) - .await - else { - // Discovery can recover on the next delivery; reopen - // transport dedup just as for transient authority reads. - if let Err(error) = relay.replay_event( - buzz_event.channel_id, - buzz_event.event.id.to_hex(), - buzz_event.event.created_at.as_secs(), - ).await { - tracing::warn!(%error, "failed to arrange workflow identity replay"); - } - continue; - }; - let Some(wake) = workflow_wake::authenticate( - &buzz_event.event, - workflow_relay_pubkey, - ) else { - tracing::warn!("workflow wake authentication failed"); + let Some((wake, workflow_relay_pubkey)) = workflow_wake::authenticate_for_listener( + &mut author_gate_ctx, &relay, &ctx.rest_client, &buzz_event, + ).await else { continue; }; let authority = match ctx @@ -7151,6 +7160,75 @@ mod author_gate_tests { server.abort(); } + #[tokio::test] + async fn wake_identity_missing_is_terminal_until_a_new_generation() { + use inbound_author_gate::WakeIdentity; + let keys = nostr::Keys::generate(); + let (rest, server) = nip11_scripted_server(std::collections::VecDeque::from([ + Ok(serde_json::json!({})), + Ok(serde_json::json!({})), // /info fallback also has no identity + Ok(serde_json::json!({"self": keys.public_key().to_hex()})), + ])) + .await; + let mut gate = InboundAuthorGate::connect(&rest, "agent", "test").await; + // The scripted valid key must remain unread: repeated deliveries on a + // completed generation cannot make progress and must not request replay. + for _ in 0..3 { + assert_eq!( + gate.wake_identity_for_generation(&rest, 0).await, + WakeIdentity::Unavailable + ); + } + assert_eq!( + gate.wake_identity_for_generation(&rest, 1).await, + WakeIdentity::Ready(keys.public_key()) + ); + server.abort(); + } + + #[tokio::test] + async fn wake_identity_transient_rotation_is_paced_and_replayable() { + use inbound_author_gate::WakeIdentity; + let old = nostr::Keys::generate(); + let new = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let (rest, server) = nip11_scripted_server(std::collections::VecDeque::from([ + Ok(serde_json::json!({"self": old.public_key().to_hex()})), + Err(()), + Err(()), // /info fallback also fails + Ok(serde_json::json!({"self": new.public_key().to_hex()})), + ])) + .await; + let mut gate = + InboundAuthorGate::connect(&rest, &agent.public_key().to_hex(), "test").await; + let wake = buzz_core::workflow_wake::WorkflowMentionWake::new( + agent.public_key(), + Uuid::new_v4(), + Uuid::new_v4(), + nostr::EventId::from_byte_array([1; 32]), + nostr::EventId::from_byte_array([2; 32]), + ) + .sign(&new) + .unwrap(); + let started = tokio::time::Instant::now(); + assert_eq!( + gate.wake_identity_for_generation(&rest, 1).await, + WakeIdentity::Retry + ); + assert!(started.elapsed() >= std::time::Duration::from_secs(1)); + // Ordinary admission may retain A, but the durable-wake boundary must + // not authenticate against A and permanently consume B's wake. + assert_eq!( + gate.relay_identity_for_test(), + Some(old.public_key().to_hex().as_str()) + ); + let WakeIdentity::Ready(key) = gate.wake_identity_for_generation(&rest, 1).await else { + panic!("replayed wake must recover the new signing identity"); + }; + assert!(workflow_wake::authenticate(&wake, key).is_some()); + server.abort(); + } + #[tokio::test] async fn test_gate_refresh_arms_attribution_after_reconnect() { let relay_keys = nostr::Keys::generate(); diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 09271c64a3e..d2ad3e06e6c 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -480,6 +480,20 @@ impl RestClient { .await } + async fn bridge_get(&self, path: &str) -> Result { + let url = format!("{}{}", self.base_url, path); + let auth_tag_header = self.auth_tag_json.clone(); + self.request_with_retry("GET", path, || { + let auth = self.nip98_header("GET", &url, None).unwrap_or_default(); + let mut request = self.http.get(&url).header("Authorization", auth); + if let Some(ref tag) = auth_tag_header { + request = request.header("x-auth-tag", tag); + } + request.send() + }) + .await + } + /// Fetch one exact workflow-wake authority bundle. pub async fn workflow_wake_authority( &self, diff --git a/crates/buzz-acp/src/workflow_wake.rs b/crates/buzz-acp/src/workflow_wake.rs index 2902e429a1b..014e353cad6 100644 --- a/crates/buzz-acp/src/workflow_wake.rs +++ b/crates/buzz-acp/src/workflow_wake.rs @@ -38,6 +38,37 @@ pub struct WorkflowWakeAuthority { pub message: Event, } +/// Production wake ingress: resolve current identity before definitive signer +/// rejection, and reopen transport dedup only for paced, pending discovery. +pub(crate) async fn authenticate_for_listener( + gate: &mut crate::inbound_author_gate::InboundAuthorGate, + relay: &crate::relay::HarnessRelay, + rest: &crate::relay::RestClient, + event: &crate::relay::BuzzEvent, +) -> Option<(WorkflowMentionWake, PublicKey)> { + use crate::inbound_author_gate::WakeIdentity; + match gate + .wake_identity_for_generation(rest, event.connection_generation) + .await + { + WakeIdentity::Ready(key) => authenticate(&event.event, key).map(|wake| (wake, key)), + WakeIdentity::Unavailable => None, + WakeIdentity::Retry => { + if let Err(error) = relay + .replay_event( + event.channel_id, + event.event.id.to_hex(), + event.event.created_at.as_secs(), + ) + .await + { + tracing::warn!(%error, "failed to arrange workflow identity replay"); + } + None + } + } +} + /// Return whether a revision-labelled workflow message must dispatch only /// through its separately verified wake, regardless of relay key rotation. pub fn requires_verified_wake(event: &Event) -> bool { diff --git a/crates/buzz-acp/src/workflow_wake_recovery_tests.rs b/crates/buzz-acp/src/workflow_wake_recovery_tests.rs index 1b290397c20..97b0a33e176 100644 --- a/crates/buzz-acp/src/workflow_wake_recovery_tests.rs +++ b/crates/buzz-acp/src/workflow_wake_recovery_tests.rs @@ -3,6 +3,7 @@ use super::*; use buzz_core::kind::{KIND_STREAM_MESSAGE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_MENTION_WAKE}; use buzz_core::workflow_wake::WorkflowMentionWake; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use std::sync::Arc; #[tokio::test] async fn exhausted_authority_failure_replays_exact_wake_and_verifies_before_dispatch() { @@ -264,3 +265,90 @@ async fn complete_malformed_authority_body_is_terminal() { } server.await.unwrap(); } + +#[tokio::test] +async fn missing_identity_ingress_does_not_reopen_transport_dedup() { + assert_identity_ingress(true).await; +} + +#[tokio::test] +async fn pending_rotation_ingress_reopens_transport_dedup_until_identity_recovers() { + assert_identity_ingress(false).await; +} + +async fn assert_identity_ingress(missing: bool) { + use crate::inbound_author_gate::InboundAuthorGate; + use std::sync::atomic::{AtomicUsize, Ordering}; + let agent = Keys::generate(); + let old = Keys::generate(); + let new = Keys::generate(); + let channel = Uuid::new_v4(); + let wake = WorkflowMentionWake::new(agent.public_key(), channel, Uuid::new_v4(), + nostr::EventId::from_byte_array([1; 32]), nostr::EventId::from_byte_array([2; 32])) + .sign(&new).unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let probes = Arc::new(AtomicUsize::new(0)); + let count = probes.clone(); + let key = new.public_key(); + let http_server = tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 4096]; + let len = stream.read(&mut request).await.unwrap(); + let request = String::from_utf8_lossy(&request[..len]); + assert!(request.starts_with("GET / ") || request.starts_with("GET /info ")); + let index = count.fetch_add(1, Ordering::SeqCst); + let (status, body) = if missing { + ("200 OK", json!({}).to_string()) + } else if index == 1 || index == 2 { + ("500 Internal Server Error", String::new()) + } else { + ("200 OK", json!({"self": if index == 0 { old.public_key() } else { key }.to_hex()}).to_string()) + }; + stream.write_all(format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + } + }); + let http = reqwest::Client::new(); + let rest = RestClient { http: http.clone(), base_url: format!("http://{address}"), keys: agent.clone(), auth_tag_json: None }; + let mut gate = InboundAuthorGate::connect(&rest, &agent.public_key().to_hex(), "test").await; + let (ws, mut server) = test_ws_pair().await; + let (event_tx, event_rx) = mpsc::channel(16); + let (observer_control_tx, observer_control_rx) = mpsc::channel(16); + let (cmd_tx, cmd_rx) = mpsc::channel(16); + let bg = tokio::spawn(run_background_task(ws, VecDeque::new(), event_tx, observer_control_tx, + cmd_rx, agent.clone(), "ws://unused".into(), agent.public_key().to_hex(), None)); + let mut harness = HarnessRelay { event_rx, observer_control_rx: Some(observer_control_rx), cmd_tx, + http, relay_url: "ws://unused".into(), keys: agent, auth_tag: None, bg_handle: Some(bg) }; + harness.subscribe_channel_from(channel, ChannelFilter { kinds: Some(vec![KIND_WORKFLOW_MENTION_WAKE]), + require_mention: false }, Some(wake.created_at.as_secs())).await.unwrap(); + let initial = next_data_frame(&mut server).await; + let frame = json!(["EVENT", channel_sub_id(channel), wake]).to_string(); + server.send(Message::Text(frame.clone().into())).await.unwrap(); + let mut received = timeout(Duration::from_secs(2), harness.next_event()).await.unwrap().unwrap(); + // Supply the reconnect generation at the production ingress seam. The + // transport below is real; its separate reconnect tests cover the counter. + received.connection_generation = u64::from(!missing); + let start = tokio::time::Instant::now(); + assert!(crate::workflow_wake::authenticate_for_listener(&mut gate, &harness, &rest, &received).await.is_none()); + if missing { + assert!(timeout(Duration::from_millis(100), server.next()).await.is_err(), "terminal absence must not issue replay REQ"); + assert_eq!(probes.load(Ordering::SeqCst), 2); + } else { + assert!(start.elapsed() >= Duration::from_secs(1), "immediate discovery failures must be paced"); + let replay = next_data_frame(&mut server).await; + assert_eq!(replay[0], "REQ"); + assert_eq!(replay[1], initial[1]); + server.send(Message::Text(frame.clone().into())).await.unwrap(); + let mut replayed = timeout(Duration::from_secs(2), harness.next_event()).await.unwrap().unwrap(); + assert_eq!(replayed.event.id, wake.id); + replayed.connection_generation = 1; + let (_, current) = crate::workflow_wake::authenticate_for_listener(&mut gate, &harness, &rest, &replayed).await.expect("same wake recovers before definitive signer rejection"); + assert_eq!(current, key); + assert_eq!(probes.load(Ordering::SeqCst), 4); + } + server.send(Message::Text(frame.into())).await.unwrap(); + assert!(timeout(Duration::from_millis(100), harness.next_event()).await.is_err(), "terminal/successful ingress preserves dedup"); + http_server.abort(); + harness.shutdown().await; +} From 8f7809cb5363ea8bbd7f6be4d5b32feeb9ea46ce Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 1 Sep 2026 13:09:10 -0400 Subject: [PATCH 31/33] test(acp): ignore heartbeat frames in wake replay assertion Signed-off-by: Logan Johnson --- crates/buzz-acp/src/workflow_wake_recovery_tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/buzz-acp/src/workflow_wake_recovery_tests.rs b/crates/buzz-acp/src/workflow_wake_recovery_tests.rs index 97b0a33e176..6b6fbe9e462 100644 --- a/crates/buzz-acp/src/workflow_wake_recovery_tests.rs +++ b/crates/buzz-acp/src/workflow_wake_recovery_tests.rs @@ -332,7 +332,9 @@ async fn assert_identity_ingress(missing: bool) { let start = tokio::time::Instant::now(); assert!(crate::workflow_wake::authenticate_for_listener(&mut gate, &harness, &rest, &received).await.is_none()); if missing { - assert!(timeout(Duration::from_millis(100), server.next()).await.is_err(), "terminal absence must not issue replay REQ"); + // The transport heartbeat can send Ping independently of replay. + // Answer control frames and assert that no application REQ is sent. + assert!(timeout(Duration::from_millis(100), next_data_frame(&mut server)).await.is_err(), "terminal absence must not issue replay REQ"); assert_eq!(probes.load(Ordering::SeqCst), 2); } else { assert!(start.elapsed() >= Duration::from_secs(1), "immediate discovery failures must be paced"); From 4a0dda2aec7c699d726227375be017653ac95bab Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 1 Sep 2026 13:12:36 -0400 Subject: [PATCH 32/33] ci: test wake migrations on the supported PostgreSQL 17 Signed-off-by: Logan Johnson --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0f3b0e4b2a..3da114eb5e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -448,7 +448,8 @@ jobs: contents: read services: postgres: - image: postgres:16 + # Match docker-compose.yml: wake FTS migration uses PG17 SET EXPRESSION. + image: postgres:17 env: POSTGRES_USER: buzz POSTGRES_PASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} From 15826a98fd03579dd9fc0ce59d453797ca8c26aa Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 1 Sep 2026 14:23:38 -0400 Subject: [PATCH 33/33] fix(workflows): rely on read authorization without rewriting event search history Signed-off-by: Logan Johnson --- .github/workflows/ci.yml | 2 +- crates/buzz-core/src/kind.rs | 15 +- crates/buzz-db/src/runtime/migration.rs | 17 +- .../tests/postgres_workflow_supersession.rs | 72 +++++++ crates/buzz-relay/src/handlers/req.rs | 2 +- .../buzz-relay/src/workflow_delivery_tests.rs | 3 + .../buzz-relay/src/workflow_search_tests.rs | 185 ++++++++++++++++++ .../tests/postgres_fts_integration.rs | 40 +--- .../tests/postgres_workflow_wake_fts.rs | 137 ------------- docs/workflow-wake-fts-rollout.md | 157 +++++++++------ migrations/0044_workflow_mention_wake_fts.sql | 78 -------- ...=> 0044_workflow_superseded_authority.sql} | 0 schema/schema.sql | 2 +- 13 files changed, 381 insertions(+), 329 deletions(-) create mode 100644 crates/buzz-db/tests/postgres_workflow_supersession.rs create mode 100644 crates/buzz-relay/src/workflow_search_tests.rs delete mode 100644 crates/buzz-search/tests/postgres_workflow_wake_fts.rs delete mode 100644 migrations/0044_workflow_mention_wake_fts.sql rename migrations/{0045_workflow_superseded_authority.sql => 0044_workflow_superseded_authority.sql} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3da114eb5e6..b5a64a5b8d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -448,7 +448,7 @@ jobs: contents: read services: postgres: - # Match docker-compose.yml: wake FTS migration uses PG17 SET EXPRESSION. + # Match the PostgreSQL 17 contract in VISION.md and docker-compose.yml. image: postgres:17 env: POSTGRES_USER: buzz diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index e40552accf1..85be237507c 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -150,16 +150,11 @@ pub const RESULT_GATED_KINDS: &[u32] = &[ /// /// The relay enforces this at the filter layer (`p_gated_filters_authorized`): /// a REQ that can match any kind in this set is closed unless the filter's -/// `#p` values exactly equal the authenticated reader's pubkey. For stored -/// (non-ephemeral) kinds in this set, the storage layer additionally writes a -/// NULL `search_tsv` so the event is unsearchable through NIP-50 FTS -/// (`schema/schema.sql` and the forward FTS migrations — drift -/// caught by `p_gated_persistent_kinds_have_storage_null_tsvector` in -/// `crates/buzz-search/tests/fts_integration.rs`). -/// -/// Ephemeral kinds (20000–29999, e.g. [`KIND_AGENT_OBSERVER_FRAME`]) are -/// included for filter-layer enforcement but are never stored, so the -/// storage-layer search defense does not apply to them. +/// `#p` values exactly equal the authenticated reader's pubkey. Existing +/// private event families also have storage-level search exclusions. Those +/// exclusions are not implied by this access-control list: durable workflow +/// wakes use the result-level recipient and current-membership checks even +/// when their content has an indexed vector. Ephemeral kinds are never stored. pub const P_GATED_KINDS: &[u32] = &[ KIND_AGENT_OBSERVER_FRAME, KIND_WORKFLOW_MENTION_WAKE, diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index c28b5facc8e..c1dfb614f09 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -699,7 +699,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 45); + assert_eq!(migrations.len(), 44); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -908,7 +908,7 @@ mod postgres_tests { assert!(migrations[32].sql.as_str().contains("search_tsv")); assert!(!migrations[0].sql.as_str().contains("30179")); assert!(include_str!("../../../../schema/schema.sql") - .contains("kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200, 44620)")); + .contains("kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200)")); // Public push-gateway authority is intentionally deployment-global and // durable: immediate revocation and hostile-relay admission cannot be @@ -1191,12 +1191,15 @@ mod postgres_tests { 2 ); - // Durable workflow wakes are recipient-gated and must remain outside - // full-text search on both fresh and brownfield databases. + // Supersession metadata is additive; there is no wake FTS migration. assert_eq!(migrations[43].version, 44); - let workflow_wake_fts = migrations[43].sql.as_str(); - assert!(workflow_wake_fts.contains("kind = 44620")); - assert!(desired_schema.contains("44200, 44620")); + let workflow_supersession = migrations[43].sql.as_str(); + assert!(workflow_supersession + .contains("workflow_revision_superseded BOOLEAN NOT NULL DEFAULT false")); + assert!(!workflow_supersession.contains("UPDATE events")); + assert!( + desired_schema.contains("workflow_revision_superseded BOOLEAN NOT NULL DEFAULT false") + ); // pgschema intentionally reconciles DDL, not seed DML or table storage // parameters. Its post-apply reconciliation must restore and verify diff --git a/crates/buzz-db/tests/postgres_workflow_supersession.rs b/crates/buzz-db/tests/postgres_workflow_supersession.rs new file mode 100644 index 00000000000..5bbb4309918 --- /dev/null +++ b/crates/buzz-db/tests/postgres_workflow_supersession.rs @@ -0,0 +1,72 @@ +//! The supersession marker adds metadata, not inferred historical authority. +use sqlx::{postgres::PgPoolOptions, Executor}; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn supersession_fast_default_preserves_partition_storage_and_denies_unknown_history() { + let url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .expect("isolated test database URL"); + let pool = PgPoolOptions::new() + .max_connections(1) + .connect(&url) + .await + .expect("connect"); + let mut tx = pool.begin().await.expect("transaction"); + let schema = format!("supersession_{}", uuid::Uuid::new_v4().simple()); + tx.execute(sqlx::AssertSqlSafe(format!( + "CREATE SCHEMA {schema}; SET LOCAL search_path = {schema}" + ))) + .await + .expect("isolated schema"); + tx.execute( + "CREATE TABLE events (id int, deleted_at timestamptz) PARTITION BY RANGE (id); + CREATE TABLE events_old PARTITION OF events FOR VALUES FROM (0) TO (10); + CREATE INDEX events_id ON events (id); + INSERT INTO events VALUES (1, NULL), (2, now()); + CREATE TABLE original_storage AS + SELECT oid, relfilenode FROM pg_class WHERE relnamespace = current_schema()::regnamespace + AND relkind IN ('r', 'i');", + ) + .await + .expect("populated partition and index"); + tx.execute(include_str!( + "../../../migrations/0044_workflow_superseded_authority.sql" + )) + .await + .expect("supersession migration"); + let unchanged: bool = sqlx::query_scalar( + "SELECT bool_and(c.relfilenode = o.relfilenode) FROM original_storage o JOIN pg_class c USING (oid)" + ).fetch_one(&mut *tx).await.expect("physical storage"); + assert!(unchanged); + let unknown_denied: bool = + sqlx::query_scalar("SELECT bool_and(workflow_revision_superseded = false) FROM events") + .fetch_one(&mut *tx) + .await + .expect("no historical inference"); + assert!(unknown_denied); + let fast_default: bool = sqlx::query_scalar( + "SELECT atthasmissing AND attmissingval::text = '{f}' FROM pg_attribute + WHERE attrelid = 'events_old'::regclass AND attname = 'workflow_revision_superseded'", + ) + .fetch_one(&mut *tx) + .await + .expect("fast default catalog"); + assert!( + fast_default, + "existing partition rows must use a metadata-only default" + ); + tx.execute( + "CREATE TABLE events_future PARTITION OF events FOR VALUES FROM (10) TO (20); + INSERT INTO events VALUES (11, NULL);", + ) + .await + .expect("future partition inherits default"); + let new_default: bool = + sqlx::query_scalar("SELECT NOT workflow_revision_superseded FROM events WHERE id=11") + .fetch_one(&mut *tx) + .await + .expect("new row default"); + assert!(new_default); + tx.rollback().await.expect("cleanup"); +} diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 99c208aa313..56860630249 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -1193,7 +1193,7 @@ fn extract_channel_id_from_filters(filters: &[Filter]) -> Option { pub(crate) fn p_gated_filters_authorized(filters: &[Filter], authed_pubkey_hex: &str) -> bool { let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); filters.iter().all(|filter| { - // Kindless full-text searches cannot surface p-gated rows, and a + // Kindless full-text searches use per-event authorization, and a // kindless channel filter is safe to register: global p-gated events // cannot enter its channel index, while channel-scoped workflow wakes // are removed by the shared per-event recipient gate. Preserve the diff --git a/crates/buzz-relay/src/workflow_delivery_tests.rs b/crates/buzz-relay/src/workflow_delivery_tests.rs index 1cf217ee8db..f16e598d6cf 100644 --- a/crates/buzz-relay/src/workflow_delivery_tests.rs +++ b/crates/buzz-relay/src/workflow_delivery_tests.rs @@ -622,3 +622,6 @@ async fn rendered_trigger_mentions_never_create_durable_wakes_or_authority() { } } } + +#[path = "workflow_search_tests.rs"] +mod workflow_search_postgres_tests; diff --git a/crates/buzz-relay/src/workflow_search_tests.rs b/crates/buzz-relay/src/workflow_search_tests.rs new file mode 100644 index 00000000000..dc612452b87 --- /dev/null +++ b/crates/buzz-relay/src/workflow_search_tests.rs @@ -0,0 +1,185 @@ +//! Search visibility must not depend on the wake's physical FTS projection. +use super::*; +use buzz_core::{kind::KIND_WORKFLOW_MENTION_WAKE, workflow_wake::WorkflowMentionWake}; +use serde_json::{json, Value}; + +async fn assert_search(f: &Fixture, filter: Value, expected: &[&str]) { + let body = axum::body::Bytes::from(serde_json::to_vec(&json!([filter])).expect("body")); + let response = + crate::api::bridge::query_events(State(f.state.clone()), f.headers(), body.clone()) + .await + .expect("HTTP search"); + let mut http_ids: Vec<&str> = response + .0 + .as_array() + .expect("events") + .iter() + .map(|event| event["id"].as_str().expect("event id")) + .collect(); + http_ids.sort_unstable(); + let mut expected = expected.to_vec(); + expected.sort_unstable(); + assert_eq!( + http_ids, expected, + "HTTP returns authorized events, not raw hits" + ); + + let (conn, mut frames) = f.connection(); + let filters = serde_json::from_slice(&body).expect("filters"); + crate::handlers::req::handle_req("search".into(), filters, conn.clone(), f.state.clone()).await; + let mut ws_ids = Vec::new(); + loop { + let frame = next_frame(&mut frames); + if frame[0] == "EOSE" { + break; + } + assert_eq!(frame[0], "EVENT", "unexpected search frame: {frame}"); + ws_ids.push(frame[2]["id"].as_str().expect("event id").to_owned()); + } + ws_ids.sort_unstable(); + assert_eq!(ws_ids, expected, "WS uses the same visibility boundary"); + assert!(frames.try_recv().is_err()); +} + +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn indexed_wakes_are_filtered_by_actual_http_and_ws_search_boundaries() { + let f = Fixture::new().await; + let revision = f.revision(Timestamp::now().as_secs()).await; + let run = f + .state + .db + .create_workflow_run( + f.community, + f.workflow, + Some(revision.id.as_bytes()), + None, + None, + ) + .await + .expect("run"); + let message = RelayActionSink::new(&f.state) + .send_message( + WorkflowMessageContext { + community_id: f.community, + run_id: run, + step_id: "notify".into(), + definition_event_id: Some(revision.id.as_bytes().to_vec()), + }, + &f.channel.to_string(), + "@Worker searchneedle", + "@Worker searchneedle", + &f.owner.public_key().to_hex(), + None, + ) + .await + .expect("ordinary sink stores message and wake"); + let filter = json!({"kinds":[KIND_WORKFLOW_MENTION_WAKE], "#p":[f.agent.public_key().to_hex()], "#h":[f.channel.to_string()]}); + let body = axum::body::Bytes::from(serde_json::to_vec(&json!([filter])).expect("body")); + let response = crate::api::bridge::query_events(State(f.state.clone()), f.headers(), body) + .await + .expect("ordinary wake replay"); + assert_eq!(response.0.as_array().expect("events").len(), 1); + let wake: Event = serde_json::from_value(response.0[0].clone()).expect("canonical wake"); + let other = WorkflowMentionWake::new( + f.owner.public_key(), + f.channel, + run, + revision.id, + nostr::EventId::from_hex(&message).expect("message id"), + ) + .sign(&f.state.relay_keypair) + .expect("other recipient"); + // Represent arbitrary legacy/malformed storage without relaxing ingress. + // Reuse canonical tags but sign nonempty content; parse must reject it. + let malformed = EventBuilder::new( + Kind::Custom(KIND_WORKFLOW_MENTION_WAKE as u16), + "searchneedle", + ) + .tags(wake.tags.clone()) + .sign_with_keys(&f.state.relay_keypair) + .expect("malformed wake"); + for event in [&other, &malformed] { + f.state + .db + .insert_event(f.community, event, Some(f.channel)) + .await + .expect("legacy row"); + } + // Desired-state's existing broad policy really indexes these rows. This + // assertion prevents the authorization test from passing vacuously via NULL. + let candidates = f + .state + .search + .search(&buzz_search::SearchQuery { + community: f.community, + q: "-neverpresentqzx".into(), + channel_scope: buzz_search::ChannelScope::Channels(vec![f.channel]), + kinds: Some(vec![KIND_WORKFLOW_MENTION_WAKE as i32]), + authors: None, + since: None, + until: None, + page: 1, + per_page: 100, + mode: buzz_search::SearchMode::FullText, + }) + .await + .expect("raw NOT-only candidate search"); + for event in [&wake, &other, &malformed] { + assert!( + candidates + .hits + .iter() + .any(|hit| hit.event_id == event.id.to_bytes()), + "fixture must expose even the empty wake to candidate search" + ); + } + let wake_id = wake.id.to_hex(); + // IDs bypass the filter-level p gate; result-level recipient + shape gates + // must still reject the other recipient and the malformed row. + let ids = json!([message, wake_id, other.id.to_hex(), malformed.id.to_hex()]); + let broad = json!({"ids":ids, "search":"-neverpresentqzx", "#h":[f.channel.to_string()]}); + assert_search(&f, broad.clone(), &[&message, &wake_id]).await; + assert_search( + &f, + json!({"ids":ids, "search":"searchneedle", "#h":[f.channel.to_string()]}), + &[&message], + ) + .await; + assert_search(&f, json!({"kinds":[9,40002,45001,45003], "search":"-neverpresentqzx", "#h":[f.channel.to_string()]}), &[&message]).await; + assert_search(&f, json!({"kinds":[KIND_WORKFLOW_MENTION_WAKE], "#p":[f.agent.public_key().to_hex()], "search":"-neverpresentqzx"}), &[&wake_id]).await; + + let unauthorized = axum::body::Bytes::from(serde_json::to_vec(&json!([{ + "kinds":[KIND_WORKFLOW_MENTION_WAKE], "#p":[f.owner.public_key().to_hex()], "search":"-neverpresentqzx" + }])).expect("body")); + assert_eq!( + crate::api::bridge::query_events(State(f.state.clone()), f.headers(), unauthorized.clone()) + .await + .expect_err("foreign recipient filter") + .0, + StatusCode::FORBIDDEN + ); + let (conn, mut frames) = f.connection(); + crate::handlers::req::handle_req( + "denied".into(), + serde_json::from_slice(&unauthorized).expect("filters"), + conn.clone(), + f.state.clone(), + ) + .await; + assert_eq!(next_frame(&mut frames)[0], "CLOSED"); + + f.state + .db + .remove_member( + f.community, + f.channel, + &f.agent.public_key().to_bytes(), + &f.owner.public_key().to_bytes(), + ) + .await + .expect("remove member"); + // The open channel remains readable; the public control must still return, + // while wake membership is checked from DB rather than the channel cache. + assert_search(&f, broad, &[&message]).await; +} diff --git a/crates/buzz-search/tests/postgres_fts_integration.rs b/crates/buzz-search/tests/postgres_fts_integration.rs index 8cde7f80026..62da1a897c8 100644 --- a/crates/buzz-search/tests/postgres_fts_integration.rs +++ b/crates/buzz-search/tests/postgres_fts_integration.rs @@ -30,8 +30,6 @@ const MIGRATION_0008_SQL: &str = const MIGRATION_0014_SQL: &str = include_str!("../../../migrations/0014_push_lease_fts.sql"); const MIGRATION_0033_SQL: &str = include_str!("../../../migrations/0033_private_managed_agent_fts.sql"); -const MIGRATION_0042_SQL: &str = - include_str!("../../../migrations/0044_workflow_mention_wake_fts.sql"); async fn setup() -> (PgPool, String) { setup_with_search_policy(true).await @@ -60,8 +58,8 @@ async fn setup_with_search_policy(apply_fresh_allowlist: bool) -> (PgPool, Strin .connect(&url_with_search_path) .await .expect("connect with search_path"); - // Apply the full migration chain in order so the test schema exactly matches - // production. Future FTS-affecting migrations must be added here. + // Apply the selected FTS-affecting chain, not the full production schema. + // Preserve both existing fresh allowlist and brownfield skip-set policies. pool.execute(MIGRATION_0001_SQL) .await .expect("apply 0001 migration"); @@ -94,9 +92,6 @@ async fn setup_with_search_policy(apply_fresh_allowlist: bool) -> (PgPool, Strin pool.execute(MIGRATION_0033_SQL) .await .expect("apply 0033 migration"); - pool.execute(MIGRATION_0042_SQL) - .await - .expect("apply 0042 migration"); (pool, schema) } @@ -1416,26 +1411,9 @@ async fn author_only_kinds_are_storage_level_unsearchable() { teardown(pool, &schema).await; } -/// Tripwire: every Rust-side `P_GATED_KINDS` entry that is *persistent* (not -/// in the ephemeral 20000–29999 range) MUST be excluded from `search_tsv` at -/// the storage layer. -/// -/// L2 (the filter-level `#p` gate in `p_gated_filters_authorized`) prevents -/// reachable leaks today, but it is Rust logic — a future bug or new exempt -/// search entry point could surface tokenized content from these kinds. The -/// L1 NULL tsvector is the unbreakable backstop: `@@` mathematically cannot -/// match NULL. This test catches the drift where someone adds a persistent -/// kind to `P_GATED_KINDS` without the matching desired schema and forward -/// migration exclusion. -/// -/// Ephemeral kinds (20000–29999) are skipped: they are never stored, so the -/// storage-layer defense does not apply to them regardless of the schema -/// CASE. `p_gated_filters_authorized` remains their sole defense by design. -/// -/// Companion to `author_only_kinds_are_storage_level_unsearchable`: that test -/// covers `AUTHOR_ONLY_KINDS` drift; this one covers `P_GATED_KINDS` -/// persistent-subset drift. Together they tripwire both Rust-side privacy -/// constants against the schema literal. +/// Preserve the existing storage exclusions for private event families. +/// Wake authorization is instead enforced at the relay's read/count boundary; +/// index membership does not grant visibility. See workflow_search_tests. #[tokio::test] #[ignore = "requires Postgres"] async fn p_gated_persistent_kinds_have_storage_null_tsvector() { @@ -1472,7 +1450,9 @@ async fn assert_p_gated_storage_null(apply_fresh_allowlist: bool) { let persistent: Vec = P_GATED_KINDS .iter() .copied() - .filter(|&k| !buzz_core::kind::is_ephemeral(k)) + .filter(|&k| { + !buzz_core::kind::is_ephemeral(k) && k != buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE + }) .collect(); assert!( !persistent.is_empty(), @@ -1492,8 +1472,8 @@ async fn assert_p_gated_storage_null(apply_fresh_allowlist: bool) { 1_700_000_100 + i as i64, ) .await; - // Empty content is the canonical wake payload, but an empty vector is - // not NULL: it matches a NOT-only query. Exercise both payload shapes. + // Preserve NULL rather than empty vectors for these existing exclusions. + // An empty vector can match a NOT-only query. insert_event( &pool, c, diff --git a/crates/buzz-search/tests/postgres_workflow_wake_fts.rs b/crates/buzz-search/tests/postgres_workflow_wake_fts.rs deleted file mode 100644 index cfc05e882b6..00000000000 --- a/crates/buzz-search/tests/postgres_workflow_wake_fts.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! Storage/rollout contract for the workflow wake FTS migration on PostgreSQL 17. -use sqlx::{postgres::PgPoolOptions, Executor, PgPool}; -use uuid::Uuid; - -const MIGRATION: &str = include_str!("../../../migrations/0044_workflow_mention_wake_fts.sql"); -const ALLOWLIST: &str = "CASE WHEN kind IN (0,9,40002,45001,45003) THEN to_tsvector('simple',content) ELSE NULL::tsvector END"; -const DESIRED: &str = "CASE WHEN kind IN (1059,30179,30300,30350,30622,44100,44101,44200,44620) THEN NULL::tsvector ELSE to_tsvector('simple',content) END"; - -async fn fixture(expression: &str) -> (PgPool, String) { - let url = std::env::var("BUZZ_TEST_DATABASE_URL").expect("isolated PostgreSQL URL"); - let schema = format!("wake_fts_{}", Uuid::new_v4().simple()); - let pool = PgPoolOptions::new() - .max_connections(1) - .connect(&url) - .await - .expect("connect"); - pool.execute(sqlx::AssertSqlSafe(format!( - "CREATE SCHEMA {schema}; SET search_path = {schema}; - CREATE TABLE events(kind int, content text, created_at int, - search_tsv tsvector GENERATED ALWAYS AS ({expression}) STORED) - PARTITION BY RANGE(created_at); - CREATE TABLE events_old PARTITION OF events FOR VALUES FROM (0) TO (100); - CREATE INDEX custom_fts_index ON events USING gin(search_tsv) WITH (fastupdate=off); - INSERT INTO events(kind,content,created_at) VALUES - (9,'public control',1),(44620,'private payload',2),(44620,'',3); - CREATE TEMP TABLE original_nodes AS SELECT oid, relname, relfilenode FROM pg_class - WHERE relnamespace = '{schema}'::regnamespace; - CREATE TEMP TABLE original_indexes AS SELECT c.relname, pg_get_indexdef(c.oid) AS definition, c.reloptions - FROM pg_class c WHERE c.relnamespace = '{schema}'::regnamespace AND c.relkind IN ('i','I');" - ))) - .await - .expect("fixture schema"); - (pool, schema) -} - -async fn cleanup(pool: PgPool, schema: String) { - pool.execute(sqlx::AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE"))) - .await - .expect("cleanup"); - pool.close().await; -} - -async fn assert_null_and_future_partition(pool: &PgPool) { - pool.execute( - "CREATE TABLE events_future PARTITION OF events FOR VALUES FROM (100) TO (200); - INSERT INTO events_future(kind,content,created_at) VALUES (44620,'future private',101); - UPDATE events SET content='changed private' WHERE kind=44620; - INSERT INTO events(kind,content,created_at) VALUES (9,'was public',102); - UPDATE events SET kind=44620 WHERE created_at=102;", - ) - .await - .expect("parent and direct leaf writes"); - let nonnull: i64 = sqlx::query_scalar( - "SELECT count(*) FROM events WHERE kind=44620 AND search_tsv IS NOT NULL", - ) - .fetch_one(pool) - .await - .expect("raw vectors"); - assert_eq!(nonnull, 0); - let matches: Vec = sqlx::query_scalar( - "SELECT kind FROM events WHERE search_tsv @@ websearch_to_tsquery('simple','-absentword')", - ) - .fetch_all(pool) - .await - .expect("NOT-only query"); - assert_eq!(matches, vec![9]); - assert!( - pool.execute("UPDATE events SET search_tsv=to_tsvector('private') WHERE kind=44620") - .await - .is_err(), - "generated column must reject direct vector assignments" - ); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn safe_policies_preserve_heap_and_index_files() { - let migrated = format!("CASE WHEN kind=30179 THEN NULL::tsvector ELSE (CASE WHEN kind=30350 THEN NULL::tsvector ELSE ({ALLOWLIST}) END) END"); - for expression in [ALLOWLIST, &migrated, DESIRED] { - let (pool, schema) = fixture(expression).await; - pool.execute(MIGRATION).await.expect("safe migration"); - let changed: i64 = sqlx::query_scalar("SELECT count(*) FROM original_nodes b JOIN pg_class c USING(oid) WHERE b.relfilenode <> c.relfilenode") - .fetch_one(&pool).await.expect("physical files"); - assert_eq!(changed, 0, "safe policy must not rewrite heaps or indexes"); - assert_null_and_future_partition(&pool).await; - cleanup(pool, schema).await; - } -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn legacy_policy_preserves_column_dependencies_and_nonwake_values() { - let (pool, schema) = fixture("to_tsvector('simple',content)").await; - pool.execute("CREATE VIEW public_projection AS SELECT search_tsv FROM events WHERE kind=9; - CREATE TEMP TABLE original_projection AS SELECT created_at,content,search_tsv FROM events WHERE kind<>44620;") - .await.expect("dependent view"); - pool.execute(MIGRATION).await.expect("legacy migration"); - let differences: i64 = sqlx::query_scalar("SELECT count(*) FROM original_indexes b FULL JOIN (SELECT c.relname, pg_get_indexdef(c.oid) AS definition, c.reloptions FROM pg_class c WHERE c.relnamespace=current_schema()::regnamespace AND c.relkind IN ('i','I')) a USING(relname) WHERE (b.definition,b.reloptions) IS DISTINCT FROM (a.definition,a.reloptions)") - .fetch_one(&pool).await.expect("custom index definitions"); - assert_eq!( - differences, 0, - "custom index definitions and options must survive" - ); - let changed: i64 = sqlx::query_scalar("SELECT count(*) FROM original_nodes b JOIN pg_class c ON c.relname=b.relname AND c.relnamespace=current_schema()::regnamespace WHERE b.relfilenode <> c.relfilenode") - .fetch_one(&pool).await.expect("physical files"); - assert!(changed > 0, "legacy correction honestly requires a rewrite"); - let differences: i64 = sqlx::query_scalar("SELECT count(*) FROM original_projection o JOIN events e USING(created_at) WHERE (o.content,o.search_tsv) IS DISTINCT FROM (e.content,e.search_tsv)") - .fetch_one(&pool).await.expect("nonwake values"); - assert_eq!(differences, 0); - let visible: i64 = - sqlx::query_scalar("SELECT count(*) FROM public_projection WHERE search_tsv IS NOT NULL") - .fetch_one(&pool) - .await - .expect("dependent view still works"); - assert_eq!(visible, 1); - assert_null_and_future_partition(&pool).await; - cleanup(pool, schema).await; -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn divergent_partition_policy_fails_without_mutation() { - let (pool, schema) = fixture(ALLOWLIST).await; - pool.execute("DROP INDEX custom_fts_index; ALTER TABLE events_old ALTER COLUMN search_tsv SET EXPRESSION AS (to_tsvector('simple',content));") - .await.expect("divergent leaf"); - let before: String = sqlx::query_scalar("SELECT pg_get_expr(adbin,adrelid) FROM pg_attrdef d JOIN pg_attribute a ON a.attrelid=d.adrelid AND a.attnum=d.adnum WHERE a.attrelid='events_old'::regclass AND a.attname='search_tsv'") - .fetch_one(&pool).await.expect("leaf expression"); - let error = pool - .execute(MIGRATION) - .await - .expect_err("reject divergent policy"); - assert!(error.to_string().contains("divergent search_tsv policy")); - let after: String = sqlx::query_scalar("SELECT pg_get_expr(adbin,adrelid) FROM pg_attrdef d JOIN pg_attribute a ON a.attrelid=d.adrelid AND a.attnum=d.adnum WHERE a.attrelid='events_old'::regclass AND a.attname='search_tsv'") - .fetch_one(&pool).await.expect("unchanged leaf expression"); - assert_eq!(before, after); - cleanup(pool, schema).await; -} diff --git a/docs/workflow-wake-fts-rollout.md b/docs/workflow-wake-fts-rollout.md index a00d6b92422..8f6db7608f4 100644 --- a/docs/workflow-wake-fts-rollout.md +++ b/docs/workflow-wake-fts-rollout.md @@ -1,75 +1,104 @@ -# Workflow wake FTS rollout (PostgreSQL 17) +# Workflow revision and wake database rollout (PostgreSQL 17) -Kind 44620 is durable workflow delivery, not searchable chat. Its `search_tsv` -must be SQL NULL even for empty or malformed private content. An empty vector -is not equivalent: it matches NOT-only queries. Neither query-layer filtering -nor the canonical empty wake payload replaces this storage contract. +## Decision: use ordinary event storage and the existing access boundary -## Decision +There is **no wake-specific FTS migration or generated-expression change**. +Kind 44620 is a signed, durable, empty-content hint stored/replayed as an ordinary +event. Index membership is not permission to read it. We do not rewrite event +history, add projection-maintenance triggers, or create another event store. +Existing storage exclusions for unrelated private kinds are unchanged. -Migration 0044 retains **stored generated** search vectors. It inspects the -parent and every existing partition under a tree-wide lock. Exact PostgreSQL -catalog expression comparisons recognize the fresh positive allowlist, its -0014/0033-wrapped form, and the desired-schema policy including 44620. These -installations perform **no heap or index rewrite**. This is a conservative -recognizer, not an arbitrary SQL equivalence checker. +`buzz-search/src/query.rs` returns community-scoped candidate IDs and ranking +metadata, not snippets or aggregate totals. Its only production consumers are +WS NIP-50 REQ (`handlers/req.rs`) and HTTP `/query` (`api/bridge.rs`). Both hydrate +through scoped event reads, match the original NIP-01 filter (including IDs and +tags), and call `event_visible_to_reader` before delivery. Wakes require canonical +shape, the reader's recipient tag, and current channel membership from the DB; +open-channel readability alone is insufficient. Known-ID/kindless filters do not +bypass result authorization. COUNT uses the same per-event gate for wakes even +when `#p` is pinned to self; it does not expose FTS candidate counts. COUNT is not +a separate FTS search-total endpoint. Client snippets are derived from returned +authorized events, not raw indexed content. -Other uniform generated policies are wrapped with `CASE WHEN kind = 44620 THEN -NULL::tsvector ELSE existing_expression END` using PostgreSQL 17's `SET -EXPRESSION`. This preserves other kinds' search policy and the column, -dependent view and index definitions/options; it **does rewrite heaps and -indexes**. Index OIDs/physical files can change. This is a maintenance operation, -not an online migration. Unknown custom expressions are not assumed safe. -Divergent parent/partition expressions and non-generated columns fail before -mutation: operators must reconcile that drift before upgrading, not silently -lose a leaf's custom policy. +Normal CLI/mobile message search selects kinds 9, 40002, 45001, 45003; profile +search uses kind 0. The ordered fresh-install search allowlist already excludes +wakes. The desired-state/brownfield broader policy may index them. NOT-only +queries can match an empty vector, but that is still just a candidate: current +recipient/membership checks decide visibility. Malformed nonempty wakes are +rejected by the same read gate. A currently authorized recipient may receive a +canonical wake through an explicit search, just as through ordinary replay. +There is no requirement that it have physical SQL NULL in the index. -A tested alternative, `DROP EXPRESSION` plus an ALWAYS write trigger, can retain -existing heap/index files and repair only historical wake vectors. We have not -chosen it: it adds permanent bootstrap, restoration and replication obligations, -and row-level repair fails for deletion-fenced historical wakes. There is no -assumption that pre-existing kind-44620 rows cannot exist. Generated-expression -recomputation repairs their unsigned projection without modifying signed event -fields, executing row UPDATE hooks, or granting a deletion-fence bypass. The -fence remains effective for ordinary writes. +Search pagination is bounded and post-filtering can underfill a page. These +paths do not promise constant-time execution or elimination of every statistical +pagination/timing side channel. Direct database operators are already trusted +with the underlying signed events; raw SQL access is not a tenant API. -## Before upgrade +## Migrations assessed separately -- Inventory the generated expressions for `events.search_tsv` across - `pg_partition_tree('events')`; record heap/index/TOAST sizes, free disk, - replica lag and WAL retention. Do not infer the policy from an empty-content - probe or a substring match. The same PostgreSQL-normalized whole-expression - comparison used in the migration is authoritative for its skip path. -- Confirm PostgreSQL 17 and the repository's normal schema/destruction lock - discipline. Migration startup must not race tenant destruction. -- For an unrecognized policy, size and schedule a maintenance window. Budget - replacement heap/index storage plus WAL/replica headroom. No production - duration or throughput estimate is claimed by the small disposable tests. -- Lock acquisition is limited to five seconds; a busy table makes the migration - fail transactionally for a later controlled retry. Once acquired, an unsafe - policy holds ACCESS EXCLUSIVE for its rewrite. Normal relay startup explicitly - sets `statement_timeout = 0` on its migration connection, so it imposes no - rewrite-duration bound. A manually controlled SQL session may set its own - statement timeout, with transactional rollback on expiration. Even the safe skip path briefly blocks - readers/writers while inspecting the tree; it is not lock-free. +- **0043 workflow revision binding: retained, validation changed.** Nullable + `definition_event_id` on workflows and runs is necessary to bind new runs to + their exact signed definition, not a guessed current/historical revision. + Adding the columns without defaults is metadata-only. Length-32 CHECKs are + added `NOT VALID` to avoid historical validation scans under ACCESS EXCLUSIVE. + PostgreSQL still checks every subsequent INSERT/UPDATE. All pre-existing rows + read NULL because the columns are new. The catalog deliberately remains + unvalidated: **no deferred validation job or backfill is planned**. Fresh + desired-state schema creates validated checks on empty tables. + The existing semantic-column BEFORE UPDATE trigger is retained to clear + revision provenance for mixed older writers, even equal-value updates; new + signed-event writers rebind within their locked transaction. This is an + authority guard, not FTS projection maintenance. +- **Former 0044 wake FTS: removed.** No whole-events-tree inspection, lock, + generated-expression rewrite, or history/index rebuild for search policy. + Unknown custom FTS expressions need no wake-specific normalization. +- **Former 0045 superseded authority: retained as 0044.** A boolean with + `NOT NULL DEFAULT false` uses PostgreSQL's fast-default metadata addition. + `deleted_at` alone cannot distinguish replacement from explicit revocation. + Positive replacement marks the bit; explicit deletion clears it, including + on previously superseded rows. `get_workflow_revision` accepts live or + positively superseded definitions, never unknown historical deletions. + No historical provenance inference is introduced. This small metadata change + still acquires ACCESS EXCLUSIVE locks on the events parent/partitions, but + does not rewrite their heaps or indexes. -Fresh desired-state bootstrap already includes 44620 in its generated policy; -no new reconciliation trigger or seed DML is needed. Future partitions inherit -that policy. Ordered migration bootstrap keeps the fresh positive allowlist. -These paths intentionally preserve their pre-existing search differences for -other kinds. Direct vector assignments remain rejected by PostgreSQL. +The PRs were open and unmerged at reassessment, with main ending at 0042. These +migration files have not been shipped by this stack. Disposable databases that +ran the superseded draft migration sequence must be recreated, not silently +reused with different SQLx checksums. If an operator has applied a draft outside +that recorded state, stop and reconcile its migration ledger explicitly before +upgrading. There are no down migrations. -## Evidence and limits +## Operational cost and rollout -`postgres_workflow_wake_fts` exercises safe-policy heap/index relfilenode -preservation, unsafe-policy correction with custom indexes and a dependent view, -raw NULL/NOT-only semantics, future partitions and direct leaf writes, -kind/content changes, rejected direct vector assignment, and divergent-policy -rollback. `postgres_fts_integration` also covers every persistent p-gated kind -under both fresh and legacy policies, with empty and nonempty payloads. +Runtime migration remains opt-in via `BUZZ_AUTO_MIGRATE`. The runner sets +`lock_timeout = 0` and `statement_timeout = 0` before taking its session-scoped +schema/destruction advisory lock; SQLx applies each migration file transactionally. +Neither retained migration supplies its own timeout. Thus both advisory and +relation-lock waits are unbounded in normal auto-migrate startup. Metadata-only +is **not lock-free or guaranteed low latency**: a long-running transaction can +block DDL, whose queued lock can delay subsequent reads/writes. Operators should +schedule controlled schema application with this locking behavior in mind; +manual sessions can set explicit timeouts, and a failed file rolls back for retry. +No production duration estimate or live database operation is claimed. -A disposable current desired-schema test additionally established correction of -an existing wake in a genuinely deletion-fenced community, with executor bypass -settings cleared before migration and ordinary UPDATE still rejected afterward. -These are correctness checks, not a production benchmark or deployment approval. -No live database modification is part of this PR's validation. +PostgreSQL 17 remains the documented VISION/architecture/docker-compose contract, +so CI still uses 17; this feature no longer needs `SET EXPRESSION` support. +Deploy schema before new binaries. Mixed older binaries may clear provenance or +fail closed on replacements they cannot positively identify, not invent legacy +revision authority. Signed wake persistence, captured-definition validation, +endpoint authorization, ACP verification/admission and identity/retry lifecycles +are unchanged by this database redesign. + +## Regression evidence + +- `postgres_workflow_revision_binding`: populated legacy tables retain heap + files; checks stay unvalidated yet reject invalid new/updated IDs; legacy NULL, + operational-update preservation and equal-value old-writer invalidation. +- `workflow_search_postgres_tests`: ordinary sink/replay, real indexed empty and + malformed wakes, shared FTS candidates, actual HTTP/WS search responses, + positive/NOT-only queries, explicit normal kinds, known-ID bypass attempts, + recipient denial and revoked membership while a public control still returns. +- Existing unrelated FTS privacy tests and deletion-aware wake/count/approval + tests remain in place. Previous ACP/sink/combined execution evidence applies + to unchanged authority code, not as a claim of testing the new migration DDL. diff --git a/migrations/0044_workflow_mention_wake_fts.sql b/migrations/0044_workflow_mention_wake_fts.sql deleted file mode 100644 index 6c50cd3fef3..00000000000 --- a/migrations/0044_workflow_mention_wake_fts.sql +++ /dev/null @@ -1,78 +0,0 @@ --- Kind:44620 must have a raw NULL search vector, including malformed payloads. --- Keep generated storage: no trigger/replication/restore maintenance contract and --- no row UPDATE that could cross a community deletion fence. PG17 SET EXPRESSION --- preserves the column and its dependent indexes, but STILL rewrites heaps and --- indexes. Only installations needing correction pay that cost. See --- docs/workflow-wake-fts-rollout.md before upgrading a populated legacy database. -DO $$ -DECLARE - existing_expression TEXT; - partition_expression TEXT; - safe_expressions TEXT[]; - relation RECORD; - previous_lock_timeout TEXT := current_setting('lock_timeout'); -BEGIN - -- Bound lock acquisition, not the rewrite duration. Hold the entire tree - -- stable while inspecting it, including against partition attach/detach. - PERFORM set_config('lock_timeout', '5s', true); - LOCK TABLE events IN ACCESS EXCLUSIVE MODE; - - -- Ask PostgreSQL to canonicalize the known safe policies. Comparing whole - -- expressions is deliberate: a substring or an empty-content probe cannot - -- prove NULL for every possible private payload. Unknown policies are not - -- guessed safe. The temporary relation contains no event data. - CREATE TEMP TABLE workflow_wake_safe_fts ( - kind INT, - content TEXT, - allowlist TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (0, 9, 40002, 45001, 45003) - THEN to_tsvector('simple', content) ELSE NULL::tsvector END - ) STORED, - migrated_allowlist TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind = 30179 THEN NULL::tsvector ELSE ( - CASE WHEN kind = 30350 THEN NULL::tsvector ELSE ( - CASE WHEN kind IN (0, 9, 40002, 45001, 45003) - THEN to_tsvector('simple', content) ELSE NULL::tsvector END - ) END - ) END - ) STORED, - desired_policy TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200, 44620) - THEN NULL::tsvector ELSE to_tsvector('simple', content) END - ) STORED - ) ON COMMIT DROP; - SELECT array_agg(pg_get_expr(adbin, adrelid)) INTO safe_expressions - FROM pg_attrdef WHERE adrelid = 'pg_temp.workflow_wake_safe_fts'::regclass; - - SELECT pg_get_expr(d.adbin, d.adrelid) INTO existing_expression - FROM pg_attribute a JOIN pg_attrdef d - ON d.adrelid = a.attrelid AND d.adnum = a.attnum - WHERE a.attrelid = 'events'::regclass AND a.attname = 'search_tsv' - AND a.attgenerated = 's'; - IF existing_expression IS NULL THEN - RAISE EXCEPTION 'events.search_tsv must be a stored generated column'; - END IF; - - FOR relation IN SELECT relid FROM pg_partition_tree('events'::regclass) LOOP - SELECT pg_get_expr(d.adbin, d.adrelid) INTO partition_expression - FROM pg_attribute a JOIN pg_attrdef d - ON d.adrelid = a.attrelid AND d.adnum = a.attnum - WHERE a.attrelid = relation.relid AND a.attname = 'search_tsv' - AND a.attgenerated = 's'; - IF partition_expression IS DISTINCT FROM existing_expression THEN - RAISE EXCEPTION 'divergent search_tsv policy on %; reconcile partition policy before upgrading', relation.relid::regclass; - END IF; - END LOOP; - - IF NOT (existing_expression = ANY(safe_expressions)) THEN - -- Preserve every non-wake kind's existing policy, signed event fields, - -- column identity, privileges and dependent objects. DDL recomputation - -- does not replay row UPDATE triggers or bypass their deletion fences. - EXECUTE format( - 'ALTER TABLE events ALTER COLUMN search_tsv SET EXPRESSION AS (CASE WHEN kind = 44620 THEN NULL::tsvector ELSE (%s) END)', - existing_expression - ); - END IF; - DROP TABLE pg_temp.workflow_wake_safe_fts; - PERFORM set_config('lock_timeout', previous_lock_timeout, true); -END $$; diff --git a/migrations/0045_workflow_superseded_authority.sql b/migrations/0044_workflow_superseded_authority.sql similarity index 100% rename from migrations/0045_workflow_superseded_authority.sql rename to migrations/0044_workflow_superseded_authority.sql diff --git a/schema/schema.sql b/schema/schema.sql index 062fedd62dd..e8b6285d142 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -221,7 +221,7 @@ CREATE TABLE events ( -- never matches `@@`. -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033 + 0036). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200, 44620) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED,