block / block/buzz

relay: same-second command events lose a coordinate tie-break and are discarded as accepted:true — workflow updates and manual triggers silently vanish

Open
#3,468 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

## Summary

`persist_command_event` applies NIP-33 coordinate replacement to **every command event that carries a `d` tag**, gated on the tag's presence rather than on the kind being parameterized-replaceable. `created_at` is second-resolution, so when two command events for the same coordinate land in the same wall-clock second, the stale-write guard breaks the tie on event id and the loser is discarded — reported as `accepted: true`, exit 0.

Two consequences:

1. **Workflow definition updates are lost.** A `workflows update` issued in the same second as the preceding create/update is dropped *before* `upsert_workflow` runs, so the definition event and the scheduler both keep the old YAML. The executor then runs the stale steps.
2. **Manual triggers are swallowed.** Kind 46020 is not a replaceable kind, but it carries `d=`, so it goes through the same coordinate dedup. Repeat triggers within a second are silent no-ops. Same for 46030/46031 (approval grant/deny).

## Environment

- Buzz Desktop 0.5.0, macOS 26.2 (Apple Silicon)
- Relay: a self-hosted deployment of this repo, NIP-11 `version: 0.2.0`
- Source read at `6300a6b1d03e32c473c7b6568df663c8927565cf` (main)

## Reproduction

Create then update in the same second:

```
$ buzz workflows create --channel --yaml - # text: "EXEC-V1"
{"accepted":true,"event_id":"7fefe8fa…","message":"response:{…\"workflow_id\":\"867b6baa-…\"}"}

$ buzz workflows update --channel --workflow 867b6baa-… --yaml - # text: "EXEC-V2"
{"accepted":true,"message":"duplicate: already processed"} # exit 0

$ buzz workflows trigger --workflow 867b6baa-…
$ buzz messages get --channel
EXEC-V1 # the definition the operator replaced
```

With a >1s gap the same sequence works. Twelve updates in a tight loop lost 3 of 12, depending only on whether the new event id sorts below the stored one. Five manual triggers in one second produced 2 runs.

## Root cause

`crates/buzz-relay/src/handlers/command_executor.rs:128-190`, inside `persist_command_event`:

```rust
let d_tag = buzz_db::event::extract_d_tag(event);
if let Some(ref d_tag) = d_tag {

let dominated = created_at < existing_ts
|| (created_at == existing_ts && incoming_id >= existing_id.as_slice());
if dominated {
return Ok(PersistResult::Duplicate);
}
```

- The gate is `d_tag.is_some()`, not `is_parameterized_replaceable(kind)`. `buzz_core::kind::is_parameterized_replaceable` already exists and is used for exactly this in `ingest.rs:2420` and `side_effects.rs:2159`.
- For 30620, where replacement *is* correct, nothing prevents the same-second tie. `PersistResult::Duplicate` returns at `command_executor.rs:754`, before `upsert_workflow` at `:784`, so the control-plane row is not updated either.

The tie-break itself matches NIP-01. The bug is that nothing prevents the tie, and that losing it is reported as success.

## Related

The block was added by #1369 (merged 2026-06-30), which moved the NIP-33 stale-write guard inline into `persist_command_event`; #1340 notes the same move. Searched open and closed issues and PRs for `persist_command_event`, coordinate dedup, and same-second `created_at` ties — nothing else found for the tie-break itself.

## Prior art

The same failure was fixed on the DM-visibility path — `side_effects.rs:3182-3206` forces `created_at` strictly past any prior snapshot, with a regression test at `crates/buzz-test-client/tests/e2e_nostr_interop.rs:1324`. Those events are relay-signed. Kind 30620 is client-signed, so that guard cannot be applied relay-side without breaking the signature.

## Suggested fix

1. Gate the dedup block on `is_parameterized_replaceable(kind_u32) && d_tag.is_some()`. Fixes triggers and approvals, which never belonged in this path.
2. Distinguish "this exact event id was already stored" from "a newer write lost the coordinate tie and was discarded". Today both return `accepted: true`, so a client cannot tell a replay from data loss; the latter should be a rejection with a non-zero exit.
3. With (2) in place, `cmd_update_workflow` can sign with `custom_created_at(max(now, prior + 1))` — the DM-path guard, moved client-side because the event is client-signed.

(1) and (2) are load-bearing; (3) makes back-to-back scripted updates reliable rather than merely honest about failing.

Scripted setup hits this constantly, because batched calls land in the same second by construction.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.