feat: enforce exact task diff scope
CI / test (push) Successful in 11s

This commit is contained in:
m
2026-08-16 16:47:06 +03:00
parent b007f098f9
commit 111ad75dea
11 changed files with 768 additions and 18 deletions
+6
View File
@@ -8,9 +8,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: '1.26.6'
- name: Active task preflight
run: go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json
- name: Exact diff scope
run: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json
- name: Format check
run: test -z "$(gofmt -l .)"
- name: Architecture Sentinel
@@ -0,0 +1,35 @@
# ADR-0004: Exact Diff-to-Task Scope Guardrail
Status: Accepted
Date: 2026-08-16
Decision authority: explicit user continuation after `ARCH-000003`
## Context
Architecture preflight validates declared task authority but did not compare it with the complete Git change set. Promotion could therefore not prove that every changed path was explicitly authorized by one reviewed task packet.
## Decision
1. `dev/ACTIVE_TASK.json` selects exactly one versioned task packet and must agree with its task ID and revision.
2. Every active or future packet has a `base_repo_revision` that resolves to an ancestor commit of `HEAD`.
3. The guardrail evaluates tracked, staged, unstaged, untracked and deleted paths from the task base. Rename detection is disabled so source and destination are evaluated independently.
4. Allowed paths use exact repository-relative paths, trailing `directory/**` subtrees, or segment-local `*` and `?` globs.
5. Missing, malformed, ambiguous, escaping, non-ancestor or out-of-scope state fails closed.
6. Promotion CI uses the active pointer. Explicit `-task` selection exists only for proposal/bootstrap diagnostics.
7. `.gitea/workflows/ci.yml`, `cmd/devctl/**`, and `dev/ACTIVE_TASK.json` are Tier E protected paths.
8. Scope validation does not grant architecture authority and cannot self-certify a changed verifier. Tier E changes retain independent review requirements.
9. Inactive historical task packets are preserved. If activated, they must satisfy the current rules.
## Consequences
- Multi-commit tasks remain bounded by their immutable start revision.
- CI requires complete history containing the declared base.
- Task activation is a visible reviewed repository change.
- Guardrail control surfaces cannot be changed by an ordinary feature task.
- A new task begins by updating the active pointer and declaring both the pointer and its own packet in scope.
## Verification
`devctl check-diff` is covered by isolated Git-repository fixtures for valid matching and fail-closed cases. Promotion invokes both active-task preflight and exact diff validation before the existing build checks.
+3
View File
@@ -265,6 +265,9 @@
"architecture/architecture-registry.yaml",
"architecture/adrs/**",
"ci/architecture/**",
".gitea/workflows/ci.yml",
"cmd/devctl/**",
"dev/ACTIVE_TASK.json",
"security/**"
],
"default_feature_task_authority": {
+3
View File
@@ -137,6 +137,9 @@ protected_paths:
- architecture/architecture-registry.yaml
- architecture/adrs/**
- ci/architecture/**
- .gitea/workflows/ci.yml
- cmd/devctl/**
- dev/ACTIVE_TASK.json
- security/**
default_feature_task_authority:
+3
View File
@@ -3,4 +3,7 @@ architecture/architecture-registry.yaml
architecture/architecture-registry.json
architecture/adrs/
ci/architecture/
.gitea/workflows/ci.yml
cmd/devctl/
dev/ACTIVE_TASK.json
security/
+327
View File
@@ -0,0 +1,327 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"sort"
"strings"
)
const defaultActiveTaskPath = "dev/ACTIVE_TASK.json"
type ActiveTask struct {
TaskFile string `json:"task_file"`
TaskID string `json:"task_id"`
TaskRevision int `json:"task_revision"`
}
type taskSelection struct {
activePath string
taskPath string
}
func checkDiff(args []string) error {
selection, err := parseTaskSelection(args, true)
if err != nil {
return err
}
root, err := gitRepositoryRoot(".")
if err != nil {
return err
}
return checkDiffAt(root, selection)
}
func parseTaskSelection(args []string, defaultActive bool) (taskSelection, error) {
var selection taskSelection
for i := 0; i < len(args); i++ {
switch args[i] {
case "-active", "-task":
if i+1 >= len(args) {
return taskSelection{}, fmt.Errorf("missing value for %s", args[i])
}
value := args[i+1]
i++
if args[i-1] == "-active" {
if selection.activePath != "" {
return taskSelection{}, errors.New("duplicate -active")
}
selection.activePath = value
} else {
if selection.taskPath != "" {
return taskSelection{}, errors.New("duplicate -task")
}
selection.taskPath = value
}
default:
return taskSelection{}, fmt.Errorf("unknown argument %q", args[i])
}
}
if selection.activePath != "" && selection.taskPath != "" {
return taskSelection{}, errors.New("-active and -task are mutually exclusive")
}
if selection.activePath == "" && selection.taskPath == "" {
if !defaultActive {
return taskSelection{}, errors.New("missing -active <json> or -task <json>")
}
selection.activePath = defaultActiveTaskPath
}
return selection, nil
}
func gitRepositoryRoot(from string) (string, error) {
out, err := runGit(from, "rev-parse", "--show-toplevel")
if err != nil {
return "", fmt.Errorf("locate Git repository: %w", err)
}
root := strings.TrimSpace(string(out))
if root == "" {
return "", errors.New("Git repository root is empty")
}
return filepath.Clean(root), nil
}
func checkDiffAt(root string, selection taskSelection) error {
taskPacket, taskPath, activePath, err := loadSelectedTask(root, selection)
if err != nil {
return err
}
if taskPacket.TaskID == "" || taskPacket.TaskRevision < 1 || taskPacket.BaseRepoRevision == "" || taskPacket.RiskTier == "" || taskPacket.Intent == "" {
return errors.New("task packet missing task_id, positive task_revision, base_repo_revision, risk_tier, or intent")
}
patterns, err := compileScopePatterns(taskPacket.AllowedChangeScope.Paths)
if err != nil {
return err
}
if !matchesAny(taskPath, patterns) {
return fmt.Errorf("task packet must allow its own path %s", taskPath)
}
if activePath != "" && !matchesAny(activePath, patterns) {
return fmt.Errorf("active task packet must allow pointer path %s", activePath)
}
base, err := resolveBaseCommit(root, taskPacket.BaseRepoRevision)
if err != nil {
return err
}
changed, err := changedPaths(root, base)
if err != nil {
return err
}
var outside []string
for _, changedPath := range changed {
if !matchesAny(changedPath, patterns) {
outside = append(outside, changedPath)
}
}
if len(outside) > 0 {
return fmt.Errorf("%d changed path(s) outside task %s allowed_change_scope: %s", len(outside), taskPacket.TaskID, strings.Join(outside, ", "))
}
fmt.Printf("diff scope checks: OK task=%s base=%s paths=%d\n", taskPacket.TaskID, base, len(changed))
return nil
}
func loadSelectedTask(root string, selection taskSelection) (TaskPacket, string, string, error) {
if selection.activePath != "" {
activePath, err := cleanRepositoryPath(selection.activePath)
if err != nil {
return TaskPacket{}, "", "", fmt.Errorf("active pointer path: %w", err)
}
var active ActiveTask
if err := decodeJSONFile(filepath.Join(root, filepath.FromSlash(activePath)), &active, true); err != nil {
return TaskPacket{}, "", "", fmt.Errorf("active task pointer: %w", err)
}
if active.TaskID == "" || active.TaskRevision < 1 {
return TaskPacket{}, "", "", errors.New("active task pointer missing task_id or positive task_revision")
}
taskPath, err := cleanTaskPath(active.TaskFile)
if err != nil {
return TaskPacket{}, "", "", err
}
taskPacket, err := loadTaskPacket(root, taskPath)
if err != nil {
return TaskPacket{}, "", "", err
}
if taskPacket.TaskID != active.TaskID || taskPacket.TaskRevision != active.TaskRevision {
return TaskPacket{}, "", "", fmt.Errorf("active pointer %s revision %d does not match task packet %s revision %d", active.TaskID, active.TaskRevision, taskPacket.TaskID, taskPacket.TaskRevision)
}
return taskPacket, taskPath, activePath, nil
}
taskPath, err := cleanTaskPath(selection.taskPath)
if err != nil {
return TaskPacket{}, "", "", err
}
taskPacket, err := loadTaskPacket(root, taskPath)
return taskPacket, taskPath, "", err
}
func loadTaskPacket(root, taskPath string) (TaskPacket, error) {
var taskPacket TaskPacket
if err := decodeJSONFile(filepath.Join(root, filepath.FromSlash(taskPath)), &taskPacket, false); err != nil {
return TaskPacket{}, fmt.Errorf("task packet: %w", err)
}
return taskPacket, nil
}
func decodeJSONFile(filePath string, target any, strict bool) error {
b, err := os.ReadFile(filePath)
if err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(b))
if strict {
decoder.DisallowUnknownFields()
}
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
if err := decoder.Decode(&struct{}{}); errors.Is(err, io.EOF) {
return nil
} else if err != nil {
return fmt.Errorf("invalid trailing JSON: %w", err)
}
return errors.New("invalid JSON: multiple values")
}
func cleanTaskPath(value string) (string, error) {
cleaned, err := cleanRepositoryPath(value)
if err != nil {
return "", fmt.Errorf("task_file: %w", err)
}
if !strings.HasPrefix(cleaned, "dev/tasks/") || !strings.HasSuffix(cleaned, ".json") {
return "", fmt.Errorf("task_file must be a JSON file below dev/tasks/: %s", cleaned)
}
return cleaned, nil
}
func cleanRepositoryPath(value string) (string, error) {
if value == "" || strings.Contains(value, "\\") || strings.Contains(value, ":") || strings.HasPrefix(value, "/") {
return "", fmt.Errorf("path must be non-empty, relative, slash-normalized and volume-free: %q", value)
}
cleaned := path.Clean(value)
if cleaned != value || cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", fmt.Errorf("path is not a clean repository-relative path: %q", value)
}
return cleaned, nil
}
type scopePattern struct {
raw string
subtree string
}
func compileScopePatterns(values []string) ([]scopePattern, error) {
patterns := make([]scopePattern, 0, len(values))
for _, value := range values {
cleaned, err := cleanRepositoryPath(value)
if err != nil {
return nil, fmt.Errorf("invalid allowed_change_scope path: %w", err)
}
if strings.ContainsAny(cleaned, "[]{}") {
return nil, fmt.Errorf("invalid allowed_change_scope path %q: only * and ? glob characters are supported", value)
}
pattern := scopePattern{raw: cleaned}
if strings.HasSuffix(cleaned, "/**") {
pattern.subtree = strings.TrimSuffix(cleaned, "/**")
if pattern.subtree == "" || strings.ContainsAny(pattern.subtree, "*?") {
return nil, fmt.Errorf("invalid subtree scope pattern %q", value)
}
} else if strings.Contains(cleaned, "**") {
return nil, fmt.Errorf("invalid allowed_change_scope path %q: ** is supported only as a trailing subtree marker", value)
}
if _, err := path.Match(pattern.raw, pattern.raw); err != nil {
return nil, fmt.Errorf("invalid allowed_change_scope path %q: %w", value, err)
}
patterns = append(patterns, pattern)
}
return patterns, nil
}
func matchesAny(candidate string, patterns []scopePattern) bool {
for _, pattern := range patterns {
if pattern.subtree != "" {
if candidate == pattern.subtree || strings.HasPrefix(candidate, pattern.subtree+"/") {
return true
}
continue
}
matched, _ := path.Match(pattern.raw, candidate)
if matched {
return true
}
}
return false
}
func resolveBaseCommit(root, revision string) (string, error) {
out, err := runGit(root, "rev-parse", "--verify", revision+"^{commit}")
if err != nil {
return "", fmt.Errorf("base_repo_revision %q does not resolve to a commit: %w", revision, err)
}
base := strings.TrimSpace(string(out))
if _, err := runGit(root, "merge-base", "--is-ancestor", base, "HEAD"); err != nil {
return "", fmt.Errorf("base_repo_revision %s is not an ancestor of HEAD: %w", base, err)
}
return base, nil
}
func changedPaths(root, base string) ([]string, error) {
tracked, err := runGit(root, "diff", "--name-only", "--no-renames", "-z", base, "--")
if err != nil {
return nil, fmt.Errorf("read tracked diff: %w", err)
}
untracked, err := runGit(root, "ls-files", "--others", "--exclude-standard", "-z")
if err != nil {
return nil, fmt.Errorf("read untracked paths: %w", err)
}
unique := make(map[string]struct{})
for _, raw := range append(splitNUL(tracked), splitNUL(untracked)...) {
candidate := filepath.ToSlash(raw)
cleaned, err := cleanRepositoryPath(candidate)
if err != nil {
return nil, fmt.Errorf("Git returned invalid changed path: %w", err)
}
unique[cleaned] = struct{}{}
}
result := make([]string, 0, len(unique))
for candidate := range unique {
result = append(result, candidate)
}
sort.Strings(result)
return result, nil
}
func splitNUL(value []byte) []string {
parts := bytes.Split(value, []byte{0})
result := make([]string, 0, len(parts))
for _, part := range parts {
if len(part) > 0 {
result = append(result, string(part))
}
}
return result
}
func runGit(root string, args ...string) ([]byte, error) {
cmd := exec.Command("git", append([]string{"-C", root}, args...)...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
message := strings.TrimSpace(stderr.String())
if message == "" {
message = err.Error()
}
return nil, errors.New(message)
}
return out, nil
}
+288
View File
@@ -0,0 +1,288 @@
package main
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestCheckDiffAcceptsExactSubtreeAndSegmentGlob(t *testing.T) {
repo := newDiffRepo(t, map[string]string{"README.md": "base\n"})
repo.writeTask(t, []string{
repo.taskPath,
repo.activePath,
"exact.txt",
"nested/**",
"logs/*.txt",
})
writeFixture(t, filepath.Join(repo.root, "exact.txt"), "exact\n")
writeFixture(t, filepath.Join(repo.root, "nested", "deep", "value.txt"), "nested\n")
writeFixture(t, filepath.Join(repo.root, "logs", "today.txt"), "log\n")
if err := repo.check(t); err != nil {
t.Fatalf("accepted scope rejected: %v", err)
}
}
func TestCheckDiffRejectsTrackedStagedUnstagedAndUntrackedPaths(t *testing.T) {
tests := []struct {
name string
mutate func(*testing.T, *diffRepo)
}{
{
name: "tracked unstaged",
mutate: func(t *testing.T, repo *diffRepo) {
writeFixture(t, filepath.Join(repo.root, "tracked.txt"), "changed\n")
},
},
{
name: "tracked staged",
mutate: func(t *testing.T, repo *diffRepo) {
writeFixture(t, filepath.Join(repo.root, "tracked.txt"), "changed\n")
repo.git(t, "add", "tracked.txt")
},
},
{
name: "untracked",
mutate: func(t *testing.T, repo *diffRepo) {
writeFixture(t, filepath.Join(repo.root, "outside.txt"), "new\n")
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repo := newDiffRepo(t, map[string]string{"tracked.txt": "base\n"})
repo.writeTask(t, []string{repo.taskPath, repo.activePath})
test.mutate(t, repo)
assertDiffError(t, repo.check(t), "outside task")
})
}
}
func TestCheckDiffRejectsDeletionAndBothSidesOfRename(t *testing.T) {
t.Run("deletion", func(t *testing.T) {
repo := newDiffRepo(t, map[string]string{"remove.txt": "base\n"})
repo.writeTask(t, []string{repo.taskPath, repo.activePath})
if err := os.Remove(filepath.Join(repo.root, "remove.txt")); err != nil {
t.Fatal(err)
}
assertDiffError(t, repo.check(t), "remove.txt")
})
t.Run("rename source", func(t *testing.T) {
repo := newDiffRepo(t, map[string]string{"source.txt": "base\n"})
repo.writeTask(t, []string{repo.taskPath, repo.activePath, "destination.txt"})
repo.git(t, "mv", "source.txt", "destination.txt")
assertDiffError(t, repo.check(t), "source.txt")
})
t.Run("rename destination", func(t *testing.T) {
repo := newDiffRepo(t, map[string]string{"source.txt": "base\n"})
repo.writeTask(t, []string{repo.taskPath, repo.activePath, "source.txt"})
repo.git(t, "mv", "source.txt", "destination.txt")
assertDiffError(t, repo.check(t), "destination.txt")
})
}
func TestCheckDiffRejectsInvalidActiveTaskState(t *testing.T) {
t.Run("missing pointer", func(t *testing.T) {
repo := newDiffRepo(t, nil)
assertDiffError(t, repo.check(t), "active task pointer")
})
t.Run("malformed pointer", func(t *testing.T) {
repo := newDiffRepo(t, nil)
writeFixture(t, filepath.Join(repo.root, repo.activePath), "{")
assertDiffError(t, repo.check(t), "invalid JSON")
})
t.Run("mismatched pointer", func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.writeTask(t, []string{repo.taskPath, repo.activePath})
active := ActiveTask{TaskFile: repo.taskPath, TaskID: "OTHER", TaskRevision: 1}
repo.writeJSON(t, repo.activePath, active)
assertDiffError(t, repo.check(t), "does not match")
})
t.Run("task outside dev tasks", func(t *testing.T) {
repo := newDiffRepo(t, nil)
active := ActiveTask{TaskFile: "task.json", TaskID: "TEST-1", TaskRevision: 1}
repo.writeJSON(t, repo.activePath, active)
assertDiffError(t, repo.check(t), "below dev/tasks")
})
}
func TestCheckDiffRejectsInvalidBaseRevisions(t *testing.T) {
t.Run("missing object or history", func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.base = "0000000000000000000000000000000000000000"
repo.writeTask(t, []string{repo.taskPath, repo.activePath})
assertDiffError(t, repo.check(t), "does not resolve")
})
t.Run("non ancestor", func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.git(t, "checkout", "--orphan", "unrelated")
if err := os.Remove(filepath.Join(repo.root, "base.txt")); err != nil {
t.Fatal(err)
}
writeFixture(t, filepath.Join(repo.root, "unrelated.txt"), "unrelated\n")
repo.git(t, "add", "-A")
repo.git(t, "commit", "-m", "unrelated")
unrelated := strings.TrimSpace(repo.git(t, "rev-parse", "HEAD"))
repo.git(t, "checkout", "master")
repo.base = unrelated
repo.writeTask(t, []string{repo.taskPath, repo.activePath})
assertDiffError(t, repo.check(t), "not an ancestor")
})
}
func TestCheckDiffRejectsMalformedScopeAndImplicitPrefix(t *testing.T) {
tests := []struct {
name string
pattern string
want string
}{
{"parent traversal", "../outside", "clean repository-relative"},
{"backslash", `directory\\file`, "slash-normalized"},
{"unsupported class", "file[0].txt", "only * and ?"},
{"invalid double star", "directory/**/file", "trailing subtree"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.writeTask(t, []string{repo.taskPath, repo.activePath, test.pattern})
assertDiffError(t, repo.check(t), test.want)
})
}
t.Run("implicit prefix", func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.writeTask(t, []string{repo.taskPath, repo.activePath, "directory"})
writeFixture(t, filepath.Join(repo.root, "directory", "file.txt"), "outside\n")
assertDiffError(t, repo.check(t), "directory/file.txt")
})
t.Run("segment glob cannot cross slash", func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.writeTask(t, []string{repo.taskPath, repo.activePath, "logs/*.txt"})
writeFixture(t, filepath.Join(repo.root, "logs", "nested", "file.txt"), "outside\n")
assertDiffError(t, repo.check(t), "logs/nested/file.txt")
})
}
func TestCheckDiffRequiresTaskAndPointerInScope(t *testing.T) {
t.Run("task packet", func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.writeTask(t, []string{repo.activePath})
assertDiffError(t, repo.check(t), "allow its own path")
})
t.Run("active pointer", func(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.writeTask(t, []string{repo.taskPath})
assertDiffError(t, repo.check(t), "allow pointer path")
})
}
func TestCheckDiffCoversMultipleCommitsFromBase(t *testing.T) {
repo := newDiffRepo(t, nil)
repo.writeTask(t, []string{repo.taskPath, repo.activePath, "first.txt", "second.txt"})
writeFixture(t, filepath.Join(repo.root, "first.txt"), "first\n")
repo.git(t, "add", "-A")
repo.git(t, "commit", "-m", "first task commit")
writeFixture(t, filepath.Join(repo.root, "second.txt"), "second\n")
if err := repo.check(t); err != nil {
t.Fatalf("multi-commit diff rejected: %v", err)
}
}
func TestTaskSelectionIsExplicit(t *testing.T) {
if _, err := parseTaskSelection([]string{"-active", "active.json", "-task", "task.json"}, true); err == nil {
t.Fatal("active and task selection accepted together")
}
if _, err := parseTaskSelection([]string{"-skip"}, true); err == nil {
t.Fatal("unknown skip argument accepted")
}
selection, err := parseTaskSelection(nil, true)
if err != nil || selection.activePath != defaultActiveTaskPath {
t.Fatalf("default active selection = %+v, %v", selection, err)
}
}
type diffRepo struct {
root string
base string
taskPath string
activePath string
}
func newDiffRepo(t *testing.T, baseFiles map[string]string) *diffRepo {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is required")
}
repo := &diffRepo{
root: t.TempDir(),
taskPath: "dev/tasks/TEST-0001.json",
activePath: defaultActiveTaskPath,
}
repo.git(t, "init", "--initial-branch=master")
repo.git(t, "config", "user.email", "devctl-test@example.invalid")
repo.git(t, "config", "user.name", "devctl test")
writeFixture(t, filepath.Join(repo.root, "base.txt"), "base\n")
for name, content := range baseFiles {
writeFixture(t, filepath.Join(repo.root, filepath.FromSlash(name)), content)
}
repo.git(t, "add", "-A")
repo.git(t, "commit", "-m", "base")
repo.base = strings.TrimSpace(repo.git(t, "rev-parse", "HEAD"))
return repo
}
func (repo *diffRepo) writeTask(t *testing.T, allowed []string) {
t.Helper()
packet := TaskPacket{
TaskID: "TEST-0001",
TaskRevision: 1,
BaseRepoRevision: repo.base,
RiskTier: "E",
Intent: "test exact diff scope",
}
packet.AllowedChangeScope.Paths = allowed
repo.writeJSON(t, repo.taskPath, packet)
repo.writeJSON(t, repo.activePath, ActiveTask{TaskFile: repo.taskPath, TaskID: packet.TaskID, TaskRevision: packet.TaskRevision})
}
func (repo *diffRepo) writeJSON(t *testing.T, name string, value any) {
t.Helper()
b, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatal(err)
}
writeFixture(t, filepath.Join(repo.root, filepath.FromSlash(name)), string(b)+"\n")
}
func (repo *diffRepo) check(t *testing.T) error {
t.Helper()
return checkDiffAt(repo.root, taskSelection{activePath: repo.activePath})
}
func (repo *diffRepo) git(t *testing.T, args ...string) string {
t.Helper()
out, err := runGit(repo.root, args...)
if err != nil {
t.Fatalf("git %s: %v", strings.Join(args, " "), err)
}
return string(out)
}
func assertDiffError(t *testing.T, err error, want string) {
t.Helper()
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("check-diff error = %v, want substring %q", err, want)
}
}
+13 -15
View File
@@ -30,6 +30,8 @@ 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 {
@@ -52,6 +54,8 @@ func main() {
err = showRegistry()
case "preflight":
err = preflight(os.Args[2:])
case "check-diff":
err = checkDiff(os.Args[2:])
default:
usage()
os.Exit(2)
@@ -63,7 +67,7 @@ func main() {
}
func usage() {
fmt.Println("devctl doctor | check-architecture | show-registry | preflight -task <json>")
fmt.Println("devctl doctor | check-architecture | show-registry | preflight (-active <json> | -task <json>) | check-diff [-active <json> | -task <json>]")
}
func loadRegistry() (Registry, error) {
@@ -195,23 +199,17 @@ func checkArchitectureAt(root string) error {
}
func preflight(args []string) error {
var task string
for i := 0; i < len(args); i++ {
if args[i] == "-task" && i+1 < len(args) {
task = args[i+1]
i++
}
}
if task == "" {
return errors.New("missing -task <json>")
}
b, err := os.ReadFile(task)
selection, err := parseTaskSelection(args, false)
if err != nil {
return err
}
var taskPacket TaskPacket
if err = json.Unmarshal(b, &taskPacket); err != nil {
return fmt.Errorf("task packet must be valid JSON in V1: %w", err)
root, err := gitRepositoryRoot(".")
if err != nil {
return err
}
taskPacket, _, _, 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")
+5
View File
@@ -0,0 +1,5 @@
{
"task_file": "dev/tasks/ARCH-000004-implement-diff-scope-guardrail.json",
"task_id": "ARCH-000004",
"task_revision": 1
}
@@ -0,0 +1,82 @@
{
"task_id": "ARCH-000004",
"task_revision": 1,
"architecture_revision": "ARCH-000003 accepted by explicit user continuation",
"base_repo_revision": "b007f09",
"risk_tier": "E",
"intent": "Implement the accepted exact Git diff-to-active-task scope guardrail, protect its control surfaces, and make promotion CI fail closed.",
"requirement_ids": ["CONSTITUTION-1.3.27", "CONSTITUTION-1.3.28"],
"allowed_change_scope": {
"modules": [],
"paths": [
".gitea/workflows/ci.yml",
"architecture/adrs/ADR-0004-exact-diff-scope-guardrail.md",
"architecture/architecture-registry.yaml",
"architecture/architecture-registry.json",
"ci/architecture/protected-paths.txt",
"cmd/devctl/**",
"dev/ACTIVE_TASK.json",
"dev/tasks/ARCH-000004-implement-diff-scope-guardrail.json",
"docs/DIFF_SCOPE_GUARDRAIL_PROPOSAL.md"
]
},
"forbidden_change_scope": {
"paths": [
"architecture/ARCHITECTURE_CONSTITUTION.md",
"architecture/CORE_SEMANTIC_MODEL_V1.md",
"security/**",
"database/**",
"modules/**",
"contracts/**",
"adapters/**",
"deploy/**",
"tests/**"
]
},
"allowed_architecture_delta": {
"new_cross_module_dependencies": 0,
"new_canonical_owners": 0,
"new_privileges": 0
},
"contracts": {
"consume": ["Git repository state", "Codex Task Packet JSON"],
"may_change": ["devctl command surface", "promotion CI guardrail", "protected path registry"]
},
"semantic_owner": "none-architecture-guardrail",
"canonical_state": ["none"],
"state_transitions": ["active task pointer selects exactly one task packet for promotion validation"],
"side_effects_and_privileges": ["reads Git metadata and working-tree path names only", "does not read changed file contents", "does not mutate repository or external systems"],
"persistence_and_migrations": ["versioned dev/ACTIVE_TASK.json only", "no database migration"],
"failure_retry_restart": ["all missing, invalid, ambiguous or out-of-scope states fail closed", "command is deterministic and safe to rerun"],
"assumptions": [
"The user's continuation after the ARCH-000003 acceptance request accepts all eight proposal decisions",
"The implementation task starts at commit b007f09",
"Inactive historical task packets are preserved without retroactive validation"
],
"acceptance_scenarios": [
"one committed active-task pointer selects and agrees with one packet",
"base revision resolves to an ancestor commit",
"tracked, staged, unstaged, untracked, deleted and renamed paths are checked",
"only deterministic exact, subtree and segment-local glob matches are allowed",
"all malformed or ambiguous inputs fail closed",
"CI checks out full history and invokes preflight plus check-diff through the active pointer",
"workflow, devctl and active pointer become protected Tier E surfaces",
"registry YAML and JSON remain equivalent",
"negative fixtures demonstrate every required rejection"
],
"required_tests": [
"unit",
"negative fixtures",
"architecture preflight",
"architecture sentinel",
"registry parity",
"go vet",
"exact diff-scope self-check",
"independent Tier E CI review"
],
"terminal_gap_states": [
"BLOCKED_BY_ARCHITECTURE_GAP",
"BLOCKED_BY_SPEC_GAP",
"BLOCKED_BY_CONFLICT"
]
}
+3 -3
View File
@@ -1,6 +1,6 @@
# Exact Diff-to-Task Scope Guardrail Proposal
Status: **PROPOSED — requires explicit Tier E acceptance**
Status: **ACCEPTED — recorded by ADR-0004**
Task: `ARCH-000003`, revision 1
@@ -127,6 +127,6 @@ Until step 3 is promoted, the current preflight and manual diff-scope review rem
7. Preserve independent Tier E acceptance; diff checking cannot authorize architecture or guardrail changes.
8. Grandfather inactive historical packets, but apply all rules whenever any packet is activated.
## Current resolution
## Resolution
`BLOCKED_BY_ARCHITECTURE_GAP` until the eight decisions above receive explicit Tier E acceptance. No guardrail or promotion implementation is authorized by this proposal alone.
The user explicitly continued after the Tier E acceptance request. All eight decisions are recorded by `ADR-0004` and implemented under the separate `ARCH-000004` task packet.