diff --git a/cmd/core-runtime/development_gate.go b/cmd/core-runtime/development_gate.go index 17c0e55..063569d 100644 --- a/cmd/core-runtime/development_gate.go +++ b/cmd/core-runtime/development_gate.go @@ -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 { diff --git a/cmd/core-runtime/development_gate_test.go b/cmd/core-runtime/development_gate_test.go index 548fd84..2af36cb 100644 --- a/cmd/core-runtime/development_gate_test.go +++ b/cmd/core-runtime/development_gate_test.go @@ -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) diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 3bdad98..a48c176 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -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 { diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 163498c..693d37e 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -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 } diff --git a/dev/tasks/DEV-000254-development-gate-canary-promotion-rollback.json b/dev/tasks/DEV-000254-development-gate-canary-promotion-rollback.json new file mode 100644 index 0000000..e3f6eee --- /dev/null +++ b/dev/tasks/DEV-000254-development-gate-canary-promotion-rollback.json @@ -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"] +}