12 KiB
Approval Gate — Implementation Log
Overview
New policy type POLICY_TYPE_EXTERNAL_APPROVAL that requires human approval before a release can deploy to a target environment.
Rules:
- Scoped to a single release intent + target environment
- Release author cannot approve (unless admin → red "Bypass" button)
- All org members can approve
- Rejection is a vote, not a permanent block
- No timer retry — NATS signal on decision triggers re-evaluation
Forage (client) Changes — DONE
Proto
New file: interface/proto/forest/v1/policies.proto
POLICY_TYPE_EXTERNAL_APPROVAL = 3added toPolicyTypeenumApprovalConfig { target_environment, required_approvals }messageApprovalState { required_approvals, current_approvals, decisions }messageApprovalDecisionEntry { user_id, username, decision, decided_at, comment }messagePolicyEvaluationextended withoptional ApprovalState approval_state = 10EvaluatePoliciesRequestextended withoptional string release_intent_id = 4Policy,CreatePolicyRequest,UpdatePolicyRequestoneofs extended withApprovalConfig approval = 12- New RPCs:
ApproveRelease,RejectRelease,GetApprovalStatewith request/response messages
New file: scripts/sync-protos.sh
- Copies all
.protofiles from forest repo to forage, runsbuf generate
Regenerated: crates/forage-grpc/src/grpc/forest/v1/forest.v1.rs and forest.v1.tonic.rs
Domain Model
File: crates/forage-core/src/platform/mod.rs
PolicyConfig::Approval { target_environment, required_approvals }variantApprovalStatestructApprovalDecisionEntrystructPolicyEvaluation.approval_state: Option<ApprovalState>field- New
ForestPlatformtrait methods:evaluate_policies,approve_release,reject_release,get_approval_state
gRPC Client
File: crates/forage-server/src/forest_client.rs
convert_policy: handlespolicy::Config::Approval→PolicyConfig::Approvalpolicy_config_to_grpc: handlesPolicyConfig::Approval→ gRPCconvert_policy_evaluation: maps policy type 3 → "approval", mapsapproval_stateconvert_approval_state: maps gRPCApprovalState→ domainevaluate_policiesimpl: callsPolicyServiceClient::evaluate_policieswithrelease_intent_idapprove_releaseimpl: callsPolicyServiceClient::approve_releasereject_releaseimpl: callsPolicyServiceClient::reject_releaseget_approval_stateimpl: callsPolicyServiceClient::get_approval_state- Fixed
PipelineStage::Planmatch arm (new variant from forest proto sync) - Fixed
ReleaseRequestmissingprepare_onlyfield (new field from forest proto sync)
Test Support
File: crates/forage-server/src/test_support.rs
MockPlatformClient: default impls forevaluate_policies,approve_release,reject_release,get_approval_state
Routes
File: crates/forage-server/src/routes/platform.rs
New routes:
POST /orgs/{org}/projects/{project}/releases/{slug}/approve→approve_release_submitPOST /orgs/{org}/projects/{project}/releases/{slug}/reject→reject_release_submit
New handler structs:
ApprovalForm { csrf_token, release_intent_id, target_environment, comment, force_bypass }CreatePolicyFormextended withrequired_approvals: Option<i32>
Modified handlers:
create_policy_submit: handlespolicy_type = "approval"with validationpolicies_page: mapsPolicyConfig::Approvalto template contextedit_policy_page: mapsPolicyConfig::Approvalto template contextartifact_detail: fetches policy evaluations per environment, passespolicy_evaluations,release_intent_id,is_release_author,is_adminto template
Templates
File: templates/pages/policies.html.jinja
- Policy list: "Approval Required" badge with target env + approval count
- Create form: "Approval Required" option in type dropdown
- Approval fields: target environment select + required approvals number input
- JavaScript: toggles visibility of soak/branch/approval field sets
File: templates/pages/artifact_detail.html.jinja
- New "Policy Requirements" section between Pipeline and Destinations
- Shows all policy evaluations (soak, branch, approval) with pass/fail icons
- Approval UI:
- Approval count badge (current/required)
- Decision history (username, approved/rejected, comment)
- Approve button (green) — shown to non-authors
- Bypass (Admin) button (red) — shown to admin authors with confirmation dialog
- Reject button (red outline) — shown to all eligible members
- "You cannot approve your own release" message for non-admin authors
Forest (core) Changes — NEEDS MANUAL APPLICATION
Proto
File: interface/proto/forest/v1/policies.proto — same changes as forage copy above
DB Migration
New file: crates/forest-server/migrations/20260315000001_approval_decisions.sql
CREATE TABLE approval_decisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
release_intent_id UUID NOT NULL REFERENCES release_intents(id) ON DELETE CASCADE,
policy_id UUID NOT NULL REFERENCES policies(id) ON DELETE CASCADE,
target_environment TEXT NOT NULL,
user_id UUID NOT NULL,
username TEXT NOT NULL,
decision TEXT NOT NULL CHECK (decision IN ('approved', 'rejected')),
comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- unique per user per intent per env, lookup index for counting
Policy Engine
File: crates/forest-server/src/services/policy.rs
PolicyType::Approval,ApprovalConfigstructPolicyConfig::Approval(ApprovalConfig)variantApprovalStateInfo,ApprovalDecisionInfostructsPolicyEvaluation.approval_state: Option<ApprovalStateInfo>evaluate_for_environmentgainsrelease_intent_id: Option<&Uuid>paramcheck_approval: queries approval_decisions, compares count vs requiredrecord_approval_decision: upserts into approval_decisionsget_intent_actor_id: queries release_intents.actor_idfind_approval_policy_for_environment: finds enabled approval policy for target envget_approval_state: returns current approval state for display
Intent Coordinator
File: crates/forest-server/src/intent_coordinator.rs
check_approval_policiescalled aftercheck_soak_time_policiesfor deploy stages- If blocked: logs and continues (no timer retry, NATS-triggered re-eval on decision)
Release Event Store
File: crates/forest-server/src/services/release_event_store.rs
check_approval_policies(tx, project_id, release_intent_id, target_environment) -> Option<String>- Loads enabled approval policies, counts approved decisions, blocks if insufficient
gRPC Handlers
File: crates/forest-server/src/grpc/policies.rs
record_to_grpc: handlesPolicyConfig::Approvaleval_to_grpc: handlesPolicyType::Approval, mapsapproval_stateextract_config/extract_update_config: handles approval configevaluate_policies: passesrelease_intent_idthroughapprove_release: validates actor != intent author (unless force_bypass), records decision, publishes NATSreject_release: records rejection decisionget_approval_state: returns current approval state
Caller Updates
src/grpc/release.rs:evaluate_for_environmentcalls gainNoneas 4th argsrc/scheduler.rs: same
Verification
- Forage: 169 tests passing, compiles clean (0 errors, 0 warnings)
- Forest: tool permissions blocked writes — all code is ready, needs to be applied from forest repo context
Next Steps
- Apply forest changes (run claude from the forest directory, or grant write access)
- Run
buf generatein forest to regenerate gRPC interface stubs - Run forest tests
- E2E test: create approval policy, trigger release, verify UI shows approval buttons
Plan Stage Support (Prepare-Before-Deploy)
Overview
Added support for "plan" pipeline stages — destinations that run a prepare/dry-run (e.g. terraform plan) and require approval of the output before the actual deploy proceeds. Forest already had full infrastructure for this; this work surfaces it in the Forage UI.
Changes
forage-core (crates/forage-core/src/platform/mod.rs)
- Added
PipelineStageConfig::Plan { environment, auto_approve }variant - Added
approval_status: Option<String>andauto_approve: Option<bool>toPipelineRunStageState - Added 3 new
ForestPlatformtrait methods:approve_plan_stage,reject_plan_stage,get_plan_output - Added
PlanOutputstruct (plan_output: String,status: String)
forage-server gRPC client (forest_client.rs)
convert_pipeline_stage: handlesPlanconfig variant (was previously mapped to empty Deploy)convert_pipeline_stage_state: recognizesPlanstage type +AwaitingApprovalstatus + new fieldsconvert_stages_to_grpc: handlesPipelineStageConfig::Plan→PlanStageConfig- Implemented
approve_plan_stage,reject_plan_stage,get_plan_outputcalling forest's RPCs
forage-server routes (routes/platform.rs)
- Added 3 API routes:
POST /api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/approvePOST /api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/rejectGET /api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/output
ApiPipelineStagenow includesapproval_statusandauto_approvebuild_timeline_json: plan stages withAWAITING_APPROVALstatus are shown with that status; releases with plan stages awaiting approval are treated asneeds_action(not hidden)
Pipeline builder (static/js/pipeline-builder.js)
- Added "plan" as third stage type in dropdown
- Plan stage config: environment + auto-approve checkbox
- Purple color scheme for plan nodes in DAG visualization
Svelte timeline (frontend/src/ReleaseTimeline.svelte)
approvePlanStage(release, stage, reject)function for approve/reject via APIviewPlanOutput(release, stage)function for on-demand plan output fetching (toggle)- Plan stages render with purple shield icon when
AWAITING_APPROVAL - "Approve plan" / "Reject" buttons on plan stages awaiting approval
- "View plan" / "Hide plan" button to toggle plan output display
- Plan output shown in collapsible
<pre>block (monospace, max-height 256px with scroll) - Summary line shows plan stage badge + approve button when plan awaiting approval
Status helpers (frontend/src/lib/status.js)
- Added
planStageLabel(status)function pipelineSummary: detectsAWAITING_APPROVALplan stages → "Awaiting plan approval" (purple)
Slack notifications (forage-core/src/integrations/router.rs)
- Plan stage rendering in Slack blocks: "Planning", "Awaiting plan approval", "Plan approved", "Plan failed"
- Shield emoji for AWAITING_APPROVAL status
Test support (test_support.rs)
- Added default mock implementations for the 3 new trait methods
Forest Runner Infrastructure
Proto (runner.proto)
- Added
ReleaseModeenum:RELEASE_MODE_UNSPECIFIED,RELEASE_MODE_DEPLOY,RELEASE_MODE_PLAN - Added
modefield (typeReleaseMode) toWorkAssignment— tells remote runners whether to deploy or plan - Added
plan_outputfield (optional string) toCompleteReleaseRequest— runners send plan output back
Scheduler (scheduler.rs)
- Reads
release_state.modeand maps toReleaseMode::Plan/ReleaseMode::Deploy - Includes
modeinWorkAssignmentwhen dispatching to remote runners
Runner gRPC handler (grpc/runner.rs)
complete_release: storesplan_outputfromCompleteReleaseRequesttorelease_states.plan_outputin DB
Terraform destination (destinations/terraformv1.rs)
plan(): now captures actual terraform plan stdout (not just a marker)- Added
run_capture()method — same asrun()but captures stdout into a String - Added
run_command_capture()— likerun_command()but returns captured stdout while still logging
Runner crate (forest-runner)
RunnerDestinationtrait: addedplan()method (default returns None)Executor: checksReleaseModefromWorkAssignment, callsplan()instead ofrelease()for plan modeRunnerSession::complete_release: accepts optionalplan_outputparameterrun_destination_plan()function: prepare + plan, returnsOption<String>