Files
buzz/crates/buzz-workflow/src/lib.rs
Will Pfleger fc8c802253 feat(workflow): add owner-only agent mentions for workflow messages (#9227)
Adds owner-only agent mentions, a relay setting,
`BUZZ_WORKFLOW_AGENT_MENTIONS`. Its values are `any`, the default and
today's behaviour, and `owner`. Any other value fails startup. With
`owner`, a workflow message can direct an agent only if the run's cause
and definition allow it. The setting is relay-wide.

**Recording what caused each run.** `workflow_runs` gets `cause_source`
and `cause_pubkey` columns. Migration `0087` adds them with named checks
marked `NOT VALID`, so it does not scan existing rows under its lock;
`0088` validates the checks separately. `create_workflow_run` now takes
a required `RunCause`, so every place that creates a run has to set one:
- **Event trigger:** the cause is the signer of the message, reaction or
diff. For a reaction, that is the reactor, not the author of the message
being reacted to.
- **Cron and interval schedules:** the cause is the timer.
- **Webhook:** the caller is unknown.
- **Manual trigger:** the cause is the invoking pubkey.

Rows written before this change have no cause. A stored source without
the pubkey it requires, or with one it forbids, is read as no cause.

**Deciding eligibility.** A run's messages may direct agents only when
all of these hold:
- the cause is a schedule, or an event or manual trigger whose pubkey is
the workflow owner's;
- every step of the definition that is running is `send_message` or
`delay`. This is an allowlist, so any other action, including one added
later, makes the definition ineligible, even when an `if:` skips that
step;
- the run did not resume after an approval.

Webhook runs and runs with no recorded cause are never eligible. The
executor decides this from `WorkflowConfig::agent_mentions`, and passes
`ActionSink::send_message` the owner's stored step text with a
`may_direct_agents` flag. The sink adds `buzz:workflow-mention` tags
only when the flag is set, and returns `SentMessage { event_id,
agent_mentions_withheld }`, which is true only when it left out a tag
that a member named in both the stored and rendered text would otherwise
have received. Webhook and manual runs start through `execute_run`, so
`execute_from_step` is used only to resume after an approval.

**The gate.** With `owner`, the relay adds `buzz:workflow-mention` only
to messages from eligible runs. Messages from other runs still post with
their plain `p` tags and still notify people. With `any`, mention tags
are added exactly as before.

**Making it visible.** When a `send_message` step leaves the tags out:
- its output in the run trace gets `"agent_mentions": {"withheld": true,
"reasons": [...]}`, listing each reason that applies: `cause` (not
started by the owner or a timer), `steps` (a step other than
`send_message` or `delay`), `resumed` (resumed after an approval). The
key is absent when nothing was withheld. The trace is what the app's run
history shows;
- the relay logs it at info level under the
`buzz_relay::workflow_agent_mentions` target (visible under the default
filter) with the community, workflow, run, step and reasons;
- the counter `buzz_workflow_agent_mentions_withheld_total` increases
once per reason, labelled `reason`.

**Discovery.** The NIP-11 document always carries
`"workflow_agent_mentions": "any" | "owner"`, so a missing field means
an older relay.

`NOSTR.md` now specifies `buzz:workflow-mention` for clients: what the
tag means, the checks an agent makes before acting, that a relay may
leave it out under a policy, and the NIP-11 `workflow_agent_mentions`
field. The relay policy itself is in the new operator guide,
`docs/operator-workflow-agent-mentions.md`: the setting and its values,
what counts as a run's cause for each trigger, which steps are allowed,
the trace key, the log and metric, the NIP-11 field, when to use
`owner`, rollout across pods, and what it doesn't cover. The
`TESTING.md` and `.env.example` entries link to it.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-11-12 11:34:30 -08:00

2793 lines
109 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![deny(unsafe_code)]
#![warn(missing_docs)]
//! `buzz-workflow` — Workflow engine for Buzz.
//!
//! Channel-scoped automations with sequential execution, variable substitution,
//! conditional logic, and execution traces.
//!
//! ## Architecture
//!
//! - [`WorkflowEngine`] — top-level handle; lives in `AppState`
//! - [`schema`] — YAML/JSON definition types (`WorkflowDef`, `TriggerDef`, `ActionDef`, `Step`)
//! - [`executor`] — sequential execution, template resolution, condition evaluation
//! - [`error`] — [`WorkflowError`] enum
//!
//! ## Usage
//!
//! ```rust,ignore
//! let engine = Arc::new(WorkflowEngine::new(db, WorkflowConfig::default()));
//!
//! // Parse and validate a YAML definition.
//! let (def, json) = WorkflowEngine::parse_yaml(yaml_str)?;
//!
//! // React to an incoming event (called from event handler post-store hook).
//! // The community is the event's server-resolved tenant, threaded from the
//! // relay's bound `TenantContext` — the same workflow UUID can exist in two
//! // communities, so execution is always scoped to its owner.
//! engine.on_event(community_id, &stored_event).await?;
//!
//! // Run the background scheduler (cron triggers).
//! tokio::spawn(async move { engine.run().await });
//! ```
pub mod action_sink;
pub mod error;
pub mod executor;
pub mod schema;
pub use action_sink::{ActionSink, ActionSinkError, SentMessage};
pub use error::{PartialProgress, WorkflowError};
pub use executor::ExecutionResult;
pub use schema::{ActionDef, Step, TriggerDef, WorkflowDef};
/// Tracing target for withheld agent mentions. It sits under `buzz_relay` so
/// the relay's default log filter shows it without enabling other workflow logs.
pub const AGENT_MENTIONS_LOG_TARGET: &str = "buzz_relay::workflow_agent_mentions";
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::tenant::CommunityId;
use buzz_db::workflow::RunStatus;
use buzz_db::Db;
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use tokio::sync::Semaphore;
use uuid::Uuid;
/// Runtime configuration for the workflow engine.
#[derive(Clone, Debug)]
pub struct WorkflowConfig {
/// Maximum number of concurrently executing workflow runs. Default: 100.
pub max_concurrent: usize,
/// Default per-step timeout in seconds. Default: 300 (5 minutes).
pub default_timeout_secs: u64,
/// Which workflow runs may direct agents. Default: [`AgentMentions::Any`].
pub agent_mentions: AgentMentions,
}
/// Which workflow runs' messages carry agent-directing `buzz:workflow-mention`
/// tags. Set relay-wide by `BUZZ_WORKFLOW_AGENT_MENTIONS`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentMentions {
/// Every run may direct the agents its owner's step text names.
#[default]
Any,
/// Owner-only agent mentions: only runs the owner or a timer started, on a
/// definition of only `send_message` and `delay` steps, may direct agents.
Owner,
}
impl std::str::FromStr for AgentMentions {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"any" => Ok(Self::Any),
"owner" => Ok(Self::Owner),
other => Err(format!("expected `any` or `owner`, got `{other}`")),
}
}
}
impl Default for WorkflowConfig {
fn default() -> Self {
Self {
max_concurrent: 100,
default_timeout_secs: 300,
agent_mentions: AgentMentions::Any,
}
}
}
/// The workflow engine. Clone is cheap (Arc-backed DB pool + semaphore).
pub struct WorkflowEngine {
pub(crate) db: Db,
pub(crate) config: WorkflowConfig,
/// Semaphore enforcing `config.max_concurrent` simultaneous workflow runs.
pub(crate) run_semaphore: Arc<Semaphore>,
/// Last-fired timestamps for interval-triggered workflows, keyed by
/// `(community_id, workflow_id)`. The same workflow UUID can exist in two
/// communities (the PK is `(community_id, id)`); keying by bare id would let
/// one community's interval fire suppress the other's for the interval.
/// In-memory only — lost on restart. Missed fires during downtime are
/// not replayed (acceptable for MVP).
pub(crate) last_fired: DashMap<(CommunityId, Uuid), DateTime<Utc>>,
/// Action sink for executing side-effects (SendMessage, etc.).
/// Late-initialized via [`set_action_sink`] after `AppState` construction.
pub(crate) action_sink: OnceLock<Arc<dyn ActionSink>>,
/// Short-TTL cache for the per-event enabled-workflow lookup, keyed
/// `(community_id, channel_id)`. Most channels have no workflows, so this
/// removes one SELECT from nearly every ingested event.
///
/// Consistency: the relay invalidates this cache on its own pod at the two
/// workflow mutation sites (command upsert, NIP-09 deletion). There is
/// deliberately no cross-pod invalidation — workflow triggering is not an
/// access-control fence, so the worst case on another pod is a just-deleted
/// workflow firing (or a just-created one missing events) for up to the TTL.
/// The same TTL also bounds the same-pod look-aside race (a stale fill
/// landing just after an invalidation). Workflow mutations are rare; the
/// 10s window matches the relay's other moka caches (see `AppState` in
/// `buzz-relay`).
pub(crate) workflow_cache:
moka::sync::Cache<(CommunityId, Uuid), Arc<Vec<buzz_db::workflow::WorkflowRecord>>>,
/// Workflows whose ban denial was logged at WARN within the last
/// [`BAN_DENIAL_WARN_INTERVAL`], keyed `(community_id, workflow_id)`. A
/// banned owner's workflow is denied on every matching event and every
/// cron tick; this keeps that expected denial from flooding the log.
pub(crate) ban_denial_warned: moka::sync::Cache<(CommunityId, Uuid), ()>,
}
/// How often a banned owner's workflow denial is logged at WARN. Denials in
/// between log at DEBUG. Other authority failures always log at WARN.
const BAN_DENIAL_WARN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(600);
impl WorkflowEngine {
/// Create a new `WorkflowEngine`.
pub fn new(db: Db, config: WorkflowConfig) -> Self {
let permits = config.max_concurrent.max(1);
let run_semaphore = Arc::new(Semaphore::new(permits));
Self {
db,
config,
run_semaphore,
last_fired: DashMap::new(),
action_sink: OnceLock::new(),
workflow_cache: moka::sync::Cache::builder()
.max_capacity(10_000)
.time_to_live(std::time::Duration::from_secs(10))
.build(),
ban_denial_warned: ban_denial_warn_cache(BAN_DENIAL_WARN_INTERVAL),
}
}
/// Drop the cached enabled-workflow list for a channel.
///
/// Must be called after any write to a workflow's trigger eligibility or
/// channel binding (currently the relay's command upsert and NIP-09
/// deletion paths) so same-pod trigger matching sees the change
/// immediately instead of after the cache TTL.
pub fn invalidate_channel_workflows(&self, community_id: CommunityId, channel_id: Uuid) {
self.workflow_cache.invalidate(&(community_id, channel_id));
}
/// Fail-closed pre-run authority gate (SEC-006).
///
/// A workflow executes with its **owner's** standing authority long after
/// the definition was saved, so every run-creation door must recheck the
/// owner's *current* channel authority immediately before creating a run:
///
/// - the owner must still be an active member of the workflow's channel,
/// in a role that can write (a guest cannot);
/// - if the definition contains an exfiltration-capable action
/// (`call_webhook`), the owner must currently hold the `owner` or
/// `admin` role;
/// - the owner, or the human owner of an agent owner, must not be banned
/// from the community. Bans leave channel membership in place, so the
/// role check alone would keep a banned owner's workflows running.
///
/// Any lookup error denies (fail-closed): a removed owner must never keep
/// exfiltration authority because a membership read happened to fail.
/// This is a point-in-time check: it does not cancel runs that already
/// exist, and a ban that commits after it can race with run creation.
pub async fn check_owner_authority(
&self,
community_id: CommunityId,
channel_id: Uuid,
owner_pubkey: &[u8],
def: &WorkflowDef,
) -> Result<(), WorkflowError> {
let role = self
.db
.get_member_role(community_id, channel_id, owner_pubkey)
.await
.map_err(|e| {
WorkflowError::Unauthorized(format!(
"owner authority lookup failed (fail-closed): {e}"
))
})?;
if !owner_authority_allows(role.as_deref(), def.requires_elevated_authority()) {
return Err(WorkflowError::Unauthorized(
"workflow owner lacks current channel authority".into(),
));
}
let restrictions = self
.db
.moderation_restriction_state(community_id, owner_pubkey)
.await
.map_err(|e| {
WorkflowError::Unauthorized(format!(
"owner restriction lookup failed (fail-closed): {e}"
))
})?;
if restrictions.banned {
return Err(WorkflowError::OwnerBanned);
}
Ok(())
}
/// Log a [`check_owner_authority`](Self::check_owner_authority) denial
/// that skipped a run. A ban denial logs at WARN at most once per
/// workflow per [`BAN_DENIAL_WARN_INTERVAL`], and at DEBUG otherwise;
/// every other denial, including a failed lookup, logs at WARN.
fn log_owner_denial(
&self,
community_id: CommunityId,
workflow_id: Uuid,
trigger: &'static str,
err: &WorkflowError,
) {
if should_warn_owner_denial(&self.ban_denial_warned, community_id, workflow_id, err) {
tracing::warn!(
workflow_id = %workflow_id,
trigger,
"Skipping workflow — owner authority check failed: {err}"
);
} else {
tracing::debug!(
workflow_id = %workflow_id,
trigger,
"Skipping workflow — owner authority check failed: {err}"
);
}
}
/// Set the action sink. Called once after `AppState` construction.
///
/// # Panics
/// Panics if called more than once.
pub fn set_action_sink(&self, sink: Arc<dyn ActionSink>) {
if self.action_sink.set(sink).is_err() {
panic!("action_sink already initialized");
}
}
/// Get the action sink reference.
///
/// Returns `Err(WorkflowError)` if the sink has not been initialized via
/// [`set_action_sink`]. This avoids a panic if the engine is used before
/// wiring is complete.
pub(crate) fn action_sink(&self) -> Result<&dyn ActionSink, WorkflowError> {
self.action_sink.get().map(|s| s.as_ref()).ok_or_else(|| {
WorkflowError::InvalidDefinition(
"action_sink not initialized — call set_action_sink() before executing workflows"
.into(),
)
})
}
/// Parse and validate a YAML workflow definition.
///
/// Returns `(WorkflowDef, canonical_json)` on success. The canonical JSON
/// is suitable for storage in the `definition` column.
pub fn parse_yaml(yaml: &str) -> Result<(WorkflowDef, String), WorkflowError> {
schema::parse_yaml(yaml)
}
/// Finalize a workflow run after execution completes or fails.
///
/// This is the **single** place that maps an executor result to a DB status
/// update. All execution paths (event-triggered, manual trigger/webhook,
/// approval resume) call this instead of duplicating the 3-way match.
///
/// `existing_trace` is prepended to the executor's trace — used by the
/// approval-resume path where pre-approval steps already have trace entries.
pub async fn finalize_run(
&self,
community_id: CommunityId,
run_id: uuid::Uuid,
result: Result<ExecutionResult, (WorkflowError, PartialProgress)>,
existing_trace: Option<Vec<serde_json::Value>>,
) {
let prefix = existing_trace.unwrap_or_default();
match result {
Ok(result) => {
let mut full_trace = prefix;
full_trace.extend(result.trace);
let trace_json = serde_json::Value::Array(full_trace);
let step_count = result.step_index as i32;
if result.approval_token.is_some() {
// Approval gates are not yet implemented (WF-08).
// Fail explicitly rather than creating unreachable WaitingApproval rows.
tracing::warn!(
run_id = %run_id,
step_index = result.step_index,
"Workflow hit approval gate — not yet implemented, marking as failed"
);
if let Err(e) = self
.db
.update_workflow_run(
community_id,
run_id,
RunStatus::Failed,
step_count,
&trace_json,
Some(buzz_db::workflow::WorkflowRunFailure {
code: "approval_not_supported",
message: "approval gates not yet implemented — see WF-08",
}),
)
.await
{
tracing::error!(
run_id = %run_id,
"Failed to update run to Failed (approval gate): {e}"
);
}
} else {
tracing::info!(run_id = %run_id, "Workflow run completed");
if let Err(e) = self
.db
.update_workflow_run(
community_id,
run_id,
RunStatus::Completed,
step_count,
&trace_json,
None,
)
.await
{
tracing::error!(
run_id = %run_id,
"Failed to update run to Completed: {e}"
);
}
}
}
Err((e, progress)) => {
tracing::error!(run_id = %run_id, error = %e.log_text(), "Workflow run failed");
let mut full_trace = prefix;
full_trace.extend(progress.trace);
let trace_json = serde_json::Value::Array(full_trace);
if let Err(db_err) = self
.db
.update_workflow_run(
community_id,
run_id,
RunStatus::Failed,
progress.step_index as i32,
&trace_json,
Some(buzz_db::workflow::WorkflowRunFailure {
code: e.code(),
message: &e.to_string(),
}),
)
.await
{
tracing::error!(
run_id = %run_id,
"Failed to update run to Failed: {db_err}"
);
}
}
}
}
/// Called from the event handler post-store hook for every stored event.
///
/// Checks whether any workflow in the event's channel has a matching trigger.
/// Workflow execution events (kinds 46001–46012) are excluded to prevent loops.
///
/// `community_id` is the server-resolved community the event was stored
/// under — `StoredEvent` does not carry it, and the same channel UUID can
/// exist in two communities, so the workflow lookup/run-creation must be
/// scoped to the caller's tenant or community B could trigger community A's
/// workflow on a colliding channel id.
///
/// The method takes `self: &Arc<Self>` so that the spawned task can hold a
/// clone of the `Arc` without requiring `'static` on `&self`.
pub async fn on_event(
self: &Arc<Self>,
community_id: CommunityId,
event: &buzz_core::StoredEvent,
) -> Result<(), WorkflowError> {
let Some(channel_id) = event.channel_id else {
tracing::debug!(
event_id = %event.event.id.to_hex(),
kind = event_kind_u32(&event.event),
"Skipping workflow trigger — event has no channel_id"
);
return Ok(());
};
let kind_u32 = event_kind_u32(&event.event);
// Exclude workflow execution events to prevent infinite loops.
if is_workflow_execution_kind(kind_u32) {
return Ok(());
}
let cache_key = (community_id, channel_id);
let workflows = match self.workflow_cache.get(&cache_key) {
Some(cached) => cached,
None => {
let fresh = Arc::new(
self.db
.list_enabled_channel_workflows(community_id, channel_id)
.await
.map_err(WorkflowError::from)?,
);
self.workflow_cache.insert(cache_key, Arc::clone(&fresh));
fresh
}
};
if workflows.is_empty() {
return Ok(());
}
let trigger_ctx = build_trigger_context(event);
let trigger_ctx_json: serde_json::Value = match serde_json::to_value(&trigger_ctx) {
Ok(v) => v,
Err(e) => {
tracing::error!("Failed to serialize trigger context: {e}");
return Ok(());
}
};
for workflow in workflows.iter() {
let def: WorkflowDef = match serde_json::from_value(workflow.definition.clone()) {
Ok(d) => d,
Err(e) => {
tracing::warn!(workflow_id = %workflow.id, "Failed to parse definition: {e}");
continue;
}
};
if !def.enabled || !trigger_matches_event(&def.trigger, kind_u32) {
continue;
}
if !should_fire_workflow(&def, &trigger_ctx, workflow.id).await {
continue;
}
// SEC-006: recheck the owner's *current* channel authority
// immediately before run creation. The cached workflow list can be
// up to 10s stale, and disable-on-removal can race a concurrent
// event — this per-fire gate is the authoritative, fail-closed
// check that a removed (or under-privileged, for exfiltration
// definitions) owner cannot cause a run.
if let Err(e) = self
.check_owner_authority(community_id, channel_id, &workflow.owner_pubkey, &def)
.await
{
self.log_owner_denial(community_id, workflow.id, "event", &e);
continue;
}
let trigger_event_id_bytes = event.event.id.as_bytes().to_vec();
let run_id = match self
.db
.create_workflow_run(
community_id,
workflow.id,
Some(&trigger_event_id_bytes),
Some(&trigger_ctx_json),
// The signer of the triggering event: for a reaction, the
// reactor, never the author of the reacted-to message.
&buzz_db::workflow::RunCause::Event(event.event.pubkey.to_bytes().to_vec()),
)
.await
{
Ok(id) => id,
Err(e) => {
tracing::error!(workflow_id = %workflow.id, "Failed to create run: {e}");
continue;
}
};
tracing::debug!(
workflow_id = %workflow.id,
run_id = %run_id,
"Workflow triggered — spawning execution"
);
let engine = Arc::clone(self);
let def_clone = def.clone();
let ctx_clone = trigger_ctx.clone();
tokio::spawn(async move {
let result =
executor::execute_run(&engine, community_id, run_id, &def_clone, &ctx_clone)
.await;
engine
.finalize_run(community_id, run_id, result, None)
.await;
});
}
Ok(())
}
/// Interval prefilter: decide whether the interval workflow should fire this
/// tick, applying the cold-start anchor seed as a side effect.
///
/// `last` is the resolved anchor (in-memory entry if present, else the
/// durable `latest_scheduled_workflow_fire` read). Returns `true` to proceed
/// to the durable claim, `false` to suppress this tick.
///
/// Cold-start liveness: a brand-new interval workflow has no in-memory entry
/// AND no prior claim, so `last` is `None`. `interval_should_fire` then reads
/// `last = now` and suppresses — correct for the first tick (wait a full
/// interval), but the in-memory anchor is only written *after* a successful
/// claim, and no claim is attempted until the prefilter passes. Without
/// seeding, every subsequent tick repeats with `last = None` and the workflow
/// suppresses forever. So on the `None` suppress path we seed `now`: the next
/// tick counts from a real anchor and the workflow fires after one interval.
/// We seed ONLY when `last` was `None`; when `last` is `Some` we are correctly
/// mid-interval and must not advance the anchor, or it would never elapse.
fn interval_prefilter_should_fire(
&self,
community_id: CommunityId,
workflow_id: Uuid,
dur: &str,
last: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> bool {
interval_prefilter_should_fire(&self.last_fired, community_id, workflow_id, dur, last, now)
}
/// Background loop for scheduled (cron/interval) triggers.
///
/// Ticks every 60 seconds. For each active workflow with a `Schedule`
/// trigger, checks whether the cron expression or interval has elapsed
/// and spawns execution if so.
///
/// Uses window-based matching for cron expressions to handle tick drift:
/// `schedule.after(&(now - 60s)).next() <= now` instead of `includes(now)`.
///
/// Interval tracking is anchored on the durable scheduled-fire claim:
/// `last_fired` is an in-memory pre-filter, but the
/// `(community_id, workflow_id, scheduled_for)` claim row is the
/// at-most-once boundary across pods and restarts. On the first tick after
/// a restart the interval anchor is seeded from
/// `latest_scheduled_workflow_fire` so a process bounce cannot double-fire
/// within an interval.
pub async fn run(self: &Arc<Self>) {
tracing::info!("WorkflowEngine cron loop started (60s tick)");
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
self.cron_tick(Utc::now()).await;
}
}
/// One pass of the cron loop at `now`: fire every enabled schedule
/// workflow that is due, then prune the interval clock.
pub(crate) async fn cron_tick(self: &Arc<Self>, now: DateTime<Utc>) {
let workflows = match self.db.list_all_enabled_workflows().await {
Ok(wf) => wf,
Err(e) => {
tracing::error!("Cron tick: failed to load workflows: {e}");
return;
}
};
for workflow in &workflows {
// The same workflow UUID may exist in another community; carry
// the row's owning community through fire-tracking, run creation,
// and execution so a fire/run never crosses tenants.
let community_id = workflow.community_id;
let def: schema::WorkflowDef = match serde_json::from_value(workflow.definition.clone())
{
Ok(d) => d,
Err(e) => {
tracing::warn!(
workflow_id = %workflow.id,
"Cron tick: failed to parse workflow definition: {e}"
);
continue;
}
};
if !def.enabled {
continue;
}
// Fix 2: skip workflows with no channel_id — an empty channel_id
// causes silent downstream failures when the run tries to act on a channel.
let Some(channel_id) = workflow.channel_id else {
tracing::warn!(
workflow_id = %workflow.id,
"Cron tick: skipping schedule workflow with no channel_id"
);
continue;
};
// Resolve the *deterministic* schedule instant this tick is
// firing for. `scheduled_for` is computed identically on every
// pod (cron's own scheduled time, or the interval bucket
// boundary) so all pods collide on a single durable claim —
// never `now`, which is per-pod and would let every pod fire.
let (scheduled_for, trigger_type) = match &def.trigger {
schema::TriggerDef::Schedule {
cron: Some(expr),
interval: None,
} => match cron_fire_instant(expr, now, 60, workflow.id) {
Some(instant) => (instant, "cron"),
None => continue,
},
schema::TriggerDef::Schedule {
cron: None,
interval: Some(dur),
} => {
// Cheap pre-filter: skip the claim attempt when the
// in-memory clock says we're clearly mid-interval. The
// durable claim below is the real at-most-once boundary;
// this only avoids a DB write every tick. Seed the
// anchor from the DB on the first tick after restart so
// a process bounce can't double-fire within an interval.
let last = match self.last_fired.get(&(community_id, workflow.id)) {
Some(t) => Some(*t),
None => match self
.db
.latest_scheduled_workflow_fire(community_id, workflow.id)
.await
{
Ok(anchor) => anchor,
Err(e) => {
// Fail closed: a missing anchor reads as
// last_fired = now in interval_should_fire,
// so this tick is suppressed and the next
// tick retries. Surface the read failure so
// a persistently-unreadable anchor is visible
// rather than silently stalling the schedule.
tracing::warn!(
community_id = %community_id,
workflow_id = %workflow.id,
"Cron tick: failed to read interval restart anchor, \
suppressing this tick: {e}"
);
None
}
},
};
if !self.interval_prefilter_should_fire(
community_id,
workflow.id,
dur,
last,
now,
) {
continue;
}
match interval_fire_instant(dur, now, workflow.id) {
Some(instant) => (instant, "interval"),
None => continue,
}
}
_ => continue, // Non-schedule triggers handled by on_event()
};
// SEC-006: recheck the owner's current channel authority
// BEFORE the durable claim. Placing the gate after the claim
// would let a revoked owner's workflow consume the
// at-most-once fire slot (claims are never re-fired), turning
// revocation into a denial-of-fire for a later re-enable.
if let Err(e) = self
.check_owner_authority(community_id, channel_id, &workflow.owner_pubkey, &def)
.await
{
self.log_owner_denial(community_id, workflow.id, trigger_type, &e);
continue;
}
// Durable at-most-once claim — the cross-pod fire boundary.
// The loser receives `None` and skips BEFORE any run creation or
// side effect. `community_id` is the workflow row's own
// community (server provenance from the scan), never client
// input; the claim binds `(community_id, workflow_id,
// scheduled_for)` so a duplicate workflow UUID in another
// community claims independently.
match self
.db
.claim_scheduled_workflow_fire(community_id, workflow.id, scheduled_for)
.await
{
Ok(Some(_)) => {}
Ok(None) => {
// Another pod (or an earlier tick this pod) already
// claimed this instant. Still advance the in-memory
// interval clock so we don't re-attempt the claim every
// tick for the rest of the interval.
if trigger_type == "interval" {
self.last_fired.insert((community_id, workflow.id), now);
}
continue;
}
Err(e) => {
tracing::error!(
workflow_id = %workflow.id,
"Cron tick: scheduled-fire claim failed: {e}"
);
continue;
}
}
// Fix 5: handle serialization errors explicitly rather than silently
// dropping the trigger context with .ok().
let trigger_ctx = executor::TriggerContext {
channel_id: channel_id.to_string(),
timestamp: now.timestamp().to_string(),
..Default::default()
};
let trigger_ctx_json = match serde_json::to_value(&trigger_ctx) {
Ok(v) => Some(v),
Err(e) => {
tracing::error!(
workflow_id = %workflow.id,
"Cron tick: failed to serialize trigger context: {e}"
);
continue;
}
};
let run_id = match self
.db
.create_workflow_run(
community_id,
workflow.id,
None, // no trigger event for cron
trigger_ctx_json.as_ref(),
&buzz_db::workflow::RunCause::Schedule,
)
.await
{
Ok(id) => id,
Err(e) => {
tracing::error!(
workflow_id = %workflow.id,
"Cron tick: failed to create workflow run: {e}"
);
// The claim is held but the run failed to create. The
// claim row intentionally stays (its `workflow_run_id`
// NULL) so this instant is not re-fired: at-most-once is
// preserved over exactly-once on transient run-insert
// failures.
continue;
}
};
// Link the won claim to its run for ops/audit forensics. The
// claim row already guarantees dedupe; this is best-effort.
if let Err(e) = self
.db
.attach_scheduled_workflow_run(community_id, workflow.id, scheduled_for, run_id)
.await
{
tracing::warn!(
workflow_id = %workflow.id,
run_id = %run_id,
"Cron tick: failed to attach run to scheduled-fire claim: {e}"
);
}
// Update last_fired AFTER a successful claim+insert so that a
// failure doesn't suppress the next tick for the full interval.
// Only needed for interval triggers — cron uses window-based
// matching which already prevents double-fire within the same
// minute, and the durable claim backstops both.
if trigger_type == "interval" {
self.last_fired.insert((community_id, workflow.id), now);
}
// Fix 6: log the specific trigger type (cron vs interval).
tracing::info!(
workflow_id = %workflow.id,
run_id = %run_id,
trigger = trigger_type,
"Cron trigger fired"
);
let engine = Arc::clone(self);
let def_clone = def.clone();
let ctx_clone = trigger_ctx.clone();
tokio::spawn(async move {
let result =
executor::execute_run(&engine, community_id, run_id, &def_clone, &ctx_clone)
.await;
engine
.finalize_run(community_id, run_id, result, None)
.await;
});
}
// Fix 1: prune stale last_fired entries for workflows that are no longer
// active/enabled. Without this the DashMap grows monotonically as
// workflows are deleted or disabled. Keyed by `(community_id, id)` so
// entries are matched to the same scope they were inserted under.
let active_ids: std::collections::HashSet<(CommunityId, Uuid)> =
workflows.iter().map(|w| (w.community_id, w.id)).collect();
self.last_fired.retain(|key, _| active_ids.contains(key));
}
}
/// The cache behind [`WorkflowEngine::ban_denial_warned`]: an entry expires
/// `warn_interval` after the WARN that inserted it, so the next ban denial
/// for that workflow warns again.
fn ban_denial_warn_cache(
warn_interval: std::time::Duration,
) -> moka::sync::Cache<(CommunityId, Uuid), ()> {
moka::sync::Cache::builder()
.max_capacity(10_000)
.time_to_live(warn_interval)
.build()
}
/// Whether an owner-authority denial for this workflow logs at WARN. A ban
/// denial warns only when no warning for the same workflow is still inside
/// `warned`'s TTL; every other denial always warns.
fn should_warn_owner_denial(
warned: &moka::sync::Cache<(CommunityId, Uuid), ()>,
community_id: CommunityId,
workflow_id: Uuid,
err: &WorkflowError,
) -> bool {
if !matches!(err, WorkflowError::OwnerBanned) {
return true;
}
warned
.entry((community_id, workflow_id))
.or_insert(())
.is_fresh()
}
/// Find the cron schedule instant that fired within the `window_secs`-wide
/// window ending at `now`, if any.
///
/// Uses window-based matching: finds the next scheduled time after
/// `(now - window_secs)` and returns it when it falls at or before `now`.
/// This tolerates tick drift gracefully — a 61s tick won't miss a
/// minute-granularity cron expression. The returned instant is the cron's own
/// scheduled time (not `now`), so every pod evaluating the same expression in
/// the same window computes the *same* value — making it a safe, deterministic
/// claim anchor for cross-pod at-most-once firing.
///
/// Returns `None` (and logs a warning) if the expression is invalid or nothing
/// is due in the window.
fn cron_fire_instant(
expr: &str,
now: DateTime<Utc>,
window_secs: i64,
workflow_id: Uuid,
) -> Option<DateTime<Utc>> {
let normalized = schema::normalize_cron(expr);
match normalized.parse::<cron::Schedule>() {
Ok(sched) => {
let window_start = now - chrono::Duration::seconds(window_secs);
sched.after(&window_start).next().filter(|t| *t <= now)
}
Err(e) => {
tracing::warn!(
workflow_id = %workflow_id,
"Cron tick: invalid cron expression '{expr}': {e}"
);
None
}
}
}
/// Quantize `now` to the interval bucket boundary, yielding a deterministic
/// claim anchor that every pod computes identically within the same bucket.
///
/// The boundary is `floor(now / interval) * interval` from the Unix epoch.
/// Because the scheduler ticks every 60s and interval schedules are minutes or
/// longer, bounded cross-pod clock skew keeps all pods inside the same bucket,
/// so they collide on one `(community, workflow, scheduled_for)` claim — only
/// one wins and creates the run. Returns `None` if the duration is unparseable
/// or non-positive (the caller skips firing).
fn interval_fire_instant(
dur: &str,
now: DateTime<Utc>,
workflow_id: Uuid,
) -> Option<DateTime<Utc>> {
match executor::parse_duration_secs(dur) {
Ok(interval_secs) if interval_secs > 0 => {
let secs = interval_secs as i64;
let bucket = (now.timestamp().div_euclid(secs)) * secs;
DateTime::from_timestamp(bucket, 0)
}
Ok(_) => {
tracing::warn!(
workflow_id = %workflow_id,
"Cron tick: interval duration is zero — skipping"
);
None
}
Err(e) => {
tracing::warn!(
workflow_id = %workflow_id,
"Cron tick: invalid interval '{dur}': {e}"
);
None
}
}
}
/// Check whether an interval trigger should fire based on the last-fired time.
///
/// `last_fired` is `None` on the first tick after startup — in that case we
/// default to `now`, which prevents an immediate fire and waits a full interval.
///
/// Returns `false` (and logs a warning) if the duration string is invalid.
fn interval_should_fire(
dur: &str,
last_fired: Option<DateTime<Utc>>,
now: DateTime<Utc>,
workflow_id: Uuid,
) -> bool {
match executor::parse_duration_secs(dur) {
Ok(interval_secs) => {
// Default to now on first tick — prevents immediate fire after startup.
let last = last_fired.unwrap_or(now);
let elapsed = (now - last).num_seconds().unsigned_abs();
elapsed >= interval_secs
}
Err(e) => {
tracing::warn!(
workflow_id = %workflow_id,
"Cron tick: invalid interval '{dur}': {e}"
);
false
}
}
}
/// Interval prefilter decision + cold-start anchor seed. See the
/// [`WorkflowEngine::interval_prefilter_should_fire`] wrapper for the liveness
/// rationale. Free function over the `last_fired` map so it is unit-testable
/// without a `Db`/Postgres: the only state it touches is the in-memory anchor.
///
/// Returns `true` to fire, `false` to suppress. On the cold-start `None` suppress
/// path it seeds `now` so the next tick has a real anchor; it never advances an
/// existing (`Some`) anchor, which is mid-interval and must elapse on its own.
fn interval_prefilter_should_fire(
last_fired: &DashMap<(CommunityId, Uuid), DateTime<Utc>>,
community_id: CommunityId,
workflow_id: Uuid,
dur: &str,
last: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> bool {
if interval_should_fire(dur, last, now, workflow_id) {
return true;
}
if last.is_none() {
last_fired.insert((community_id, workflow_id), now);
}
false
}
/// Check emoji and filter-expression conditions that determine whether a
/// matched workflow should actually fire. Extracted from `on_event` to keep
/// the per-workflow loop body small.
///
/// Returns `true` if the workflow should fire, `false` to skip.
async fn should_fire_workflow(
def: &WorkflowDef,
trigger_ctx: &executor::TriggerContext,
workflow_id: uuid::Uuid,
) -> bool {
if let TriggerDef::ReactionAdded {
emoji: Some(ref expected),
..
} = def.trigger
{
if &trigger_ctx.emoji != expected {
tracing::debug!(
workflow_id = %workflow_id,
expected_emoji = %expected,
actual_emoji = %trigger_ctx.emoji,
"Reaction emoji mismatch — skipping workflow"
);
return false;
}
}
let filter = match &def.trigger {
TriggerDef::MessagePosted { filter }
| TriggerDef::ReactionAdded { filter, .. }
| TriggerDef::DiffPosted { filter } => filter.as_ref(),
TriggerDef::Schedule { .. } | TriggerDef::Webhook => None,
};
if let Some(expr) = filter {
match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await {
Ok(true) => {}
Ok(false) => {
tracing::debug!(
workflow_id = %workflow_id,
"Trigger filter evaluated false — skipping workflow"
);
return false;
}
Err(e) => {
tracing::warn!(
workflow_id = %workflow_id,
error_kind = e.code(),
"Trigger filter error — skipping workflow"
);
return false;
}
}
}
true
}
/// Build a [`executor::TriggerContext`] from a [`buzz_core::StoredEvent`].
///
/// - `text` — event content (message body or reaction emoji character)
/// - `author` — pubkey hex string
/// - `channel_id` — channel UUID as string (empty if no channel scope)
/// - `timestamp` — Unix timestamp as string
/// - `emoji` — for `KIND_REACTION` events, the content is the emoji; otherwise empty
/// - `message_id` — for reactions, the target message's event ID (from `e` tag);
/// for all other events, the event's own ID
pub fn build_trigger_context(event: &buzz_core::StoredEvent) -> executor::TriggerContext {
let kind_u32 = event_kind_u32(&event.event);
let content = event.event.content.clone();
// Workflow conditions make authorization decisions from `trigger_author`,
// so it must come from the event signature. An `actor` tag is ordinary
// signer-controlled metadata and cannot speak for another pubkey.
let author = event.event.pubkey.to_hex();
// For reaction events (NIP-25), the content field holds the emoji character
// or shortcode (e.g. "👍", "+", "-"). Expose it as `emoji`.
let emoji = if kind_u32 == KIND_REACTION {
content.clone()
} else {
String::new()
};
// For reactions (NIP-25), `message_id` should be the target message, not
// the reaction event itself. NIP-25 stores the target in an `e` tag whose
// value is a 64-char hex event ID (not a UUID channel reference).
// Per NIP-25, the last `e` tag is the direct target (earlier ones may be thread roots).
let message_id = if kind_u32 == KIND_REACTION {
event
.event
.tags
.iter()
.rev()
.find_map(|tag| {
let key = tag.kind().to_string();
if key == "e" {
tag.content().and_then(|v| {
// Distinguish hex event IDs (64 chars) from UUID channel refs.
if v.len() == 64 && v.chars().all(|c| c.is_ascii_hexdigit()) {
Some(v.to_string())
} else {
None
}
})
} else {
None
}
})
// Fallback to the reaction event's own ID if no valid `e` tag found.
.unwrap_or_else(|| event.event.id.to_hex())
} else {
event.event.id.to_hex()
};
executor::TriggerContext {
text: content,
author,
channel_id: event
.channel_id
.map(|id| id.to_string())
.unwrap_or_default(),
timestamp: event.event.created_at.as_secs().to_string(),
emoji,
message_id,
is_reply: event_is_reply(&event.event),
webhook_fields: HashMap::new(),
}
}
/// True when an event is a threaded reply — it carries a valid NIP-10 `reply`
/// marker. Delegates to the shared [`buzz_core::nip10`] parser so this stays in
/// lockstep with ingest's `resolve_nip10_thread_meta`: a `root` marker alone is
/// top-level, and a marker with a malformed (non-64-hex) event id is ignored by
/// ingest, so it must not flip `trigger_is_reply` either — else a
/// `trigger_is_reply == false` workflow would skip a message ingest stored as a
/// new top-level post.
fn event_is_reply(event: &nostr::Event) -> bool {
buzz_core::nip10::parse_thread_markers(&event.tags)
.reply
.is_some()
}
/// Pure authority decision for [`WorkflowEngine::check_owner_authority`].
///
/// `role` is the owner's *current* active role in the workflow's channel
/// (`None` = not an active member — removed, left, or never joined).
/// `needs_elevated` is true when the definition contains an
/// exfiltration-capable action (see `WorkflowDef::requires_elevated_authority`).
///
/// Rules:
/// - not a member ⇒ deny, always;
/// - a role that cannot write (`guest`, or a role this build does not
/// know) ⇒ deny: a run acts as its owner, and a guest cannot write;
/// - member or bot ⇒ allowed for ordinary definitions;
/// - elevated definitions ⇒ only `owner` / `admin` roles.
fn owner_authority_allows(role: Option<&str>, needs_elevated: bool) -> bool {
let Some(role) = role.and_then(|r| r.parse::<buzz_core::channel::MemberRole>().ok()) else {
return false;
};
if needs_elevated {
role.is_elevated()
} else {
role.can_post()
}
}
/// Returns `true` if the trigger type matches the given event kind.
fn trigger_matches_event(trigger: &TriggerDef, kind_u32: u32) -> bool {
use buzz_core::kind::{KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF};
match trigger {
TriggerDef::MessagePosted { .. } => kind_u32 == KIND_STREAM_MESSAGE,
TriggerDef::ReactionAdded { .. } => kind_u32 == KIND_REACTION,
TriggerDef::DiffPosted { .. } => kind_u32 == KIND_STREAM_MESSAGE_DIFF,
// Schedule and Webhook triggers are not fired by channel events.
TriggerDef::Schedule { .. } | TriggerDef::Webhook => false,
}
}
#[cfg(test)]
mod postgres_tests {
use super::*;
#[test]
fn cron_fire_instant_matches_within_window() {
// "every minute" cron — should always fire within a 60s window.
let now = chrono::DateTime::parse_from_rfc3339("2026-06-15T12:00:30Z")
.unwrap()
.with_timezone(&Utc);
let wf_id = Uuid::new_v4();
// The matched instant is the minute boundary 12:00:00, NOT `now`.
assert_eq!(
cron_fire_instant("* * * * *", now, 60, wf_id),
Some(
chrono::DateTime::parse_from_rfc3339("2026-06-15T12:00:00Z")
.unwrap()
.with_timezone(&Utc)
),
"every-minute cron should return the minute boundary as the anchor"
);
}
#[test]
fn cron_fire_instant_returns_none_for_invalid_expr() {
let now = Utc::now();
let wf_id = Uuid::new_v4();
assert!(
cron_fire_instant("not-a-cron", now, 60, wf_id).is_none(),
"invalid cron should return None"
);
}
#[test]
fn cron_fire_instant_returns_none_outside_window() {
// Fixed time: 2026-06-15 14:30:00 UTC (a Sunday in June)
let now = chrono::DateTime::parse_from_rfc3339("2026-06-15T14:30:00Z")
.unwrap()
.with_timezone(&Utc);
let wf_id = Uuid::new_v4();
// "0 0 1 1 *" = midnight on Jan 1 only — June 15 is definitely outside.
assert!(
cron_fire_instant("0 0 1 1 *", now, 60, wf_id).is_none(),
"Jan-1-only cron should not fire on June 15"
);
}
#[test]
fn cron_fire_instant_at_exact_minute_boundary() {
// Fixed time: exactly 09:00:00 UTC. Cron "0 9 * * *" fires at 09:00.
// Window [08:59:00, 09:00:00] should contain the fire time.
let now = chrono::DateTime::parse_from_rfc3339("2026-06-15T09:00:00Z")
.unwrap()
.with_timezone(&Utc);
let wf_id = Uuid::new_v4();
assert_eq!(
cron_fire_instant("0 9 * * *", now, 60, wf_id),
Some(now),
"cron should fire at exact minute boundary, anchored on 09:00:00"
);
}
#[test]
fn cron_fire_instant_within_drift_window_anchors_on_scheduled_time() {
// Fixed time: 09:00:45 UTC (45s drift). Cron "0 9 * * *" fires at 09:00.
// Window [08:59:45, 09:00:45] should still contain 09:00:00. Critically,
// the anchor is the *scheduled* 09:00:00 — not the drifted `now` — so a
// second pod ticking at 09:00:50 computes the identical claim key.
let now = chrono::DateTime::parse_from_rfc3339("2026-06-15T09:00:45Z")
.unwrap()
.with_timezone(&Utc);
let wf_id = Uuid::new_v4();
assert_eq!(
cron_fire_instant("0 9 * * *", now, 60, wf_id),
Some(
chrono::DateTime::parse_from_rfc3339("2026-06-15T09:00:00Z")
.unwrap()
.with_timezone(&Utc)
),
"cron anchor must be the scheduled instant, stable across pod tick drift"
);
}
#[test]
fn cron_fire_instant_returns_none_just_outside_window() {
// Fixed time: 09:01:01 UTC. Cron "0 9 * * *" fires at 09:00:00.
// Window [09:00:01, 09:01:01] does NOT contain 09:00:00.
let now = chrono::DateTime::parse_from_rfc3339("2026-06-15T09:01:01Z")
.unwrap()
.with_timezone(&Utc);
let wf_id = Uuid::new_v4();
assert!(
cron_fire_instant("0 9 * * *", now, 60, wf_id).is_none(),
"cron should not fire 61s after the scheduled time"
);
}
#[test]
fn interval_fire_instant_quantizes_to_bucket_boundary() {
// Two pods ticking at different sub-interval offsets must compute the
// *same* bucket boundary so they collide on one claim. 1h interval,
// epoch-aligned: 12:34:56 and 12:59:01 both floor to 12:00:00.
let wf_id = Uuid::new_v4();
let a = chrono::DateTime::parse_from_rfc3339("2026-06-15T12:34:56Z")
.unwrap()
.with_timezone(&Utc);
let b = chrono::DateTime::parse_from_rfc3339("2026-06-15T12:59:01Z")
.unwrap()
.with_timezone(&Utc);
let bucket = chrono::DateTime::parse_from_rfc3339("2026-06-15T12:00:00Z")
.unwrap()
.with_timezone(&Utc);
assert_eq!(interval_fire_instant("1h", a, wf_id), Some(bucket));
assert_eq!(interval_fire_instant("1h", b, wf_id), Some(bucket));
// Next hour is a distinct bucket.
let c = chrono::DateTime::parse_from_rfc3339("2026-06-15T13:00:10Z")
.unwrap()
.with_timezone(&Utc);
let next_bucket = chrono::DateTime::parse_from_rfc3339("2026-06-15T13:00:00Z")
.unwrap()
.with_timezone(&Utc);
assert_eq!(interval_fire_instant("1h", c, wf_id), Some(next_bucket));
}
#[test]
fn interval_fire_instant_returns_none_for_invalid_duration() {
let now = Utc::now();
let wf_id = Uuid::new_v4();
assert!(interval_fire_instant("not-a-duration", now, wf_id).is_none());
}
#[test]
fn interval_should_fire_returns_false_on_first_tick() {
// When last_fired is None (first tick), defaults to now → elapsed = 0 → false.
let now = Utc::now();
let wf_id = Uuid::new_v4();
assert!(
!interval_should_fire("1h", None, now, wf_id),
"first tick should not fire immediately"
);
}
#[test]
fn interval_should_fire_returns_true_after_interval_elapsed() {
let wf_id = Uuid::new_v4();
let now = Utc::now();
// last_fired was 2 hours ago; interval is 1h → should fire.
let last = now - chrono::Duration::hours(2);
assert!(
interval_should_fire("1h", Some(last), now, wf_id),
"should fire after interval elapsed"
);
}
#[test]
fn interval_should_fire_returns_false_before_interval_elapsed() {
let wf_id = Uuid::new_v4();
let now = Utc::now();
// last_fired was 30 minutes ago; interval is 1h → should not fire.
let last = now - chrono::Duration::minutes(30);
assert!(
!interval_should_fire("1h", Some(last), now, wf_id),
"should not fire before interval elapsed"
);
}
#[test]
fn interval_should_fire_returns_false_for_invalid_duration() {
let now = Utc::now();
let wf_id = Uuid::new_v4();
assert!(
!interval_should_fire("not-a-duration", None, now, wf_id),
"invalid duration should return false"
);
}
#[test]
fn interval_should_fire_at_exact_boundary() {
let wf_id = Uuid::new_v4();
let now = Utc::now();
// last_fired was exactly 1 hour ago; interval is 1h → should fire (elapsed >= interval).
let last = now - chrono::Duration::hours(1);
assert!(
interval_should_fire("1h", Some(last), now, wf_id),
"should fire at exact interval boundary"
);
}
// ── Interval cold-start liveness (Max's blocker on the scheduled lane) ──
// A brand-new interval workflow has no in-memory anchor and no prior durable
// claim, so the prefilter resolves `last = None`. Without seeding, every tick
// reads `None`, suppresses, and writes nothing — the workflow never fires.
// `interval_prefilter_should_fire` must seed `now` on that first suppress so a
// real anchor exists for the next tick.
#[test]
fn interval_cold_start_seeds_anchor_then_fires_after_one_interval() {
let map: DashMap<(CommunityId, Uuid), DateTime<Utc>> = DashMap::new();
let community = CommunityId::from_uuid(Uuid::new_v4());
let wf = Uuid::new_v4();
let t0 = Utc::now();
// Tick 1 (cold start): no in-memory entry, DB anchor is None → last = None.
let fired_1 = interval_prefilter_should_fire(&map, community, wf, "1h", None, t0);
assert!(!fired_1, "first tick must suppress (wait a full interval)");
let seeded = map.get(&(community, wf)).map(|v| *v);
assert_eq!(
seeded,
Some(t0),
"first suppressed tick must seed the anchor to `now`, else it suppresses forever"
);
// Tick 2, mid-interval: caller now passes the seeded anchor as `last`.
let t1 = t0 + chrono::Duration::minutes(30);
let last = map.get(&(community, wf)).map(|v| *v);
let fired_2 = interval_prefilter_should_fire(&map, community, wf, "1h", last, t1);
assert!(!fired_2, "still mid-interval → suppress");
assert_eq!(
map.get(&(community, wf)).map(|v| *v),
Some(t0),
"mid-interval suppress must NOT advance the anchor (or it would never elapse)"
);
// Tick 3, one interval elapsed → fire.
let t2 = t0 + chrono::Duration::hours(1);
let last = map.get(&(community, wf)).map(|v| *v);
let fired_3 = interval_prefilter_should_fire(&map, community, wf, "1h", last, t2);
assert!(
fired_3,
"after one full interval the cold-started workflow must fire"
);
}
#[test]
fn interval_prefilter_does_not_advance_existing_anchor_on_suppress() {
// Regression for the inverse bug: if a `Some` anchor were re-seeded to
// `now` on every suppressed tick, the interval would never elapse.
let map: DashMap<(CommunityId, Uuid), DateTime<Utc>> = DashMap::new();
let community = CommunityId::from_uuid(Uuid::new_v4());
let wf = Uuid::new_v4();
let now = Utc::now();
let anchor = now - chrono::Duration::minutes(10); // 10m into a 1h interval
map.insert((community, wf), anchor);
let fired = interval_prefilter_should_fire(&map, community, wf, "1h", Some(anchor), now);
assert!(!fired, "mid-interval suppress");
assert_eq!(
map.get(&(community, wf)).map(|v| *v),
Some(anchor),
"existing anchor must be preserved exactly, not advanced to now"
);
}
#[test]
fn interval_prefilter_passes_through_a_due_fire_without_touching_anchor() {
// When the interval has elapsed the prefilter returns true and leaves the
// anchor to the post-claim update path (which writes `now` only on a won
// claim), so the prefilter must not seed here.
let map: DashMap<(CommunityId, Uuid), DateTime<Utc>> = DashMap::new();
let community = CommunityId::from_uuid(Uuid::new_v4());
let wf = Uuid::new_v4();
let now = Utc::now();
let anchor = now - chrono::Duration::hours(2); // overdue on a 1h interval
let fired = interval_prefilter_should_fire(&map, community, wf, "1h", Some(anchor), now);
assert!(fired, "overdue interval must fire");
assert!(
map.get(&(community, wf)).is_none(),
"a firing tick must not seed via the prefilter; the post-claim path owns the write"
);
}
#[test]
fn workflow_config_defaults() {
let cfg = WorkflowConfig::default();
assert_eq!(cfg.max_concurrent, 100);
assert_eq!(cfg.default_timeout_secs, 300);
assert_eq!(cfg.agent_mentions, AgentMentions::Any);
}
#[test]
fn parse_yaml_roundtrip() {
let yaml = r#"
name: "Test Workflow"
trigger:
on: message_posted
steps:
- id: s1
action: send_message
text: "Hello {{trigger.author}}"
"#;
let (def, json) = WorkflowEngine::parse_yaml(yaml).expect("parse failed");
assert_eq!(def.name, "Test Workflow");
let reparsed: WorkflowDef = serde_json::from_str(&json).expect("json round-trip");
assert_eq!(reparsed.name, def.name);
assert_eq!(reparsed.steps.len(), 1);
}
#[test]
fn trigger_matches_stream_message() {
let trigger = TriggerDef::MessagePosted { filter: None };
assert!(trigger_matches_event(
&trigger,
buzz_core::kind::KIND_STREAM_MESSAGE
));
assert!(!trigger_matches_event(
&trigger,
buzz_core::kind::KIND_REACTION
));
}
#[test]
fn trigger_matches_reaction() {
let trigger = TriggerDef::ReactionAdded {
emoji: None,
filter: None,
};
assert!(trigger_matches_event(
&trigger,
buzz_core::kind::KIND_REACTION
));
assert!(!trigger_matches_event(
&trigger,
buzz_core::kind::KIND_STREAM_MESSAGE
));
}
#[tokio::test]
async fn reaction_filter_matches_target_message() {
let yaml = r#"
name: "React to one message"
trigger:
on: reaction_added
filter: 'trigger_message_id == "target-message"'
steps:
- id: wait
action: delay
duration: 1s
"#;
let (def, _) = WorkflowEngine::parse_yaml(yaml).expect("parse failed");
let mut trigger_ctx = executor::TriggerContext {
message_id: "target-message".to_owned(),
..Default::default()
};
assert!(
should_fire_workflow(&def, &trigger_ctx, Uuid::new_v4()).await,
"reaction to the selected message should fire"
);
trigger_ctx.message_id = "different-message".to_owned();
assert!(
!should_fire_workflow(&def, &trigger_ctx, Uuid::new_v4()).await,
"reaction to a different message should be filtered out"
);
}
#[test]
fn schedule_trigger_never_matches_events() {
let trigger = TriggerDef::Schedule {
cron: Some("0 9 * * 1-5".to_owned()),
interval: None,
};
// Schedule triggers are fired by the cron loop, not by events.
assert!(!trigger_matches_event(
&trigger,
buzz_core::kind::KIND_STREAM_MESSAGE
));
assert!(!trigger_matches_event(
&trigger,
buzz_core::kind::KIND_REACTION
));
assert!(!trigger_matches_event(
&trigger,
buzz_core::kind::KIND_WORKFLOW_TRIGGERED
));
}
#[test]
fn webhook_trigger_never_matches_events() {
let trigger = TriggerDef::Webhook;
assert!(!trigger_matches_event(
&trigger,
buzz_core::kind::KIND_STREAM_MESSAGE
));
assert!(!trigger_matches_event(&trigger, 0));
}
#[test]
fn message_posted_matches_kind_9_only() {
let trigger = TriggerDef::MessagePosted { filter: None };
// Must match KIND_STREAM_MESSAGE = 9.
assert!(trigger_matches_event(&trigger, 9));
// Must NOT match reaction (kind 7).
assert!(!trigger_matches_event(&trigger, 7));
// Must NOT match forum post (kind 45001).
assert!(!trigger_matches_event(&trigger, 45001));
// Must NOT match stream message v2 (kind 40002).
assert!(!trigger_matches_event(&trigger, 40002));
}
#[test]
fn reaction_added_matches_kind_7_only() {
let trigger = TriggerDef::ReactionAdded {
emoji: None,
filter: None,
};
// Must match KIND_REACTION = 7.
assert!(trigger_matches_event(&trigger, 7));
// Must NOT match stream message (kind 9).
assert!(!trigger_matches_event(&trigger, 9));
// Must NOT match forum post (kind 45001).
assert!(!trigger_matches_event(&trigger, 45001));
}
#[test]
fn reaction_added_with_emoji_filter_still_matches_kind_7() {
// The emoji filter is evaluated at execution time, not trigger-matching time.
// trigger_matches_event only checks the kind number.
let trigger = TriggerDef::ReactionAdded {
emoji: Some("thumbsup".to_owned()),
filter: None,
};
assert!(trigger_matches_event(&trigger, 7));
assert!(!trigger_matches_event(&trigger, 9));
}
#[test]
fn message_posted_with_filter_still_matches_kind_9() {
// The filter expression is evaluated at execution time, not trigger-matching time.
let trigger = TriggerDef::MessagePosted {
filter: Some("str_contains(trigger_text, \"P1\")".to_owned()),
};
assert!(trigger_matches_event(&trigger, 9));
assert!(!trigger_matches_event(&trigger, 7));
}
#[test]
fn workflow_execution_kinds_do_not_match_any_trigger() {
// Workflow execution events (46001–46012) must never match triggers
// to prevent infinite loops. The on_event() method filters these out
// before calling trigger_matches_event, but verify the function itself
// also returns false for these kinds.
let msg_trigger = TriggerDef::MessagePosted { filter: None };
let react_trigger = TriggerDef::ReactionAdded {
emoji: None,
filter: None,
};
for kind in buzz_core::kind::KIND_WORKFLOW_TRIGGERED
..=buzz_core::kind::KIND_WORKFLOW_APPROVAL_DENIED
{
assert!(
!trigger_matches_event(&msg_trigger, kind),
"message_posted should not match workflow execution kind {kind}"
);
assert!(
!trigger_matches_event(&react_trigger, kind),
"reaction_added should not match workflow execution kind {kind}"
);
}
}
#[test]
fn trigger_matches_event_kind_zero_matches_nothing() {
// Kind 0 is a profile event — no trigger should match it.
let msg_trigger = TriggerDef::MessagePosted { filter: None };
let react_trigger = TriggerDef::ReactionAdded {
emoji: None,
filter: None,
};
let sched_trigger = TriggerDef::Schedule {
cron: None,
interval: Some("1h".to_owned()),
};
let webhook_trigger = TriggerDef::Webhook;
assert!(!trigger_matches_event(&msg_trigger, 0));
assert!(!trigger_matches_event(&react_trigger, 0));
assert!(!trigger_matches_event(&sched_trigger, 0));
assert!(!trigger_matches_event(&webhook_trigger, 0));
}
#[test]
fn diff_posted_matches_kind_40008_only() {
let trigger = TriggerDef::DiffPosted { filter: None };
assert!(trigger_matches_event(&trigger, 40008));
assert!(!trigger_matches_event(&trigger, 9));
assert!(!trigger_matches_event(&trigger, 7));
}
#[test]
fn message_posted_does_not_match_kind_40008() {
let trigger = TriggerDef::MessagePosted { filter: None };
assert!(!trigger_matches_event(&trigger, 40008));
assert!(trigger_matches_event(&trigger, 9));
}
#[test]
fn agent_mentions_parses_only_any_and_owner() {
assert_eq!("any".parse(), Ok(AgentMentions::Any));
assert_eq!("owner".parse(), Ok(AgentMentions::Owner));
for invalid in ["", "true", "Owner", "all"] {
assert!(invalid.parse::<AgentMentions>().is_err(), "{invalid}");
}
}
#[test]
fn workflow_config_custom_values() {
let cfg = WorkflowConfig {
max_concurrent: 50,
default_timeout_secs: 600,
agent_mentions: AgentMentions::Owner,
};
assert_eq!(cfg.max_concurrent, 50);
assert_eq!(cfg.default_timeout_secs, 600);
assert_eq!(cfg.agent_mentions, AgentMentions::Owner);
}
fn make_message_event() -> buzz_core::StoredEvent {
use nostr::{EventBuilder, Keys, Kind};
use uuid::Uuid;
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "hello world")
.tags([])
.sign_with_keys(&keys)
.expect("sign");
buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()))
}
/// Create a reaction event with an `e` tag pointing to a target message.
fn make_reaction_event() -> (buzz_core::StoredEvent, String) {
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
let keys = Keys::generate();
// Create a dummy target message ID (64-char hex).
let target_keys = Keys::generate();
let target_event = EventBuilder::new(Kind::Custom(9), "target msg")
.tags([])
.sign_with_keys(&target_keys)
.expect("sign target");
let target_id_hex = target_event.id.to_hex();
// NIP-25: reaction references the target via an `e` tag.
let e_tag = Tag::parse(["e", &target_id_hex]).expect("tag parse");
let event = EventBuilder::new(Kind::Reaction, "👍")
.tags([e_tag])
.sign_with_keys(&keys)
.expect("sign");
(
buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())),
target_id_hex,
)
}
#[test]
fn build_trigger_context_message_event() {
let stored = make_message_event();
let ctx = build_trigger_context(&stored);
assert_eq!(ctx.text, "hello world");
assert_eq!(ctx.author, stored.event.pubkey.to_hex());
assert_eq!(ctx.channel_id, stored.channel_id.unwrap().to_string());
assert_eq!(ctx.timestamp, stored.event.created_at.as_secs().to_string());
assert_eq!(ctx.message_id, stored.event.id.to_hex());
// Non-reaction events have empty emoji.
assert_eq!(ctx.emoji, "");
assert!(ctx.webhook_fields.is_empty());
// A top-level message (no e-tags) is not a reply.
assert!(!ctx.is_reply);
}
#[test]
fn build_trigger_context_is_reply_true_for_threaded_message() {
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
let root = Keys::generate();
let root_event = EventBuilder::new(Kind::Custom(9), "root")
.tags([])
.sign_with_keys(&root)
.expect("sign root");
let root_hex = root_event.id.to_hex();
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "a threaded reply")
.tags([
Tag::parse(["e", &root_hex, "", "root"]).expect("root tag"),
Tag::parse(["e", &root_hex, "", "reply"]).expect("reply tag"),
])
.sign_with_keys(&keys)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
assert!(ctx.is_reply, "message with reply/root e-tags is a reply");
}
#[test]
fn build_trigger_context_is_reply_true_for_reply_only_marker() {
// A NIP-10 `reply` marker without a `root` marker (the fallback ingest
// treats as `root == reply`) is still a threaded reply.
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
let parent = Keys::generate();
let parent_event = EventBuilder::new(Kind::Custom(9), "parent")
.sign_with_keys(&parent)
.expect("sign parent");
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "reply only")
.tags([Tag::parse(["e", &parent_event.id.to_hex(), "", "reply"]).expect("reply tag")])
.sign_with_keys(&keys)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
assert!(ctx.is_reply, "a lone `reply` marker is a reply");
}
#[test]
fn build_trigger_context_is_reply_false_for_root_only_marker() {
// Ingest treats `(root=Some, reply=None)` as top-level, so
// `event_is_reply` must too — otherwise `trigger_is_reply == false`
// would skip a message the relay stored as a new top-level post.
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
let root = Keys::generate();
let root_event = EventBuilder::new(Kind::Custom(9), "root")
.sign_with_keys(&root)
.expect("sign root");
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "root marker only")
.tags([Tag::parse(["e", &root_event.id.to_hex(), "", "root"]).expect("root tag")])
.sign_with_keys(&keys)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
assert!(
!ctx.is_reply,
"a lone `root` marker is top-level to ingest, not a reply"
);
}
#[test]
fn build_trigger_context_is_reply_false_for_unmarked_e_tag() {
// A bare `e` tag with no NIP-10 marker (e.g. a plain mention/quote) is
// not treated as a thread reply — only `reply`/`root` markers count.
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
let other = Keys::generate();
let other_event = EventBuilder::new(Kind::Custom(9), "other")
.tags([])
.sign_with_keys(&other)
.expect("sign");
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "quotes another")
.tags([Tag::parse(["e", &other_event.id.to_hex()]).expect("bare e tag")])
.sign_with_keys(&keys)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
assert!(!ctx.is_reply, "unmarked e-tag must not count as a reply");
}
#[test]
fn build_trigger_context_is_reply_false_for_malformed_reply_id() {
// Ingest gates a marker on a valid 64-hex event id; a malformed reply
// id is not a thread link, so ingest stores the event top-level. The
// predicate must agree, or `trigger_is_reply == false` would skip it.
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "malformed reply marker")
.tags([Tag::parse(["e", "bad", "", "reply"]).expect("reply tag")])
.sign_with_keys(&keys)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
assert!(
!ctx.is_reply,
"a malformed reply id is ignored by ingest, so it is top-level"
);
}
#[test]
fn build_trigger_context_is_reply_false_for_valid_root_malformed_reply() {
// A valid `root` marker but a malformed `reply` id: ingest ignores the
// reply and stores the event as root-only, i.e. top-level. The predicate
// must not flip to reply on the malformed marker.
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
let root = Keys::generate();
let root_event = EventBuilder::new(Kind::Custom(9), "root")
.sign_with_keys(&root)
.expect("sign root");
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "valid root, malformed reply")
.tags([
Tag::parse(["e", &root_event.id.to_hex(), "", "root"]).expect("root tag"),
Tag::parse(["e", "bad", "", "reply"]).expect("reply tag"),
])
.sign_with_keys(&keys)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
assert!(
!ctx.is_reply,
"a valid root with a malformed reply id is top-level to ingest"
);
}
#[test]
fn build_trigger_context_reaction_event() {
let (stored, target_id_hex) = make_reaction_event();
let ctx = build_trigger_context(&stored);
// For reactions, content IS the emoji.
assert_eq!(ctx.text, "👍");
assert_eq!(ctx.emoji, "👍");
assert_eq!(ctx.author, stored.event.pubkey.to_hex());
// message_id should be the TARGET message, not the reaction event itself.
assert_eq!(ctx.message_id, target_id_hex);
assert_ne!(ctx.message_id, stored.event.id.to_hex());
assert!(ctx.webhook_fields.is_empty());
}
#[test]
fn build_trigger_context_no_channel_id() {
use nostr::{EventBuilder, Keys, Kind};
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "msg")
.tags([])
.sign_with_keys(&keys)
.expect("sign");
// channel_id = None (global/DM event)
let stored = buzz_core::StoredEvent::new(event, None);
let ctx = build_trigger_context(&stored);
assert_eq!(ctx.channel_id, "");
assert_eq!(ctx.text, "msg");
}
#[test]
fn build_trigger_context_author_is_hex_pubkey() {
let stored = make_message_event();
let ctx = build_trigger_context(&stored);
// Pubkey hex is 64 lowercase hex characters.
assert_eq!(ctx.author.len(), 64);
assert!(ctx.author.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn build_trigger_context_ignores_actor_tag() {
use nostr::{EventBuilder, Keys, Kind, Tag};
let signer = Keys::generate();
let impersonated = Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "forged actor")
.tags([Tag::parse(["actor", &impersonated.public_key().to_hex()]).expect("actor tag")])
.sign_with_keys(&signer)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(uuid::Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
assert_eq!(ctx.author, signer.public_key().to_hex());
assert_ne!(ctx.author, impersonated.public_key().to_hex());
}
#[test]
fn build_trigger_context_message_id_is_hex() {
let stored = make_message_event();
let ctx = build_trigger_context(&stored);
// Event ID hex is 64 lowercase hex characters.
assert_eq!(ctx.message_id.len(), 64);
assert!(ctx.message_id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn build_trigger_context_timestamp_is_numeric_string() {
let stored = make_message_event();
let ctx = build_trigger_context(&stored);
// Timestamp must parse as a u64.
ctx.timestamp
.parse::<u64>()
.expect("timestamp should be a u64 string");
}
#[test]
fn test_build_trigger_context_reaction_multiple_e_tags() {
// NIP-25: last e tag is the direct target, first may be thread root
use nostr::{EventBuilder, EventId, Keys, Kind, Tag};
use uuid::Uuid;
let keys = Keys::generate();
let thread_root_id = EventId::all_zeros();
let direct_target_id = EventId::from_byte_array([0x42; 32]);
let event = EventBuilder::new(Kind::Reaction, "👍")
.tags([
Tag::parse(["e", &thread_root_id.to_hex()]).unwrap(),
Tag::parse(["e", &direct_target_id.to_hex()]).unwrap(),
])
.sign_with_keys(&keys)
.expect("sign");
let stored = buzz_core::StoredEvent::new(event, Some(Uuid::new_v4()));
let ctx = build_trigger_context(&stored);
// Should pick the LAST e tag (direct target), not the first (thread root)
assert_eq!(ctx.message_id, direct_target_id.to_hex());
}
// -- SEC-006: owner authority decision --------------------------------
#[test]
fn owner_authority_denies_non_members_always() {
assert!(!owner_authority_allows(None, false));
assert!(!owner_authority_allows(None, true));
}
#[test]
fn owner_authority_allows_any_member_for_ordinary_definitions() {
assert!(owner_authority_allows(Some("member"), false));
assert!(owner_authority_allows(Some("bot"), false));
assert!(owner_authority_allows(Some("admin"), false));
assert!(owner_authority_allows(Some("owner"), false));
}
#[test]
fn owner_authority_denies_roles_that_cannot_write() {
for elevated in [false, true] {
assert!(!owner_authority_allows(None, elevated));
assert!(!owner_authority_allows(Some("guest"), elevated));
assert!(!owner_authority_allows(Some("superuser"), elevated));
}
}
#[test]
fn owner_authority_requires_elevated_role_for_exfiltration_definitions() {
assert!(!owner_authority_allows(Some("member"), true));
assert!(owner_authority_allows(Some("admin"), true));
assert!(owner_authority_allows(Some("owner"), true));
}
#[test]
fn requires_elevated_authority_detects_call_webhook() {
let (plain, _) = WorkflowEngine::parse_yaml(concat!(
"name: plain\n",
"trigger:\n on: message_posted\n",
"steps:\n - id: s1\n action: send_message\n text: hi\n",
))
.expect("parse plain");
assert!(!plain.requires_elevated_authority());
let (hook, _) = WorkflowEngine::parse_yaml(concat!(
"name: hook\n",
"trigger:\n on: message_posted\n",
"steps:\n - id: s1\n action: send_message\n text: hi\n",
" - id: s2\n action: call_webhook\n url: https://example.com/x\n",
))
.expect("parse hook");
assert!(hook.requires_elevated_authority());
}
// -- SEC-006: event-path regression (requires Postgres) ----------------
fn test_database_url() -> String {
std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
// Local-only test default; this is not a production credential.
.unwrap_or_else(|_| {
let local_test_database = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
local_test_database.to_owned()
})
}
async fn setup_db() -> buzz_db::Db {
buzz_db::Db::new(&buzz_db::DbConfig {
database_url: test_database_url(),
..Default::default()
})
.await
.expect("connect test DB")
}
/// Create a community, a channel owned by `creator`, and add `member` as a
/// plain member. Returns `(community, channel)`.
async fn setup_channel(db: &buzz_db::Db, creator: &[u8], member: &[u8]) -> (CommunityId, Uuid) {
let host = format!("sec006-{}.example", Uuid::new_v4().simple());
let community = match db
.create_community_with_owner(&host, &hex::encode(creator), &nostr::Keys::generate())
.await
.map(|write| write.outcome)
.expect("create community")
{
buzz_db::CreateCommunityWithOwnerResult::Created(rec) => rec.id,
other => panic!("unexpected community create result: {other:?}"),
};
db.ensure_user(community, creator)
.await
.expect("creator user");
db.ensure_user(community, member)
.await
.expect("member user");
let channel_id = Uuid::new_v4();
db.create_channel_with_id(
community,
channel_id,
&format!("ch-{}", channel_id.simple()),
buzz_db::channel::ChannelType::Stream,
buzz_db::channel::ChannelVisibility::Open,
None,
creator,
None,
)
.await
.expect("create channel");
db.add_member(
community,
channel_id,
member,
buzz_db::channel::MemberRole::Member,
Some(creator),
)
.await
.expect("add member");
(community, channel_id)
}
fn message_event(channel_id: Uuid) -> buzz_core::StoredEvent {
let keys = nostr::Keys::generate();
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "hello")
.sign_with_keys(&keys)
.expect("sign");
buzz_core::StoredEvent::new(event, Some(channel_id))
}
/// The event path must stop creating runs the moment the workflow's owner
/// loses channel membership — even while the workflow row is still
/// `enabled` (the disable-on-removal side effect is a separate, relay-side
/// write; this gate must hold on its own).
#[tokio::test]
#[ignore = "requires Postgres"]
async fn on_event_denies_run_after_owner_removed() {
let db = setup_db().await;
let creator = nostr::Keys::generate().public_key().to_bytes().to_vec();
let member = nostr::Keys::generate().public_key().to_bytes().to_vec();
let (community, channel_id) = setup_channel(&db, &creator, &member).await;
let def_json = serde_json::json!({
"name": "sec006-event",
"trigger": {"on": "message_posted"},
"steps": [{"id": "s1", "action": "send_message", "text": "hi"}],
"enabled": true,
})
.to_string();
let workflow_id = db
.create_workflow(
community,
Some(channel_id),
&member,
"sec006-event",
&def_json,
&[0u8; 32],
)
.await
.expect("create workflow");
let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default()));
// Owner is an active member: the event fires the workflow.
engine
.on_event(community, &message_event(channel_id))
.await
.expect("on_event while member");
let runs = db
.list_workflow_runs(community, workflow_id, 10)
.await
.expect("list runs");
assert_eq!(runs.len(), 1, "member owner's workflow must fire");
// Remove the owner (actor = channel creator, an owner-role member).
db.remove_member(community, channel_id, &member, &creator)
.await
.expect("remove member");
// Workflow row is still enabled — only the authority gate stands.
engine
.on_event(community, &message_event(channel_id))
.await
.expect("on_event after removal");
let runs = db
.list_workflow_runs(community, workflow_id, 10)
.await
.expect("list runs after removal");
assert_eq!(
runs.len(),
1,
"no new run may be created after the owner lost membership"
);
}
/// A ban leaves the owner's channel membership in place, so the event
/// path must still stop creating runs while the owner is banned, and
/// resume once the ban is lifted. Cron and webhook runs pass through the
/// same `check_owner_authority` gate.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn on_event_denies_run_while_owner_banned() {
let db = setup_db().await;
let creator = nostr::Keys::generate().public_key().to_bytes().to_vec();
let member = nostr::Keys::generate().public_key().to_bytes().to_vec();
let (community, channel_id) = setup_channel(&db, &creator, &member).await;
let def_json = serde_json::json!({
"name": "ban-event",
"trigger": {"on": "message_posted"},
"steps": [{"id": "s1", "action": "send_message", "text": "hi"}],
"enabled": true,
})
.to_string();
let workflow_id = db
.create_workflow(
community,
Some(channel_id),
&member,
"ban-event",
&def_json,
&[0u8; 32],
)
.await
.expect("create workflow");
let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default()));
let run_count = || async {
db.list_workflow_runs(community, workflow_id, 10)
.await
.expect("list runs")
.len()
};
db.ban_community_member(community, &member, &creator, None, None)
.await
.expect("ban owner");
engine
.on_event(community, &message_event(channel_id))
.await
.expect("on_event while banned");
assert_eq!(
run_count().await,
0,
"a banned owner's workflow must not fire"
);
assert!(db
.unban_community_member(community, &member, &creator)
.await
.expect("unban owner"));
engine
.on_event(community, &message_event(channel_id))
.await
.expect("on_event after unban");
assert_eq!(
run_count().await,
1,
"an unbanned owner's workflow fires again"
);
}
/// Exfiltration-capable definitions (call_webhook) require the owner to
/// currently hold an elevated role — a plain member's workflow must not
/// fire even though the owner is still an active channel member.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn on_event_denies_webhook_definition_for_plain_member_owner() {
let db = setup_db().await;
let creator = nostr::Keys::generate().public_key().to_bytes().to_vec();
let member = nostr::Keys::generate().public_key().to_bytes().to_vec();
let (community, channel_id) = setup_channel(&db, &creator, &member).await;
let def_json = serde_json::json!({
"name": "sec006-hook",
"trigger": {"on": "message_posted"},
"steps": [{"id": "s1", "action": "call_webhook", "url": "https://example.com/x"}],
"enabled": true,
})
.to_string();
// Same definition, two owners: plain member vs channel owner.
let wf_member = db
.create_workflow(
community,
Some(channel_id),
&member,
"hook-member",
&def_json,
&[0u8; 32],
)
.await
.expect("create member workflow");
let wf_owner = db
.create_workflow(
community,
Some(channel_id),
&creator,
"hook-owner",
&def_json,
&[1u8; 32],
)
.await
.expect("create owner workflow");
let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default()));
engine
.on_event(community, &message_event(channel_id))
.await
.expect("on_event");
let member_runs = db
.list_workflow_runs(community, wf_member, 10)
.await
.expect("member runs");
assert!(
member_runs.is_empty(),
"plain member's call_webhook workflow must not fire"
);
let owner_runs = db
.list_workflow_runs(community, wf_owner, 10)
.await
.expect("owner runs");
assert_eq!(
owner_runs.len(),
1,
"channel owner's call_webhook workflow fires"
);
}
#[test]
fn ban_denial_warns_once_per_interval_while_other_denials_always_warn() {
let interval = std::time::Duration::from_millis(500);
let warned = ban_denial_warn_cache(interval);
let community = CommunityId::from_uuid(Uuid::new_v4());
let (wf_a, wf_b) = (Uuid::new_v4(), Uuid::new_v4());
let banned = WorkflowError::OwnerBanned;
let lookup_failed = WorkflowError::Unauthorized(
"owner restriction lookup failed (fail-closed): boom".into(),
);
assert!(should_warn_owner_denial(&warned, community, wf_a, &banned));
assert!(
!should_warn_owner_denial(&warned, community, wf_a, &banned),
"a repeated ban denial inside the interval logs below WARN"
);
assert!(
should_warn_owner_denial(&warned, community, wf_b, &banned),
"each workflow gets its own first warning"
);
assert!(
should_warn_owner_denial(&warned, community, wf_a, &lookup_failed),
"a failed lookup always warns"
);
assert!(
should_warn_owner_denial(&warned, community, wf_a, &lookup_failed),
"a failed lookup is never rate-limited"
);
std::thread::sleep(interval * 2);
assert!(
should_warn_owner_denial(&warned, community, wf_a, &banned),
"a ban denial warns again once the interval has passed"
);
assert!(
!should_warn_owner_denial(&warned, community, wf_a, &banned),
"and is suppressed again for the next interval"
);
}
fn schedule_def(name: &str) -> String {
serde_json::json!({
"name": name,
"trigger": {"on": "schedule", "cron": "* * * * *"},
"steps": [{"id": "s1", "action": "send_message", "text": "tick"}],
"enabled": true,
})
.to_string()
}
/// A cron tick must skip a banned owner's workflow and an agent-owned
/// workflow whose human owner is banned, without consuming either
/// workflow's scheduled-fire claim: once unbanned, a tick for the same
/// instant fires them. An unbanned workflow on the same tick still fires.
///
/// `cron_tick` scans every enabled workflow in the database. The
/// PostgreSQL lane gives each test process its own database
/// (`scripts/postgres-test-wrapper.sh`), and the assertions read only
/// this test's workflows, so another test's schedules cannot affect it.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn cron_tick_skips_banned_owners_without_consuming_the_claim() {
let db = setup_db().await;
let creator = nostr::Keys::generate().public_key().to_bytes().to_vec();
let member = nostr::Keys::generate().public_key().to_bytes().to_vec();
let (community, channel_id) = setup_channel(&db, &creator, &member).await;
let human = nostr::Keys::generate().public_key().to_bytes().to_vec();
let agent = nostr::Keys::generate().public_key().to_bytes().to_vec();
db.ensure_user(community, &human).await.expect("human user");
db.ensure_user(community, &agent).await.expect("agent user");
assert!(db
.set_agent_owner(community, &agent, &human)
.await
.expect("set agent owner"));
db.add_member(
community,
channel_id,
&agent,
buzz_db::channel::MemberRole::Member,
Some(&creator),
)
.await
.expect("add agent member");
let mut workflows = Vec::new();
for (owner, name, secret) in [
(&member, "cron-banned-owner", [0u8; 32]),
(&agent, "cron-banned-agent-owner", [1u8; 32]),
(&creator, "cron-control", [2u8; 32]),
] {
let id = db
.create_workflow(
community,
Some(channel_id),
owner,
name,
&schedule_def(name),
&secret,
)
.await
.expect("create workflow");
workflows.push(id);
}
let [banned_wf, agent_wf, control_wf] =
<[Uuid; 3]>::try_from(workflows).expect("three workflows created");
let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default()));
let run_count = |workflow_id| {
let db = db.clone();
async move {
db.list_workflow_runs(community, workflow_id, 10)
.await
.expect("list runs")
.len()
}
};
db.ban_community_member(community, &member, &creator, None, None)
.await
.expect("ban owner");
db.ban_community_member(community, &human, &creator, None, None)
.await
.expect("ban agent's human owner");
let now = Utc::now();
engine.cron_tick(now).await;
assert_eq!(run_count(banned_wf).await, 0, "banned owner must not fire");
assert_eq!(
run_count(agent_wf).await,
0,
"an agent whose human owner is banned must not fire"
);
assert_eq!(
run_count(control_wf).await,
1,
"an unbanned workflow on the same tick still fires"
);
for pubkey in [&member, &human] {
assert!(db
.unban_community_member(community, pubkey, &creator)
.await
.expect("unban"));
}
engine.cron_tick(now).await;
assert_eq!(
run_count(banned_wf).await,
1,
"the denied tick must not have consumed the owner's claim"
);
assert_eq!(
run_count(agent_wf).await,
1,
"the denied tick must not have consumed the agent's claim"
);
assert_eq!(
run_count(control_wf).await,
1,
"the control's claim for this instant is already taken"
);
}
/// The event path fails closed when the owner's restriction state cannot
/// be read, and the same event fires once the read succeeds.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn on_event_denies_run_when_restriction_lookup_fails() {
use sqlx::postgres::PgConnectOptions;
let db = setup_db().await;
let creator = nostr::Keys::generate().public_key().to_bytes().to_vec();
let member = nostr::Keys::generate().public_key().to_bytes().to_vec();
let (community, channel_id) = setup_channel(&db, &creator, &member).await;
let def_json = serde_json::json!({
"name": "lookup-fails",
"trigger": {"on": "message_posted"},
"steps": [{"id": "s1", "action": "send_message", "text": "hi"}],
"enabled": true,
})
.to_string();
let workflow_id = db
.create_workflow(
community,
Some(channel_id),
&member,
"lookup-fails",
&def_json,
&[0u8; 32],
)
.await
.expect("create workflow");
// Shadow `community_bans` with a view that errors on read; every
// other table still resolves to `public`.
let schema = format!("ban_lookup_fails_{}", Uuid::new_v4().simple());
sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
"CREATE SCHEMA {schema}; \
CREATE VIEW {schema}.community_bans AS \
SELECT * FROM public.community_bans WHERE 1 / (SELECT count(*) * 0 FROM public.communities) = 1;"
)))
.execute(db.pool())
.await
.expect("create failing restriction schema");
let options = test_database_url()
.parse::<PgConnectOptions>()
.expect("database url")
.options([("search_path", format!("{schema}, public"))]);
let failing_pool = sqlx::PgPool::connect_with(options)
.await
.expect("failing restriction pool");
let failing_engine = Arc::new(WorkflowEngine::new(
buzz_db::Db::from_pool(failing_pool.clone()),
WorkflowConfig::default(),
));
let run_count = || async {
db.list_workflow_runs(community, workflow_id, 10)
.await
.expect("list runs")
.len()
};
failing_engine
.on_event(community, &message_event(channel_id))
.await
.expect("on_event with failing lookup");
assert_eq!(
run_count().await,
0,
"a failed restriction lookup must not create a run"
);
let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default()));
engine
.on_event(community, &message_event(channel_id))
.await
.expect("on_event with healthy lookup");
assert_eq!(run_count().await, 1, "a readable, unbanned owner fires");
failing_pool.close().await;
sqlx::raw_sql(sqlx::AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE")))
.execute(db.pool())
.await
.expect("drop failing restriction schema");
}
/// Condition errors quote the message they evaluated. The trigger-filter
/// WARN, the step-condition WARN and the run-failure ERROR log a fixed
/// code; the run keeps the full diagnostic for its owner.
#[tokio::test(flavor = "current_thread")]
#[ignore = "requires Postgres"]
async fn condition_errors_log_a_fixed_code_without_message_text() {
#[derive(Clone, Default)]
struct Captured(Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for Captured {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let db = setup_db().await;
let creator = nostr::Keys::generate().public_key().to_bytes().to_vec();
let member = nostr::Keys::generate().public_key().to_bytes().to_vec();
let (community, channel_id) = setup_channel(&db, &creator, &member).await;
let mut workflow_ids = Vec::new();
for (name, trigger, steps) in [
(
"filter-error",
serde_json::json!({"on": "message_posted", "filter": "trigger_text > 5"}),
serde_json::json!([{"id": "s1", "action": "send_message", "text": "hi"}]),
),
(
"step-error",
serde_json::json!({"on": "message_posted"}),
serde_json::json!([{
"id": "guarded", "if": "trigger_text > 5",
"action": "send_message", "text": "hi",
}]),
),
] {
let def = serde_json::json!({
"name": name, "trigger": trigger, "steps": steps, "enabled": true,
})
.to_string();
let hash = [workflow_ids.len() as u8; 32];
let id = db
.create_workflow(community, Some(channel_id), &member, name, &def, &hash)
.await
.expect("create workflow");
workflow_ids.push(id);
}
let (filter_wf, step_wf) = (workflow_ids[0], workflow_ids[1]);
let marker = format!("wf-marker-{}", Uuid::new_v4().simple());
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), &marker)
.sign_with_keys(&nostr::Keys::generate())
.expect("sign");
let event = buzz_core::StoredEvent::new(event, Some(channel_id));
let out = Captured::default();
let writer = out.clone();
let _guard = tracing::subscriber::set_default(
tracing_subscriber::fmt()
.json()
.flatten_event(true)
.with_max_level(tracing::Level::DEBUG)
.with_writer(move || writer.clone())
.finish(),
);
let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default()));
engine.on_event(community, &event).await.expect("on_event");
let run = tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
let runs = db
.list_workflow_runs(community, step_wf, 10)
.await
.expect("list runs");
if let Some(run) = runs.into_iter().find(|r| r.status == RunStatus::Failed) {
break run;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
})
.await
.expect("the step-condition run must fail");
let filter_runs = db
.list_workflow_runs(community, filter_wf, 10)
.await
.expect("list filter runs");
assert!(
filter_runs.is_empty(),
"a failed filter must skip the workflow"
);
let raw = String::from_utf8(out.0.lock().unwrap().clone()).unwrap();
let lines: Vec<serde_json::Value> = raw
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
let line = |message: &str| {
lines
.iter()
.find(|l| l["message"] == message)
.unwrap_or_else(|| panic!("no {message:?} line in {raw}"))
};
assert_eq!(
line("Trigger filter error — skipping workflow")["error_kind"],
"condition_evaluation_failed"
);
assert_eq!(
line("Condition error")["error_kind"],
"condition_evaluation_failed"
);
assert_eq!(
line("Workflow run failed")["error"],
"condition_evaluation_failed"
);
assert!(!raw.contains(&marker), "marker leaked: {raw}");
assert!(
run.error_message
.as_deref()
.is_some_and(|m| m.contains(&marker)),
"the stored run error keeps the diagnostic: {:?}",
run.error_message
);
}
/// Every run records what caused it: the signer of the triggering message,
/// reaction or diff (for a reaction, the reactor, not the author of the
/// message reacted to), or the timer for cron and interval schedules.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn runs_record_their_cause() {
use buzz_core::kind::{KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF};
use buzz_db::workflow::RunCause;
let db = setup_db().await;
let creator = nostr::Keys::generate().public_key().to_bytes().to_vec();
let owner = nostr::Keys::generate();
let outsider = nostr::Keys::generate();
let owner_bytes = owner.public_key().to_bytes().to_vec();
let outsider_bytes = outsider.public_key().to_bytes().to_vec();
let (community, channel_id) = setup_channel(&db, &creator, &owner_bytes).await;
let mut workflows = Vec::new();
for (name, trigger) in [
("cause-message", serde_json::json!({"on": "message_posted"})),
(
"cause-reaction",
serde_json::json!({"on": "reaction_added"}),
),
("cause-diff", serde_json::json!({"on": "diff_posted"})),
(
"cause-cron",
serde_json::json!({"on": "schedule", "cron": "* * * * *"}),
),
(
"cause-interval",
serde_json::json!({"on": "schedule", "interval": "1m"}),
),
] {
let def = serde_json::json!({
"name": name,
"trigger": trigger,
"steps": [{"id": "s1", "action": "send_message", "text": "hi"}],
"enabled": true,
});
let id = db
.create_workflow(
community,
Some(channel_id),
&owner_bytes,
name,
&def.to_string(),
&[workflows.len() as u8; 32],
)
.await
.expect("create workflow");
workflows.push(id);
}
let [message_wf, reaction_wf, diff_wf, cron_wf, interval_wf] =
<[Uuid; 5]>::try_from(workflows).expect("five workflows created");
let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default()));
let event = |keys: &nostr::Keys, kind: u32, tags: Vec<nostr::Tag>| {
let event = nostr::EventBuilder::new(nostr::Kind::Custom(kind as u16), "+")
.tags(tags)
.sign_with_keys(keys)
.expect("sign");
buzz_core::StoredEvent::new(event, Some(channel_id))
};
let latest_cause = |workflow_id| {
let db = db.clone();
async move {
db.list_workflow_runs(community, workflow_id, 10)
.await
.expect("list runs")
.into_iter()
.max_by_key(|run| run.created_at)
.expect("a run was created")
.cause
}
};
let owner_message = event(&owner, KIND_STREAM_MESSAGE, vec![]);
let outsider_message = event(&outsider, KIND_STREAM_MESSAGE, vec![]);
for (message, signer) in [
(&owner_message, &owner_bytes),
(&outsider_message, &outsider_bytes),
] {
engine.on_event(community, message).await.expect("message");
assert_eq!(
latest_cause(message_wf).await,
Some(RunCause::Event(signer.clone()))
);
}
for (reactor, target, signer) in [
(&owner, &outsider_message, &owner_bytes),
(&outsider, &owner_message, &outsider_bytes),
] {
let target_tag = nostr::Tag::parse(["e", &target.event.id.to_hex()]).expect("e tag");
let reaction = event(reactor, KIND_REACTION, vec![target_tag]);
engine
.on_event(community, &reaction)
.await
.expect("reaction");
assert_eq!(
latest_cause(reaction_wf).await,
Some(RunCause::Event(signer.clone())),
"a reaction's cause is its signer, not the reacted-to author"
);
}
let diff = event(&outsider, KIND_STREAM_MESSAGE_DIFF, vec![]);
engine.on_event(community, &diff).await.expect("diff");
assert_eq!(
latest_cause(diff_wf).await,
Some(RunCause::Event(outsider_bytes.clone()))
);
// The first tick fires the cron and seeds the interval anchor; the
// interval fires once a full interval has elapsed.
let now = Utc::now();
engine.cron_tick(now).await;
assert_eq!(latest_cause(cron_wf).await, Some(RunCause::Schedule));
engine.cron_tick(now + chrono::Duration::seconds(61)).await;
assert_eq!(latest_cause(interval_wf).await, Some(RunCause::Schedule));
}
}