feat(workflow): implement WF-08 approval gates - #3327
Conversation
When a workflow step returns StepResult::Suspended, push a trace entry with status "suspended" before returning. The raw approval token is excluded from the trace for security — only a redacted flag is recorded. This ensures execute_from_step() can reconstruct complete step history on resume after an approval grant. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Alexander Kehaya <alexanderkehaya@Mac.attwifi.manager>
Extend the ActionSink trait with emit_approval_requested() for emitting kind:46010 relay-signed events when a workflow hits a request_approval step. The RelayActionSink implementation follows the send_message pattern: resolves community context, builds the event with d/h/p/ buzz:workflow tags, signs with the relay keypair, persists, and dispatches for fan-out. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Alexander Kehaya <alexanderkehaya@Mac.attwifi.manager>
Replace the WF-08 stub that marked approval-gate runs as Failed with the full persistence path: fetch the workflow definition to extract step metadata, create the approval record in workflow_approvals (token hashed with SHA-256), transition the run to WaitingApproval, and emit a kind:46010 relay-signed event to notify approvers. Event emission is best-effort — the approval record and run status are persisted first (durable-before-transmit ordering) so the grant/deny handlers can resume or cancel even if the notification fails. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Alexander Kehaya <alexanderkehaya@Mac.attwifi.manager>
Add e2e_workflow_approval.rs with four integration tests covering the full WF-08 approval gate flow: - approval_grant_resumes_workflow: trigger → suspend → grant → resume - approval_deny_cancels_workflow: trigger → suspend → deny → cancel - approval_emits_kind_46010: verify relay-signed notification event - workflow_without_approval_completes_normally: regression for R5 All tests are #[ignore] (require running Postgres + Redis). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Alexander Kehaya <alexanderkehaya@Mac.attwifi.manager>
- Bundle emit_approval_requested params into ApprovalRequestParams struct (fixes clippy too_many_arguments) - Fix import ordering for rustfmt (sha2 after dashmap) - Change approver spec from '@anyone' to 'any' in E2E and DB tests (matches check_approver_spec allowlist) - Remove stale TODO (WF-08) comment from executor.rs - Add non-vacuousness guards to suspended trace redaction test - Guard channel_id None with early return instead of silent Uuid parse failure Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Alexander Kehaya <alexanderkehaya@Mac.attwifi.manager>
…st infra - Extract `resolve_channel` helper in workflow_sink.rs, deduplicating ~45 lines of state-upgrade/community-host/channel-validation between `send_message` and `emit_approval_requested` - Promote `hash_approval_token` to pub in buzz-db and call it from persist_approval_gate instead of hand-rolling sha2::Sha256::digest; removes the sha2 dependency from buzz-workflow entirely - Import kind constants from buzz_core::kind in E2E tests instead of redefining 7 local u16 constants - Cache reqwest::Client and relay HTTP URL in E2E tests via OnceLock (was creating a new connection pool on every poll iteration) - Destructure workflow record to avoid .definition.clone() - Remove change-narrating WF-08 comment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Alexander Kehaya <alexanderkehaya@Mac.attwifi.manager>
…overage - Flip write order in persist_approval_gate: update run to WaitingApproval before creating the approval record, with rollback on failure. Eliminates the window where a fast grant sees Running status and bails permanently. - Validate approver spec at workflow definition time in schema.rs validate(): reject unsupported formats like @anyone at parse time instead of grant time. Update existing test fixtures from @manager/@engineering-lead to 'any'. - Add three missing E2E tests: expired token rejection, wrong approver rejection, and double-grant idempotency (all #[ignore], require live infra). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Alexander Kehaya <alexanderkehaya@Mac.attwifi.manager>
667a889 to
8f7cd70
Compare
mfanafuthimhlanga
left a comment
There was a problem hiding this comment.
Read through this against #3525's caveat list. The shape looks right to me, and two decisions stand out as better than the obvious alternative.
Re-loading run → workflow → definition inside persist_approval_gate, rather than widening ExecutionResult, keeps the executor ignorant of persistence at the cost of two queries on a step that is about to block on a human. That seems like the right trade. And the _ => "step {step_id} at index {step_index} is not a RequestApproval action" arm is what actually defuses #3525's step_index caveat — an off-by-one becomes a loud definition error instead of a silent second token. Worth keeping even if the surrounding code moves.
Disclosure before the substance: I am looking at building a governance layer on top of approval gates, so I have a stake in point 2 below. Flagging that up front — the finding stands on its own, but you should know where I am coming from.
1. I do not think the tests in this PR run in CI.
just test-unit, which is what the Unit Tests job runs, covers buzz-core, buzz-auth, buzz-voice, buzz-cli, buzz-db --lib, buzz-conformance, and buzz-push-gateway. I could not find buzz-workflow in any cargo test or nextest invocation under .github/workflows/ or scripts/run-tests.sh, and buzz-relay's lib tests appear to run only when named by one of the four -E filters in the Backend Integration job.
I checked rather than assumed: on a fork I added a test, watched CI go green, then inverted the assertion so it had to fail — still green. They compile, because cargo clippy --workspace --all-targets builds test targets, but nothing executes them.
If that is right, the schema.rs changes here would pass or fail silently. Adding cargo nextest run -p buzz-workflow --lib to just test-unit made them run (154 tests, all passing). -p buzz-relay --lib is not safe to add wholesale — four tests in api::admin and api::media need Postgres but are not #[ignore]d, so they hang ~30s each and then fail. Is the gap known, or worth a separate PR?
2. The from validation is a behavioral change worth calling out explicitly.
ActionDef::RequestApproval documents the field as "User mention or role (e.g. @release-manager)" (schema.rs:134), and two existing tests encode that: parse_workflow_with_all_actions uses @manager, parse_approval_gate_example uses @engineering-lead. This PR rewrites both fixtures to 'any' and adds validate_rejects_at_anyone_approver_spec, while the doc comment still promises mentions.
The new validator faithfully mirrors check_approver_spec, which already fails closed on role specs — so today the documented syntax parses, saves, and then can never be approved. That is #2878, and moving the failure from grant time to definition time is a real improvement.
I pinned both halves in CI to be sure of it rather than arguing from a read:
PASS buzz-workflow schema::tests::accepts_documented_mention_syntax_at_definition_time_see_issue_2878
PASS buzz-relay handlers::command_executor::approver_spec_tests::rejects_documented_mention_syntax_see_issue_2878
My concern is that a plumbing PR ends up deciding, via a fixture edit, that mentions and roles are not coming to from:. Practically that leaves two options for anyone wanting a specific human to approve: hardcode a pubkey into every workflow definition, or use "any" and let anyone in the community approve.
So: is the empty / "any" / 64-hex allowlist intended as the final set, or is #2878 expected to widen it? If it is final, could the schema.rs:134 doc comment change alongside it? If it is meant to widen, keeping those two fixtures #[ignore]d with a pointer to #2878 would preserve the record better than rewriting them — and I would be glad to take the resolution work (from: "@handle" and from: "role:admin" against channel membership) as its own PR. workflow_sink.rs already has resolve_mention_pubkeys, so the primitive may largely exist.
3. #3525's double-approve caveat looks unaddressed.
This does not touch command_executor.rs, so the run.status != WaitingApproval guards at :1240 and :1296 stay check-then-act rather than an atomic WaitingApproval → Running. I think it is safe in practice — update_approval_by_stored_hash's if !updated serializes on the approval row, and a correct step_index means one run never has two live tokens — but that is three assumptions holding it up and none are written down. Worth a single-flight guard on run_id, or a comment recording why the row-level check suffices?
Two smaller things: parse_duration_secs(timeout_str).unwrap_or(86400) silently swallows a malformed timeout, which the new definition-time validation seems well placed to catch instead; and a workflow with no channel_id persists the gate but returns before emitting kind:46010, leaving the run suspended with nobody notified.
Happy to be wrong on any of these.
Summary
Implements WF-08: the missing bridge between the workflow executor's
StepResult::Suspendedand the existing approval grant/deny/resume infrastructure.This PR ports proven approval-gate patterns from Interchange into Buzz's Nostr-native workflow engine as an open-source contribution. Interchange's workflow state-machine and inference gate layers already solved durable approval gating with battle-tested patterns — rather than reinventing from scratch, this adapts those concepts for relay-signed Nostr events and NIP-33 parameterized-replaceable semantics.
Relationship to existing work
finalize_run,ActionSink::emit_approval_requested,hash_approval_tokenpromoted to pub). Our PR differs in: the race condition fix (status-before-record write order with rollback), definition-time approver spec validation inschema.rs, theresolve_channelhelper extraction, and the Interchange-informed design decisions documented below. Happy to consolidate or defer to whichever approach maintainers prefer.from:syntax is rejected at approval time. Our PR fixes this:schema.rs validate()now rejects unsupported specs at definition time, and all test fixtures use the accepted"any"format.request_approvalaction.No prior issue was opened for this specific contribution — none found that covers the Interchange pattern-porting angle specifically.
What it does
request_approvalstep, it generates a CSPRNG token and returnsSuspendedwith a redacted trace entry (the raw token never appears in traces)emit_approval_requestedon ActionSink — new trait method +RelayActionSinkimpl that builds a kind:46010 Nostr event withd/h/p/buzz:workflowtags, persists it, and fans out to channel subscriberspersist_approval_gateinfinalize_run— replaces the WF-08 stub with the real pipeline: fetch workflow metadata, transition run toWaitingApproval, create approval record, emit kind:46010 (best-effort)schema.rs validate()now rejects unsupportedfrom:formats at definition time (only"","any", and 64-char hex pubkeys are accepted)persist_approval_gateso status transitions before record creation, with rollback on failureresolve_channelhelper — extracts ~45 lines of shared validation betweensend_messageandemit_approval_requestedKey design decisions from Interchange
packages/workflow/src/state-machine/) — persist the approval record and run status to Postgres before emitting the notification event. If event emission fails, the approval is still actionable via the token hash.packages/inference/src/gates.ts) — store a computedexpires_attimestamp rather than running countdown timers. The grant handler checksexpires_at < nowwith no background jobs needed.WaitingApprovalbefore creating the approval record, so a fast grant sees the correct status. If record creation fails, a rollback marks the run asFailed.Quality process
The implementation went through two critique loops (12 findings, all resolved), a three-reviewer simplification pass (reuse, quality, efficiency — 6 improvements applied), and a final fix round for the race condition, definition-time validation, and missing E2E coverage.
Test plan
cargo test -p buzz-workflow— 156 tests passcargo clippy -p buzz-workflow -p buzz-relay -p buzz-test-client -- -D warnings— cleancargo fmt --check— cleancargo check -p buzz-test-client --tests— 7 E2E tests compilecargo test --test e2e_workflow_approval -- --ignored)