Skip to content

feat(workflow): implement WF-08 approval gates - #3327

Open
afkehaya wants to merge 7 commits into
block:mainfrom
afkehaya:feat/wf-08-approval-gates
Open

feat(workflow): implement WF-08 approval gates#3327
afkehaya wants to merge 7 commits into
block:mainfrom
afkehaya:feat/wf-08-approval-gates

Conversation

@afkehaya

@afkehaya afkehaya commented Jul 28, 2026

Copy link
Copy Markdown

Summary

Implements WF-08: the missing bridge between the workflow executor's StepResult::Suspended and 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

No prior issue was opened for this specific contribution — none found that covers the Interchange pattern-porting angle specifically.

What it does

  • Suspended trace entry — when the executor hits a request_approval step, it generates a CSPRNG token and returns Suspended with a redacted trace entry (the raw token never appears in traces)
  • emit_approval_requested on ActionSink — new trait method + RelayActionSink impl that builds a kind:46010 Nostr event with d/h/p/buzz:workflow tags, persists it, and fans out to channel subscribers
  • persist_approval_gate in finalize_run — replaces the WF-08 stub with the real pipeline: fetch workflow metadata, transition run to WaitingApproval, create approval record, emit kind:46010 (best-effort)
  • Approver spec validationschema.rs validate() now rejects unsupported from: formats at definition time (only "", "any", and 64-char hex pubkeys are accepted)
  • Race condition fix — flipped write order in persist_approval_gate so status transitions before record creation, with rollback on failure
  • resolve_channel helper — extracts ~45 lines of shared validation between send_message and emit_approval_requested
  • 7 E2E tests — grant resumes, deny cancels, kind:46010 emission, no-approval regression, expired token rejection, wrong approver rejection, double-grant idempotency

Key design decisions from Interchange

  • Durable-before-transmit ordering (from Interchange's 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.
  • Absolute-timeout design (from Interchange's packages/inference/src/gates.ts) — store a computed expires_at timestamp rather than running countdown timers. The grant handler checks expires_at < now with no background jobs needed.
  • Status-before-record write order — transition the run to WaitingApproval before creating the approval record, so a fast grant sees the correct status. If record creation fails, a rollback marks the run as Failed.

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 pass
  • cargo clippy -p buzz-workflow -p buzz-relay -p buzz-test-client -- -D warnings — clean
  • cargo fmt --check — clean
  • cargo check -p buzz-test-client --tests — 7 E2E tests compile
  • E2E tests against live relay (cargo test --test e2e_workflow_approval -- --ignored)

@afkehaya
afkehaya requested a review from a team as a code owner July 28, 2026 15:44
Alexander Kehaya and others added 7 commits July 28, 2026 12:02
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>

@mfanafuthimhlanga mfanafuthimhlanga left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants