Skip to content

feat(workflow): persist and resume approval gates (WF-08)#2377

Open
brocoppler wants to merge 1 commit into
block:mainfrom
brocoppler:feat/workflow-approval-gates
Open

feat(workflow): persist and resume approval gates (WF-08)#2377
brocoppler wants to merge 1 commit into
block:mainfrom
brocoppler:feat/workflow-approval-gates

Conversation

@brocoppler

Copy link
Copy Markdown

What this solves

Closes the WF-08 gap called out in VISION.md and ARCHITECTURE.md (known-gap #5): runs that hit request_approval were marked Failed with "approval gates not yet implemented". The resume half — grant/deny handlers, resume_workflow_after_approval, execute_from_step, workflow_approvals CRUD, migration, CLI, SDK — already existed but was unreachable because the suspend half never persisted anything. This PR wires that half. Design decisions are laid out in #2376 for sign-off.

How it works

  • Executor (buzz-workflow): StepResult::Suspended now carries a PendingApproval (token, step id, resolved approver spec, message, timeout), captured where the resolved action is in scope. The approver spec is resolved before suspension — "any", 64-char hex, or npub1…, each with optional leading @. Unresolvable specs (display names) fail the step with an actionable error instead of minting an approval check_approver_spec could never authorize. Invalid timeout: strings now error at the step as well.
  • Engine (finalize_run): the Failed stub is replaced with arm-then-announce: (1) create_approval row — raw token hashed by buzz-db; (2) run → WaitingApproval at the suspended step with the accumulated trace; (3) announce via the new ActionSink::emit_approval_requested. Ordering means a half-armed gate is never observable. Any persistence failure marks the run Failed; announce failures are logged at ERROR but don't fail the run (the gate is armed and grantable by token).
  • Relay sink: emits kind:46010 signed by the relay keypair — d tag = SHA-256 token hash (the grant handlers' get_approval_by_stored_hash key, derived from the now-pub buzz_db::workflow::hash_approval_token, keeping every hash derivation on one definition), p tag = resolved approver or the workflow owner for "any" (so the request lands in query_needs_action), h tag when channel-scoped, and content JSON carrying the raw token, message, workflow/run/step ids, and expiry. Persist + fan-out mirror the send_message path (insert_event_with_thread_metadata + dispatch_persistent_event).

With the row minted and the run in WaitingApproval, the existing grant path (handle_approval_grant → spawn resume_workflow_after_approval(step_index + 1)) and deny path (→ Cancelled) operate unchanged — no relay handler changes.

Testing

  • New unit tests for approver-spec resolution (any/hex/@-prefixed/npub round-trip via bech32, display-name and malformed inputs fail closed).
  • cargo test -p buzz-workflow: 153 passed, 0 failed. cargo clippy --all-targets --all-features clean across buzz-workflow/buzz-relay/buzz-db; fmt clean.
  • cargo test -p buzz-relay --lib: 719 passed; 1 failure in api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo which reproduces identically on clean main in my environment (passes standalone, fails under the parallel suite) — pre-existing flake, unrelated to this change; can file separately.
  • Not yet covered: an end-to-end integration test (trigger → suspend → grant → resume → complete). The two #[ignore]d conformance rows (approval_token_is_community_confined, workflow_trigger_is_community_confined) are now unblocked — I'd like to rewrite them as a follow-up PR unless you want them in this one.

Follow-ups (deliberately out of scope)

  • kind:46011/46012 lifecycle emission from the grant/deny handlers.
  • Display-name approver resolution via the member directory (needs the same ambiguity policy as resolve_mention_pubkeys).
  • Rewriting the two pending_lane conformance rows against the live gate.

🤖 Generated with Claude Code

Runs that hit a request_approval step were marked Failed with 'approval
gates not yet implemented' — the executor minted a token and returned
Suspended, but finalize_run discarded it, so the grant/deny handlers,
resume_workflow_after_approval, and the workflow_approvals CRUD were all
dead code. This wires the missing suspend half:

- executor: StepResult::Suspended now carries PendingApproval (token,
  step_id, resolved approver spec, message, timeout). The approver spec
  is resolved up front — "any", hex pubkey, or npub1…, with or without
  a leading @ — and unresolvable specs (display names) fail the step
  loudly instead of minting an approval that check_approver_spec could
  never authorize. Invalid timeout strings now error at the step too.
- finalize_run: replaces the Failed stub with arm-and-announce ordering:
  create the workflow_approvals row (raw token hashed by buzz-db), move
  the run to WaitingApproval at the suspended step, then announce via
  the new ActionSink::emit_approval_requested. Any persistence failure
  marks the run Failed so a run never waits on an ungrantable gate; the
  announcement is best-effort once the gate is armed, logged at ERROR
  on failure.
- relay sink: emits kind:46010 signed by the relay keypair — d tag =
  SHA-256 token hash (the grant handlers' lookup key, now derived from
  the shared buzz_db::workflow::hash_approval_token), p tag = resolved
  approver (or workflow owner for "any") so the request lands in the
  needs-action feed, h tag when the workflow is channel-scoped, raw
  token + message + run/step metadata in the JSON content.

With the row minted and the run in WaitingApproval, the existing grant
path (handle_approval_grant → resume_workflow_after_approval →
execute_from_step) and deny path (→ Cancelled) operate unchanged.

Not covered here, noted for follow-up: kind:46011/46012 lifecycle
emission on grant/deny, approval-token multi-tenant conformance rows
(currently #[ignore]d pending_lane), and display-name approver
resolution via the member directory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Broc Oppler <brocoppler@gmail.com>
@brocoppler
brocoppler force-pushed the feat/workflow-approval-gates branch from 4f34ee5 to 38b2681 Compare July 22, 2026 21:04
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.

1 participant