fix(relay): report workflow deletions that match nothing

A kind 5 deletion addressing a 30620 workflow coordinate is accepted and
stored per NIP-09 even when no workflow or definition exists under that
owner (or the live definition is newer). The OK response was an empty
message either way, so clients such as `buzz workflows delete` reported
success for deletions that did nothing.

`insert_workflow_deletion` now returns its `WorkflowDeletionOutcome`
alongside the dispatch flag, and ingest keeps `accepted: true` but sets
the message to `info: no matching workflow found for deletion` when
nothing was removed. Real removals and legacy repairs still return an
empty message; repeated identical requests still return `duplicate:`.

The approach and message wording come from Orland00's #2242, which
predates the atomic deletion path from #7735.

Signed-off-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz>
This commit is contained in:
Fizz
2026-10-13 13:34:04 -07:00
parent 8599efb6a3
commit 367a62e4c2
3 changed files with 116 additions and 10 deletions

View File

@@ -48,6 +48,7 @@ impl Db {
/// The returned boolean grants dispatch ownership: either this transaction
/// inserted the request, or it repaired an older relay's incomplete deletion.
/// Concurrent identical requests cannot split those two outcomes across commits.
/// The returned outcome reports whether anything was actually removed.
/// Authorization of the coordinate is the caller's responsibility.
pub async fn insert_workflow_deletion(
&self,
@@ -55,7 +56,7 @@ impl Db {
event: &Event,
owner_pubkey: &[u8],
d_tag: &str,
) -> Result<(StoredEvent, bool, Option<Uuid>)> {
) -> Result<(StoredEvent, bool, WorkflowDeletionOutcome)> {
let mut tx = self.begin_event_write_transaction(community_id).await?;
let (stored, inserted) =
crate::event::insert_event_in_transaction(&mut tx, event, None).await?;
@@ -72,7 +73,8 @@ impl Db {
)
.await?;
tx.commit().await?;
Ok((stored, inserted || outcome.changed, outcome.channel_id))
let dispatch = inserted || outcome.changed;
Ok((stored, dispatch, outcome))
}
}

View File

@@ -2378,6 +2378,10 @@ fn validate_canvas_future_timestamp(event_ts: i64, now: i64) -> Result<(), &'sta
Ok(())
}
/// OK message for an accepted workflow deletion that removed nothing: the
/// workflow does not exist under the addressed owner, or is newer than the request.
const WORKFLOW_DELETION_NO_MATCH: &str = "info: no matching workflow found for deletion";
async fn ingest_event_inner(
state: &Arc<AppState>,
tracer: &Arc<dyn buzz_conformance::Tracer>,
@@ -3398,13 +3402,23 @@ async fn ingest_event_inner(
};
let workflow_deletion = crate::handlers::side_effects::is_workflow_deletion(&event);
let mut message = String::new();
let (stored_event, was_inserted) = if workflow_deletion {
// A single commit owns public acceptance, domain mutation, and dispatch.
// Failure rolls everything back; identical concurrent requests cannot
// divide insertion and repair ownership between two relay workers.
crate::handlers::side_effects::persist_workflow_deletion(tenant, &event, state)
.await
.map_err(|e| IngestError::Internal(format!("error: workflow deletion failed: {e}")))?
let (stored, dispatch, removed) =
crate::handlers::side_effects::persist_workflow_deletion(tenant, &event, state)
.await
.map_err(|e| {
IngestError::Internal(format!("error: workflow deletion failed: {e}"))
})?;
// The request is stored either way (NIP-09), but `accepted` alone would
// read as success to a client whose deletion matched nothing.
if !removed {
message = WORKFLOW_DELETION_NO_MATCH.into();
}
(stored, dispatch)
} else if buzz_core::kind::is_replaceable(kind_u32) {
// NIP-16 replaceable event — atomic replace with stale-write protection.
// channel_id is None for global kinds (0, 1, 3) due to step 5b above.
@@ -3581,7 +3595,7 @@ async fn ingest_event_inner(
Ok(IngestResult {
event_id: event_id_hex,
accepted: true,
message: String::new(),
message,
})
}
@@ -6917,6 +6931,93 @@ mod postgres_tests {
assert_eq!(stored.channel_id, Some(channels[0]));
}
/// A workflow deletion that removes nothing is still stored (NIP-09), but
/// its OK message says so; a deletion that removes a workflow stays silent.
/// Mutation: always report removal from `persist_workflow_deletion` → RED.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn workflow_deletion_reports_when_nothing_matched() {
use nostr::{Keys, Tag};
let state = ingest_state().await;
let host = format!("workflow-delete-noop-{}.test", Uuid::new_v4().simple());
let community = state
.db
.ensure_configured_community(&host)
.await
.expect("ensure community")
.id;
let tenant = TenantContext::resolved(community, &host);
let tracer: Arc<dyn buzz_conformance::Tracer> = Arc::new(VecTracer::default());
let owner = Keys::generate();
let owner_bytes = owner.public_key().to_bytes();
state
.db
.ensure_user(community, &owner_bytes)
.await
.expect("ensure owner");
let workflow_id = Uuid::new_v4();
state
.db
.upsert_workflow(
community,
workflow_id,
None,
&owner_bytes,
"noop-deletion-test",
r#"{"trigger":{"on":"manual"},"steps":[]}"#,
&[0; 32],
)
.await
.expect("seed workflow");
let delete = |d_tag: &str| {
let coordinate = format!("{KIND_WORKFLOW_DEF}:{}:{d_tag}", owner.public_key());
EventBuilder::new(Kind::EventDeletion, "")
.tags([Tag::parse(["a", &coordinate]).unwrap()])
.sign_with_keys(&owner)
.expect("sign deletion")
};
let auth = || IngestAuth::Http {
pubkey: owner.public_key(),
scopes: vec![Scope::MessagesWrite],
auth_method: HttpAuthMethod::Nip98,
};
let missing = delete(&Uuid::new_v4().to_string());
let missing_id = missing.id;
let result = ingest_event_inner(&state, &tracer, &tenant, missing, auth())
.await
.expect("no-op deletion is accepted");
assert!(result.accepted);
assert_eq!(result.message, WORKFLOW_DELETION_NO_MATCH);
assert!(
state
.db
.get_event_by_id(community, missing_id.as_bytes())
.await
.expect("read request")
.is_some(),
"the request is stored per NIP-09"
);
let result = ingest_event_inner(
&state,
&tracer,
&tenant,
delete(&workflow_id.to_string()),
auth(),
)
.await
.expect("deletion is accepted");
assert!(result.accepted);
assert_eq!(result.message, "");
assert!(matches!(
state.db.get_workflow(community, workflow_id).await,
Err(buzz_db::DbError::NotFound(_))
));
}
// ── Owner-aware ban/timeout coverage ─────────────────────────────────────
/// Fresh community plus an agent owned by `owner` (users.agent_owner_pubkey).

View File

@@ -2298,11 +2298,14 @@ pub(crate) fn is_workflow_deletion(event: &Event) -> bool {
}
/// Persist an already-authorized workflow deletion and its domain changes atomically.
///
/// Returns the stored request, whether this ingest owns dispatch, and whether
/// the deletion removed a workflow or definition.
pub(crate) async fn persist_workflow_deletion(
tenant: &TenantContext,
event: &Event,
state: &Arc<AppState>,
) -> anyhow::Result<(buzz_core::StoredEvent, bool)> {
) -> anyhow::Result<(buzz_core::StoredEvent, bool, bool)> {
let coordinate = event
.tags
.iter()
@@ -2315,16 +2318,16 @@ pub(crate) async fn persist_workflow_deletion(
let d_tag = parts
.next()
.ok_or_else(|| anyhow::anyhow!("invalid workflow coordinate"))?;
let (stored, dispatch, channel_id) = state
let (stored, dispatch, outcome) = state
.db
.insert_workflow_deletion(tenant.community(), event, &owner, d_tag)
.await?;
if let Some(channel_id) = channel_id {
if let Some(channel_id) = outcome.channel_id {
state
.workflow_engine
.invalidate_channel_workflows(tenant.community(), channel_id);
}
Ok((stored, dispatch))
Ok((stored, dispatch, outcome.changed))
}
/// Handle NIP-09 deletion via `a` tag (addressable/parameterized-replaceable events).