diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7e58946..a9bffa0 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -15,6 +15,8 @@ jobs: go-version: '1.26.6' - name: Active task preflight run: go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json + - name: Independent governance verifier + run: go run ./cmd/devctl/independent - name: Exact diff scope run: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json - name: Format check @@ -25,3 +27,5 @@ jobs: run: go test ./... - name: Vet run: go vet ./... + - name: Diff whitespace check + run: git diff --check diff --git a/cmd/devctl/diff_scope.go b/cmd/devctl/diff_scope.go index 2c6087d..a976037 100644 --- a/cmd/devctl/diff_scope.go +++ b/cmd/devctl/diff_scope.go @@ -166,7 +166,7 @@ func loadSelectedTask(root string, selection taskSelection) (TaskPacket, string, func loadTaskPacket(root, taskPath string) (TaskPacket, error) { var taskPacket TaskPacket - if err := decodeJSONFile(filepath.Join(root, filepath.FromSlash(taskPath)), &taskPacket, false); err != nil { + if err := decodeJSONFile(filepath.Join(root, filepath.FromSlash(taskPath)), &taskPacket, true); err != nil { return TaskPacket{}, fmt.Errorf("task packet: %w", err) } return taskPacket, nil diff --git a/cmd/devctl/independent/main.go b/cmd/devctl/independent/main.go new file mode 100644 index 0000000..36b49fc --- /dev/null +++ b/cmd/devctl/independent/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +var required = []string{ + "task_id", "task_revision", "architecture_revision", "base_repo_revision", "risk_tier", "intent", + "requirement_ids", "allowed_change_scope", "forbidden_change_scope", "allowed_architecture_delta", + "contracts", "semantic_owner", "canonical_state", "state_transitions", "side_effects_and_privileges", + "persistence_and_migrations", "failure_retry_restart", "assumptions", "acceptance_scenarios", + "required_tests", "terminal_gap_states", +} + +func main() { + activePath := filepath.FromSlash("dev/ACTIVE_TASK.json") + var active struct { + TaskFile string `json:"task_file"` + TaskID string `json:"task_id"` + TaskRevision int `json:"task_revision"` + } + if err := decode(activePath, &active); err != nil { + fail("active pointer: %v", err) + } + if active.TaskFile == "" || active.TaskID == "" || active.TaskRevision < 1 { + fail("active pointer is incomplete") + } + var raw map[string]json.RawMessage + if err := decode(filepath.FromSlash(active.TaskFile), &raw); err != nil { + fail("task packet: %v", err) + } + for _, key := range required { + if _, ok := raw[key]; !ok { + fail("task packet missing required field %q", key) + } + } + var packet struct { + TaskID string `json:"task_id"` + TaskRevision int `json:"task_revision"` + } + if err := decode(filepath.FromSlash(active.TaskFile), &packet); err != nil { + fail("task identity: %v", err) + } + if packet.TaskID != active.TaskID || packet.TaskRevision != active.TaskRevision { + fail("active pointer and packet identity differ") + } + fmt.Printf("independent governance verification: OK task=%s revision=%d\n", packet.TaskID, packet.TaskRevision) +} + +func decode(path string, target any) error { + b, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(b, target) +} + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "independent verifier: "+format+"\n", args...) + os.Exit(1) +} diff --git a/cmd/devctl/main.go b/cmd/devctl/main.go index 85f9af2..52660ff 100644 --- a/cmd/devctl/main.go +++ b/cmd/devctl/main.go @@ -2,7 +2,6 @@ package main import ( "encoding/json" - "errors" "fmt" "go/parser" "go/token" @@ -29,14 +28,35 @@ type Registry struct { } type TaskPacket struct { - TaskID string `json:"task_id"` - TaskRevision int `json:"task_revision"` - BaseRepoRevision string `json:"base_repo_revision"` - RiskTier string `json:"risk_tier"` - Intent string `json:"intent"` - AllowedChangeScope struct { - Paths []string `json:"paths"` + TaskID string `json:"task_id"` + TaskRevision int `json:"task_revision"` + ArchitectureRevision string `json:"architecture_revision"` + BaseRepoRevision string `json:"base_repo_revision"` + RiskTier string `json:"risk_tier"` + Intent string `json:"intent"` + RequirementIDs []string `json:"requirement_ids"` + AllowedChangeScope struct { + Modules []string `json:"modules"` + Paths []string `json:"paths"` } `json:"allowed_change_scope"` + ForbiddenChangeScope struct { + Paths []string `json:"paths"` + } `json:"forbidden_change_scope"` + AllowedArchitectureDelta map[string]int `json:"allowed_architecture_delta"` + Contracts struct { + Consume []string `json:"consume"` + MayChange []string `json:"may_change"` + } `json:"contracts"` + SemanticOwner string `json:"semantic_owner"` + CanonicalState []string `json:"canonical_state"` + StateTransitions []string `json:"state_transitions"` + SideEffectsAndPrivileges []string `json:"side_effects_and_privileges"` + PersistenceAndMigrations []string `json:"persistence_and_migrations"` + FailureRetryRestart []string `json:"failure_retry_restart"` + Assumptions []string `json:"assumptions"` + AcceptanceScenarios []string `json:"acceptance_scenarios"` + RequiredTests []string `json:"required_tests"` + TerminalGapStates []string `json:"terminal_gap_states"` } func main() { @@ -207,12 +227,12 @@ func preflight(args []string) error { if err != nil { return err } - taskPacket, _, _, err := loadSelectedTask(root, selection) + taskPacket, taskPath, _, err := loadSelectedTask(root, selection) if err != nil { return err } - if taskPacket.TaskID == "" || taskPacket.Intent == "" || taskPacket.RiskTier == "" { - return errors.New("task packet missing task_id, intent, or risk_tier") + if err := validateTaskPacket(root, taskPacket, taskPath); err != nil { + return err } registry, err := loadRegistry() if err != nil { diff --git a/cmd/devctl/task_validation.go b/cmd/devctl/task_validation.go new file mode 100644 index 0000000..0212f64 --- /dev/null +++ b/cmd/devctl/task_validation.go @@ -0,0 +1,163 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" +) + +var requiredPacketKeys = []string{ + "task_id", "task_revision", "architecture_revision", "base_repo_revision", "risk_tier", "intent", + "requirement_ids", "allowed_change_scope", "forbidden_change_scope", "allowed_architecture_delta", + "contracts", "semantic_owner", "canonical_state", "state_transitions", "side_effects_and_privileges", + "persistence_and_migrations", "failure_retry_restart", "assumptions", "acceptance_scenarios", + "required_tests", "terminal_gap_states", +} + +var requiredDeltaKeys = []string{ + "new_cross_module_dependencies", "new_canonical_owners", "new_privileges", + "new_third_party_dependencies", "new_database_owner_roles", +} + +func validateTaskPacket(root string, task TaskPacket, taskPath string) error { + if task.TaskID == "" || task.TaskRevision < 1 || task.ArchitectureRevision == "" || task.BaseRepoRevision == "" || task.RiskTier == "" || task.Intent == "" { + return fmt.Errorf("task %s is incomplete: identity, revision, architecture_revision, base_repo_revision, risk_tier and intent are required", task.TaskID) + } + if taskPath == "" || !matchesAny(taskPath, mustCompile([]string{"dev/tasks/*.json"})) { + return fmt.Errorf("task %s must be selected from dev/tasks", task.TaskID) + } + if task.RiskTier < "A" || task.RiskTier > "E" { + return fmt.Errorf("task %s has invalid risk_tier %q", task.TaskID, task.RiskTier) + } + if len(task.RequirementIDs) == 0 || len(task.AcceptanceScenarios) == 0 || len(task.RequiredTests) == 0 || len(task.TerminalGapStates) == 0 { + return fmt.Errorf("task %s must declare requirements, acceptance_scenarios, required_tests and terminal_gap_states", task.TaskID) + } + if len(task.AllowedChangeScope.Paths) == 0 { + return fmt.Errorf("task %s must declare allowed_change_scope.paths", task.TaskID) + } + if err := validateScopeOverlap(task.AllowedChangeScope.Paths, task.ForbiddenChangeScope.Paths); err != nil { + return fmt.Errorf("task %s scope: %w", task.TaskID, err) + } + if err := validateDelta(task.AllowedArchitectureDelta); err != nil { + return fmt.Errorf("task %s architecture delta: %w", task.TaskID, err) + } + if err := validateRequiredTests(task.RequiredTests); err != nil { + return fmt.Errorf("task %s required_tests: %w", task.TaskID, err) + } + if err := validateArchitectureRevision(root, task.ArchitectureRevision); err != nil { + return fmt.Errorf("task %s architecture_revision: %w", task.TaskID, err) + } + if _, err := resolveBaseCommit(root, task.BaseRepoRevision); err != nil { + return fmt.Errorf("task %s base_repo_revision: %w", task.TaskID, err) + } + if err := validateObservedDelta(root, task); err != nil { + return fmt.Errorf("task %s observed architecture delta: %w", task.TaskID, err) + } + return nil +} + +func validateRequiredTests(tests []string) error { + joined := strings.Join(tests, "\n") + for _, required := range []string{"go test", "go vet", "preflight", "check-diff", "git diff --check", "independent"} { + if !strings.Contains(joined, required) { + return fmt.Errorf("missing required governance evidence %q", required) + } + } + return nil +} + +func validateObservedDelta(root string, task TaskPacket) error { + changed, err := changedPaths(root, task.BaseRepoRevision) + if err != nil { + return err + } + for _, changedPath := range changed { + if strings.HasPrefix(changedPath, "architecture/architecture-registry.") && task.AllowedArchitectureDelta["new_cross_module_dependencies"] == 0 && task.AllowedArchitectureDelta["new_canonical_owners"] == 0 { + return fmt.Errorf("registry changed while cross-module and canonical-owner deltas are both zero: %s", changedPath) + } + if (strings.HasPrefix(changedPath, "security/") || strings.HasPrefix(changedPath, "deploy/")) && task.AllowedArchitectureDelta["new_privileges"] == 0 { + return fmt.Errorf("privileged surface changed with new_privileges=0: %s", changedPath) + } + if strings.HasPrefix(changedPath, "database/") && task.AllowedArchitectureDelta["new_database_owner_roles"] == 0 { + return fmt.Errorf("database surface changed with new_database_owner_roles=0: %s", changedPath) + } + if (changedPath == "go.mod" || changedPath == "go.sum") && task.AllowedArchitectureDelta["new_third_party_dependencies"] == 0 { + return fmt.Errorf("dependency manifest changed with new_third_party_dependencies=0: %s", changedPath) + } + if strings.HasPrefix(changedPath, "contracts/") && len(task.Contracts.MayChange) == 0 { + return fmt.Errorf("contract surface changed but contracts.may_change is empty: %s", changedPath) + } + } + return nil +} + +func validateDelta(delta map[string]int) error { + if len(delta) != len(requiredDeltaKeys) { + return fmt.Errorf("must contain exactly %s", strings.Join(requiredDeltaKeys, ", ")) + } + for _, key := range requiredDeltaKeys { + value, ok := delta[key] + if !ok || value < 0 { + return fmt.Errorf("%s must be a non-negative integer", key) + } + } + return nil +} + +func validateArchitectureRevision(root, revision string) error { + if strings.HasPrefix(revision, "ADR-") { + matches, err := filepath.Glob(filepath.Join(root, "architecture", "adrs", revision+"-*.md")) + if err != nil || len(matches) != 1 { + return fmt.Errorf("accepted ADR %q is missing or ambiguous", revision) + } + return nil + } + if strings.HasPrefix(revision, "ARCH-") { + matches, err := filepath.Glob(filepath.Join(root, "dev", "tasks", revision+"*.json")) + if err != nil || len(matches) == 0 { + return fmt.Errorf("architecture task %q is missing", revision) + } + return nil + } + return fmt.Errorf("must reference an accepted ADR-NNNN or architecture task") +} + +func validateScopeOverlap(allowed, forbidden []string) error { + allowedPatterns, err := compileScopePatterns(allowed) + if err != nil { + return err + } + forbiddenPatterns, err := compileScopePatterns(forbidden) + if err != nil { + return fmt.Errorf("forbidden_change_scope: %w", err) + } + for _, a := range allowedPatterns { + for _, f := range forbiddenPatterns { + if scopePatternsOverlap(a, f) { + return fmt.Errorf("allowed path %q overlaps forbidden path %q", a.raw, f.raw) + } + } + } + return nil +} + +func scopePatternsOverlap(a, b scopePattern) bool { + if a.subtree != "" && b.subtree != "" { + return a.subtree == b.subtree || strings.HasPrefix(a.subtree, b.subtree+"/") || strings.HasPrefix(b.subtree, a.subtree+"/") + } + if a.subtree != "" { + return b.raw == a.subtree || strings.HasPrefix(b.raw, a.subtree+"/") + } + if b.subtree != "" { + return a.raw == b.subtree || strings.HasPrefix(a.raw, b.subtree+"/") + } + return a.raw == b.raw +} + +func mustCompile(values []string) []scopePattern { + patterns, err := compileScopePatterns(values) + if err != nil { + panic(err) + } + return patterns +} diff --git a/cmd/devctl/task_validation_test.go b/cmd/devctl/task_validation_test.go new file mode 100644 index 0000000..3696395 --- /dev/null +++ b/cmd/devctl/task_validation_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "strings" + "testing" +) + +func TestValidateScopeOverlapRejectsAllowedForbiddenConflict(t *testing.T) { + err := validateScopeOverlap([]string{"cmd/creator-web/server.go"}, []string{"cmd/creator-web/**"}) + if err == nil || !strings.Contains(err.Error(), "overlaps forbidden") { + t.Fatalf("overlap error = %v", err) + } +} + +func TestValidateRequiredTestsRejectsIncompleteEvidence(t *testing.T) { + err := validateRequiredTests([]string{"go test ./..."}) + if err == nil || !strings.Contains(err.Error(), "missing required governance evidence") { + t.Fatalf("required test error = %v", err) + } +} + +func TestValidateDeltaRejectsUnknownOrMissingFields(t *testing.T) { + err := validateDelta(map[string]int{"new_privileges": 0}) + if err == nil || !strings.Contains(err.Error(), "exactly") { + t.Fatalf("delta error = %v", err) + } +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 2a509a9..ab5dc67 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000096-builtin-cognitive-usage-telemetry.json", - "task_id": "DEV-000096", + "task_file": "dev/tasks/DEV-000098-governance-sentinel-v2.json", + "task_id": "DEV-000098", "task_revision": 1 } diff --git a/dev/tasks/DEV-000098-governance-sentinel-v2.json b/dev/tasks/DEV-000098-governance-sentinel-v2.json new file mode 100644 index 0000000..63e2d80 --- /dev/null +++ b/dev/tasks/DEV-000098-governance-sentinel-v2.json @@ -0,0 +1,34 @@ +{ + "task_id": "DEV-000098", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "b439a813e5932b1774cb3b6c2364c6cac3891314", + "risk_tier": "E", + "intent": "Implement Governance Sentinel V2 so task packets are fully validated and required governance evidence is enforced before promotion.", + "requirement_ids": ["WP-000", "CONSTITUTION-1.3.27", "CONSTITUTION-1.3.28"], + "allowed_change_scope": { + "modules": [], + "paths": ["cmd/devctl/**", ".gitea/workflows/ci.yml", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000098-governance-sentinel-v2.json"] + }, + "forbidden_change_scope": { + "paths": ["architecture/ARCHITECTURE_CONSTITUTION.md", "architecture/CORE_SEMANTIC_MODEL_V1.md", "security/**", "database/**", "deploy/**", "modules/**", "contracts/**", "adapters/**", "platform/**"] + }, + "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": ["Architecture registry", "Accepted ADR files", "Codex Task Packet JSON", "Git repository state"], "may_change": []}, + "semantic_owner": "none-architecture-guardrail", + "canonical_state": [], + "state_transitions": ["Task packet validation returns pass or typed gap"], + "side_effects_and_privileges": ["Reads repository metadata and required test commands; no external mutation or secret access"], + "persistence_and_migrations": ["No database schema or migration changes"], + "failure_retry_restart": ["Malformed, incomplete, conflicting or unverifiable packets fail closed; checks are deterministic and rerunnable"], + "assumptions": ["ADR-0018 is the current accepted architecture revision", "Independent verification remains a CI/review responsibility", "docker-test remains prohibited"], + "acceptance_scenarios": ["Full packet fields are strictly parsed", "Allowed and forbidden scope overlap is rejected", "Architecture revision existence and base ancestry are checked", "Architecture deltas, dependencies, contracts, privileges and required tests are checked", "Tier E does not bypass validation", "CI executes the required governance checks"], + "required_tests": ["go test ./cmd/devctl", "go run ./cmd/devctl/independent", "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", "go vet ./...", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"] +}