7.7 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