Repository navigation
Description
Summary
Workflows created while a relay ran the pre-#1369 code path (≤ relay-v0.1.1) are permanently unmanageable — invisible orphans that keep firing on their cron schedules and cannot be disabled or deleted through any client-visible ID. We hit this in production (staging relay sprout-oss) and it took several days and a code-level investigation to stop twice-daily ghost messages.
Background: how the orphans are created
Before #1369 ("Harden relay attack surfaces", merged 2026-06-30), handle_workflow_def called create_workflow, which generated a random server-side UUID (Uuid::new_v4()) for the DB row and ignored the kind:30620 event's d-tag:
// crates/buzz-db/src/workflow.rs (relay-v0.1.1)
pub async fn create_workflow(...) -> Result<Uuid> {
let id = Uuid::new_v4(); // <-- never matches the event d-tag
...
}Clients (CLI buzz workflows list, desktop) list workflows by querying kind:30620 events and reading the d-tag as the workflow ID. So every workflow ID a user has ever seen never matched the actual workflows row. Consequences:
- Disable is a no-op: re-submitting the definition with
enabled: false(same d-tag) created a new DB row under yet another random UUID. The originalenabled: truerow keeps firing. - Delete is a no-op: kind:5 with a-tag
30620:<owner>:<visible-uuid>looks up a UUID that doesn't exist in the DB. Worse, the relay returnsaccepted: truewith no error, so the user believes the deletion worked (side-effect failures are logged server-side only). - After the relay was upgraded past Harden relay attack surfaces #1369 (upsert-by-d-tag), all new operations behave correctly — but the legacy orphan rows remain, enabled, firing forever, with UUIDs no client can discover.
How we escaped
handle_a_tag_deletion has a name-based fallback: when the a-tag d-value is not a UUID, it resolves via find_workflow_by_owner_and_name(community, actor, name). This reaches orphans regardless of UUID. The stock CLI rejects non-UUID values (parse_uuid), so we patched cmd_delete_workflow locally to emit 30620:<owner>:<name> and swept the orphans (repeating, since the lookup is LIMIT 1). Verified with webhook canaries (202 → delete → 404).
Proposed fixes
- Orphan reconciliation (the actual long-term fix). A migration or startup/admin sweep that deletes (or at minimum disables + logs)
workflowsrows whoseiddoes not match any live kind:30620 event's d-tag for the same community. Every community that created workflows before the relay crossed Harden relay attack surfaces #1369 has these landmines. - Surface side-effect failures for deletions. kind:5 processing returns
accepted: trueeven when the workflow deletion hitsNotFound. The OK/response message should indicate "no matching workflow" so users don't believe a no-op succeeded. (Same applies to disable-via-update flows.) - CLI: support name-based deletion. The relay already supports it and it's the only recovery path for ID-mismatch situations:
buzz workflows delete --name <workflow-name>. - Name-based deletion ignores the a-tag pubkey.
handle_a_tag_deletion's name branch looks up byactor_bytes(the deletion signer), so theis_agent_ownerallowance invalidate_standard_deletion_event(humans managing agent-owned content, feat(relay): allow agent owners to edit/manage agent-owned content #1403) is dead code on this path — a human owner cannot clean up their agent's orphans by name. The lookup should honor the a-tag's pubkey component after ownership validation passes.
Related
- fix(workflows): soft-delete kind:30620 event on authorized deletion #1340 (soft-delete of the kind:30620 event row on authorized deletion — the visibility half of this problem)
- relay: NIP-09 a-tag deletion of kind:30620 (workflow def) leaves the events row alive #717, relay: NIP-09 a-tag deletion is a no-op for kind:30023 (and all non-workflow addressables) #714 (deletion side-effect gaps)
- Harden relay attack surfaces #1369 (fixed d-tag upsert going forward, but did not reconcile existing rows)
Repro sketch
Not directly reproducible on current main (creation is fixed); the failure mode requires rows created under ≤ relay-v0.1.1. To simulate: insert a workflows row with enabled=true and a random id unrelated to any 30620 event d-tag, then attempt to disable/delete it through the CLI.
Activity
AaronGoldsmith commentedon Jul 9, 2026
Update 2026-07-09: the "escape hatch" doesn't work either — orphans are fully unreachable
Correction to the "How we escaped" section above: we did not actually escape. The name-based deletion sweep appeared to succeed (
accepted:trueon every run) but relay logs show every attempt was a silent no-op, and the orphan crons kept firing on 07-08 and 07-09.A fifth bug: pre-#1369 rows have
name= d-tag UUID stringThe ≤ relay-v0.1.1 create path set the row name from the event's
nametag, falling back to the d-tag:…and
buzz_sdk::build_workflow_defonly emitsdandhtags — nonametag. So every CLI-created workflow row from that era hasname = "<uuid-string>", not the YAMLname:.Consequences for the orphans:
Uuid::parse_strand routes to the UUID branch (DELETE … WHERE id = …), which misses because the rowidis a different random v4.There is no event a client can sign that reaches these rows. Client-side remediation is impossible, which upgrades proposed fix #1 (orphan reconciliation) from "nice to have" to the only fix.
Evidence (Datadog,
service:sprout env:staging)NIP-09 a-tag deletion: no workflow 'sims-daily-pr-triage' found for owner(warn) — one for every name-based deletion attempt by three different owners on 07-07 and 07-09. All receivedaccepted:true(bug Initial release — Sprout Nostr relay with enterprise extensions #2 in action — this silent-success behavior misled us into declaring victory twice).Workflow SendMessage: posting kind 9 eventcontinued firing on schedule: 07-08 09:00/17:00×2, 07-09 09:00.Workflow run failed: webhook error: channel is archived: 51f7899e…, 1×channel not found: 430f0276…, plus an orphan posting into an unrelated channel under a different owner. ≥10 orphan rows fire daily on sprout-oss staging alone.Reconciliation sweep sketch
Given the name bug, orphans are cheaply identifiable without joining against events:
(The stricter "id matches no live 30620 d-tag" join from proposed fix #1 is still the right general-purpose migration; the above catches the pre-#1369 CLI-created population specifically.)
Until a sweep runs, the affected rows fire forever; our only working mitigation is archiving the destination channel (orphan runs into archived channels error out without posting — confirmed in logs above).
Orland00 commentedon Jul 21, 2026
Confirmed both core bugs against current
main, and I'm working on a PR for the going-forward half.The validation/execution owner mismatch is real and is the root of the "no workflow found for owner" no-ops.
validate_standard_deletion_event(crates/buzz-relay/src/handlers/side_effects.rs) correctly authorizes against the a-tag pubkey (including theis_agent_ownerallowance), buthandle_a_tag_deletionthen parsesparts[1]intopubkey_hexand never uses it — both the UUID delete and the name fallback are scoped byactor_bytes(the deletion signer). So a human owner deleting an agent-owned workflow passes validation and then silently deletes nothing. The name fallback additionally inheritsLIMIT 1(noORDER BY) fromfind_by_owner_and_nameincrates/buzz-db/src/workflow.rs, so with N same-named rows it removes an arbitrary one.From a cost angle this class of bug is nasty: a scheduled workflow whose owner has lost every kill switch is recurring compute/API spend with no accountable off switch — and @AaronGoldsmith's update shows
accepted: trueon no-op deletions actively misleads operators into believing they've stopped it.PR scope I have in flight (going-forward fixes only):
handle_a_tag_deletionexecutes against the a-tag owner (authz unchanged — validation already checks it; this aligns execution with validation).LIMIT 1.accepted: true(your fix proposal Initial release — Sprout Nostr relay with enterprise extensions #2).Question for maintainers on the reconciliation half: given the 2026-07-09 update (pre-#1369 rows have
name= d-tag UUID string, so no client-signable event can ever reach them — client-side remediation is impossible), the orphan sweep is the only fix for existing rows. Would you prefer (a) a one-shot migration (0025_…), (b) a startup/admin reconciliation sweep, or (c) a documentedscripts/maintenance/SQL script (existing precedent)? Happy to implement whichever fits, as a separate PR — I'd rather not ship a row-deleting migration without maintainer buy-in on shape.— Orlando (Olive One)
fix(relay): execute a-tag workflow deletions against the a-tag owner,…
fix(relay): execute a-tag workflow deletions against the a-tag owner,…