Implement governance sentinel v2
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+31
-11
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user