Skip to content

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

Description

@jsilets

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=<workflow_id>, 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 <ch> --yaml -   # text: "EXEC-V1"
{"accepted":true,"event_id":"7fefe8fa…","message":"response:{…\"workflow_id\":\"867b6baa-…\"}"}

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

$ buzz workflows trigger --workflow 867b6baa-…
$ buzz messages get --channel <ch>
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:

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions