feat(workflow): persist and resume approval gates (WF-08) - #2377
feat(workflow): persist and resume approval gates (WF-08)#2377brocoppler wants to merge 1 commit into
Conversation
d1423cf to
4f34ee5
Compare
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>
4f34ee5 to
38b2681
Compare
|
Reviewing against #2376 since the sign-off questions live there. I came at this from the authorization-flow side rather than the Rust. I was tracing who can grant one of these gates, and my assistant walked the source with me to check the flow matched. Decision 1, arm-then-announce: agree. Persist the row, move the run to Decision 2, fail-closed approver resolution: agree, and resolving before suspension beats minting an approval Decision 3, kind:46010 shape: agree on structure, changes requested. Deriving the The announcement isn't read-confined
So the announcement, message and On its own that's disclosure. Combined with an The raw token isn't a bearer credential, and that changes what needs fixing. Two requests:
Adjacent, happy to take both#2878. #2830. Neither collides with this PR's scope and #2376 already defers the first. Happy to take both on top of this branch once it lands, same working split as here: I map the flow, my assistant writes the Rust. @tlongwell-block, flagging given the recent authorization work on the ingest path. |
What this solves
Closes the WF-08 gap called out in VISION.md and ARCHITECTURE.md (known-gap #5): runs that hit
request_approvalwere marked Failed with "approval gates not yet implemented". The resume half — grant/deny handlers,resume_workflow_after_approval,execute_from_step,workflow_approvalsCRUD, 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
buzz-workflow):StepResult::Suspendednow carries aPendingApproval(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, ornpub1…, each with optional leading@. Unresolvable specs (display names) fail the step with an actionable error instead of minting an approvalcheck_approver_speccould never authorize. Invalidtimeout:strings now error at the step as well.finalize_run): the Failed stub is replaced with arm-then-announce: (1)create_approvalrow — raw token hashed bybuzz-db; (2) run →WaitingApprovalat the suspended step with the accumulated trace; (3) announce via the newActionSink::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).dtag = SHA-256 token hash (the grant handlers'get_approval_by_stored_hashkey, derived from the now-pubbuzz_db::workflow::hash_approval_token, keeping every hash derivation on one definition),ptag = resolved approver or the workflow owner for"any"(so the request lands inquery_needs_action),htag when channel-scoped, and content JSON carrying the raw token, message, workflow/run/step ids, and expiry. Persist + fan-out mirror thesend_messagepath (insert_event_with_thread_metadata+dispatch_persistent_event).With the row minted and the run in
WaitingApproval, the existing grant path (handle_approval_grant→ spawnresume_workflow_after_approval(step_index + 1)) and deny path (→ Cancelled) operate unchanged — no relay handler changes.Testing
cargo test -p buzz-workflow: 153 passed, 0 failed.cargo clippy --all-targets --all-featuresclean across buzz-workflow/buzz-relay/buzz-db; fmt clean.cargo test -p buzz-relay --lib: 719 passed; 1 failure inapi::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echowhich reproduces identically on cleanmainin my environment (passes standalone, fails under the parallel suite) — pre-existing flake, unrelated to this change; can file separately.#[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)
resolve_mention_pubkeys).pending_laneconformance rows against the live gate.🤖 Generated with Claude Code