feat(governance): add development gate evidence transitions #232

Merged
m merged 1 commits from codex/dev-000248-self-development-runtime-status into main 2026-08-23 18:40:45 +00:00
5 changed files with 122 additions and 2 deletions
+41
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"strings"
"time"
"git.cin.su/m/Assistant/adapters/developmentexecutor"
@@ -16,6 +17,8 @@ var errDevelopmentGateNotIsolated = errors.New("development gate is not isolated
var errDevelopmentVerifierNotIndependent = errors.New("development verifier is not independent")
var errDevelopmentWorktreeMismatch = errors.New("verification worktree does not match gate")
var errDevelopmentChecksFailed = errors.New("development verification checks failed")
var errDevelopmentEvidenceRequired = errors.New("development transition evidence is required")
var errDevelopmentEvidenceStage = errors.New("development transition evidence does not match stage")
type developmentGateVerificationRequest struct {
GateID string
@@ -24,6 +27,44 @@ type developmentGateVerificationRequest struct {
Plan developmentexecutor.Plan
}
type developmentGateEvidenceRequest struct {
GateID string
ExpectedRevision int64
NextStage work.DevelopmentStage
EvidenceRef string
}
func recordDevelopmentGateEvidence(ctx context.Context, store ports.DevelopmentGateStore, request developmentGateEvidenceRequest, now time.Time) (work.DevelopmentGate, error) {
gate, err := store.LoadDevelopmentGate(ctx, request.GateID)
if err != nil {
return work.DevelopmentGate{}, err
}
if request.ExpectedRevision < 1 || gate.Revision != request.ExpectedRevision {
return gate, errDevelopmentGateNotIsolated
}
if strings.TrimSpace(request.EvidenceRef) == "" {
return gate, errDevelopmentEvidenceRequired
}
switch request.NextStage {
case work.DevelopmentCanary:
gate.CanaryEvidence = strings.TrimSpace(request.EvidenceRef)
case work.DevelopmentPromoted:
gate.PromotionRef = strings.TrimSpace(request.EvidenceRef)
case work.DevelopmentRolledBack:
gate.RollbackRef = strings.TrimSpace(request.EvidenceRef)
default:
return gate, errDevelopmentEvidenceStage
}
advanced, err := advanceDevelopmentGate(gate, request.NextStage, now)
if err != nil {
return gate, err
}
if err := store.SaveDevelopmentGate(ctx, advanced, request.ExpectedRevision); err != nil {
return gate, err
}
return advanced, nil
}
func verifyDevelopmentGate(ctx context.Context, store ports.DevelopmentGateStore, request developmentGateVerificationRequest, now time.Time, runner developmentexecutor.CommandRunner) (work.DevelopmentGate, developmentexecutor.Result, error) {
gate, err := store.LoadDevelopmentGate(ctx, request.GateID)
if err != nil {
+29
View File
@@ -119,6 +119,35 @@ func TestVerifyDevelopmentGateRejectsProducerAsVerifier(t *testing.T) {
}
}
func TestRecordDevelopmentGateEvidenceAdvancesCanaryAndPromotion(t *testing.T) {
store := &developmentGateStoreStub{gate: work.DevelopmentGate{
ID: "gate-1", Revision: 6, Stage: work.DevelopmentVerified,
EvidenceRefs: []string{"evidence-1"}, ProposalRef: "proposal-1", TaskPacketRef: "task-1", WorktreeRef: "sandbox-1",
ProducerRunRefs: []work.AgentRunID{"producer-1"}, VerifierRunRefs: []work.AgentRunID{"verifier-1"}, VerificationEvidence: "verification-1",
}}
gate, err := recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-1", ExpectedRevision: 6, NextStage: work.DevelopmentCanary, EvidenceRef: "canary-1"}, time.UnixMilli(30))
if err != nil || gate.Stage != work.DevelopmentCanary || gate.Revision != 7 || gate.CanaryEvidence != "canary-1" {
t.Fatalf("canary transition failed: gate=%#v err=%v", gate, err)
}
gate, err = recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-1", ExpectedRevision: 7, NextStage: work.DevelopmentPromoted, EvidenceRef: "promotion-1"}, time.UnixMilli(31))
if err != nil || gate.Stage != work.DevelopmentPromoted || gate.Revision != 8 || gate.PromotionRef != "promotion-1" {
t.Fatalf("promotion transition failed: gate=%#v err=%v", gate, err)
}
}
func TestRecordDevelopmentGateEvidenceSupportsCanaryRollbackAndRejectsMissingEvidence(t *testing.T) {
store := &developmentGateStoreStub{gate: work.DevelopmentGate{ID: "gate-1", Revision: 2, Stage: work.DevelopmentCanary, EvidenceRefs: []string{"evidence-1"}, CanaryEvidence: "canary-1"}}
gate, err := recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-1", ExpectedRevision: 2, NextStage: work.DevelopmentRolledBack, EvidenceRef: "rollback-1"}, time.UnixMilli(40))
if err != nil || gate.Stage != work.DevelopmentRolledBack || gate.RollbackRef != "rollback-1" {
t.Fatalf("rollback transition failed: gate=%#v err=%v", gate, err)
}
store = &developmentGateStoreStub{gate: work.DevelopmentGate{ID: "gate-2", Revision: 2, Stage: work.DevelopmentCanary, EvidenceRefs: []string{"evidence-1"}, CanaryEvidence: "canary-1"}}
gate, err = recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-2", ExpectedRevision: 2, NextStage: work.DevelopmentRolledBack}, time.UnixMilli(40))
if err != errDevelopmentEvidenceRequired || gate.Stage != work.DevelopmentCanary || store.saved {
t.Fatalf("missing rollback evidence changed gate: gate=%#v err=%v store=%#v", gate, err, store)
}
}
func TestDevelopmentGateControllerRequiresSequentialVerifiedEvidence(t *testing.T) {
now := time.UnixMilli(10)
gate, err := prepareDevelopmentGate(work.DevelopmentGate{ID: "gate-1", Revision: 1, Stage: work.DevelopmentEvidenceCollected, EvidenceRefs: []string{"evidence-1"}}, now)
+27
View File
@@ -335,6 +335,33 @@ func main() {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"gate": gate, "verification": result})
})
registerDevelopmentGateEvidenceRoute := func(path string, next work.DevelopmentStage, fieldName string) {
mux.HandleFunc("POST /v1/creator/development/gates/{id}/"+path, func(w http.ResponseWriter, r *http.Request) {
if _, ok := authenticateCreator(w, r); !ok {
return
}
var request struct {
ExpectedRevision int64 `json:"expected_revision"`
EvidenceRef string `json:"evidence_ref"`
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil || request.ExpectedRevision < 1 || request.EvidenceRef == "" {
http.Error(w, "invalid development transition evidence", http.StatusBadRequest)
return
}
gate, err := recordDevelopmentGateEvidence(r.Context(), workRepository, developmentGateEvidenceRequest{GateID: r.PathValue("id"), ExpectedRevision: request.ExpectedRevision, NextStage: next, EvidenceRef: request.EvidenceRef}, time.Now())
if err != nil {
http.Error(w, "development "+fieldName+" transition rejected", http.StatusUnprocessableEntity)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(gate)
})
}
registerDevelopmentGateEvidenceRoute("canary", work.DevelopmentCanary, "canary")
registerDevelopmentGateEvidenceRoute("promote", work.DevelopmentPromoted, "promotion")
registerDevelopmentGateEvidenceRoute("rollback", work.DevelopmentRolledBack, "rollback")
mux.HandleFunc("GET /v1/creator/focus", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000253-development-gate-verifier-integration.json",
"task_id": "DEV-000253",
"task_file": "dev/tasks/DEV-000254-development-gate-canary-promotion-rollback.json",
"task_id": "DEV-000254",
"task_revision": 1
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000254",
"task_revision": 1,
"architecture_revision": "ADR-0018",
"base_repo_revision": "3bfe76278b7e44965cbb0e3b4ac2911ad4a4bbae",
"risk_tier": "E",
"intent": "Expose typed authenticated transitions for durable governed self-development canary, promotion and rollback evidence with optimistic revision checks.",
"requirement_ids": ["governed self-development", "durable canary evidence", "promotion and rollback lineage"],
"allowed_change_scope": {"modules": ["governed-development"], "paths": ["cmd/core-runtime/development_gate.go", "cmd/core-runtime/main.go", "cmd/core-runtime/development_gate_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000254-development-gate-canary-promotion-rollback.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "contracts/**", "LLM-local/**", "modules/work/**", "cmd/agent-worker/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
"contracts": {"consume": ["existing authenticated Creator session", "existing DevelopmentGate CAS store", "existing governed stage machine"], "may_change": ["internal authenticated canary/promotion/rollback routes"]},
"semantic_owner": "Work-owned DevelopmentGate controller",
"canonical_state": ["existing work.development_gates row only"],
"state_transitions": ["VERIFIED -> CANARY", "CANARY -> PROMOTED", "CANARY -> ROLLED_BACK"],
"side_effects_and_privileges": ["one existing Work-owner CAS update per transition; no source mutation, secrets, external mutation or deployment authority"],
"persistence_and_migrations": ["none"],
"failure_retry_restart": ["stale revisions conflict; missing evidence is rejected; terminal stages cannot be reopened; retry uses a new reference and current revision"],
"assumptions": ["canary execution and deployment evidence are produced by their owning executor; this task records only typed evidence and advances the durable gate"],
"acceptance_scenarios": ["unauthenticated transition denied", "missing evidence denied", "stale revision denied", "canary evidence advances VERIFIED to CANARY", "promotion evidence advances CANARY to PROMOTED", "rollback evidence advances CANARY to ROLLED_BACK", "terminal gates cannot be reopened"],
"required_tests": ["go test ./cmd/core-runtime", "go vet ./cmd/core-runtime", "go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-architecture", "git diff --check", "independent verifier", "CI", "production health smoke", "authenticated transition smoke"],
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
}