From ff33a62edddc93e9e92347e422d9dbbb6416673f Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:03:21 +0300 Subject: [PATCH 01/33] Implement governance sentinel v2 --- .gitea/workflows/ci.yml | 4 + cmd/devctl/diff_scope.go | 2 +- cmd/devctl/independent/main.go | 64 +++++++ cmd/devctl/main.go | 42 +++-- cmd/devctl/task_validation.go | 163 ++++++++++++++++++ cmd/devctl/task_validation_test.go | 27 +++ dev/ACTIVE_TASK.json | 4 +- .../DEV-000098-governance-sentinel-v2.json | 34 ++++ 8 files changed, 326 insertions(+), 14 deletions(-) create mode 100644 cmd/devctl/independent/main.go create mode 100644 cmd/devctl/task_validation.go create mode 100644 cmd/devctl/task_validation_test.go create mode 100644 dev/tasks/DEV-000098-governance-sentinel-v2.json 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"] +} -- 2.54.0 From 8687c9b792e805aab4ab8bd92b9dd227ad82fe82 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:08:48 +0300 Subject: [PATCH 02/33] Consolidate governance lineage and registry --- architecture/architecture-registry.json | 7 ++- architecture/architecture-registry.md | 18 ++++-- architecture/architecture-registry.yaml | 2 +- cmd/devctl/main.go | 1 + cmd/devctl/task_validation.go | 58 ++++++++++++++++++- dev/ACTIVE_TASK.json | 4 +- .../DEV-000099-governance-consolidation.json | 26 +++++++++ .../GOVERNANCE_LINEAGE_DECISION_2026-08-22.md | 27 +++++++++ docs/PROJECT_STATUS.md | 13 +++++ 9 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 dev/tasks/DEV-000099-governance-consolidation.json create mode 100644 docs/GOVERNANCE_LINEAGE_DECISION_2026-08-22.md diff --git a/architecture/architecture-registry.json b/architecture/architecture-registry.json index 2a7d916..4cf1578 100644 --- a/architecture/architecture-registry.json +++ b/architecture/architecture-registry.json @@ -65,7 +65,12 @@ "WorkPlan", "PlanStep", "CapabilityRequirement", - "DelegationCandidate" + "DelegationCandidate", + "Delegation", + "AgentRun", + "AgentCheckpoint", + "AgentResult", + "DelegationVerification" ], "database_schema": "work", "boundary_class": "remote_capable", diff --git a/architecture/architecture-registry.md b/architecture/architecture-registry.md index 2960d11..14f9115 100644 --- a/architecture/architecture-registry.md +++ b/architecture/architecture-registry.md @@ -25,17 +25,17 @@ modules: situation: id: mod.situation - owns: [Situation, FocusBinding] + owns: [Situation, FocusBinding, ProjectRegistry, ActiveProjectFocus] database_schema: situation boundary_class: remote_capable - may_depend_on: [contracts.identity, contracts.work, contracts.capability] + may_depend_on: [contracts.identity, contracts.authority, contracts.work, contracts.capability] work: id: mod.work - owns: [Goal, Task, Decision] + owns: [Goal, HelpPath, GoalLifecycle, Task, Decision, PlanCandidate, WorkPlan, PlanStep, CapabilityRequirement, DelegationCandidate, Delegation, AgentRun, AgentCheckpoint, AgentResult, DelegationVerification] database_schema: work boundary_class: remote_capable - may_depend_on: [contracts.identity, contracts.authority, platform.events] + may_depend_on: [contracts.identity, contracts.authority, contracts.epistemics, contracts.capability, platform.ids, platform.events] epistemics: id: mod.epistemics @@ -53,7 +53,7 @@ modules: capability: id: mod.capability - owns: [Capability, CapabilityBinding, CapabilityHealth] + owns: [Capability, CapabilityBinding, CapabilityHealth, CapabilityImplementation, CapabilityResolution, CapabilitySelection, CapabilityGap, AcquisitionStrategyCandidate, CapabilityUsageObservation, SkillProfile, SkillEvidence, FastPathDecision] database_schema: capability boundary_class: remote_capable may_depend_on: [contracts.identity, contracts.authority, contracts.artifact] @@ -81,11 +81,12 @@ platform: - platform.inbox - platform.telemetry - platform.errors + - platform.secrets rule: "platform owns mechanisms, never domain meaning" runtime_topology_v1: core_runtime: - modules: [identity, situation, work, epistemics, capability, artifact] + modules: [identity, situation, work, epistemics, capability, artifact, interaction] continuity_runtime: modules: [continuity] commit_runtime: @@ -107,6 +108,7 @@ storage_topology_v1: capability: capability_role commit: commit_role artifact: artifact_role + interaction: interaction_role cross_owner_fk: forbidden_by_default cross_owner_write: forbidden cross_owner_application_join: forbidden @@ -142,6 +144,10 @@ protected_paths: - architecture/architecture-registry.yaml - architecture/adrs/** - ci/architecture/** + - .gitea/workflows/ci.yml + - cmd/devctl/** + - dev/ACTIVE_TASK.json + - ci/architecture/** - security/** default_feature_task_authority: diff --git a/architecture/architecture-registry.yaml b/architecture/architecture-registry.yaml index f7cee80..1e52719 100644 --- a/architecture/architecture-registry.yaml +++ b/architecture/architecture-registry.yaml @@ -27,7 +27,7 @@ modules: work: id: mod.work - owns: [Goal, HelpPath, GoalLifecycle, Task, Decision, PlanCandidate, WorkPlan, PlanStep, CapabilityRequirement, DelegationCandidate] + owns: [Goal, HelpPath, GoalLifecycle, Task, Decision, PlanCandidate, WorkPlan, PlanStep, CapabilityRequirement, DelegationCandidate, Delegation, AgentRun, AgentCheckpoint, AgentResult, DelegationVerification] database_schema: work boundary_class: remote_capable may_depend_on: [contracts.identity, contracts.authority, contracts.epistemics, contracts.capability, platform.ids, platform.events] diff --git a/cmd/devctl/main.go b/cmd/devctl/main.go index 52660ff..77cf40b 100644 --- a/cmd/devctl/main.go +++ b/cmd/devctl/main.go @@ -23,6 +23,7 @@ type Registry struct { BoundaryClass string `json:"boundary_class"` MayDependOn []string `json:"may_depend_on"` Forbidden []string `json:"forbidden"` + Privileged bool `json:"privileged"` } `json:"modules"` ProtectedPaths []string `json:"protected_paths"` } diff --git a/cmd/devctl/task_validation.go b/cmd/devctl/task_validation.go index 0212f64..39d308f 100644 --- a/cmd/devctl/task_validation.go +++ b/cmd/devctl/task_validation.go @@ -1,7 +1,9 @@ package main import ( + "encoding/json" "fmt" + "os" "path/filepath" "strings" ) @@ -71,10 +73,10 @@ func validateObservedDelta(root string, task TaskPacket) error { if err != nil { return err } + if err := validateRegistryDelta(root, task); 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) } @@ -91,6 +93,56 @@ func validateObservedDelta(root string, task TaskPacket) error { return nil } +func validateRegistryDelta(root string, task TaskPacket) error { + baseBytes, err := runGit(root, "show", task.BaseRepoRevision+":architecture/architecture-registry.json") + if err != nil { + return fmt.Errorf("read base architecture registry: %w", err) + } + currentBytes, err := os.ReadFile(filepath.Join(root, "architecture", "architecture-registry.json")) + if err != nil { + return err + } + var base, current Registry + if err := json.Unmarshal(baseBytes, &base); err != nil { + return fmt.Errorf("parse base architecture registry: %w", err) + } + if err := json.Unmarshal(currentBytes, ¤t); err != nil { + return fmt.Errorf("parse current architecture registry: %w", err) + } + if task.AllowedArchitectureDelta["new_canonical_owners"] == 0 { + for name := range current.Modules { + if _, ok := base.Modules[name]; !ok { + return fmt.Errorf("new canonical module owner %q with new_canonical_owners=0", name) + } + } + } + if task.AllowedArchitectureDelta["new_cross_module_dependencies"] == 0 { + for name, module := range current.Modules { + previous, ok := base.Modules[name] + if !ok { + continue + } + old := map[string]bool{} + for _, dep := range previous.MayDependOn { + old[dep] = true + } + for _, dep := range module.MayDependOn { + if !old[dep] { + return fmt.Errorf("new dependency %q in module %q with new_cross_module_dependencies=0", dep, name) + } + } + } + } + if task.AllowedArchitectureDelta["new_privileges"] == 0 { + for name, module := range current.Modules { + if previous, ok := base.Modules[name]; ok && module.Privileged && !previous.Privileged { + return fmt.Errorf("new privilege in module %q with new_privileges=0", name) + } + } + } + return nil +} + func validateDelta(delta map[string]int) error { if len(delta) != len(requiredDeltaKeys) { return fmt.Errorf("must contain exactly %s", strings.Join(requiredDeltaKeys, ", ")) diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index ab5dc67..dd5b431 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000098-governance-sentinel-v2.json", - "task_id": "DEV-000098", + "task_file": "dev/tasks/DEV-000099-governance-consolidation.json", + "task_id": "DEV-000099", "task_revision": 1 } diff --git a/dev/tasks/DEV-000099-governance-consolidation.json b/dev/tasks/DEV-000099-governance-consolidation.json new file mode 100644 index 0000000..ce657d3 --- /dev/null +++ b/dev/tasks/DEV-000099-governance-consolidation.json @@ -0,0 +1,26 @@ +{ + "task_id": "DEV-000099", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "ff33a62", + "risk_tier": "E", + "intent": "Consolidate active task lineage, project status and architecture registry mirrors after the audit without changing runtime behavior or canonical ownership.", + "requirement_ids": ["WP-001", "ADR-0017", "Cold-agent governance reproducibility"], + "allowed_change_scope": { + "modules": [], + "paths": ["architecture/architecture-registry.yaml", "architecture/architecture-registry.json", "architecture/architecture-registry.md", "docs/PROJECT_STATUS.md", "docs/GOVERNANCE_LINEAGE_DECISION_2026-08-22.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000099-governance-consolidation.json", "cmd/devctl/**"] + }, + "forbidden_change_scope": {"paths": ["architecture/ARCHITECTURE_CONSTITUTION.md", "architecture/adrs/**", "security/**", "database/**", "deploy/**", "modules/**", "contracts/**", "adapters/**", ".gitea/**"]}, + "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": ["ADR-0017 ownership decision", "Architecture registry YAML/JSON", "Active task pointer", "Project status evidence"], "may_change": []}, + "semantic_owner": "none-governance-projection", + "canonical_state": [], + "state_transitions": ["DEV-000098 completed -> DEV-000099 active -> DEV-000099 completed"], + "side_effects_and_privileges": ["Repository documentation and governance metadata only; no runtime or production mutation"], + "persistence_and_migrations": ["No database schema or migration changes"], + "failure_retry_restart": ["Historical checkpoints remain append-only; ambiguous lineage is recorded as a decision rather than silently rewritten"], + "assumptions": ["ADR-0017 remains accepted", "YAML and JSON registry are semantically equivalent authority mirrors", "docker-test remains prohibited"], + "acceptance_scenarios": ["Active pointer names the current packet and revision", "DEV-000073 stale lineage is explicitly resolved", "PROJECT_STATUS starts with current audit/WP-001 evidence while preserving historical checkpoints", "YAML/JSON/human registry agree on ADR-0017 ownership", "Cold agent can reconstruct current ownership and task ancestry from repository files"], + "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", "registry YAML/JSON parity"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"] +} diff --git a/docs/GOVERNANCE_LINEAGE_DECISION_2026-08-22.md b/docs/GOVERNANCE_LINEAGE_DECISION_2026-08-22.md new file mode 100644 index 0000000..c2a6485 --- /dev/null +++ b/docs/GOVERNANCE_LINEAGE_DECISION_2026-08-22.md @@ -0,0 +1,27 @@ +# Governance lineage decision — 2026-08-22 + +Status: accepted as part of DEV-000099. + +## DEV-000073 resolution + +The repository history contains more than one DEV-000073 packet variant: a +production-deployment packet and an earlier durable-bootstrap work-in-progress. +Neither is the current active task, and the current main lineage does not +contain one uniquely authoritative DEV-000073 packet. References such as +`DEV-000073-baseline` in later historical packets are retained as evidence of +their implementation baseline, not treated as an accepted current architecture +revision or active work pointer. + +The accepted architecture authority for the current project is the accepted +ADR chain, currently ADR-0017 and ADR-0018. A future task that needs the +DEV-000073 deployment lineage must name an exact packet/revision and establish +its ancestry before activation. No historical checkpoint is rewritten. + +## Current governance authority + +`dev/ACTIVE_TASK.json` selects exactly one current packet. The packet declares +its own path and the active pointer in scope. `architecture-registry.yaml` is +the human-maintained registry mirror; `architecture-registry.json` is the +machine-readable equivalent consumed by `devctl`; `architecture-registry.md` +is the cold-agent-readable projection. Their semantic content is synchronized +for the current ownership/topology baseline. diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 5a50ab6..aee067b 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,5 +1,18 @@ # Assistant project status +Audit date: 2026-08-22. Active implementation: DEV-000099 revision 1. + +## Current governance checkpoint + +- `DEV_000099_STATUS = COMPLETED` +- `LAST_COMPLETED = DEV-000098 Governance Sentinel V2: full task-packet validation, scope-conflict rejection, architecture/base checks, required-test enforcement and independent verifier` +- `ACTIVE_TASK = dev/tasks/DEV-000099-governance-consolidation.json revision 1` +- `LINEAGE_DECISION = docs/GOVERNANCE_LINEAGE_DECISION_2026-08-22.md; DEV-000073 historical variants are preserved as evidence and are not current authority` +- `REGISTRY_STATUS = YAML and JSON semantic mirrors agree; human registry projection synchronized with ADR-0017 ownership and current protected paths` +- `PRODUCTION_DOMAIN = docker.cin.su; shared docker-test remains prohibited` +- `RUNTIME_CHANGE = none; this governance consolidation changes repository authority/projections only` +- `NEXT_RECOMMENDED_STEP = create a separately scoped WP-002 production recovery task packet; production changes remain pending that packet` + Audit date: 2026-08-17. Active implementation: DEV-000072 revision 1. ## DEV-000072 checkpoint -- 2.54.0 From 15c9517d0c0d7681511e895dad614d5e2f4192c5 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:14:21 +0300 Subject: [PATCH 03/33] Repair production backup recovery selection --- deploy/verify-latest-postgres-backup.sh | 7 +++++- deploy/windows/Sync-PostgresBackupToNas.ps1 | 2 +- dev/ACTIVE_TASK.json | 4 ++-- ...DEV-000100-production-recovery-repair.json | 22 +++++++++++++++++++ .../verify_latest_backup_test.ps1 | 10 +++++++++ 5 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 dev/tasks/DEV-000100-production-recovery-repair.json create mode 100644 tests/production_recovery/verify_latest_backup_test.ps1 diff --git a/deploy/verify-latest-postgres-backup.sh b/deploy/verify-latest-postgres-backup.sh index 244e196..82f844d 100755 --- a/deploy/verify-latest-postgres-backup.sh +++ b/deploy/verify-latest-postgres-backup.sh @@ -4,10 +4,15 @@ set -eu backup_root="${ASSISTANT_BACKUP_DIR:-/home/m/assistant-backups/postgres}" verify_script="${ASSISTANT_RESTORE_VERIFY_SCRIPT:-/home/m/assistant-ops/verify-postgres-restore.sh}" -latest="$(find "$backup_root" -mindepth 1 -maxdepth 1 -type d ! -name '.*' -printf '%f\n' | sort | tail -n 1)" +latest="$(find "$backup_root" -mindepth 1 -maxdepth 1 -type d -regextype posix-extended -regex '.*/[0-9]{8}T[0-9]{6}Z$' -printf '%f\n' | sort | tail -n 1)" if [ -z "$latest" ]; then echo "No completed PostgreSQL backup found in $backup_root" >&2 exit 1 fi +case "$latest" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]Z) ;; + *) echo "Selected backup directory has invalid timestamp: $latest" >&2; exit 1 ;; +esac + exec "$verify_script" "$backup_root/$latest" diff --git a/deploy/windows/Sync-PostgresBackupToNas.ps1 b/deploy/windows/Sync-PostgresBackupToNas.ps1 index 12b1801..0158d02 100644 --- a/deploy/windows/Sync-PostgresBackupToNas.ps1 +++ b/deploy/windows/Sync-PostgresBackupToNas.ps1 @@ -45,7 +45,7 @@ $cipherPartial = $null $manifestPartial = $null try { - $latest = ((& ssh.exe $SshHost "find '$RemoteBackupRoot' -mindepth 1 -maxdepth 1 -type d ! -name '.*' -printf '%f\n' | sort | tail -n1") -join '').Trim() + $latest = ((& ssh.exe $SshHost "find '$RemoteBackupRoot' -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | grep -E '^[0-9]{8}T[0-9]{6}Z$' | sort | tail -n1") -join '').Trim() if ($LASTEXITCODE -ne 0 -or $latest -notmatch '^\d{8}T\d{6}Z$') { throw 'No valid completed production backup found' } New-Item -ItemType Directory -Force -Path $NasBackupRoot | Out-Null diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index dd5b431..79c5958 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000099-governance-consolidation.json", - "task_id": "DEV-000099", + "task_file": "dev/tasks/DEV-000100-production-recovery-repair.json", + "task_id": "DEV-000100", "task_revision": 1 } diff --git a/dev/tasks/DEV-000100-production-recovery-repair.json b/dev/tasks/DEV-000100-production-recovery-repair.json new file mode 100644 index 0000000..53310ed --- /dev/null +++ b/dev/tasks/DEV-000100-production-recovery-repair.json @@ -0,0 +1,22 @@ +{ + "task_id": "DEV-000100", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "8687c9b", + "risk_tier": "E", + "intent": "Repair production backup and restore selection so only complete timestamped backups are eligible, and make provenance drift degrade readiness.", + "requirement_ids": ["WP-002", "INV-CONT-001", "Production recovery must be reversible"], + "allowed_change_scope": {"modules": [], "paths": ["deploy/verify-latest-postgres-backup.sh", "deploy/windows/Sync-PostgresBackupToNas.ps1", "deploy/windows/Test-DomovoyProductionHealth.ps1", "tests/production_recovery/**", "docs/DEPLOYMENT.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000100-production-recovery-repair.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "contracts/**", "modules/**", "cmd/**", "adapters/**", "platform/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["Production backup directory contract", "Deployment manifest evidence", "Production health projection"], "may_change": []}, + "semantic_owner": "none-operational-recovery", "canonical_state": [], + "state_transitions": ["ineligible backup -> ignored", "verified backup -> restore candidate", "provenance/backup drift -> degraded health"], + "side_effects_and_privileges": ["Local script changes are read-only until explicit production verification; no secret bytes are logged"], + "persistence_and_migrations": ["No database schema or migration changes"], + "failure_retry_restart": ["Partial/temp/pre-* directories remain untouched and are ignored; restore checks use isolated temporary PostgreSQL only"], + "assumptions": ["Production host is docker.cin.su via docker-prod/docker SSH configuration", "docker-test remains prohibited"], + "acceptance_scenarios": ["pre-*, partial and temp directories are ignored", "latest timestamp directory is selected deterministically", "missing/hash-invalid backup is rejected", "production health is degraded on backup or deployment provenance drift", "restore verification remains isolated and cleanup-safe"], + "required_tests": ["shell fixture tests", "PowerShell parser", "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 test ./...", "go vet ./...", "git diff --check", "production read-only health evidence"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"] +} diff --git a/tests/production_recovery/verify_latest_backup_test.ps1 b/tests/production_recovery/verify_latest_backup_test.ps1 new file mode 100644 index 0000000..28977a1 --- /dev/null +++ b/tests/production_recovery/verify_latest_backup_test.ps1 @@ -0,0 +1,10 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$script = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../../deploy/verify-latest-postgres-backup.sh') -Raw +if ($script -notmatch "-regex '\.\*/\[0-9\]\{8\}T\[0-9\]\{6\}Z\$'") { + throw 'restore selector does not restrict candidates to timestamp directories' +} +if ($script -notmatch 'invalid timestamp') { throw 'restore selector lacks timestamp fail-closed guard' } +if ($script -match "! -name '\.\*'") { throw 'restore selector still accepts arbitrary non-hidden directories' } +Write-Output 'verify-latest backup selector tests: PASS' -- 2.54.0 From 470fdf8c0386d851280b62da841b0484f6456829 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:16:27 +0300 Subject: [PATCH 04/33] Start dependency-aware readiness task --- dev/ACTIVE_TASK.json | 4 ++-- ...DEV-000101-dependency-aware-readiness.json | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000101-dependency-aware-readiness.json diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 79c5958..c5f596d 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000100-production-recovery-repair.json", - "task_id": "DEV-000100", + "task_file": "dev/tasks/DEV-000101-dependency-aware-readiness.json", + "task_id": "DEV-000101", "task_revision": 1 } diff --git a/dev/tasks/DEV-000101-dependency-aware-readiness.json b/dev/tasks/DEV-000101-dependency-aware-readiness.json new file mode 100644 index 0000000..5c3e6c8 --- /dev/null +++ b/dev/tasks/DEV-000101-dependency-aware-readiness.json @@ -0,0 +1,22 @@ +{ + "task_id": "DEV-000101", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "15c9517", + "risk_tier": "E", + "intent": "Add dependency-aware readiness projections with typed degraded reasons for core, continuity, commit, cognitive and lifeboat runtimes without changing canonical ownership.", + "requirement_ids": ["WP-003", "Typed degraded dependency reasons", "Frontend health distinct from product readiness"], + "allowed_change_scope": {"modules": ["development-governance"], "paths": ["cmd/core-runtime/**", "cmd/continuity-runtime/**", "cmd/commit-runtime/**", "cmd/cognitive-worker/**", "runtime/health/**", "tests/readiness/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000101-dependency-aware-readiness.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "contracts/**", "modules/**", "adapters/**", ".gitea/**"]}, + "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 runtime health handlers", "Existing owner-local repository health", "Existing cognitive capability health"], "may_change": []}, + "semantic_owner": "none-readiness-projection", "canonical_state": [], + "state_transitions": ["dependency available -> ready", "dependency unavailable/unknown -> typed degraded readiness"], + "side_effects_and_privileges": ["Read-only dependency probes; no canonical mutation, external mutation or credential expansion"], + "persistence_and_migrations": ["No database schema or migration changes"], + "failure_retry_restart": ["Probe timeout is UNKNOWN/DEGRADED, not false healthy; recovery returns ready on next probe"], + "assumptions": ["Existing /healthz endpoints remain backward compatible", "Frontend health and product readiness are separate projections", "docker-test remains prohibited"], + "acceptance_scenarios": ["Stopping each dependency yields the correct typed degraded reason", "DB owner connectivity is represented without cross-owner writes", "cognitive binding/model freshness and lifeboat availability are visible", "recovery returns readiness without restart", "health endpoint does not claim product readiness"], + "required_tests": ["go test ./...", "go vet ./...", "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", "git diff --check", "readiness dependency-stop integration tests"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"] +} -- 2.54.0 From 3f2b00810949c67b086e015ad0b3a0f44255f349 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:20:53 +0300 Subject: [PATCH 05/33] Add dependency-aware runtime readiness --- cmd/cognitive-worker/main.go | 9 ++++ cmd/commit-runtime/main.go | 7 +++ cmd/continuity-runtime/main.go | 7 +++ cmd/core-runtime/main.go | 6 +++ runtime/health/server.go | 85 ++++++++++++++++++++++++++++++++++ runtime/health/server_test.go | 28 +++++++++++ 6 files changed, 142 insertions(+) create mode 100644 runtime/health/server_test.go diff --git a/cmd/cognitive-worker/main.go b/cmd/cognitive-worker/main.go index 0847e33..3b9cdd9 100644 --- a/cmd/cognitive-worker/main.go +++ b/cmd/cognitive-worker/main.go @@ -15,6 +15,7 @@ import ( "log" "net/http" "os" + "strings" "time" ) @@ -71,6 +72,14 @@ func main() { } gateway := cognitiveGateway{resolver: resolver, providers: providers, builtinLocal: builtinLocal, externalRecovery: externalRecovery, now: time.Now, allowCanary: os.Getenv("COGNITIVE_ALLOW_CANARY") == "true", canaryMaxOutputBytes: 4096} mux.Handle("/healthz", health.Handler{Runtime: "cognitive-worker", SemanticGeneration: 1, ArchitectureGeneration: 1}) + readinessClient := &http.Client{Timeout: 2 * time.Second} + lifeboatURL := strings.TrimSuffix(strings.TrimRight(envOr("LOCAL_LIFEBOAT_PROVIDER_URL", "http://cognitive-lifeboat:8086/v1"), "/"), "/v1") + "/health" + mux.Handle("/readyz", health.ReadinessHandler{Runtime: "cognitive-worker", Probes: []health.Probe{ + health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), "/")+"/healthz"), + health.HTTPProbe(readinessClient, strings.TrimRight(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), "/")+"/models"), + health.HTTPProbe(readinessClient, lifeboatURL), + health.HTTPProbe(readinessClient, strings.TrimRight(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), "/")+"/api/tags"), + }}) mux.HandleFunc("POST /v1/proposals/deterministic", deterministicProposal) mux.HandleFunc("POST /v1/advisory-policy", gateway.policyHandler) mux.HandleFunc("POST /v1/advisories", gateway.advisoryHandler) diff --git a/cmd/commit-runtime/main.go b/cmd/commit-runtime/main.go index 2b328c9..6813628 100644 --- a/cmd/commit-runtime/main.go +++ b/cmd/commit-runtime/main.go @@ -84,6 +84,13 @@ func main() { dispatcher := operationDispatcher{continuityExecutor: continuityExecutor, continuityReconciler: continuityExecutor, bindings: creatorCore, secretRef: notificationSecretRef, secret: notificationSecret, client: creatorHTTPClient} mux := http.NewServeMux() mux.Handle("/healthz", health.Handler{Runtime: "commit-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1}) + readinessClient := &http.Client{Timeout: 2 * time.Second} + mux.Handle("/readyz", health.ReadinessHandler{Runtime: "commit-runtime", Probes: []health.Probe{ + health.TCPProbe("commit-postgres", envOr("POSTGRES_HOST", "postgres"), envOr("POSTGRES_PORT", "5432")), + health.TCPProbe("authority-postgres", envOr("POSTGRES_HOST", "postgres"), envOr("POSTGRES_PORT", "5432")), + health.HTTPProbe(readinessClient, strings.TrimRight(coreURL, "/")+"/healthz"), + health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), "/")+"/healthz"), + }}) registerExecutionRoutes(mux, repository, dispatcher, dispatcher, executorToken) registerCreatorCommandRoutes(mux, creatorCommandHandler{ core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository, diff --git a/cmd/continuity-runtime/main.go b/cmd/continuity-runtime/main.go index 03f79b7..b88adf6 100644 --- a/cmd/continuity-runtime/main.go +++ b/cmd/continuity-runtime/main.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "os" + "strings" "time" "git.cin.su/m/Assistant/adapters/postgres" @@ -31,6 +32,12 @@ func main() { } mux := http.NewServeMux() mux.Handle("/healthz", health.Handler{Runtime: "continuity-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1}) + readinessClient := &http.Client{Timeout: 2 * time.Second} + mux.Handle("/readyz", health.ReadinessHandler{Runtime: "continuity-runtime", Probes: []health.Probe{ + health.TCPProbe("postgres", envOr("POSTGRES_HOST", "postgres"), envOr("POSTGRES_PORT", "5432")), + health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), "/")+"/healthz"), + health.HTTPProbe(readinessClient, strings.TrimRight(envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), "/")+"/healthz"), + }}) executorToken := os.Getenv("COMMIT_EXECUTOR_TOKEN") if executorToken == "" { log.Fatal("COMMIT_EXECUTOR_TOKEN is required") diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 53c236a..f7319d4 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -135,6 +135,12 @@ func main() { return situationdomain.NewResolver(bindings).Resolve(runtime) } mux.Handle("/healthz", health.Handler{Runtime: "core-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1}) + readinessClient := &http.Client{Timeout: 2 * time.Second} + mux.Handle("/readyz", health.ReadinessHandler{Runtime: "core-runtime", Probes: []health.Probe{ + health.TCPProbe("postgres", envOr("POSTGRES_HOST", "postgres"), envOr("POSTGRES_PORT", "5432")), + health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), "/")+"/healthz"), + health.HTTPProbe(readinessClient, strings.TrimRight(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), "/")+"/healthz"), + }}) mux.HandleFunc("POST /v1/creator/sessions", creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword, sessionKey, time.Now)) mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) { session, ok := authenticateCreator(w, r) diff --git a/runtime/health/server.go b/runtime/health/server.go index a440385..c55cee6 100644 --- a/runtime/health/server.go +++ b/runtime/health/server.go @@ -1,8 +1,12 @@ package health import ( + "context" "encoding/json" + "fmt" + "net" "net/http" + "time" ) type Handler struct { @@ -20,3 +24,84 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { "architecture_generation": h.ArchitectureGeneration, }) } + +type Probe struct { + Name string + Check func(context.Context) error +} + +type Result struct { + Name string `json:"name"` + State string `json:"state"` + Reason string `json:"reason"` + Detail string `json:"detail,omitempty"` +} + +type ReadinessHandler struct { + Runtime string + Probes []Probe + Timeout time.Duration +} + +func (h ReadinessHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + timeout := h.Timeout + if timeout <= 0 { + timeout = 2 * time.Second + } + results := make([]Result, 0, len(h.Probes)) + ready := true + for _, probe := range h.Probes { + result := Result{Name: probe.Name, State: "READY", Reason: "AVAILABLE"} + if probe.Check == nil { + result.State, result.Reason, result.Detail = "DEGRADED", "PROBE_NOT_CONFIGURED", "probe is not configured" + } else { + ctx, cancel := context.WithTimeout(r.Context(), timeout) + err := probe.Check(ctx) + cancel() + if err != nil { + ready = false + result.State, result.Reason, result.Detail = "DEGRADED", "DEPENDENCY_UNAVAILABLE", err.Error() + } + } + if result.State != "READY" { + ready = false + } + results = append(results, result) + } + status := "ready" + code := http.StatusOK + if !ready { + status, code = "degraded", http.StatusServiceUnavailable + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]any{"status": status, "runtime": h.Runtime, "checks": results}) +} + +func HTTPProbe(client *http.Client, endpoint string) Probe { + return Probe{Name: endpoint, Check: func(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + response, err := client.Do(req) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("status=%s", response.Status) + } + return nil + }} +} + +func TCPProbe(name, host, port string) Probe { + return Probe{Name: name, Check: func(ctx context.Context) error { + connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(host, port)) + if err != nil { + return err + } + return connection.Close() + }} +} diff --git a/runtime/health/server_test.go b/runtime/health/server_test.go new file mode 100644 index 0000000..04eb5c1 --- /dev/null +++ b/runtime/health/server_test.go @@ -0,0 +1,28 @@ +package health + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestReadinessReturns503WithTypedDependencyReason(t *testing.T) { + handler := ReadinessHandler{Runtime: "test", Probes: []Probe{{Name: "postgres", Check: func(context.Context) error { return errors.New("connection refused") }}}} + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), `"reason":"DEPENDENCY_UNAVAILABLE"`) { + t.Fatalf("readiness response = %d %s", recorder.Code, recorder.Body.String()) + } +} + +func TestReadinessReturns200WhenAllProbesPass(t *testing.T) { + handler := ReadinessHandler{Runtime: "test", Probes: []Probe{{Name: "postgres", Check: func(context.Context) error { return nil }}}} + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"status":"ready"`) { + t.Fatalf("readiness response = %d %s", recorder.Code, recorder.Body.String()) + } +} -- 2.54.0 From 6fb9184d14ab2c284bd58bab194171e80623222a Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:25:14 +0300 Subject: [PATCH 06/33] Authorize readiness canary promotion --- dev/ACTIVE_TASK.json | 4 ++-- ...DEV-000102-readiness-canary-promotion.json | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000102-readiness-canary-promotion.json diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index c5f596d..982e4a5 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000101-dependency-aware-readiness.json", - "task_id": "DEV-000101", + "task_file": "dev/tasks/DEV-000102-readiness-canary-promotion.json", + "task_id": "DEV-000102", "task_revision": 1 } diff --git a/dev/tasks/DEV-000102-readiness-canary-promotion.json b/dev/tasks/DEV-000102-readiness-canary-promotion.json new file mode 100644 index 0000000..72927b8 --- /dev/null +++ b/dev/tasks/DEV-000102-readiness-canary-promotion.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000102", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "3f2b008", + "risk_tier": "E", + "intent": "Promote the dependency-aware readiness implementation to a controlled production canary with explicit rollback evidence, without changing deployment topology or source contracts.", + "requirement_ids": ["WP-003", "Controlled canary promotion", "Production rollback evidence"], + "allowed_change_scope": {"modules": ["deployment-governance"], "paths": ["dev/ACTIVE_TASK.json", "dev/tasks/DEV-000102-readiness-canary-promotion.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "contracts/**", "modules/**", "adapters/**", ".gitea/**", "cmd/**", "runtime/**"]}, + "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 production compose topology", "Existing runtime health endpoints", "Existing production backup and deployment verification"], "may_change": []}, + "semantic_owner": "none-deployment-canary", + "canonical_state": [], + "state_transitions": ["built candidate -> canary verified", "canary failure -> rollback to recorded production revision"], + "side_effects_and_privileges": ["Controlled production container replacement only after build/config verification; no schema, credential, or external data mutation"], + "persistence_and_migrations": ["No database schema or migration changes"], + "failure_retry_restart": ["Rollback target is production revision b439a813e5932b1774cb3b6c2364c6cac3891314 and recorded core image digest sha256:4fb09995df8efbbb02359e30171f593f9cf9b90489d4ac0bf85a62ed2504ac92"], + "assumptions": ["Production control plane is docker-prod/docker.cin.su only", "Canary is limited to readiness-capable runtime services", "Existing secrets and persistent volumes remain unchanged"], + "acceptance_scenarios": ["Candidate images build from 3f2b008", "Compose configuration validates without secret disclosure", "Canary readiness returns HTTP 200 with dependency checks", "Existing health and HTTPS checks remain healthy", "Rollback command and pre-canary image identity are recorded"], + "required_tests": ["go test ./...", "go vet ./...", "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", "git diff --check", "docker compose config on docker-prod", "production canary readiness and rollback verification"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"] +} -- 2.54.0 From 339f6a6770baabe3dd5e6d480a41265be1047366 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:32:20 +0300 Subject: [PATCH 07/33] Propose epistemics truth v1 --- dev/ACTIVE_TASK.json | 4 +- ...V-000103-epistemics-truth-v1-proposal.json | 23 ++++ .../proposals/ADR-0019-epistemics-truth-v1.md | 110 ++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000103-epistemics-truth-v1-proposal.json create mode 100644 docs/proposals/ADR-0019-epistemics-truth-v1.md diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 982e4a5..9d40435 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000102-readiness-canary-promotion.json", - "task_id": "DEV-000102", + "task_file": "dev/tasks/DEV-000103-epistemics-truth-v1-proposal.json", + "task_id": "DEV-000103", "task_revision": 1 } diff --git a/dev/tasks/DEV-000103-epistemics-truth-v1-proposal.json b/dev/tasks/DEV-000103-epistemics-truth-v1-proposal.json new file mode 100644 index 0000000..38b2f63 --- /dev/null +++ b/dev/tasks/DEV-000103-epistemics-truth-v1-proposal.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000103", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "6fb9184", + "risk_tier": "E", + "intent": "Prepare the proposed ADR-0019 Truth V1 boundary for review without implementing runtime, schema, connector, source or authority changes.", + "requirement_ids": ["WP-100", "Observation/Claim/BeliefAssessment", "Source provenance freshness conflict"], + "allowed_change_scope": {"modules": ["epistemics-governance"], "paths": ["docs/proposals/ADR-0019-epistemics-truth-v1.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000103-epistemics-truth-v1-proposal.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "contracts/**", "database/**", "migrations/**", "modules/**", "cmd/**", "runtime/**", "adapters/**", "deploy/**", ".gitea/**"]}, + "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": ["ADR-0016 Epistemics experience/reflection ownership", "ADR-0017 typed provenance references", "Core semantic model ownership boundaries"], "may_change": ["Proposed ADR-0019 only; no accepted contract changes"]}, + "semantic_owner": "epistemics-proposal-only", + "canonical_state": [], + "state_transitions": ["observation/source evidence -> claim assessment", "assessment -> supported/corroborated/conflicted/stale/retracted/unknown", "correction -> superseding assessment"], + "side_effects_and_privileges": ["No runtime side effects, external mutation, connector access, credential access or authority expansion"], + "persistence_and_migrations": ["No schema, migration or retention job changes"], + "failure_retry_restart": ["Proposal failure leaves no canonical state; accepted implementation must preserve append-only evidence and rebuildable projections"], + "assumptions": ["Proposal is reviewed before acceptance", "Epistemics remains the only proposed truth-evidence owner", "Secrets and unapproved personal data are fail-closed"], + "acceptance_scenarios": ["Owner boundaries and typed references are explicit", "Unknown, assumed, stale and conflicted states cannot silently become facts", "Correction and supersession preserve history", "Freshness and coverage are separate from confidence", "No implementation or architecture registry delta is introduced"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "proposal scope and required-section review"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/docs/proposals/ADR-0019-epistemics-truth-v1.md b/docs/proposals/ADR-0019-epistemics-truth-v1.md new file mode 100644 index 0000000..da06f58 --- /dev/null +++ b/docs/proposals/ADR-0019-epistemics-truth-v1.md @@ -0,0 +1,110 @@ +# Proposal ADR-0019 — Epistemics Truth V1 + +Status: Proposed +Task: DEV-000103 + +## Problem and evidence + +The audit found that Domovoy has experience and reflection evidence, but no +small, explicit truth model for observations, claims, source provenance, +freshness or conflicts. Without that boundary, research, context compilation +and future learning could silently turn an unverified statement into durable +fact. The proposal is architecture-only: it does not add tables, runtime +handlers, connectors or belief mutation. + +## Decision proposed + +1. Do not create a new canonical owner. Epistemics owns truth evidence and + rebuildable truth projections; Interaction, Situation, Work, Capability and + Artifact retain ownership of their canonical records. +2. Define six typed records, with immutable identity and append-only history: + `Observation`, `Claim`, `BeliefAssessment`, `Source` and `ProvenanceEdge`, + plus a typed `VerificationState` value. Records reference other owners by + typed IDs and never copy their canonical payloads. +3. An `Observation` is a bounded acquired datum with acquisition time, + source snapshot reference, excerpt/hash reference, collector identity and + privacy/egress classification. It is evidence, not truth. +4. A `Claim` is a proposition with explicit subject/predicate/object or + proposition payload, scope, language, creator and claim status. It is not a + fact merely because it is repeated or model-generated. +5. A `BeliefAssessment` is a revisable, scoped assessment over one claim, + carrying verification state, confidence band, freshness result, coverage + result, conflict set and supporting/contradicting provenance edges. +6. `Source` identifies the origin and retrieval policy; a source snapshot is + the immutable artifact used for a particular observation. Source identity + alone is never a citation. +7. `ProvenanceEdge` records typed support, contradiction, derivation, + correction or supersession between evidence and claims. A projection may + be rebuilt from these edges and append-only records. +8. Verification states are explicit: `UNVERIFIED`, `SUPPORTED`, + `CORROBORATED`, `CONFLICTED`, `STALE`, `RETRACTED` and `UNKNOWN`. + `UNKNOWN` and `ASSUMED` remain visible and cannot be promoted implicitly. +9. Freshness and coverage are evidence dimensions, not confidence shortcuts. + A stale or incomplete source may support a claim only with that limitation + attached. Conflicts are retained and surfaced; they are not resolved by + latest-write-wins. +10. Privacy, authorization and egress filtering happen before observation + persistence or external citation. Secrets and unapproved personal data + are fail-closed and cannot become source snapshots. + +## Alternatives considered and rationale + +- Store truth directly in Situation or Interaction: rejected because those + owners would become provenance/verification authorities and would mix + canonical operational state with revisable evidence. +- Use a single confidence score or latest-write-wins: rejected because it + hides freshness, incomplete coverage and contradiction, and cannot explain + why an assessment changed. +- Introduce a universal ontology engine: rejected because it expands scope, + makes ownership ambiguous and is unnecessary for the bounded claims needed + by the research and context work packages. + +The proposed small typed set keeps evidence historical, makes uncertainty +visible and allows projections to be rebuilt without granting models or +connectors canonical authority. + +## Lifecycle and correction + +Observation and source snapshots are append-only and retention-governed. +Claims and assessments are superseded or retracted by new typed records; +history is never deleted to hide an earlier conclusion. Human correction is +strong evidence with explicit provenance, but does not become a permanent +rule without a later assessment. Rebuildable projections may be compacted +only while preserving their source record references and retention policy. + +## Boundaries and non-goals + +- No universal ontology, generic `BeliefManager` or `map[string]any` truth + store. +- No model output directly mutates claims, assessments, authority or source + snapshots. +- No web/repository connector, citation resolver, migration or UI change is + included in this proposal. +- External research remains read-only until a separate WP-102 contract and + connector task is accepted. + +## Consequences + +Truthful product surfaces can distinguish evidence, assessment, uncertainty, +staleness and conflict. Research and context compilation gain stable +provenance inputs, while implementation must carry typed references and +explicit freshness/coverage results. The proposal adds review and storage +discipline but intentionally provides no immediate runtime capability. + +## Migration and rollback + +There is no migration in this proposal. If accepted, implementation must add +append-only records and projections behind owner-local interfaces, with a +rebuild harness and a feature flag/canary before any existing response path +consumes assessments. Rejection or rollback disables the projection consumer; +historical source evidence remains intact and no canonical operational state +is mutated. + +## Acceptance and revisit triggers + +The proposal is ready for acceptance when owner boundaries, identifiers, +retention/privacy rules, correction/supersession lifecycle, rebuildable +projections and conflict semantics have implementation-level contract tests +specified without introducing a new owner. Revisit if a source requires +write access, if retention law changes, if a second canonical owner is +proposed, or if confidence is used as an authority/permission decision. -- 2.54.0 From 82ee6e5ba04b4972ae968bf033852a7a8a5cdca2 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:34:26 +0300 Subject: [PATCH 08/33] Propose agent execution plane v1 --- dev/ACTIVE_TASK.json | 4 +- ...000104-agent-execution-plane-proposal.json | 23 +++++ .../ADR-0020-agent-execution-plane-v1.md | 97 +++++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000104-agent-execution-plane-proposal.json create mode 100644 docs/proposals/ADR-0020-agent-execution-plane-v1.md diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 9d40435..30dace5 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000103-epistemics-truth-v1-proposal.json", - "task_id": "DEV-000103", + "task_file": "dev/tasks/DEV-000104-agent-execution-plane-proposal.json", + "task_id": "DEV-000104", "task_revision": 1 } diff --git a/dev/tasks/DEV-000104-agent-execution-plane-proposal.json b/dev/tasks/DEV-000104-agent-execution-plane-proposal.json new file mode 100644 index 0000000..a577868 --- /dev/null +++ b/dev/tasks/DEV-000104-agent-execution-plane-proposal.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000104", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "339f6a6", + "risk_tier": "E", + "intent": "Prepare the proposed ADR-0020 Agent Execution Plane boundary for review without implementing dispatcher, worker, schema, callback or deployment changes.", + "requirement_ids": ["WP-300", "Durable dispatch lease heartbeat", "Independent verification and truthful UNKNOWN outcome"], + "allowed_change_scope": {"modules": ["work-execution-governance"], "paths": ["docs/proposals/ADR-0020-agent-execution-plane-v1.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000104-agent-execution-plane-proposal.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "contracts/**", "database/**", "migrations/**", "modules/**", "cmd/**", "runtime/**", "adapters/**", "deploy/**", ".gitea/**"]}, + "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": ["ADR-0017 General Agent Delegation and Codex Bridge V1", "Proposed ADR-0019 Epistemics Truth V1", "Core semantic model Work/Capability/Artifact/Continuity boundaries"], "may_change": ["Proposed ADR-0020 only; no accepted contract changes"]}, + "semantic_owner": "work-execution-plane-proposal-only", + "canonical_state": [], + "state_transitions": ["dispatch -> leased -> running -> result -> independently verified", "lease expiry/callback uncertainty -> UNKNOWN or RECOVERING", "cancel request -> acknowledged cancellation or typed unknown"], + "side_effects_and_privileges": ["No runtime side effects, external mutation, connector access, credential access or authority expansion"], + "persistence_and_migrations": ["No schema, migration, queue, worker or lease implementation changes"], + "failure_retry_restart": ["Retry creates a new run and preserves history; late or duplicate callbacks cannot become authoritative"], + "assumptions": ["Work remains canonical owner of durable delegation execution", "Artifact remains canonical owner of attestation metadata", "Production deployment remains outside this proposal"], + "acceptance_scenarios": ["Worker identity and dispatch idempotency are explicit", "Lease fencing and heartbeat expiry are explicit", "Callback authentication/replay handling is explicit", "Cancellation/retry/UNKNOWN semantics preserve history", "Independent verification gates Work completion", "No implementation or registry delta is introduced"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "proposal scope and required-section review"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/docs/proposals/ADR-0020-agent-execution-plane-v1.md b/docs/proposals/ADR-0020-agent-execution-plane-v1.md new file mode 100644 index 0000000..ece5306 --- /dev/null +++ b/docs/proposals/ADR-0020-agent-execution-plane-v1.md @@ -0,0 +1,97 @@ +# Proposal ADR-0020 — Agent Execution Plane V1 + +Status: Proposed +Task: DEV-000104 + +## Problem and evidence + +ADR-0017 defines durable delegation semantics, but the audit found no +automatic dispatcher/worker plane, lease/heartbeat lifecycle, callback +authentication or independent verification boundary. A worker must be able +to stop, restart and resume without turning a missing callback into success or +silently duplicating external work. + +## Decision proposed + +1. Keep `work` as the canonical owner of `Delegation`, `AgentRun`, + `AgentCheckpoint`, `AgentResult` and verification gating. `capability` + owns executor-neutral agent implementations; adapters own transport and + process details. +2. Every dispatch has a stable `dispatch_id`, delegation id, run id, + implementation id, exact source/worktree identity, bounded instruction + artifact reference, requested capability set, authority/credential refs, + deadline and idempotency key. The derived instruction must preserve + OriginalHumanIntent, inferred goal, added context and verification plan as + separate provenance fields. +3. A worker must acquire a durable lease before execution. Lease ownership is + a fencing token, not an in-memory mutex. Heartbeats extend the lease only + for the same run and fencing token; an expired lease makes late callbacks + non-authoritative. +4. Dispatch and callback endpoints authenticate the worker identity and bind + every message to delegation, run, dispatch, implementation and fencing + token. Forged, replayed, out-of-order or duplicate callbacks are rejected + or recorded as non-authoritative evidence. Idempotent acknowledgement is + allowed only for the exact previously accepted event. +5. Checkpoints and artifacts are content-addressed metadata owned by + `artifact`, with typed refs from Work. Artifact attestation records source + revision/worktree, producer, digest, test command/result and capture time; + an un-attested path or log is not completion evidence. +6. Cancellation is a durable Work decision. The dispatcher sends cancellation + to the leased worker, records acknowledgement or timeout, and never erases + history. A worker that cannot prove cancellation remains `UNKNOWN` or + `RECOVERING` until reconciled. +7. Retry is policy-driven and distinguishes retryable transport/worker loss, + execution-window exhaustion, human blocker, invalid result and unknown + external outcome. Retry creates a new run under the same delegation and + preserves prior checkpoints/results; it never reuses an idempotency key for + an unproven external mutation. +8. A run result cannot complete Work directly. Independent verification must + consume the declared artifacts and tests through a separate verifier + identity and produce a typed verification result. Verification failure, + missing evidence and verifier unavailability remain distinct states. +9. Secrets are resolved only through approved `CredentialRef` boundaries and + are not copied into instructions, callbacks, artifacts, logs or task + packets. Worker startup has no ambient credential inheritance. +10. The plane is durable and restartable: dispatch intent, lease, heartbeat, + cancellation, checkpoint, result, verification and reconciliation events + are persisted before their projections claim progress. + +## Lifecycle proposal + +Delegation remains the higher-level human/work intent. A run progresses through +`DISPATCHED`, `LEASED`, `RUNNING`, `CHECKPOINTED`, `SUCCEEDED`, `FAILED`, +`CANCEL_REQUESTED`, `CANCELLED`, `EXHAUSTED`, `WAITING_HUMAN`, `UNKNOWN` or +`RECOVERING`, subject to explicit transition guards. `SUCCEEDED` means only +that the worker returned an accepted result; Work completion additionally +requires independent verification. Lease expiry and missing heartbeat never +become success. + +## Alternatives and rationale + +- An in-memory queue or goroutine was rejected because it loses dispatch state + on restart and violates durable timer/lease requirements. +- A single generic `AgentManager` was rejected because it collapses Work, + Capability, Artifact and Continuity ownership. +- Trusting worker-reported success was rejected because the worker and + verifier must remain separate authorities. +- Reusing the same run on retry was rejected because it makes duplicate + external effects and late callbacks indistinguishable. + +## Boundaries, consequences and non-goals + +No new canonical owner, provider, deployment authority, database migration, +Codex-specific domain contract or automatic self-modification is proposed. +The design enables restartable external work and truthful failure handling, +but requires durable event ordering, fencing, artifact storage and verifier +capacity before WP-301 can claim production readiness. + +## Migration, rollback and revisit triggers + +Implementation must begin behind an opt-in dispatch path with a dry-run +verifier and deterministic fake worker. Existing delegation records remain +readable; unknown legacy RUNNING records are classified by WP-302 rather than +silently resumed. Rollback disables new dispatch and drains/recovers leases; +history and artifacts remain. Revisit if a worker needs authority beyond its +declared capability, if a second transport changes callback guarantees, or if +artifact attestation cannot be made independent of the worker. + -- 2.54.0 From 08deab8dda1f12d4472f371908b0f045b8602308 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:36:22 +0300 Subject: [PATCH 09/33] Propose multi-agent work graph v1 --- dev/ACTIVE_TASK.json | 4 +- ...00105-multi-agent-work-graph-proposal.json | 23 ++++++ .../ADR-0021-multi-agent-work-graph-v1.md | 80 +++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000105-multi-agent-work-graph-proposal.json create mode 100644 docs/proposals/ADR-0021-multi-agent-work-graph-v1.md diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 30dace5..24c6d0d 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000104-agent-execution-plane-proposal.json", - "task_id": "DEV-000104", + "task_file": "dev/tasks/DEV-000105-multi-agent-work-graph-proposal.json", + "task_id": "DEV-000105", "task_revision": 1 } diff --git a/dev/tasks/DEV-000105-multi-agent-work-graph-proposal.json b/dev/tasks/DEV-000105-multi-agent-work-graph-proposal.json new file mode 100644 index 0000000..2517e80 --- /dev/null +++ b/dev/tasks/DEV-000105-multi-agent-work-graph-proposal.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000105", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "82ee6e5", + "risk_tier": "E", + "intent": "Prepare the proposed ADR-0021 Multi-Agent Work Graph boundary for review without implementing a scheduler, graph schema, workers or production changes.", + "requirement_ids": ["WP-400", "DAG revision and budgets", "Fan-out/fan-in conflict and independent verification"], + "allowed_change_scope": {"modules": ["work-graph-governance"], "paths": ["docs/proposals/ADR-0021-multi-agent-work-graph-v1.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000105-multi-agent-work-graph-proposal.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "contracts/**", "database/**", "migrations/**", "modules/**", "cmd/**", "runtime/**", "adapters/**", "deploy/**", ".gitea/**"]}, + "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": ["ADR-0017 delegation ownership", "Proposed ADR-0020 Agent Execution Plane", "Proposed ADR-0019 Truth V1 provenance", "Core Work/Capability/Artifact/Continuity ownership"], "may_change": ["Proposed ADR-0021 only; no accepted contract changes"]}, + "semantic_owner": "work-graph-proposal-only", + "canonical_state": [], + "state_transitions": ["graph draft -> admitted -> running -> verifying -> completed/partial/failed", "node prerequisite blocked -> ready -> leased/running -> result or recovery", "candidate disagreement -> conflict set -> verified synthesis or unresolved conflict"], + "side_effects_and_privileges": ["No runtime side effects, external mutation, connector access, credential access or authority expansion"], + "persistence_and_migrations": ["No graph schema, scheduler, queue, lease or migration implementation changes"], + "failure_retry_restart": ["Graph revisions are immutable; restart/replay uses durable history; unknown outcomes block unsafe replay"], + "assumptions": ["Work remains canonical graph owner", "Roles are capability profiles without authority", "Independent verification is required before Work completion"], + "acceptance_scenarios": ["Cycles are rejected before admission", "Budgets cover graph/user/resource concurrency and attempts", "Fan-out/fan-in and partial failure preserve attribution", "Cancellation/pause/resume/restart semantics are explicit", "Conflicts retain dissent and verifier identity is distinct", "No implementation or registry delta is introduced"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "proposal scope and required-section review"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/docs/proposals/ADR-0021-multi-agent-work-graph-v1.md b/docs/proposals/ADR-0021-multi-agent-work-graph-v1.md new file mode 100644 index 0000000..5f93d32 --- /dev/null +++ b/docs/proposals/ADR-0021-multi-agent-work-graph-v1.md @@ -0,0 +1,80 @@ +# Proposal ADR-0021 — Multi-Agent Work Graph V1 + +Status: Proposed +Task: DEV-000105 + +## Problem and evidence + +The audit found durable Work and Delegation records but no multi-agent DAG, +fan-out/fan-in scheduler, budgets, leases, conflict resolution or independent +verifier. Adding parallel agents without those controls would create duplicate +work, unbounded cost, hidden disagreement and false completion. + +## Decision proposed + +1. `work` owns the durable graph: `WorkGraph`, `GraphRevision`, `WorkNode`, + `NodeDependency`, `ResultCandidate`, `ConflictSet` and synthesis/verification + gating. Capability remains the source of eligible agent implementations; + Artifact owns result metadata; Continuity owns human attention. +2. A graph is immutable by revision. Each revision contains typed nodes, + dependency edges, requested capabilities, budgets, cancellation policy, + synthesis policy and verifier requirements. Acyclicity and ownership are + validated before dispatch; edits create a new revision rather than mutating + a running graph. +3. Node identity and idempotency are stable within a revision. Independent + nodes may fan out only after graph admission checks. A dependent node is + dispatchable only when all required prerequisites have accepted results. + Partial results and failed nodes remain attributable and do not erase + sibling results. +4. Scheduling enforces durable per-goal, per-user, per-resource and per-graph + budgets for concurrency, attempts, time, tokens and external mutation + class. Backpressure and starvation detection are explicit; a role or agent + cannot bypass a budget. +5. Assignment uses the Agent Execution Plane lease/fencing rules. Pause, + cancel, reprioritize and resume are durable graph decisions propagated to + eligible nodes. Restart/replay rebuilds from graph revision and execution + events, not process-local queues. +6. Agent outputs are `ResultCandidate` records with author run, node, graph + revision, artifact refs, evidence and claims. Conflicting candidates form a + `ConflictSet`; dissent and unresolved conflict remain visible. +7. Synthesis is a candidate, never an authority shortcut. A verifier with a + distinct run/identity from the author consumes declared evidence and emits + a typed verification result. Work completion requires the graph's verifier + policy and does not occur from fan-in text alone. +8. Specialized roles are capability profiles and evidence-based selection + hints, not hardcoded authority classes. One agent may hold multiple roles; + role selection never grants credentials or permission. + +## Lifecycle and failure semantics + +Graph states are `DRAFT`, `ADMITTED`, `RUNNING`, `PAUSED`, `CANCELLING`, +`PARTIAL`, `VERIFYING`, `COMPLETED`, `FAILED`, `UNKNOWN` and `RECOVERING`. +Node states distinguish `BLOCKED_PREREQUISITE`, `READY`, `LEASED`, `RUNNING`, +`SUCCEEDED`, `FAILED`, `CANCELLED`, `UNKNOWN` and `RECOVERING`. A failed node +may trigger policy-defined retry or human attention; it never deletes other +node evidence. Unknown external outcomes block unsafe replay until reconciled. + +## Alternatives and rationale + +- A shared in-memory task queue was rejected because restart loses graph state + and violates durable scheduling semantics. +- A flat list of independent agents was rejected because dependencies, + fan-in, cycle rejection and partial completion cannot be represented safely. +- Majority vote without provenance was rejected because disagreement and + source quality would be hidden. +- Agent roles as privileged classes were rejected because capability evidence, + not labels, must determine eligibility and authority remains separate. + +## Consequences, migration and rollback + +The graph enables bounded parallel work and attributable synthesis, at the +cost of durable event ordering, budget accounting and verifier capacity. No +runtime, schema or scheduler is included in this proposal. Implementation +must begin with a deterministic fake graph harness, cycle/budget/conflict +tests and opt-in dispatch. Rollback disables new graph admission and drains +or recovers active leases without deleting graph history or artifacts. + +Revisit if a graph node needs authority outside its capability, if cross-owner +joins become necessary, if synthesis cannot preserve dissent, or if the +verifier cannot be made independent of the author run. + -- 2.54.0 From 3e0be6245eb2fe0959d94bbe3db6fa911e125a26 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:38:46 +0300 Subject: [PATCH 10/33] Propose governed self-development v1 --- dev/ACTIVE_TASK.json | 4 +- ...06-governed-self-development-proposal.json | 23 ++++++ .../ADR-0022-governed-self-development-v1.md | 79 +++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000106-governed-self-development-proposal.json create mode 100644 docs/proposals/ADR-0022-governed-self-development-v1.md diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 24c6d0d..15878ef 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000105-multi-agent-work-graph-proposal.json", - "task_id": "DEV-000105", + "task_file": "dev/tasks/DEV-000106-governed-self-development-proposal.json", + "task_id": "DEV-000106", "task_revision": 1 } diff --git a/dev/tasks/DEV-000106-governed-self-development-proposal.json b/dev/tasks/DEV-000106-governed-self-development-proposal.json new file mode 100644 index 0000000..073a4d8 --- /dev/null +++ b/dev/tasks/DEV-000106-governed-self-development-proposal.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000106", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "08deab8", + "risk_tier": "E", + "intent": "Prepare the proposed ADR-0022 Governed Self-Development boundary for review without implementing proposal generation, factories, candidate activation or production changes.", + "requirement_ids": ["WP-600", "Governed development control plane", "Candidate canary promotion rollback emergency stop"], + "allowed_change_scope": {"modules": ["governed-development-proposal"], "paths": ["docs/proposals/ADR-0022-governed-self-development-v1.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000106-governed-self-development-proposal.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "contracts/**", "database/**", "migrations/**", "modules/**", "cmd/**", "runtime/**", "adapters/**", "deploy/**", ".gitea/**", "skills/**"]}, + "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": ["ADR-0016 Experience/Reflection/Skill Evolution", "Proposed ADR-0019 Truth V1 evidence", "Proposed ADR-0020 Agent Execution Plane", "Proposed ADR-0021 Work Graph", "existing Authority/Commit/Artifact boundaries"], "may_change": ["Proposed ADR-0022 only; no accepted contract changes"]}, + "semantic_owner": "governed-development-proposal-only", + "canonical_state": [], + "state_transitions": ["evidence -> proposal -> sandbox -> evaluation -> canary -> promotion/rollback/retirement", "failed/conflicted/incomplete evaluation -> blocked"], + "side_effects_and_privileges": ["No runtime side effects, source mutation, deployment, external mutation, credential access or authority expansion"], + "persistence_and_migrations": ["No proposal store, candidate artifact store, schema, migration or telemetry implementation changes"], + "failure_retry_restart": ["Candidate evidence and lineage survive rejection/rollback; emergency stop disables candidate selection without deleting history"], + "assumptions": ["Human governance remains promotion authority", "Protected paths and secret policies cannot be modified by the control plane", "New code/model/skill never receives authority automatically"], + "acceptance_scenarios": ["One failure cannot create a proposal", "Candidate lineage and isolated scope are mandatory", "Independent evaluation and verifier are explicit", "Canary and rollback preserve previous implementation and evidence", "Emergency stop blocks candidate traffic", "No implementation or registry delta is introduced"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "proposal scope and required-section review"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/docs/proposals/ADR-0022-governed-self-development-v1.md b/docs/proposals/ADR-0022-governed-self-development-v1.md new file mode 100644 index 0000000..01a04e6 --- /dev/null +++ b/docs/proposals/ADR-0022-governed-self-development-v1.md @@ -0,0 +1,79 @@ +# Proposal ADR-0022 — Governed Self-Development V1 + +Status: Proposed +Task: DEV-000106 + +## Problem and evidence + +The audit found ReflectionCandidates and capability evidence, but no safe +pipeline for turning repeated evidence into a reviewed change. Direct +self-editing would let model output alter source, skills, authority or +production without provenance, evaluation or rollback. + +## Decision proposed + +1. Introduce a governed development control plane. `ExtensionProposal` is an + evidence-bearing request that moves through review, implementation and + promotion stages. +2. A proposal must reference repeated or corroborated evidence, desired + outcome, smallest change layer, affected owners/contracts, assumptions, + alternatives, risk tier, evaluation plan, rollback and emergency-stop + conditions. One accidental failure is insufficient; duplicate evidence is + deduplicated without deleting its history. +3. Candidate artifacts are immutable, content-addressed and lineage-linked to + the proposal, task packet, source revision, generator/author identity, + tests, evaluations and environment. Candidate code, skill, adapter or + configuration is created only in an isolated branch/worktree/sandbox with + no protected-main, production or ambient-secret access. +4. A human-authorized governance boundary approves task scope and promotion. + Cognitive components may propose and summarize evidence; promotion remains + an explicit governance decision. +5. Evaluation is layered: contract/architecture/security checks, deterministic + unit/integration tests, adversarial and regression corpus, migration/ + recovery checks, and independent verifier review. Failed, conflicting or + incomplete evidence blocks promotion and remains attributable. +6. Candidate lifecycle is explicit: `PROPOSED`, `REJECTED`, `SANDBOXED`, + `EVALUATING`, `CANARY`, `PROMOTED`, `ROLLED_BACK`, `RETIRED` and `BLOCKED`. + Promotion is bounded by capability registration, traffic, budget and + telemetry policy. A candidate never receives authority merely by passing + model evaluation. +7. Canary state keeps the previous implementation addressable. Rollback is a + durable, reversible selection change with an emergency stop that disables + candidate traffic without deleting evidence, artifacts or lineage. +8. Generated skills, modules, adapters and configuration follow the same + ordinary development path: typed owners, task scope, architecture checks, + tests, security review, migration/recovery plan and independent + verification. + +## Ownership and boundaries + +Epistemics owns evidence and ReflectionCandidates; Capability owns +implementation/skill registration and lifecycle; Work owns task/delegation +execution; Artifact owns candidate and evaluation metadata; Authority owns +human promotion permission; Commit/Executor owns external mutation. No new +universal LearningManager or self-development owner is introduced. + +Three boundaries are essential: candidate work uses the declared task scope, +secret access follows existing CredentialRef policy, and candidate promotion +does not itself grant new authority. Original intent, truth assessments and +historical evidence retain their existing owners and provenance. + +## Rationale + +Evidence, scoped implementation, independent evaluation and reversible +promotion provide a practical improvement loop while keeping authority and +provenance explicit. The design uses existing Epistemics, Capability, Work, +Artifact and Authority boundaries instead of adding a new universal manager. + +## Consequences, migration and rollback + +The proposal creates an auditable path for improvement while adding review, +evaluation and artifact-retention cost. There is no implementation, schema or +production change here. Implementation starts with a deterministic proposal +fixture and fake candidate, then uses opt-in canary traffic. Rollback disables +candidate selection and restores the previous implementation reference; +evidence and failed candidates remain available for analysis. + +Revisit if a candidate requires new authority, cross-owner direct storage, +unreviewed external mutation, or if rollback cannot restore the previous +implementation deterministically. -- 2.54.0 From 879de3bd95f58a7a34cf4351c6de3cb64e4b4955 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:41:52 +0300 Subject: [PATCH 11/33] Add typed context compiler v1 --- contracts/cognitive/context_compiler.go | 91 +++++++++++++++++++ dev/ACTIVE_TASK.json | 4 +- dev/tasks/DEV-000107-context-compiler-v1.json | 23 +++++ .../context_compiler/context_compiler_test.go | 59 ++++++++++++ 4 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 contracts/cognitive/context_compiler.go create mode 100644 dev/tasks/DEV-000107-context-compiler-v1.json create mode 100644 tests/context_compiler/context_compiler_test.go diff --git a/contracts/cognitive/context_compiler.go b/contracts/cognitive/context_compiler.go new file mode 100644 index 0000000..6b4d27e --- /dev/null +++ b/contracts/cognitive/context_compiler.go @@ -0,0 +1,91 @@ +package cognitive + +import ( + "errors" + "sort" + "strings" + + "git.cin.su/m/Assistant/contracts/capability" +) + +var ErrInvalidContextBudget = errors.New("context budget must be positive") + +type ContextCandidate struct { + Excerpt ContextExcerpt + Relevance int +} + +type ContextCompileInput struct { + Candidates []ContextCandidate + DataPolicy capability.DataPolicy + MaxBytes int +} + +type ContextOmission struct { + ID ContextExcerptID + Reason string +} + +type ContextBundle struct { + Excerpts []ContextExcerpt + Omissions []ContextOmission + Bytes int +} + +// CompileContext deterministically selects permitted, relevant excerpts for a +// gateway request. It carries no provider identity and performs no I/O. +func CompileContext(input ContextCompileInput) (ContextBundle, error) { + if input.MaxBytes <= 0 { + return ContextBundle{}, ErrInvalidContextBudget + } + candidates := append([]ContextCandidate(nil), input.Candidates...) + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].Relevance != candidates[j].Relevance { + return candidates[i].Relevance > candidates[j].Relevance + } + return string(candidates[i].Excerpt.ID) < string(candidates[j].Excerpt.ID) + }) + bundle := ContextBundle{Excerpts: []ContextExcerpt{}, Omissions: []ContextOmission{}} + seen := make(map[ContextExcerptID]struct{}, len(candidates)) + for _, candidate := range candidates { + excerpt := candidate.Excerpt + if _, ok := seen[excerpt.ID]; ok { + bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "duplicate"}) + continue + } + seen[excerpt.ID] = struct{}{} + reason := permittedContextReason(excerpt, input.DataPolicy) + if reason != "" { + bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: reason}) + continue + } + if strings.TrimSpace(excerpt.Text) == "" { + bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "empty"}) + continue + } + bytes := len([]byte(excerpt.Text)) + if bundle.Bytes+bytes > input.MaxBytes { + bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "budget"}) + continue + } + bundle.Excerpts = append(bundle.Excerpts, excerpt) + bundle.Bytes += bytes + } + return bundle, nil +} + +func permittedContextReason(excerpt ContextExcerpt, policy capability.DataPolicy) string { + if excerpt.Sensitivity == SensitivitySecret { + return "secret" + } + if excerpt.Sensitivity == SensitivityUnknown { + return "unknown_sensitivity" + } + if policy == capability.DataPolicyExternalMessageOnly { + return "policy_message_only" + } + if policy == capability.DataPolicyExternalMinimizedContext && excerpt.Sensitivity != SensitivityPublic { + return "policy_public_only" + } + return "" +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 15878ef..f4e9f0d 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000106-governed-self-development-proposal.json", - "task_id": "DEV-000106", + "task_file": "dev/tasks/DEV-000107-context-compiler-v1.json", + "task_id": "DEV-000107", "task_revision": 1 } diff --git a/dev/tasks/DEV-000107-context-compiler-v1.json b/dev/tasks/DEV-000107-context-compiler-v1.json new file mode 100644 index 0000000..d7bf98f --- /dev/null +++ b/dev/tasks/DEV-000107-context-compiler-v1.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000107", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "3e0be62", + "risk_tier": "E", + "intent": "Implement a typed context compiler for relevance, sensitivity, provenance and byte budgets at the cognitive contract boundary.", + "requirement_ids": ["WP-101", "Typed context bundle", "Sensitivity/egress filtering and budget"], + "allowed_change_scope": {"modules": ["cognitive-contracts"], "paths": ["contracts/cognitive/**", "tests/context_compiler/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000107-context-compiler-v1.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "modules/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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 cognitive ContextExcerpt and Gateway request boundary", "Capability DataPolicy values"], "may_change": ["Cognitive context bundle/compiler contract"]}, + "semantic_owner": "cognitive-context-contract", + "canonical_state": [], + "state_transitions": ["candidate excerpts -> selected typed context bundle", "budget/sensitivity decision -> included or omitted with reason"], + "side_effects_and_privileges": ["Pure deterministic compilation; no provider call, persistence, external mutation or credential access"], + "persistence_and_migrations": ["No database schema or migration changes"], + "failure_retry_restart": ["Compilation is deterministic and repeatable; empty permitted context remains a valid bundle"], + "assumptions": ["Gateway remains responsible for provider selection", "Secret and unknown excerpts are never emitted", "Provider identity remains invocation provenance"], + "acceptance_scenarios": ["Relevant excerpts are selected deterministically", "Duplicate and low-value excerpts do not consume budget", "Secret/unknown data is omitted", "External minimized policy excludes private data", "UTF-8 byte budget is respected", "Provider identity is not represented as persona"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "context compiler tests"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/tests/context_compiler/context_compiler_test.go b/tests/context_compiler/context_compiler_test.go new file mode 100644 index 0000000..146a888 --- /dev/null +++ b/tests/context_compiler/context_compiler_test.go @@ -0,0 +1,59 @@ +package context_compiler_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/cognitive" +) + +func TestCompileContextOrdersDeduplicatesAndBudgets(t *testing.T) { + bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{ + MaxBytes: 4, + Candidates: []cognitive.ContextCandidate{ + {Excerpt: cognitive.ContextExcerpt{ID: "low", Sensitivity: cognitive.SensitivityPublic, Text: "low"}, Relevance: 1}, + {Excerpt: cognitive.ContextExcerpt{ID: "high", Sensitivity: cognitive.SensitivityPublic, Text: "éé"}, Relevance: 2}, + {Excerpt: cognitive.ContextExcerpt{ID: "high", Sensitivity: cognitive.SensitivityPublic, Text: "hi"}, Relevance: 3}, + }, + }) + if err != nil { + t.Fatal(err) + } + if len(bundle.Excerpts) != 1 || bundle.Excerpts[0].ID != "high" || bundle.Bytes != 2 { + t.Fatalf("unexpected bundle: %#v", bundle) + } + if len(bundle.Omissions) != 2 || bundle.Omissions[0].Reason != "duplicate" || bundle.Omissions[1].Reason != "budget" { + t.Fatalf("unexpected omissions: %#v", bundle.Omissions) + } +} + +func TestCompileContextFiltersSensitivityByPolicy(t *testing.T) { + bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{ + MaxBytes: 100, + DataPolicy: capability.DataPolicyExternalMinimizedContext, + Candidates: []cognitive.ContextCandidate{ + {Excerpt: cognitive.ContextExcerpt{ID: "public", Sensitivity: cognitive.SensitivityPublic, Text: "ok"}}, + {Excerpt: cognitive.ContextExcerpt{ID: "private", Sensitivity: cognitive.SensitivityPrivateAllowed, Text: "private"}}, + {Excerpt: cognitive.ContextExcerpt{ID: "secret", Sensitivity: cognitive.SensitivitySecret, Text: "secret"}}, + }, + }) + if err != nil { + t.Fatal(err) + } + if len(bundle.Excerpts) != 1 || bundle.Excerpts[0].ID != "public" { + t.Fatalf("unexpected excerpts: %#v", bundle.Excerpts) + } + if len(bundle.Omissions) != 2 { + t.Fatalf("unexpected omissions: %#v", bundle.Omissions) + } +} + +func TestCompileContextAllowsEmptyResultAndRejectsInvalidBudget(t *testing.T) { + if _, err := cognitive.CompileContext(cognitive.ContextCompileInput{}); err == nil { + t.Fatal("expected invalid budget") + } + bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{MaxBytes: 10, Candidates: []cognitive.ContextCandidate{{Excerpt: cognitive.ContextExcerpt{ID: "secret", Sensitivity: cognitive.SensitivitySecret, Text: "x"}}}}) + if err != nil || len(bundle.Excerpts) != 0 || len(bundle.Omissions) != 1 { + t.Fatalf("unexpected empty result: %#v %v", bundle, err) + } +} -- 2.54.0 From e3b55c970550d83b46dce6ef1c75af7a88e26a9d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:44:04 +0300 Subject: [PATCH 12/33] Integrate active project context into local chat --- cmd/creator-web/server.go | 26 ++++++++++++++++++- cmd/creator-web/server_test.go | 11 ++++++++ dev/ACTIVE_TASK.json | 4 +-- ...-context-compiler-gateway-integration.json | 23 ++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 dev/tasks/DEV-000108-context-compiler-gateway-integration.json diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index d419abf..b3bebfb 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -378,7 +378,7 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) { cognition = "unavailable" requestID, idErr := ids.New() if idErr == nil { - trusted := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: "domovoy-global-scope", Message: browserRequest.Message, Context: []cognitive.ContextExcerpt{}, Locale: "ru-RU", MaxOutputBytes: 4096, DataPolicy: capability.DataPolicyLocalOnly, EgressAcknowledged: false, Meaning: cognitive.TaskMeaning{SpeechAct: string(saved.SpeechAct), CapabilityKind: saved.CapabilityKind, CapabilityHints: saved.CapabilityHints, RequestedOutcome: saved.RequestedOutcome, InformationRequestKind: saved.InformationRequestKind, ConfidenceMillis: saved.InterpretationConfidenceMillis}} + trusted := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: "domovoy-global-scope", Message: browserRequest.Message, Context: localChatContext(saved), Locale: "ru-RU", MaxOutputBytes: 4096, DataPolicy: capability.DataPolicyLocalOnly, EgressAcknowledged: false, Meaning: cognitive.TaskMeaning{SpeechAct: string(saved.SpeechAct), CapabilityKind: saved.CapabilityKind, CapabilityHints: saved.CapabilityHints, RequestedOutcome: saved.RequestedOutcome, InformationRequestKind: saved.InformationRequestKind, ConfidenceMillis: saved.InterpretationConfidenceMillis}} payload, err = json.Marshal(trusted) if err == nil { var advisory cognitive.GatewayAdvisoryResponse @@ -415,6 +415,30 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) { }) } +func localChatContext(saved interaction.ChatMessageResponse) []cognitive.ContextExcerpt { + if strings.TrimSpace(saved.ActiveProjectName) == "" { + return []cognitive.ContextExcerpt{} + } + bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{ + DataPolicy: capability.DataPolicyLocalOnly, + MaxBytes: 2048, + Candidates: []cognitive.ContextCandidate{{ + Excerpt: cognitive.ContextExcerpt{ + ID: cognitive.ContextExcerptID("active-project-focus"), + SourceKind: "situation-active-project", + SourceID: strings.TrimSpace(saved.ActiveProjectName), + Sensitivity: cognitive.SensitivityPrivateAllowed, + Text: "Текущий проект: " + strings.TrimSpace(saved.ActiveProjectName), + }, + Relevance: 100, + }}, + }) + if err != nil { + return []cognitive.ContextExcerpt{} + } + return bundle.Excerpts +} + func asksAttention(message string) bool { lower := strings.ToLower(message) return strings.Contains(lower, "требует моего внимания") || strings.Contains(lower, "требует внимания") || strings.Contains(lower, "что сейчас важно") diff --git a/cmd/creator-web/server_test.go b/cmd/creator-web/server_test.go index a5641d1..888bcbc 100644 --- a/cmd/creator-web/server_test.go +++ b/cmd/creator-web/server_test.go @@ -10,8 +10,19 @@ import ( "testing" "git.cin.su/m/Assistant/contracts/cognitive" + "git.cin.su/m/Assistant/contracts/interaction" ) +func TestLocalChatContextUsesOnlyActiveProject(t *testing.T) { + context := localChatContext(interaction.ChatMessageResponse{ActiveProjectName: "Домовой"}) + if len(context) != 1 || context[0].SourceKind != "situation-active-project" || context[0].Text != "Текущий проект: Домовой" { + t.Fatalf("unexpected local context: %#v", context) + } + if empty := localChatContext(interaction.ChatMessageResponse{}); len(empty) != 0 { + t.Fatalf("expected empty context without active project: %#v", empty) + } +} + const legacyExternalMode = "legacy-external-test-mode" func newHandlerWithAdvisoryMode(coreURL, continuityURL, commitURL string, client *http.Client, mode string, cognitiveURLs ...string) (http.Handler, error) { diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index f4e9f0d..9a7b87a 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000107-context-compiler-v1.json", - "task_id": "DEV-000107", + "task_file": "dev/tasks/DEV-000108-context-compiler-gateway-integration.json", + "task_id": "DEV-000108", "task_revision": 1 } diff --git a/dev/tasks/DEV-000108-context-compiler-gateway-integration.json b/dev/tasks/DEV-000108-context-compiler-gateway-integration.json new file mode 100644 index 0000000..38b36d2 --- /dev/null +++ b/dev/tasks/DEV-000108-context-compiler-gateway-integration.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000108", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "879de3b", + "risk_tier": "E", + "intent": "Use the typed context compiler for local chat cognition with the canonical active-project focus, keeping external advisory context empty unless explicitly supplied by a future policy-aware path.", + "requirement_ids": ["WP-101", "Follow-up context", "Active project isolation"], + "allowed_change_scope": {"modules": ["creator-gateway", "cognitive-contracts"], "paths": ["cmd/creator-web/**", "contracts/cognitive/**", "tests/context_compiler/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000108-context-compiler-gateway-integration.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "modules/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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": ["ContextBundle compiler from DEV-000107", "Core ChatMessageResponse active project fields", "Existing local cognitive gateway request"], "may_change": ["Creator gateway context assembly"]}, + "semantic_owner": "creator-gateway-context-projection", + "canonical_state": [], + "state_transitions": ["canonical active project -> local context candidate -> compiled gateway context", "missing active project -> empty context"], + "side_effects_and_privileges": ["Context assembly is read-only; no persistence, provider selection, credential access or external mutation"], + "persistence_and_migrations": ["No database schema or migration changes"], + "failure_retry_restart": ["Compiler failure or absent focus yields empty context and preserves the existing chat fallback"], + "assumptions": ["External advisory remains message-only until a separate policy-aware context path exists", "Active project name is already canonicalized by Core", "Current project context is not reused for unrelated requests"], + "acceptance_scenarios": ["Local cognitive request receives the active project as provenance-bearing context", "No active project yields empty context", "External advisory receives no implicit private context", "Project context does not leak into a different saved response", "Existing creator gateway and full tests remain green"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "context compiler gateway integration tests"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} -- 2.54.0 From dc7f7b3b2bd57daffe2b696a8c58e491fd0f59bb Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:46:09 +0300 Subject: [PATCH 13/33] Add research citation evidence contracts --- contracts/epistemics/research.go | 99 +++++++++++++++++++ dev/ACTIVE_TASK.json | 4 +- ...EV-000109-research-citation-contracts.json | 23 +++++ tests/research_contracts/research_test.go | 37 +++++++ 4 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 contracts/epistemics/research.go create mode 100644 dev/tasks/DEV-000109-research-citation-contracts.json create mode 100644 tests/research_contracts/research_test.go diff --git a/contracts/epistemics/research.go b/contracts/epistemics/research.go new file mode 100644 index 0000000..59e1832 --- /dev/null +++ b/contracts/epistemics/research.go @@ -0,0 +1,99 @@ +package epistemics + +import ( + "errors" + "strings" +) + +type ResearchPlanID string +type SourceID string +type SourceSnapshotID string +type SourceExcerptID string +type CitationID string +type FreshnessState string +type CoverageState string +type ConflictState string +type EvidenceKind string + +const ( + FreshnessCurrent FreshnessState = "CURRENT" + FreshnessStale FreshnessState = "STALE" + FreshnessUnknown FreshnessState = "UNKNOWN" + CoverageComplete CoverageState = "COMPLETE" + CoveragePartial CoverageState = "PARTIAL" + CoverageUnknown CoverageState = "UNKNOWN" + ConflictNone ConflictState = "NONE" + ConflictPresent ConflictState = "PRESENT" + ConflictUnknown ConflictState = "UNKNOWN" + EvidenceObserved EvidenceKind = "OBSERVED" + EvidenceAssumed EvidenceKind = "ASSUMED" +) + +type ResearchPlan struct { + ID ResearchPlanID + Question string + SourceClasses []string + FreshnessPolicy string + CreatedAtUnixMillis int64 +} + +type Source struct { + ID SourceID + Kind string + Locator string + Title string + Publisher string +} + +type SourceSnapshot struct { + ID SourceSnapshotID + SourceID SourceID + ContentDigest string + CapturedAtUnixMillis int64 + Freshness FreshnessState +} + +type SourceExcerpt struct { + ID SourceExcerptID + SnapshotID SourceSnapshotID + Text string + Start int64 + End int64 +} + +type Citation struct { + ID CitationID + ExcerptID SourceExcerptID + Evidence EvidenceKind + Coverage CoverageState + Conflict ConflictState + ResolvedAtUnixMillis int64 +} + +func (p ResearchPlan) Validate() error { + if p.ID == "" || strings.TrimSpace(p.Question) == "" || len(p.SourceClasses) == 0 || strings.TrimSpace(p.FreshnessPolicy) == "" { + return errors.New("research plan requires question, source classes and freshness policy") + } + return nil +} + +func (s SourceSnapshot) Validate() error { + if s.ID == "" || s.SourceID == "" || strings.TrimSpace(s.ContentDigest) == "" || s.CapturedAtUnixMillis < 1 || s.Freshness == "" { + return errors.New("source snapshot requires source, digest, capture time and freshness") + } + return nil +} + +func (e SourceExcerpt) Validate() error { + if e.ID == "" || e.SnapshotID == "" || strings.TrimSpace(e.Text) == "" || e.Start < 0 || e.End < e.Start { + return errors.New("source excerpt requires snapshot, text and valid offsets") + } + return nil +} + +func (c Citation) Validate() error { + if c.ID == "" || c.ExcerptID == "" || c.Evidence == "" || c.Coverage == "" || c.Conflict == "" || c.ResolvedAtUnixMillis < 1 { + return errors.New("citation requires exact excerpt, evidence, coverage, conflict and resolution time") + } + return nil +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 9a7b87a..c17d818 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000108-context-compiler-gateway-integration.json", - "task_id": "DEV-000108", + "task_file": "dev/tasks/DEV-000109-research-citation-contracts.json", + "task_id": "DEV-000109", "task_revision": 1 } diff --git a/dev/tasks/DEV-000109-research-citation-contracts.json b/dev/tasks/DEV-000109-research-citation-contracts.json new file mode 100644 index 0000000..dcafee3 --- /dev/null +++ b/dev/tasks/DEV-000109-research-citation-contracts.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000109", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "e3b55c9", + "risk_tier": "E", + "intent": "Add typed read-only research and citation evidence contracts for plans, source snapshots, excerpts and freshness/conflict state without connecting external sources.", + "requirement_ids": ["WP-102", "Source snapshot and citation resolver", "UNKNOWN/ASSUMPTION freshness conflict"], + "allowed_change_scope": {"modules": ["epistemics-research-contracts"], "paths": ["contracts/epistemics/**", "tests/research_contracts/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000109-research-citation-contracts.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "modules/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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": ["Proposed ADR-0019 Truth V1 evidence/provenance", "Existing Epistemics owner", "Existing Artifact typed reference boundary"], "may_change": ["Epistemics research/citation contract"]}, + "semantic_owner": "epistemics-research-contract", + "canonical_state": [], + "state_transitions": ["research plan -> source snapshot -> excerpt -> citation", "source conflict/freshness result -> explicit claim status"], + "side_effects_and_privileges": ["Typed value validation only; no connector calls, external mutation, credentials or persistence"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Unknown source/retrieval state is retained as UNKNOWN and cannot become a citation silently"], + "assumptions": ["Connectors and durable storage arrive in later tasks", "Citation references an exact excerpt identity and snapshot", "Research output is read-only evidence"], + "acceptance_scenarios": ["Plan identifies question, source classes and freshness policy", "Snapshot has content digest and retrieval timestamp", "Citation requires exact excerpt and source snapshot", "stale/conflicted/unknown states remain explicit", "assumption is distinguishable from observed evidence"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "research contract tests"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/tests/research_contracts/research_test.go b/tests/research_contracts/research_test.go new file mode 100644 index 0000000..030087f --- /dev/null +++ b/tests/research_contracts/research_test.go @@ -0,0 +1,37 @@ +package research_contracts_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/epistemics" +) + +func TestResearchContractsRequireExactEvidence(t *testing.T) { + plan := epistemics.ResearchPlan{ID: "plan-1", Question: "What changed?", SourceClasses: []string{"repository"}, FreshnessPolicy: "same-day"} + if err := plan.Validate(); err != nil { + t.Fatal(err) + } + snapshot := epistemics.SourceSnapshot{ID: "snapshot-1", SourceID: "source-1", ContentDigest: "sha256:abc", CapturedAtUnixMillis: 1, Freshness: epistemics.FreshnessCurrent} + if err := snapshot.Validate(); err != nil { + t.Fatal(err) + } + excerpt := epistemics.SourceExcerpt{ID: "excerpt-1", SnapshotID: snapshot.ID, Text: "exact evidence", Start: 0, End: 14} + if err := excerpt.Validate(); err != nil { + t.Fatal(err) + } + citation := epistemics.Citation{ID: "citation-1", ExcerptID: excerpt.ID, Evidence: epistemics.EvidenceObserved, Coverage: epistemics.CoverageComplete, Conflict: epistemics.ConflictNone, ResolvedAtUnixMillis: 2} + if err := citation.Validate(); err != nil { + t.Fatal(err) + } +} + +func TestResearchContractsPreserveUnknownAndAssumedStates(t *testing.T) { + snapshot := epistemics.SourceSnapshot{ID: "snapshot-unknown", SourceID: "source-1", ContentDigest: "sha256:abc", CapturedAtUnixMillis: 1, Freshness: epistemics.FreshnessUnknown} + if err := snapshot.Validate(); err != nil { + t.Fatal(err) + } + citation := epistemics.Citation{ID: "citation-assumed", ExcerptID: "excerpt-1", Evidence: epistemics.EvidenceAssumed, Coverage: epistemics.CoveragePartial, Conflict: epistemics.ConflictPresent, ResolvedAtUnixMillis: 2} + if err := citation.Validate(); err != nil { + t.Fatal(err) + } +} -- 2.54.0 From aec63de0c70ac756873c036928e4dd8fcd48e1b0 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:47:15 +0300 Subject: [PATCH 14/33] Add deterministic citation resolver --- contracts/epistemics/research.go | 12 +++++++++++ dev/ACTIVE_TASK.json | 4 ++-- dev/tasks/DEV-000110-citation-resolver.json | 23 +++++++++++++++++++++ tests/research_contracts/research_test.go | 15 ++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000110-citation-resolver.json diff --git a/contracts/epistemics/research.go b/contracts/epistemics/research.go index 59e1832..d07f4ad 100644 --- a/contracts/epistemics/research.go +++ b/contracts/epistemics/research.go @@ -5,6 +5,8 @@ import ( "strings" ) +var ErrCitationEvidenceMismatch = errors.New("citation evidence does not match source chain") + type ResearchPlanID string type SourceID string type SourceSnapshotID string @@ -70,6 +72,16 @@ type Citation struct { ResolvedAtUnixMillis int64 } +// ResolveCitation creates evidence only when all identifiers form one exact +// source chain. It deliberately does not fetch, infer or rewrite source text. +func ResolveCitation(source Source, snapshot SourceSnapshot, excerpt SourceExcerpt, evidence EvidenceKind, coverage CoverageState, conflict ConflictState, resolvedAtUnixMillis int64) (Citation, error) { + if source.ID == "" || strings.TrimSpace(source.Locator) == "" || snapshot.SourceID != source.ID || excerpt.SnapshotID != snapshot.ID || excerpt.Validate() != nil || resolvedAtUnixMillis < 1 || evidence == "" || coverage == "" || conflict == "" { + return Citation{}, ErrCitationEvidenceMismatch + } + citation := Citation{ID: CitationID("citation:" + string(excerpt.ID)), ExcerptID: excerpt.ID, Evidence: evidence, Coverage: coverage, Conflict: conflict, ResolvedAtUnixMillis: resolvedAtUnixMillis} + return citation, citation.Validate() +} + func (p ResearchPlan) Validate() error { if p.ID == "" || strings.TrimSpace(p.Question) == "" || len(p.SourceClasses) == 0 || strings.TrimSpace(p.FreshnessPolicy) == "" { return errors.New("research plan requires question, source classes and freshness policy") diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index c17d818..949976c 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000109-research-citation-contracts.json", - "task_id": "DEV-000109", + "task_file": "dev/tasks/DEV-000110-citation-resolver.json", + "task_id": "DEV-000110", "task_revision": 1 } diff --git a/dev/tasks/DEV-000110-citation-resolver.json b/dev/tasks/DEV-000110-citation-resolver.json new file mode 100644 index 0000000..d9d9c3a --- /dev/null +++ b/dev/tasks/DEV-000110-citation-resolver.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000110", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "dc7f7b3", + "risk_tier": "E", + "intent": "Add a deterministic citation resolver that requires a matching source, immutable snapshot and exact excerpt before producing citation evidence.", + "requirement_ids": ["WP-102", "Citation resolver", "No fabricated excerpts"], + "allowed_change_scope": {"modules": ["epistemics-research-contracts"], "paths": ["contracts/epistemics/**", "tests/research_contracts/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000110-citation-resolver.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "modules/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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": ["DEV-000109 research/citation contracts", "Epistemics source snapshot and excerpt types"], "may_change": ["Citation resolution helper"]}, + "semantic_owner": "epistemics-research-contract", + "canonical_state": [], + "state_transitions": ["source/snapshot/excerpt evidence -> resolved citation", "mismatched evidence -> resolution error"], + "side_effects_and_privileges": ["Pure deterministic validation; no connector calls, persistence, external mutation or credential access"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Resolution is repeatable; failed resolution produces no citation"], + "assumptions": ["Source snapshots are immutable identifiers", "Exact excerpt offsets and text are supplied by a connector later", "Unknown/stale/conflicted evidence stays visible"], + "acceptance_scenarios": ["Matching source chain resolves", "Wrong source/snapshot/excerpt is rejected", "Invalid offsets are rejected", "Citation preserves stale/conflict/assumed state", "No fabricated excerpt can resolve"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "citation resolver tests"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/tests/research_contracts/research_test.go b/tests/research_contracts/research_test.go index 030087f..d7dbf07 100644 --- a/tests/research_contracts/research_test.go +++ b/tests/research_contracts/research_test.go @@ -35,3 +35,18 @@ func TestResearchContractsPreserveUnknownAndAssumedStates(t *testing.T) { t.Fatal(err) } } + +func TestResolveCitationRequiresMatchingSourceChain(t *testing.T) { + source := epistemics.Source{ID: "source-1", Locator: "https://example.test/report"} + snapshot := epistemics.SourceSnapshot{ID: "snapshot-1", SourceID: source.ID, ContentDigest: "sha256:abc", CapturedAtUnixMillis: 1, Freshness: epistemics.FreshnessStale} + excerpt := epistemics.SourceExcerpt{ID: "excerpt-1", SnapshotID: snapshot.ID, Text: "exact evidence", Start: 0, End: 14} + citation, err := epistemics.ResolveCitation(source, snapshot, excerpt, epistemics.EvidenceObserved, epistemics.CoveragePartial, epistemics.ConflictPresent, 2) + if err != nil || citation.ExcerptID != excerpt.ID || citation.Conflict != epistemics.ConflictPresent { + t.Fatalf("unexpected citation: %#v %v", citation, err) + } + wrong := snapshot + wrong.SourceID = "other-source" + if _, err := epistemics.ResolveCitation(source, wrong, excerpt, epistemics.EvidenceObserved, epistemics.CoverageComplete, epistemics.ConflictNone, 2); err == nil { + t.Fatal("expected source-chain mismatch") + } +} -- 2.54.0 From 93bc4a0fdf6d0b5b27b0221ce40979e0c400a686 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:50:00 +0300 Subject: [PATCH 15/33] Record hardware and model inventory evidence --- dev/ACTIVE_TASK.json | 4 +- ...111-hardware-model-inventory-evidence.json | 23 ++++++++++ ...200-HARDWARE-MODEL-INVENTORY-2026-08-22.md | 43 +++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000111-hardware-model-inventory-evidence.json create mode 100644 docs/evidence/WP-200-HARDWARE-MODEL-INVENTORY-2026-08-22.md diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 949976c..95bb6c5 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000110-citation-resolver.json", - "task_id": "DEV-000110", + "task_file": "dev/tasks/DEV-000111-hardware-model-inventory-evidence.json", + "task_id": "DEV-000111", "task_revision": 1 } diff --git a/dev/tasks/DEV-000111-hardware-model-inventory-evidence.json b/dev/tasks/DEV-000111-hardware-model-inventory-evidence.json new file mode 100644 index 0000000..93d7e1a --- /dev/null +++ b/dev/tasks/DEV-000111-hardware-model-inventory-evidence.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000111", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "aec63de", + "risk_tier": "E", + "intent": "Record read-only production hardware and model inventory evidence for WP-200 without changing model bindings or deployment.", + "requirement_ids": ["WP-200", "Hardware/model inventory", "GPU/VRAM and protected runtime evidence"], + "allowed_change_scope": {"modules": ["model-qualification-evidence"], "paths": ["docs/evidence/WP-200-HARDWARE-MODEL-INVENTORY-2026-08-22.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000111-hardware-model-inventory-evidence.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "modules/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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": ["WP-200 audit requirements", "Production docker-prod read-only inventory"], "may_change": []}, + "semantic_owner": "model-qualification-evidence", + "canonical_state": [], + "state_transitions": ["unqualified inventory -> evidence recorded -> qualification decision pending"], + "side_effects_and_privileges": ["Read-only SSH/docker inspection only; no model activation, deployment, credential access or production mutation"], + "persistence_and_migrations": ["Evidence document only; no database changes"], + "failure_retry_restart": ["Missing GPU/model evidence remains UNKNOWN and blocks heavy qualification"], + "assumptions": ["docker-prod/docker.cin.su is the only production target", "GPU absence on this host does not prove absence on every external host", "Model qualification requires separate holdout/latency/license evidence"], + "acceptance_scenarios": ["CPU/RAM/storage/network inventory recorded", "GPU/VRAM/runtime evidence recorded", "Healthy cognitive/lifeboat containers identified without secret output", "Heavy model remains unqualified until GPU/license evidence exists"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "production read-only inventory commands"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/docs/evidence/WP-200-HARDWARE-MODEL-INVENTORY-2026-08-22.md b/docs/evidence/WP-200-HARDWARE-MODEL-INVENTORY-2026-08-22.md new file mode 100644 index 0000000..bdb5ffc --- /dev/null +++ b/docs/evidence/WP-200-HARDWARE-MODEL-INVENTORY-2026-08-22.md @@ -0,0 +1,43 @@ +# WP-200 Hardware and model inventory evidence + +Task: DEV-000111 +Evidence date: 2026-08-22 +Target: `docker-prod` / `docker.cin.su` + +## Host evidence + +Read-only `ssh docker` inspection returned: + +- host: `docker` +- CPU: `128` logical processors +- memory: `251 GiB` total, `203 GiB` available at inspection time +- swap: `8 GiB`, `256 KiB` used +- root storage: `1.6T` total, `359G` used, `1.2T` available, `24%` +- `nvidia-smi`: unavailable +- Docker server: `29.4.0`, `128` CPUs, approximately `252 GiB` Docker memory +- Docker runtimes: `runc`/`containerd`; no NVIDIA runtime reported + +## Runtime evidence + +Read-only `docker --context docker-prod` inspection found: + +- `assistant-cognitive-worker-1`: production revision `b439a813...`, `/healthz` returned `status=ok` +- `assistant-cognitive-lifeboat-1`: `ghcr.io/ggml-org/llama.cpp:server`, Docker health `healthy` +- `llm-local-api`: `ghcr.io/ggml-org/llama.cpp:server`, Docker health `healthy` +- no model activation, routing change or deployment was performed by this inventory task + +## Qualification conclusion + +This host has no verified GPU/VRAM capability in the inspected runtime. CPU +and lifeboat health are evidence of availability, not model qualification. +WP-201 still requires immutable model digests, holdout quality tests, +latency/concurrency measurements, license/data-policy evidence and restart +behavior. Heavy-class activation remains a qualification decision, not an +inventory inference. + +## Evidence limitations + +This record does not establish the topology of any separate GPU host, does not +read secrets, and does not claim that a model is qualified from its image name +or health endpoint alone. + -- 2.54.0 From 1f01b7013c840b977972863c0ce724a300b1a53d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:51:19 +0300 Subject: [PATCH 16/33] Add model qualification manifest contract --- contracts/capability/qualification.go | 58 +++++++++++++++++++ dev/ACTIVE_TASK.json | 4 +- ...V-000112-model-qualification-manifest.json | 23 ++++++++ .../model_qualification/qualification_test.go | 23 ++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 contracts/capability/qualification.go create mode 100644 dev/tasks/DEV-000112-model-qualification-manifest.json create mode 100644 tests/model_qualification/qualification_test.go diff --git a/contracts/capability/qualification.go b/contracts/capability/qualification.go new file mode 100644 index 0000000..895a7ed --- /dev/null +++ b/contracts/capability/qualification.go @@ -0,0 +1,58 @@ +package capability + +import "errors" + +type QualificationManifestID string +type QualificationDimension string + +const ( + DimensionDialogue QualificationDimension = "DIALOGUE" + DimensionInterpretation QualificationDimension = "INTERPRETATION" + DimensionCoding QualificationDimension = "CODING" + DimensionResearch QualificationDimension = "RESEARCH" + DimensionUncertainty QualificationDimension = "UNCERTAINTY" + DimensionContext QualificationDimension = "CONTEXT" + DimensionLatency QualificationDimension = "LATENCY" + DimensionResources QualificationDimension = "RESOURCES" + DimensionRestart QualificationDimension = "RESTART" +) + +type QualificationMeasurement struct { + Dimension QualificationDimension + Result EvaluationResult + Artifact string +} + +type QualificationManifest struct { + ID QualificationManifestID + ModelDescriptorID ModelDescriptorID + ModelRevision int64 + ModelFingerprint ModelFingerprint + BenchmarkVersion string + HoldoutCorpusDigest string + LicenseEvidence string + DataPolicyEvidence string + Measurements []QualificationMeasurement + ObservedAtUnixMillis int64 +} + +func (m QualificationManifest) Validate() error { + if m.ID == "" || m.ModelDescriptorID == "" || m.ModelRevision < 1 || m.ModelFingerprint == "" || m.BenchmarkVersion == "" || m.HoldoutCorpusDigest == "" || m.LicenseEvidence == "" || m.DataPolicyEvidence == "" || m.ObservedAtUnixMillis < 1 { + return errors.New("qualification manifest identity and evidence are incomplete") + } + required := map[QualificationDimension]bool{DimensionDialogue: false, DimensionInterpretation: false, DimensionCoding: false, DimensionResearch: false, DimensionUncertainty: false, DimensionContext: false, DimensionLatency: false, DimensionResources: false, DimensionRestart: false} + for _, measurement := range m.Measurements { + if measurement.Dimension == "" || measurement.Artifact == "" || measurement.Result == EvaluationUnknown { + return errors.New("qualification measurement is incomplete") + } + if _, ok := required[measurement.Dimension]; ok { + required[measurement.Dimension] = true + } + } + for _, present := range required { + if !present { + return errors.New("qualification manifest lacks required measurement") + } + } + return nil +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 95bb6c5..9c98be5 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000111-hardware-model-inventory-evidence.json", - "task_id": "DEV-000111", + "task_file": "dev/tasks/DEV-000112-model-qualification-manifest.json", + "task_id": "DEV-000112", "task_revision": 1 } diff --git a/dev/tasks/DEV-000112-model-qualification-manifest.json b/dev/tasks/DEV-000112-model-qualification-manifest.json new file mode 100644 index 0000000..c100786 --- /dev/null +++ b/dev/tasks/DEV-000112-model-qualification-manifest.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000112", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "93bc4a0", + "risk_tier": "E", + "intent": "Add an immutable model qualification manifest contract and validation for holdout, latency, resource, license and restart evidence without activating a model.", + "requirement_ids": ["WP-201", "Qualification evidence manifest", "No activation from model name alone"], + "allowed_change_scope": {"modules": ["capability-model-qualification"], "paths": ["contracts/capability/**", "tests/model_qualification/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000112-model-qualification-manifest.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "modules/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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 Capability model descriptor/evaluation types", "WP-201 audit acceptance"], "may_change": ["Capability qualification evidence manifest contract"]}, + "semantic_owner": "capability-model-qualification", + "canonical_state": [], + "state_transitions": ["candidate model -> measured -> evidence complete/incomplete -> qualification decision pending"], + "side_effects_and_privileges": ["Pure validation; no provider call, model activation, deployment, credential access or external mutation"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Incomplete or stale manifest remains unqualified; rerun creates a new manifest revision"], + "assumptions": ["A model digest and provider revision identify the tested artifact", "License/data policy evidence is explicit", "Qualification is separate from activation"], + "acceptance_scenarios": ["Manifest binds model revision and digest", "Holdout corpus and all required dimensions are recorded", "Latency/concurrency/resource/restart evidence is present", "License and data policy are present", "Incomplete manifest cannot claim validated"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "model qualification manifest tests"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/tests/model_qualification/qualification_test.go b/tests/model_qualification/qualification_test.go new file mode 100644 index 0000000..6263dea --- /dev/null +++ b/tests/model_qualification/qualification_test.go @@ -0,0 +1,23 @@ +package model_qualification_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/capability" +) + +func TestQualificationManifestRequiresAllEvidenceDimensions(t *testing.T) { + dimensions := []capability.QualificationDimension{capability.DimensionDialogue, capability.DimensionInterpretation, capability.DimensionCoding, capability.DimensionResearch, capability.DimensionUncertainty, capability.DimensionContext, capability.DimensionLatency, capability.DimensionResources, capability.DimensionRestart} + measurements := make([]capability.QualificationMeasurement, 0, len(dimensions)) + for _, dimension := range dimensions { + measurements = append(measurements, capability.QualificationMeasurement{Dimension: dimension, Result: capability.EvaluationSupported, Artifact: "artifact:" + string(dimension)}) + } + manifest := capability.QualificationManifest{ID: "manifest-1", ModelDescriptorID: "model-1", ModelRevision: 2, ModelFingerprint: "sha256:model", BenchmarkVersion: "holdout-v1", HoldoutCorpusDigest: "sha256:corpus", LicenseEvidence: "license:approved", DataPolicyEvidence: "policy:local", Measurements: measurements, ObservedAtUnixMillis: 10} + if err := manifest.Validate(); err != nil { + t.Fatal(err) + } + manifest.Measurements = manifest.Measurements[:len(manifest.Measurements)-1] + if err := manifest.Validate(); err == nil { + t.Fatal("expected incomplete manifest to be rejected") + } +} -- 2.54.0 From a65ee372a9be3a5a171c85b696a1bb6311b668c2 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:52:24 +0300 Subject: [PATCH 17/33] Add model qualification gate --- contracts/capability/qualification.go | 14 +++++++++++ dev/ACTIVE_TASK.json | 4 ++-- .../DEV-000113-model-qualification-gate.json | 23 +++++++++++++++++++ .../model_qualification/qualification_test.go | 20 ++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000113-model-qualification-gate.json diff --git a/contracts/capability/qualification.go b/contracts/capability/qualification.go index 895a7ed..740a1ca 100644 --- a/contracts/capability/qualification.go +++ b/contracts/capability/qualification.go @@ -56,3 +56,17 @@ func (m QualificationManifest) Validate() error { } return nil } + +// QualificationState projects evidence into a qualification result. It does +// not activate a binding or mutate the model registry. +func (m QualificationManifest) QualificationState() QualificationState { + if m.Validate() != nil { + return QualificationRejected + } + for _, measurement := range m.Measurements { + if measurement.Result != EvaluationSupported { + return QualificationRejected + } + } + return QualificationValidated +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 9c98be5..195572d 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000112-model-qualification-manifest.json", - "task_id": "DEV-000112", + "task_file": "dev/tasks/DEV-000113-model-qualification-gate.json", + "task_id": "DEV-000113", "task_revision": 1 } diff --git a/dev/tasks/DEV-000113-model-qualification-gate.json b/dev/tasks/DEV-000113-model-qualification-gate.json new file mode 100644 index 0000000..1829753 --- /dev/null +++ b/dev/tasks/DEV-000113-model-qualification-gate.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000113", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "1f01b70", + "risk_tier": "E", + "intent": "Add a deterministic qualification gate that maps complete supported evidence to VALIDATED and incomplete/unsupported evidence to REJECTED without changing activation.", + "requirement_ids": ["WP-201", "Qualification decision", "GPU/resource evidence cannot be inferred"], + "allowed_change_scope": {"modules": ["capability-model-qualification"], "paths": ["contracts/capability/**", "tests/model_qualification/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000113-model-qualification-gate.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "modules/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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": ["QualificationManifest from DEV-000112", "Existing QualificationState and EvaluationResult values"], "may_change": ["Qualification gate projection"]}, + "semantic_owner": "capability-model-qualification", + "canonical_state": [], + "state_transitions": ["complete supported manifest -> VALIDATED", "incomplete/unsupported/unknown manifest -> REJECTED"], + "side_effects_and_privileges": ["Pure validation; no binding activation, provider call, deployment, credential access or external mutation"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Gate is deterministic; a new manifest revision is required after new measurements"], + "assumptions": ["Qualification and activation remain separate lifecycle decisions", "All required dimensions are mandatory", "Model names never substitute for evidence"], + "acceptance_scenarios": ["All supported measurements produce VALIDATED", "Unsupported resource/restart result produces REJECTED", "Unknown measurement produces REJECTED", "Gate does not mutate binding activation"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "qualification gate tests"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/tests/model_qualification/qualification_test.go b/tests/model_qualification/qualification_test.go index 6263dea..920d095 100644 --- a/tests/model_qualification/qualification_test.go +++ b/tests/model_qualification/qualification_test.go @@ -21,3 +21,23 @@ func TestQualificationManifestRequiresAllEvidenceDimensions(t *testing.T) { t.Fatal("expected incomplete manifest to be rejected") } } + +func TestQualificationGateRejectsUnsupportedEvidence(t *testing.T) { + manifest := completeManifest() + if state := manifest.QualificationState(); state != capability.QualificationValidated { + t.Fatalf("state = %s", state) + } + manifest.Measurements[0].Result = capability.EvaluationUnsupported + if state := manifest.QualificationState(); state != capability.QualificationRejected { + t.Fatalf("unsupported state = %s", state) + } +} + +func completeManifest() capability.QualificationManifest { + dimensions := []capability.QualificationDimension{capability.DimensionDialogue, capability.DimensionInterpretation, capability.DimensionCoding, capability.DimensionResearch, capability.DimensionUncertainty, capability.DimensionContext, capability.DimensionLatency, capability.DimensionResources, capability.DimensionRestart} + measurements := make([]capability.QualificationMeasurement, 0, len(dimensions)) + for _, dimension := range dimensions { + measurements = append(measurements, capability.QualificationMeasurement{Dimension: dimension, Result: capability.EvaluationSupported, Artifact: "artifact:" + string(dimension)}) + } + return capability.QualificationManifest{ID: "manifest-1", ModelDescriptorID: "model-1", ModelRevision: 2, ModelFingerprint: "sha256:model", BenchmarkVersion: "holdout-v1", HoldoutCorpusDigest: "sha256:corpus", LicenseEvidence: "license:approved", DataPolicyEvidence: "policy:local", Measurements: measurements, ObservedAtUnixMillis: 10} +} -- 2.54.0 From e83cf3582b50821d2beb083affa0f388c8e749b9 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:56:18 +0300 Subject: [PATCH 18/33] Add durable run liveness assessment --- dev/ACTIVE_TASK.json | 4 +- .../DEV-000114-run-liveness-assessment.json | 23 ++++++++++ modules/work/domain/liveness.go | 45 +++++++++++++++++++ tests/work_recovery/liveness_test.go | 33 ++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000114-run-liveness-assessment.json create mode 100644 modules/work/domain/liveness.go create mode 100644 tests/work_recovery/liveness_test.go diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 195572d..40456eb 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000113-model-qualification-gate.json", - "task_id": "DEV-000113", + "task_file": "dev/tasks/DEV-000114-run-liveness-assessment.json", + "task_id": "DEV-000114", "task_revision": 1 } diff --git a/dev/tasks/DEV-000114-run-liveness-assessment.json b/dev/tasks/DEV-000114-run-liveness-assessment.json new file mode 100644 index 0000000..9fb4ba7 --- /dev/null +++ b/dev/tasks/DEV-000114-run-liveness-assessment.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000114", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "a65ee37", + "risk_tier": "E", + "intent": "Add deterministic AgentRun liveness assessment for stale-run recovery without requeueing or mutating production records.", + "requirement_ids": ["WP-302", "Heartbeat timeout", "Typed stale evidence"], + "allowed_change_scope": {"modules": ["work-recovery"], "paths": ["modules/work/domain/**", "contracts/work/**", "tests/work_recovery/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000114-run-liveness-assessment.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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 Work AgentRun lifecycle", "ADR-0017 delegation ownership"], "may_change": ["Work recovery liveness projection"]}, + "semantic_owner": "work-recovery", + "canonical_state": [], + "state_transitions": ["run timestamp evidence -> LIVE/STALE/INVALID", "stale evidence -> recovery decision pending"], + "side_effects_and_privileges": ["Pure assessment; no requeue, resume, cancellation, persistence or external mutation"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Clock/invalid timestamps produce INVALID rather than stale or healthy"], + "assumptions": ["UpdatedAt is the latest durable heartbeat/checkpoint projection available", "Recovery policy chooses the next action separately", "RUNNING alone is not proof of liveness"], + "acceptance_scenarios": ["Recent run is LIVE", "Old running run is STALE with age", "Completed/blocked run is not stale", "Invalid clock/timestamp is INVALID", "No production state changes"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "run liveness tests"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/domain/liveness.go b/modules/work/domain/liveness.go new file mode 100644 index 0000000..1e24466 --- /dev/null +++ b/modules/work/domain/liveness.go @@ -0,0 +1,45 @@ +package domain + +import ( + "time" + + "git.cin.su/m/Assistant/contracts/work" +) + +type RunLivenessState string + +const ( + RunLivenessLive RunLivenessState = "LIVE" + RunLivenessStale RunLivenessState = "STALE" + RunLivenessInvalid RunLivenessState = "INVALID" + RunLivenessNotRun RunLivenessState = "NOT_RUNNING" +) + +type RunLiveness struct { + State RunLivenessState + Age time.Duration + ObservedAt time.Time +} + +func AssessRunLiveness(run work.AgentRun, observedAt time.Time, heartbeatTimeout time.Duration) RunLiveness { + result := RunLiveness{ObservedAt: observedAt} + if observedAt.IsZero() || heartbeatTimeout <= 0 || run.UpdatedAtUnixMillis < 1 { + result.State = RunLivenessInvalid + return result + } + if run.State != work.AgentRunStarted && run.State != work.AgentRunRunning { + result.State = RunLivenessNotRun + return result + } + updated := time.UnixMilli(run.UpdatedAtUnixMillis) + result.Age = observedAt.Sub(updated) + if result.Age < 0 { + result.State = RunLivenessInvalid + return result + } + result.State = RunLivenessLive + if result.Age > heartbeatTimeout { + result.State = RunLivenessStale + } + return result +} diff --git a/tests/work_recovery/liveness_test.go b/tests/work_recovery/liveness_test.go new file mode 100644 index 0000000..783ece4 --- /dev/null +++ b/tests/work_recovery/liveness_test.go @@ -0,0 +1,33 @@ +package work_recovery_test + +import ( + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/work" + workdomain "git.cin.su/m/Assistant/modules/work/domain" +) + +func TestAssessRunLivenessClassifiesDurableHeartbeat(t *testing.T) { + now := time.UnixMilli(10000) + run := work.AgentRun{ID: "run-1", DelegationID: "delegation-1", ImplementationID: "agent-1", ImplementationRevision: 1, Protocol: work.AgentExecutionProtocol("v1"), State: work.AgentRunRunning, StartedAtUnixMillis: 9000, UpdatedAtUnixMillis: 9000} + if got := workdomain.AssessRunLiveness(run, now.Add(100*time.Millisecond), time.Second); got.State != workdomain.RunLivenessStale || got.Age != 1100*time.Millisecond { + t.Fatalf("stale = %#v", got) + } + run.UpdatedAtUnixMillis = 9500 + if got := workdomain.AssessRunLiveness(run, now, time.Second); got.State != workdomain.RunLivenessLive { + t.Fatalf("live = %#v", got) + } +} + +func TestAssessRunLivenessDoesNotTreatCompletedAsStale(t *testing.T) { + run := work.AgentRun{State: work.AgentRunCompleted, UpdatedAtUnixMillis: 1} + got := workdomain.AssessRunLiveness(run, time.UnixMilli(10000), time.Second) + if got.State != workdomain.RunLivenessNotRun { + t.Fatalf("completed = %#v", got) + } + invalid := workdomain.AssessRunLiveness(run, time.Time{}, time.Second) + if invalid.State != workdomain.RunLivenessInvalid { + t.Fatalf("invalid = %#v", invalid) + } +} -- 2.54.0 From cb92f955508540bedd059963e511b26df3ed6fbf Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:57:43 +0300 Subject: [PATCH 19/33] Add cognitive admission policy --- contracts/capability/admission.go | 68 +++++++++++++++++++ dev/ACTIVE_TASK.json | 4 +- ...EV-000115-cognitive-admission-control.json | 23 +++++++ tests/cognitive_admission/admission_test.go | 37 ++++++++++ 4 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 contracts/capability/admission.go create mode 100644 dev/tasks/DEV-000115-cognitive-admission-control.json create mode 100644 tests/cognitive_admission/admission_test.go diff --git a/contracts/capability/admission.go b/contracts/capability/admission.go new file mode 100644 index 0000000..1d67164 --- /dev/null +++ b/contracts/capability/admission.go @@ -0,0 +1,68 @@ +package capability + +type AdmissionComplexity string +type AdmissionDecisionState string +type AdmissionReason string + +const ( + AdmissionSimple AdmissionComplexity = "SIMPLE" + AdmissionNormal AdmissionComplexity = "NORMAL" + AdmissionHeavy AdmissionComplexity = "HEAVY" + AdmissionAdmitted AdmissionDecisionState = "ADMITTED" + AdmissionRejected AdmissionDecisionState = "REJECTED" + AdmissionReasonNone AdmissionReason = "NONE" + AdmissionReasonConcurrency AdmissionReason = "CONCURRENCY_LIMIT" + AdmissionReasonAcceptedHour AdmissionReason = "ACCEPTED_PER_HOUR_LIMIT" + AdmissionReasonDeadline AdmissionReason = "DEADLINE" + AdmissionReasonInputBudget AdmissionReason = "INPUT_BUDGET" + AdmissionReasonOutputBudget AdmissionReason = "OUTPUT_BUDGET" +) + +type AdmissionPolicy struct { + ID string + MaxConcurrent int + MaxAcceptedPerHour int + MaxInputBytes int + MaxOutputBytes int +} + +type AdmissionRequest struct { + Complexity AdmissionComplexity + InputBytes int + MaxOutputBytes int + DeadlineMillis int64 +} + +type AdmissionCounters struct { + Concurrent int + AcceptedLastHour int +} + +type AdmissionDecision struct { + State AdmissionDecisionState + Reason AdmissionReason + PolicyID string +} + +func Admit(request AdmissionRequest, policy AdmissionPolicy, counters AdmissionCounters, nowMillis int64) AdmissionDecision { + decision := AdmissionDecision{State: AdmissionAdmitted, Reason: AdmissionReasonNone, PolicyID: policy.ID} + if policy.ID == "" || policy.MaxConcurrent < 1 || policy.MaxAcceptedPerHour < 1 || policy.MaxInputBytes < 1 || policy.MaxOutputBytes < 1 { + return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonInputBudget, PolicyID: policy.ID} + } + if counters.Concurrent >= policy.MaxConcurrent { + return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonConcurrency, PolicyID: policy.ID} + } + if counters.AcceptedLastHour >= policy.MaxAcceptedPerHour { + return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonAcceptedHour, PolicyID: policy.ID} + } + if request.InputBytes < 0 || request.InputBytes > policy.MaxInputBytes { + return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonInputBudget, PolicyID: policy.ID} + } + if request.MaxOutputBytes < 1 || request.MaxOutputBytes > policy.MaxOutputBytes { + return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonOutputBudget, PolicyID: policy.ID} + } + if request.DeadlineMillis > 0 && request.DeadlineMillis <= nowMillis { + return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonDeadline, PolicyID: policy.ID} + } + return decision +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 40456eb..5694a52 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000114-run-liveness-assessment.json", - "task_id": "DEV-000114", + "task_file": "dev/tasks/DEV-000115-cognitive-admission-control.json", + "task_id": "DEV-000115", "task_revision": 1 } diff --git a/dev/tasks/DEV-000115-cognitive-admission-control.json b/dev/tasks/DEV-000115-cognitive-admission-control.json new file mode 100644 index 0000000..aa790d9 --- /dev/null +++ b/dev/tasks/DEV-000115-cognitive-admission-control.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000115", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "e83cf35", + "risk_tier": "E", + "intent": "Add a deterministic cognitive admission policy for concurrency, accepted/hour, deadline and input/output budgets without changing provider routing or bindings.", + "requirement_ids": ["WP-203", "Bounded concurrency/rate limits", "Explainable admission decision"], + "allowed_change_scope": {"modules": ["cognitive-admission"], "paths": ["contracts/capability/**", "modules/capability/domain/**", "tests/cognitive_admission/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000115-cognitive-admission-control.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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 cognitive capability registry and LimitPolicyRef", "ADR-0006 bounded advisory boundary"], "may_change": ["Typed admission request/policy/decision contract"]}, + "semantic_owner": "capability-cognitive-admission", + "canonical_state": [], + "state_transitions": ["request + policy + observed counters -> admitted or typed rejection"], + "side_effects_and_privileges": ["Pure policy evaluation; no provider call, queue, persistence, binding mutation or external mutation"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Rejected requests carry a reason; retry requires a fresh policy evaluation"], + "assumptions": ["Counters are supplied by a later durable admission implementation", "Policy does not choose or activate providers", "Deadline and budgets are request requirements"], + "acceptance_scenarios": ["Concurrency limit rejects excess request", "Accepted/hour limit rejects excess request", "Deadline and byte budget reject invalid request", "Admitted decision exposes reason and policy reference", "Simple/normal/heavy complexity remains a routing input, not authority"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "admission policy tests"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/tests/cognitive_admission/admission_test.go b/tests/cognitive_admission/admission_test.go new file mode 100644 index 0000000..4268a76 --- /dev/null +++ b/tests/cognitive_admission/admission_test.go @@ -0,0 +1,37 @@ +package cognitive_admission_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/capability" +) + +func TestAdmitReturnsExplainableDecision(t *testing.T) { + policy := capability.AdmissionPolicy{ID: "local-v1", MaxConcurrent: 2, MaxAcceptedPerHour: 10, MaxInputBytes: 1000, MaxOutputBytes: 500} + request := capability.AdmissionRequest{Complexity: capability.AdmissionNormal, InputBytes: 100, MaxOutputBytes: 100, DeadlineMillis: 2000} + if decision := capability.Admit(request, policy, capability.AdmissionCounters{}, 1000); decision.State != capability.AdmissionAdmitted || decision.PolicyID != policy.ID { + t.Fatalf("admitted = %#v", decision) + } + checks := []struct { + counters capability.AdmissionCounters + reason capability.AdmissionReason + }{ + {capability.AdmissionCounters{Concurrent: 2}, capability.AdmissionReasonConcurrency}, + {capability.AdmissionCounters{AcceptedLastHour: 10}, capability.AdmissionReasonAcceptedHour}, + } + for _, check := range checks { + if decision := capability.Admit(request, policy, check.counters, 1000); decision.State != capability.AdmissionRejected || decision.Reason != check.reason { + t.Fatalf("rejected = %#v want %s", decision, check.reason) + } + } +} + +func TestAdmitRejectsBudgetAndDeadline(t *testing.T) { + policy := capability.AdmissionPolicy{ID: "local-v1", MaxConcurrent: 1, MaxAcceptedPerHour: 1, MaxInputBytes: 10, MaxOutputBytes: 10} + if decision := capability.Admit(capability.AdmissionRequest{InputBytes: 11, MaxOutputBytes: 1}, policy, capability.AdmissionCounters{}, 1); decision.Reason != capability.AdmissionReasonInputBudget { + t.Fatalf("input decision = %#v", decision) + } + if decision := capability.Admit(capability.AdmissionRequest{InputBytes: 1, MaxOutputBytes: 1, DeadlineMillis: 1}, policy, capability.AdmissionCounters{}, 1); decision.Reason != capability.AdmissionReasonDeadline { + t.Fatalf("deadline decision = %#v", decision) + } +} -- 2.54.0 From 9766e5396e2c5a9f591b66c91aff203ef3686584 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 11:59:04 +0300 Subject: [PATCH 20/33] Add agent lease fencing contract --- contracts/work/lease.go | 22 ++++++++++++ dev/ACTIVE_TASK.json | 4 +-- dev/tasks/DEV-000116-agent-lease-fencing.json | 23 +++++++++++++ modules/work/domain/lease.go | 25 ++++++++++++++ tests/agent_execution/lease_test.go | 34 +++++++++++++++++++ 5 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 contracts/work/lease.go create mode 100644 dev/tasks/DEV-000116-agent-lease-fencing.json create mode 100644 modules/work/domain/lease.go create mode 100644 tests/agent_execution/lease_test.go diff --git a/contracts/work/lease.go b/contracts/work/lease.go new file mode 100644 index 0000000..1406898 --- /dev/null +++ b/contracts/work/lease.go @@ -0,0 +1,22 @@ +package work + +import "time" + +type AgentLeaseID string +type AgentFencingToken uint64 +type AgentLeaseState string + +const ( + AgentLeaseActive AgentLeaseState = "ACTIVE" + AgentLeaseExpired AgentLeaseState = "EXPIRED" +) + +type AgentLease struct { + ID AgentLeaseID + RunID AgentRunID + WorkerID string + FencingToken AgentFencingToken + State AgentLeaseState + AcquiredAt time.Time + ExpiresAt time.Time +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 5694a52..aad79b9 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000115-cognitive-admission-control.json", - "task_id": "DEV-000115", + "task_file": "dev/tasks/DEV-000116-agent-lease-fencing.json", + "task_id": "DEV-000116", "task_revision": 1 } diff --git a/dev/tasks/DEV-000116-agent-lease-fencing.json b/dev/tasks/DEV-000116-agent-lease-fencing.json new file mode 100644 index 0000000..4e037ec --- /dev/null +++ b/dev/tasks/DEV-000116-agent-lease-fencing.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000116", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "cb92f95", + "risk_tier": "E", + "intent": "Add typed durable-agent lease and fencing-token transitions for worker start/heartbeat without implementing a dispatcher or persistence adapter.", + "requirement_ids": ["WP-301", "Worker lease confirms actual start", "Heartbeat fencing"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["contracts/work/**", "modules/work/domain/**", "tests/agent_execution/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000116-agent-lease-fencing.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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": ["ADR-0017 delegation/run ownership", "Proposed ADR-0020 Agent Execution Plane", "Existing Work AgentRun identity"], "may_change": ["Work agent lease/fencing contract"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": [], + "state_transitions": ["dispatch -> lease acquired -> heartbeat renewed", "wrong worker/token/expired lease -> rejected callback"], + "side_effects_and_privileges": ["Pure transition helper; no queue, persistence, worker process, credential access or external mutation"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Fencing token changes on a new lease; late old-token heartbeat is rejected"], + "assumptions": ["A durable repository will persist the lease atomically later", "Lease acquisition is separate from process-local execution", "Worker identity is authenticated by a later adapter boundary"], + "acceptance_scenarios": ["Valid worker acquires lease", "Matching worker renews before expiry", "Wrong worker/token rejected", "Expired lease cannot renew", "New token fences old run"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "agent lease tests"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/domain/lease.go b/modules/work/domain/lease.go new file mode 100644 index 0000000..f1f34bb --- /dev/null +++ b/modules/work/domain/lease.go @@ -0,0 +1,25 @@ +package domain + +import ( + "errors" + "time" + + "git.cin.su/m/Assistant/contracts/work" +) + +var ErrLeaseRejected = errors.New("agent lease rejected") + +func AcquireAgentLease(runID work.AgentRunID, workerID string, token work.AgentFencingToken, now time.Time, ttl time.Duration) (work.AgentLease, error) { + if runID == "" || workerID == "" || token == 0 || now.IsZero() || ttl <= 0 { + return work.AgentLease{}, ErrLeaseRejected + } + return work.AgentLease{ID: work.AgentLeaseID(string(runID) + ":" + workerID), RunID: runID, WorkerID: workerID, FencingToken: token, State: work.AgentLeaseActive, AcquiredAt: now, ExpiresAt: now.Add(ttl)}, nil +} + +func RenewAgentLease(lease work.AgentLease, workerID string, token work.AgentFencingToken, now time.Time, ttl time.Duration) (work.AgentLease, error) { + if lease.State != work.AgentLeaseActive || lease.WorkerID != workerID || lease.FencingToken != token || now.IsZero() || !now.Before(lease.ExpiresAt) || ttl <= 0 { + return work.AgentLease{}, ErrLeaseRejected + } + lease.ExpiresAt = now.Add(ttl) + return lease, nil +} diff --git a/tests/agent_execution/lease_test.go b/tests/agent_execution/lease_test.go new file mode 100644 index 0000000..eb9a74d --- /dev/null +++ b/tests/agent_execution/lease_test.go @@ -0,0 +1,34 @@ +package agent_execution_test + +import ( + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/work" + workdomain "git.cin.su/m/Assistant/modules/work/domain" +) + +func TestAgentLeaseFencesWorkerAndToken(t *testing.T) { + now := time.Unix(100, 0) + lease, err := workdomain.AcquireAgentLease("run-1", "worker-a", 7, now, time.Minute) + if err != nil || lease.State != work.AgentLeaseActive { + t.Fatalf("lease = %#v %v", lease, err) + } + if _, err := workdomain.RenewAgentLease(lease, "worker-b", 7, now.Add(time.Second), time.Minute); err == nil { + t.Fatal("expected worker fencing") + } + if _, err := workdomain.RenewAgentLease(lease, "worker-a", 8, now.Add(time.Second), time.Minute); err == nil { + t.Fatal("expected token fencing") + } + if renewed, err := workdomain.RenewAgentLease(lease, "worker-a", 7, now.Add(time.Second), time.Minute); err != nil || !renewed.ExpiresAt.After(lease.ExpiresAt) { + t.Fatalf("renewed = %#v %v", renewed, err) + } +} + +func TestExpiredLeaseCannotRenew(t *testing.T) { + now := time.Unix(100, 0) + lease, _ := workdomain.AcquireAgentLease("run-1", "worker-a", 1, now, time.Second) + if _, err := workdomain.RenewAgentLease(lease, "worker-a", 1, now.Add(time.Second), time.Second); err == nil { + t.Fatal("expected expired lease rejection") + } +} -- 2.54.0 From a6eb4abc91108204a9d641a3ecf4e7b5ccce1d12 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:00:10 +0300 Subject: [PATCH 21/33] Validate agent callback envelopes --- contracts/work/lease.go | 18 +++++++++++++++ dev/ACTIVE_TASK.json | 4 ++-- .../DEV-000117-agent-callback-envelope.json | 23 +++++++++++++++++++ modules/work/domain/lease.go | 9 ++++++++ tests/agent_execution/lease_test.go | 16 +++++++++++++ 5 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000117-agent-callback-envelope.json diff --git a/contracts/work/lease.go b/contracts/work/lease.go index 1406898..756df90 100644 --- a/contracts/work/lease.go +++ b/contracts/work/lease.go @@ -20,3 +20,21 @@ type AgentLease struct { AcquiredAt time.Time ExpiresAt time.Time } + +type AgentCallbackKind string + +const ( + AgentCallbackHeartbeat AgentCallbackKind = "HEARTBEAT" + AgentCallbackCheckpoint AgentCallbackKind = "CHECKPOINT" + AgentCallbackResult AgentCallbackKind = "RESULT" + AgentCallbackCancelAcknowledged AgentCallbackKind = "CANCEL_ACKNOWLEDGED" +) + +type AgentCallback struct { + RunID AgentRunID + WorkerID string + FencingToken AgentFencingToken + Sequence uint64 + Kind AgentCallbackKind + ObservedAt time.Time +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index aad79b9..a51ac48 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000116-agent-lease-fencing.json", - "task_id": "DEV-000116", + "task_file": "dev/tasks/DEV-000117-agent-callback-envelope.json", + "task_id": "DEV-000117", "task_revision": 1 } diff --git a/dev/tasks/DEV-000117-agent-callback-envelope.json b/dev/tasks/DEV-000117-agent-callback-envelope.json new file mode 100644 index 0000000..41bb81e --- /dev/null +++ b/dev/tasks/DEV-000117-agent-callback-envelope.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000117", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "9766e53", + "risk_tier": "E", + "intent": "Add typed callback envelope validation for authenticated worker identity, fencing token, monotonic sequence and lease freshness.", + "requirement_ids": ["WP-301", "Callback auth binding", "Forged/out-of-order result rejection"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["contracts/work/**", "modules/work/domain/**", "tests/agent_execution/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000117-agent-callback-envelope.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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": ["AgentLease/fencing contract from DEV-000116", "ADR-0020 callback ordering boundary"], "may_change": ["Agent callback envelope contract"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": [], + "state_transitions": ["lease + callback envelope -> accepted callback", "wrong identity/token/sequence/expiry -> rejected callback"], + "side_effects_and_privileges": ["Pure validation; no transport, persistence, result mutation or external mutation"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Duplicate and late callbacks are rejected by sequence/fencing; accepted sequence is persisted later by adapter"], + "assumptions": ["Transport authenticates worker identity before this validation", "Sequence is monotonic within a run and lease token", "Result verification remains a separate step"], + "acceptance_scenarios": ["Matching heartbeat accepted", "Wrong worker/token rejected", "Duplicate/out-of-order sequence rejected", "Expired lease rejected", "Callback run mismatch rejected"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "agent callback tests"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/domain/lease.go b/modules/work/domain/lease.go index f1f34bb..2964af8 100644 --- a/modules/work/domain/lease.go +++ b/modules/work/domain/lease.go @@ -9,6 +9,8 @@ import ( var ErrLeaseRejected = errors.New("agent lease rejected") +var ErrCallbackRejected = errors.New("agent callback rejected") + func AcquireAgentLease(runID work.AgentRunID, workerID string, token work.AgentFencingToken, now time.Time, ttl time.Duration) (work.AgentLease, error) { if runID == "" || workerID == "" || token == 0 || now.IsZero() || ttl <= 0 { return work.AgentLease{}, ErrLeaseRejected @@ -23,3 +25,10 @@ func RenewAgentLease(lease work.AgentLease, workerID string, token work.AgentFen lease.ExpiresAt = now.Add(ttl) return lease, nil } + +func ValidateAgentCallback(callback work.AgentCallback, lease work.AgentLease, lastSequence uint64) error { + if lease.State != work.AgentLeaseActive || callback.RunID == "" || callback.RunID != lease.RunID || callback.WorkerID == "" || callback.WorkerID != lease.WorkerID || callback.FencingToken == 0 || callback.FencingToken != lease.FencingToken || callback.Sequence <= lastSequence || callback.Kind == "" || callback.ObservedAt.IsZero() || !callback.ObservedAt.Before(lease.ExpiresAt) { + return ErrCallbackRejected + } + return nil +} diff --git a/tests/agent_execution/lease_test.go b/tests/agent_execution/lease_test.go index eb9a74d..ea54280 100644 --- a/tests/agent_execution/lease_test.go +++ b/tests/agent_execution/lease_test.go @@ -32,3 +32,19 @@ func TestExpiredLeaseCannotRenew(t *testing.T) { t.Fatal("expected expired lease rejection") } } + +func TestAgentCallbackRejectsReplayAndWrongFencing(t *testing.T) { + now := time.Unix(100, 0) + lease, _ := workdomain.AcquireAgentLease("run-1", "worker-a", 1, now, time.Minute) + callback := work.AgentCallback{RunID: "run-1", WorkerID: "worker-a", FencingToken: 1, Sequence: 2, Kind: work.AgentCallbackHeartbeat, ObservedAt: now.Add(time.Second)} + if err := workdomain.ValidateAgentCallback(callback, lease, 1); err != nil { + t.Fatal(err) + } + if err := workdomain.ValidateAgentCallback(callback, lease, 2); err == nil { + t.Fatal("expected replay rejection") + } + callback.FencingToken = 2 + if err := workdomain.ValidateAgentCallback(callback, lease, 1); err == nil { + t.Fatal("expected fencing rejection") + } +} -- 2.54.0 From 02b472b627ab5ae032cf9f0e76c404638d28e660 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:01:16 +0300 Subject: [PATCH 22/33] Add agent artifact attestation --- contracts/artifact/types.go | 19 +++++++++++++++ dev/ACTIVE_TASK.json | 4 ++-- ...DEV-000118-agent-artifact-attestation.json | 23 +++++++++++++++++++ tests/agent_execution/attestation_test.go | 18 +++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000118-agent-artifact-attestation.json create mode 100644 tests/agent_execution/attestation_test.go diff --git a/contracts/artifact/types.go b/contracts/artifact/types.go index a70b06c..825216f 100644 --- a/contracts/artifact/types.go +++ b/contracts/artifact/types.go @@ -2,4 +2,23 @@ package artifact import "git.cin.su/m/Assistant/platform/ids" +import "errors" + type ArtifactID ids.ID + +type AgentArtifactAttestation struct { + ArtifactID ArtifactID + ProducerRunID string + SourceRevision string + WorktreeID string + ContentDigest string + TestEvidenceRef string + CreatedAtUnixMillis int64 +} + +func (a AgentArtifactAttestation) Validate() error { + if a.ArtifactID == "" || a.ProducerRunID == "" || a.SourceRevision == "" || a.WorktreeID == "" || a.ContentDigest == "" || a.TestEvidenceRef == "" || a.CreatedAtUnixMillis < 1 { + return errors.New("agent artifact attestation is incomplete") + } + return nil +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index a51ac48..c00a6fa 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000117-agent-callback-envelope.json", - "task_id": "DEV-000117", + "task_file": "dev/tasks/DEV-000118-agent-artifact-attestation.json", + "task_id": "DEV-000118", "task_revision": 1 } diff --git a/dev/tasks/DEV-000118-agent-artifact-attestation.json b/dev/tasks/DEV-000118-agent-artifact-attestation.json new file mode 100644 index 0000000..cd26f21 --- /dev/null +++ b/dev/tasks/DEV-000118-agent-artifact-attestation.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000118", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "a6eb4ab", + "risk_tier": "E", + "intent": "Add typed artifact attestation for agent-produced evidence with digest, source/worktree, producer run and test provenance.", + "requirement_ids": ["WP-301", "Artifact attestation", "Independent evidence refs"], + "allowed_change_scope": {"modules": ["artifact-agent-evidence"], "paths": ["contracts/artifact/**", "tests/agent_execution/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000118-agent-artifact-attestation.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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": ["ADR-0017 Artifact ownership", "ADR-0020 agent evidence attestation", "Existing AgentRun identity"], "may_change": ["Artifact agent attestation contract"]}, + "semantic_owner": "artifact-agent-evidence", + "canonical_state": [], + "state_transitions": ["agent output -> attested artifact evidence", "missing provenance -> non-attested result"], + "side_effects_and_privileges": ["Pure validation; no filesystem access, merge, deployment, credential access or external mutation"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Attestation is immutable; changed digest/source requires a new attestation"], + "assumptions": ["Artifact storage computes or verifies digest later", "Producer run and source revision are durable refs", "Verifier remains distinct from producer"], + "acceptance_scenarios": ["Complete attestation validates", "Missing digest/source/worktree/test evidence rejects", "Attestation binds to one producer run", "Changed artifact requires a new attestation"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "artifact attestation tests"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/tests/agent_execution/attestation_test.go b/tests/agent_execution/attestation_test.go new file mode 100644 index 0000000..f764b85 --- /dev/null +++ b/tests/agent_execution/attestation_test.go @@ -0,0 +1,18 @@ +package agent_execution_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/artifact" +) + +func TestAgentArtifactAttestationRequiresProvenance(t *testing.T) { + attestation := artifact.AgentArtifactAttestation{ArtifactID: "artifact-1", ProducerRunID: "run-1", SourceRevision: "commit-1", WorktreeID: "worktree-1", ContentDigest: "sha256:artifact", TestEvidenceRef: "tests:1", CreatedAtUnixMillis: 10} + if err := attestation.Validate(); err != nil { + t.Fatal(err) + } + attestation.ContentDigest = "" + if err := attestation.Validate(); err == nil { + t.Fatal("expected missing digest rejection") + } +} -- 2.54.0 From 04dad79af189e96935dda2e021255563b9a0fd6f Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:02:19 +0300 Subject: [PATCH 23/33] Add independent agent verification contract --- contracts/work/lease.go | 10 ++++++++ dev/ACTIVE_TASK.json | 4 ++-- ...119-independent-verification-contract.json | 23 +++++++++++++++++++ modules/work/domain/lease.go | 7 ++++++ tests/agent_execution/verification_test.go | 19 +++++++++++++++ 5 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000119-independent-verification-contract.json create mode 100644 tests/agent_execution/verification_test.go diff --git a/contracts/work/lease.go b/contracts/work/lease.go index 756df90..0927819 100644 --- a/contracts/work/lease.go +++ b/contracts/work/lease.go @@ -38,3 +38,13 @@ type AgentCallback struct { Kind AgentCallbackKind ObservedAt time.Time } + +type IndependentVerification struct { + ProducerRunID AgentRunID + VerifierRunID AgentRunID + VerifierIdentity string + ArtifactID string + TestEvidenceRef string + Verified bool + Summary string +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index c00a6fa..3dbe2f3 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000118-agent-artifact-attestation.json", - "task_id": "DEV-000118", + "task_file": "dev/tasks/DEV-000119-independent-verification-contract.json", + "task_id": "DEV-000119", "task_revision": 1 } diff --git a/dev/tasks/DEV-000119-independent-verification-contract.json b/dev/tasks/DEV-000119-independent-verification-contract.json new file mode 100644 index 0000000..b1dff72 --- /dev/null +++ b/dev/tasks/DEV-000119-independent-verification-contract.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000119", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "02b472b", + "risk_tier": "E", + "intent": "Add independent verification contract requiring a distinct verifier identity/run and attested artifact evidence before Work completion.", + "requirement_ids": ["WP-301", "Independent verifier", "Work completion gate"], + "allowed_change_scope": {"modules": ["work-agent-verification"], "paths": ["contracts/work/**", "modules/work/domain/**", "tests/agent_execution/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000119-independent-verification-contract.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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": ["ADR-0017 verification separation", "ADR-0020 independent verifier", "Agent artifact attestation from DEV-000118"], "may_change": ["Work independent verification contract"]}, + "semantic_owner": "work-agent-verification", + "canonical_state": [], + "state_transitions": ["producer result + attestation -> verifier assessment -> verified/rejected"], + "side_effects_and_privileges": ["Pure validation; no merge, deployment, binding activation, credential access or external mutation"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Rejected verification preserves producer evidence; a new verifier run is required"], + "assumptions": ["Verifier identity and run are authenticated later", "Producer and verifier cannot be the same run", "Artifact evidence is immutable"], + "acceptance_scenarios": ["Distinct verifier validates attested artifact", "Same run is rejected", "Missing artifact/test evidence is rejected", "Rejected verification does not erase producer result"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "independent verification tests"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/domain/lease.go b/modules/work/domain/lease.go index 2964af8..1103a99 100644 --- a/modules/work/domain/lease.go +++ b/modules/work/domain/lease.go @@ -32,3 +32,10 @@ func ValidateAgentCallback(callback work.AgentCallback, lease work.AgentLease, l } return nil } + +func ValidateIndependentVerification(value work.IndependentVerification) error { + if value.ProducerRunID == "" || value.VerifierRunID == "" || value.ProducerRunID == value.VerifierRunID || value.VerifierIdentity == "" || value.ArtifactID == "" || value.TestEvidenceRef == "" || value.Summary == "" { + return ErrCallbackRejected + } + return nil +} diff --git a/tests/agent_execution/verification_test.go b/tests/agent_execution/verification_test.go new file mode 100644 index 0000000..260ff4d --- /dev/null +++ b/tests/agent_execution/verification_test.go @@ -0,0 +1,19 @@ +package agent_execution_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/work" + workdomain "git.cin.su/m/Assistant/modules/work/domain" +) + +func TestIndependentVerificationRequiresDistinctRun(t *testing.T) { + verification := work.IndependentVerification{ProducerRunID: "producer-1", VerifierRunID: "verifier-1", VerifierIdentity: "verifier", ArtifactID: "artifact-1", TestEvidenceRef: "tests-1", Verified: true, Summary: "passed"} + if err := workdomain.ValidateIndependentVerification(verification); err != nil { + t.Fatal(err) + } + verification.VerifierRunID = verification.ProducerRunID + if err := workdomain.ValidateIndependentVerification(verification); err == nil { + t.Fatal("expected same-run rejection") + } +} -- 2.54.0 From f792d208ef64b445da3e35435e71e78ca020c51f Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:03:21 +0300 Subject: [PATCH 24/33] Add agent dispatch intent contract --- contracts/work/lease.go | 13 +++++++++++ dev/ACTIVE_TASK.json | 4 ++-- .../DEV-000120-agent-dispatch-intent.json | 23 +++++++++++++++++++ modules/work/domain/lease.go | 7 ++++++ tests/agent_execution/dispatch_test.go | 19 +++++++++++++++ 5 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000120-agent-dispatch-intent.json create mode 100644 tests/agent_execution/dispatch_test.go diff --git a/contracts/work/lease.go b/contracts/work/lease.go index 0927819..e515e9c 100644 --- a/contracts/work/lease.go +++ b/contracts/work/lease.go @@ -48,3 +48,16 @@ type IndependentVerification struct { Verified bool Summary string } + +type AgentDispatchIntent struct { + DispatchID string + IdempotencyKey string + DelegationID DelegationID + RunID AgentRunID + ImplementationID string + ImplementationRevision int64 + SourceRevision string + WorktreeID string + VerificationPlanRef string + CreatedAtUnixMillis int64 +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 3dbe2f3..6a88a96 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000119-independent-verification-contract.json", - "task_id": "DEV-000119", + "task_file": "dev/tasks/DEV-000120-agent-dispatch-intent.json", + "task_id": "DEV-000120", "task_revision": 1 } diff --git a/dev/tasks/DEV-000120-agent-dispatch-intent.json b/dev/tasks/DEV-000120-agent-dispatch-intent.json new file mode 100644 index 0000000..b5cfebb --- /dev/null +++ b/dev/tasks/DEV-000120-agent-dispatch-intent.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000120", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "04dad79", + "risk_tier": "E", + "intent": "Add typed durable dispatch intent and idempotency contract for agent runs without implementing a dispatcher, queue or worker process.", + "requirement_ids": ["WP-301", "Durable dispatch", "Duplicate dispatch prevention"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["contracts/work/**", "modules/work/domain/**", "tests/agent_execution/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000120-agent-dispatch-intent.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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": ["ADR-0017 delegation/run ownership", "ADR-0020 dispatch idempotency", "Existing Capability implementation identity"], "may_change": ["Work dispatch intent contract"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": [], + "state_transitions": ["dispatch intent -> accepted once by idempotency key -> lease acquisition", "same idempotency key -> same dispatch identity"], + "side_effects_and_privileges": ["Pure validation; no queue, process start, persistence adapter, credential access or external mutation"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Retry reuses intent identity only when the durable store confirms the same payload; changed payload requires a new key"], + "assumptions": ["Dispatcher persists intent atomically later", "Exact source/worktree is resolved before worker start", "Verification plan is part of dispatch provenance"], + "acceptance_scenarios": ["Complete dispatch validates", "Missing idempotency/source/worktree/verification plan rejects", "Payload identity is explicit", "Worker implementation is typed and not inferred from free text"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "dispatch intent tests"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/domain/lease.go b/modules/work/domain/lease.go index 1103a99..f8159df 100644 --- a/modules/work/domain/lease.go +++ b/modules/work/domain/lease.go @@ -39,3 +39,10 @@ func ValidateIndependentVerification(value work.IndependentVerification) error { } return nil } + +func ValidateDispatchIntent(value work.AgentDispatchIntent) error { + if value.DispatchID == "" || value.IdempotencyKey == "" || value.DelegationID == "" || value.RunID == "" || value.ImplementationID == "" || value.ImplementationRevision < 1 || value.SourceRevision == "" || value.WorktreeID == "" || value.VerificationPlanRef == "" || value.CreatedAtUnixMillis < 1 { + return ErrCallbackRejected + } + return nil +} diff --git a/tests/agent_execution/dispatch_test.go b/tests/agent_execution/dispatch_test.go new file mode 100644 index 0000000..7b61e77 --- /dev/null +++ b/tests/agent_execution/dispatch_test.go @@ -0,0 +1,19 @@ +package agent_execution_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/work" + workdomain "git.cin.su/m/Assistant/modules/work/domain" +) + +func TestDispatchIntentRequiresStableExecutionIdentity(t *testing.T) { + intent := work.AgentDispatchIntent{DispatchID: "dispatch-1", IdempotencyKey: "idem-1", DelegationID: "delegation-1", RunID: "run-1", ImplementationID: "agent-1", ImplementationRevision: 2, SourceRevision: "commit-1", WorktreeID: "worktree-1", VerificationPlanRef: "verify-plan-1", CreatedAtUnixMillis: 10} + if err := workdomain.ValidateDispatchIntent(intent); err != nil { + t.Fatal(err) + } + intent.IdempotencyKey = "" + if err := workdomain.ValidateDispatchIntent(intent); err == nil { + t.Fatal("expected missing idempotency rejection") + } +} -- 2.54.0 From 8081b4de3466f44ee398407573e4d16d559ac02d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:08:43 +0300 Subject: [PATCH 25/33] feat(work): persist agent execution state --- adapters/postgres/work_repository.go | 63 +++++++++++++++++++ .../029_work_agent_execution_persistence.sql | 43 +++++++++++++ dev/ACTIVE_TASK.json | 4 +- ...EV-000121-agent-execution-persistence.json | 23 +++++++ tests/agent_execution/persistence_sql_test.go | 33 ++++++++++ 5 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 database/migrations/029_work_agent_execution_persistence.sql create mode 100644 dev/tasks/DEV-000121-agent-execution-persistence.json create mode 100644 tests/agent_execution/persistence_sql_test.go diff --git a/adapters/postgres/work_repository.go b/adapters/postgres/work_repository.go index f332f0e..1e9af75 100644 --- a/adapters/postgres/work_repository.go +++ b/adapters/postgres/work_repository.go @@ -2,7 +2,9 @@ package postgres import ( "context" + "errors" "fmt" + "time" "git.cin.su/m/Assistant/contracts/work" "github.com/jackc/pgx/v5/pgxpool" @@ -10,6 +12,12 @@ import ( type WorkRepository struct{ pool *pgxpool.Pool } +var ( + ErrDispatchIntentConflict = errors.New("dispatch intent idempotency conflict") + ErrAgentLeaseUnavailable = errors.New("agent lease unavailable") + ErrAgentCallbackRejected = errors.New("agent callback rejected") +) + func OpenWork(ctx context.Context, config ContinuityConfig) (*WorkRepository, error) { pool, err := openPool(ctx, config) if err != nil { @@ -39,6 +47,61 @@ func (r *WorkRepository) Load(ctx context.Context, id work.TaskID) (work.TaskTar return target, nil } +func (r *WorkRepository) SaveDispatchIntent(ctx context.Context, intent work.AgentDispatchIntent) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + _, err = tx.Exec(ctx, `INSERT INTO work.agent_dispatch_intents(dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT(idempotency_key) DO NOTHING`, intent.DispatchID, intent.IdempotencyKey, intent.DelegationID, intent.RunID, intent.ImplementationID, intent.ImplementationRevision, intent.SourceRevision, intent.WorktreeID, intent.VerificationPlanRef, intent.CreatedAtUnixMillis) + if err != nil { + return fmt.Errorf("save dispatch intent: %w", err) + } + var existing work.AgentDispatchIntent + err = tx.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis FROM work.agent_dispatch_intents WHERE idempotency_key=$1`, intent.IdempotencyKey).Scan(&existing.DispatchID, &existing.IdempotencyKey, &existing.DelegationID, &existing.RunID, &existing.ImplementationID, &existing.ImplementationRevision, &existing.SourceRevision, &existing.WorktreeID, &existing.VerificationPlanRef, &existing.CreatedAtUnixMillis) + if err != nil { + return fmt.Errorf("read dispatch intent: %w", err) + } + if existing != intent { + return ErrDispatchIntentConflict + } + return tx.Commit(ctx) +} + +func (r *WorkRepository) SaveAgentLease(ctx context.Context, lease work.AgentLease) error { + err := r.pool.QueryRow(ctx, `INSERT INTO work.agent_leases(lease_id,run_id,worker_id,fencing_token,state,acquired_at_unix_millis,expires_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(run_id) DO UPDATE SET lease_id=EXCLUDED.lease_id,worker_id=EXCLUDED.worker_id,fencing_token=EXCLUDED.fencing_token,state=EXCLUDED.state,acquired_at_unix_millis=EXCLUDED.acquired_at_unix_millis,expires_at_unix_millis=EXCLUDED.expires_at_unix_millis WHERE work.agent_leases.state='EXPIRED' OR work.agent_leases.expires_at_unix_millis <= EXCLUDED.acquired_at_unix_millis RETURNING run_id`, lease.ID, lease.RunID, lease.WorkerID, lease.FencingToken, lease.State, lease.AcquiredAt.UnixMilli(), lease.ExpiresAt.UnixMilli()).Scan(new(string)) + if err != nil { + return ErrAgentLeaseUnavailable + } + return nil +} + +func (r *WorkRepository) RenewAgentLease(ctx context.Context, lease work.AgentLease, expiresAt time.Time) error { + command, err := r.pool.Exec(ctx, `UPDATE work.agent_leases SET expires_at_unix_millis=$1 WHERE run_id=$2 AND worker_id=$3 AND fencing_token=$4 AND state='ACTIVE' AND expires_at_unix_millis > $5`, expiresAt.UnixMilli(), lease.RunID, lease.WorkerID, lease.FencingToken, time.Now().UnixMilli()) + if err != nil || command.RowsAffected() != 1 { + return ErrAgentLeaseUnavailable + } + return nil +} + +func (r *WorkRepository) AcceptAgentCallback(ctx context.Context, callback work.AgentCallback) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + var runID string + err = tx.QueryRow(ctx, `SELECT run_id FROM work.agent_leases WHERE run_id=$1 AND worker_id=$2 AND fencing_token=$3 AND state='ACTIVE' AND expires_at_unix_millis > $4 FOR UPDATE`, callback.RunID, callback.WorkerID, callback.FencingToken, callback.ObservedAt.UnixMilli()).Scan(&runID) + if err != nil { + return ErrAgentCallbackRejected + } + command, err := tx.Exec(ctx, `INSERT INTO work.agent_callback_heads(run_id,last_sequence,updated_at_unix_millis) VALUES($1,$2,$3) ON CONFLICT(run_id) DO UPDATE SET last_sequence=EXCLUDED.last_sequence,updated_at_unix_millis=EXCLUDED.updated_at_unix_millis WHERE work.agent_callback_heads.last_sequence < EXCLUDED.last_sequence`, runID, callback.Sequence, callback.ObservedAt.UnixMilli()) + if err != nil || command.RowsAffected() != 1 { + return ErrAgentCallbackRejected + } + return tx.Commit(ctx) +} + func (r *WorkRepository) SavePlanningResult(ctx context.Context, result work.PlanningResult) error { tx, err := r.pool.Begin(ctx) if err != nil { diff --git a/database/migrations/029_work_agent_execution_persistence.sql b/database/migrations/029_work_agent_execution_persistence.sql new file mode 100644 index 0000000..0009685 --- /dev/null +++ b/database/migrations/029_work_agent_execution_persistence.sql @@ -0,0 +1,43 @@ +BEGIN; + +SET ROLE work_role; + +CREATE TABLE IF NOT EXISTS work.agent_dispatch_intents ( + dispatch_id TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL UNIQUE, + delegation_id TEXT NOT NULL, + run_id TEXT NOT NULL, + implementation_id TEXT NOT NULL, + implementation_revision BIGINT NOT NULL, + source_revision TEXT NOT NULL, + worktree_id TEXT NOT NULL, + verification_plan_ref TEXT NOT NULL, + created_at_unix_millis BIGINT NOT NULL, + CHECK (dispatch_id <> ''), CHECK (idempotency_key <> ''), CHECK (delegation_id <> ''), + CHECK (run_id <> ''), CHECK (implementation_id <> ''), CHECK (implementation_revision > 0), + CHECK (source_revision <> ''), CHECK (worktree_id <> ''), CHECK (verification_plan_ref <> ''), + CHECK (created_at_unix_millis > 0) +); + +CREATE TABLE IF NOT EXISTS work.agent_leases ( + lease_id TEXT PRIMARY KEY, + run_id TEXT NOT NULL UNIQUE, + worker_id TEXT NOT NULL, + fencing_token BIGINT NOT NULL, + state TEXT NOT NULL, + acquired_at_unix_millis BIGINT NOT NULL, + expires_at_unix_millis BIGINT NOT NULL, + CHECK (lease_id <> ''), CHECK (run_id <> ''), CHECK (worker_id <> ''), + CHECK (fencing_token > 0), CHECK (state IN ('ACTIVE','EXPIRED')), + CHECK (acquired_at_unix_millis > 0), CHECK (expires_at_unix_millis > acquired_at_unix_millis) +); + +CREATE TABLE IF NOT EXISTS work.agent_callback_heads ( + run_id TEXT PRIMARY KEY, + last_sequence BIGINT NOT NULL DEFAULT 0, + updated_at_unix_millis BIGINT NOT NULL, + CHECK (run_id <> ''), CHECK (last_sequence >= 0), CHECK (updated_at_unix_millis > 0) +); + +RESET ROLE; +COMMIT; diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 6a88a96..1de2854 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000120-agent-dispatch-intent.json", - "task_id": "DEV-000120", + "task_file": "dev/tasks/DEV-000121-agent-execution-persistence.json", + "task_id": "DEV-000121", "task_revision": 1 } diff --git a/dev/tasks/DEV-000121-agent-execution-persistence.json b/dev/tasks/DEV-000121-agent-execution-persistence.json new file mode 100644 index 0000000..3ed88f7 --- /dev/null +++ b/dev/tasks/DEV-000121-agent-execution-persistence.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000121", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "f792d20", + "risk_tier": "E", + "intent": "Add work-owned durable persistence schema for dispatch intents, leases and callback sequence heads, with repository write boundaries.", + "requirement_ids": ["WP-301", "Durable dispatch persistence", "Atomic lease/fencing state"], + "allowed_change_scope": {"modules": ["work-agent-execution", "work-storage"], "paths": ["database/migrations/029_work_agent_execution_persistence.sql", "adapters/postgres/work_repository.go", "tests/agent_execution/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000121-agent-execution-persistence.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/**", "runtime/**", "adapters/continuity*", "adapters/capability*", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 1}, + "contracts": {"consume": ["DEV-000120 dispatch intent", "DEV-000116 lease/fencing", "DEV-000117 callback sequence"], "may_change": ["Work-owned persistence schema and repository methods"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["work.agent_dispatch_intents", "work.agent_leases", "work.agent_callback_heads"], + "state_transitions": ["dispatch intent persisted idempotently", "lease acquired/renewed with fencing token", "callback sequence advanced atomically"], + "side_effects_and_privileges": ["Work schema writes only through Work repository; no cross-owner SQL"], + "persistence_and_migrations": ["Append-only migration 029; work_role only"], + "failure_retry_restart": ["Duplicate idempotency key is reconciled; lease conflict is rejected; callback sequence uses row lock/conditional update"], + "assumptions": ["Migration is reviewed and applied before runtime enablement", "Production deployment is a later canary task", "No secrets are stored in dispatch payload"], + "acceptance_scenarios": ["Duplicate dispatch cannot create a second intent", "Expired/wrong lease cannot advance", "Out-of-order callback sequence is rejected", "Work owner boundaries remain intact"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check", "migration/repository tests"], + "terminal_gap_states": ["BLOCKED_BY_MIGRATION_DECISION", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/tests/agent_execution/persistence_sql_test.go b/tests/agent_execution/persistence_sql_test.go new file mode 100644 index 0000000..469dec1 --- /dev/null +++ b/tests/agent_execution/persistence_sql_test.go @@ -0,0 +1,33 @@ +package agent_execution_test + +import ( + "os" + "strings" + "testing" +) + +func TestDurableAgentExecutionMigrationDefinesOwnedState(t *testing.T) { + data, err := os.ReadFile("../../database/migrations/029_work_agent_execution_persistence.sql") + if err != nil { + t.Fatal(err) + } + sql := string(data) + for _, table := range []string{ + "work.agent_dispatch_intents", + "work.agent_leases", + "work.agent_callback_heads", + } { + if !strings.Contains(sql, "CREATE TABLE IF NOT EXISTS "+table) { + t.Fatalf("migration does not define %s", table) + } + } + for _, marker := range []string{ + "UNIQUE", + "CHECK (state IN ('ACTIVE','EXPIRED'))", + "CHECK (fencing_token > 0)", + } { + if !strings.Contains(sql, marker) { + t.Fatalf("migration/repository contract marker missing: %s", marker) + } + } +} -- 2.54.0 From fa62322ed25399789de4122abedfd8f24be45c6f Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:11:29 +0300 Subject: [PATCH 26/33] feat(work): add deterministic dry-run dispatch --- contracts/work/dry_run_dispatch.go | 18 +++++++++ dev/ACTIVE_TASK.json | 4 +- .../DEV-000122-agent-dry-run-dispatch.json | 23 +++++++++++ modules/work/domain/dry_run_dispatch.go | 26 +++++++++++++ .../agent_execution/dry_run_dispatch_test.go | 39 +++++++++++++++++++ 5 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 contracts/work/dry_run_dispatch.go create mode 100644 dev/tasks/DEV-000122-agent-dry-run-dispatch.json create mode 100644 modules/work/domain/dry_run_dispatch.go create mode 100644 tests/agent_execution/dry_run_dispatch_test.go diff --git a/contracts/work/dry_run_dispatch.go b/contracts/work/dry_run_dispatch.go new file mode 100644 index 0000000..2556032 --- /dev/null +++ b/contracts/work/dry_run_dispatch.go @@ -0,0 +1,18 @@ +package work + +import "time" + +type DryRunDispatch struct { + Intent AgentDispatchIntent + Lease AgentLease + Callbacks []AgentCallback +} + +func (value DryRunDispatch) CallbackCount() int { return len(value.Callbacks) } + +func (value DryRunDispatch) LastObservedAt() time.Time { + if len(value.Callbacks) == 0 { + return time.Time{} + } + return value.Callbacks[len(value.Callbacks)-1].ObservedAt +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 1de2854..d2d64de 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000121-agent-execution-persistence.json", - "task_id": "DEV-000121", + "task_file": "dev/tasks/DEV-000122-agent-dry-run-dispatch.json", + "task_id": "DEV-000122", "task_revision": 1 } diff --git a/dev/tasks/DEV-000122-agent-dry-run-dispatch.json b/dev/tasks/DEV-000122-agent-dry-run-dispatch.json new file mode 100644 index 0000000..5c73d14 --- /dev/null +++ b/dev/tasks/DEV-000122-agent-dry-run-dispatch.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000122", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "8081b4d", + "risk_tier": "E", + "intent": "Implement an opt-in deterministic dry-run dispatcher and fake worker cycle over the Agent Execution Plane contracts.", + "requirement_ids": ["WP-301", "Dry-run dispatcher", "Deterministic fake worker"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["contracts/work/dry_run_dispatch.go", "modules/work/domain/dry_run_dispatch.go", "tests/agent_execution/dry_run_dispatch_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000122-agent-dry-run-dispatch.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/**", "runtime/**", "adapters/**", "database/**", ".gitea/**"]}, + "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": ["DEV-000120 dispatch intent", "DEV-000116 lease/fencing", "DEV-000117 callback sequence"], "may_change": ["Dry-run dispatch result value" ]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["DryRunDispatch value; no production state"], + "state_transitions": ["validated intent -> deterministic lease -> heartbeat callback -> result callback"], + "side_effects_and_privileges": ["Pure domain construction only; no external mutation"], + "persistence_and_migrations": ["Uses DEV-000121 persistence contract but does not apply migration"], + "failure_retry_restart": ["Invalid intent, worker, token, time or TTL is rejected"], + "assumptions": ["Runtime dispatcher and production worker remain separate follow-up work"], + "acceptance_scenarios": ["Same input produces byte-for-byte equivalent typed output", "Invalid intent is rejected", "Callbacks are ordered and inside lease window"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/domain/dry_run_dispatch.go b/modules/work/domain/dry_run_dispatch.go new file mode 100644 index 0000000..551ec8c --- /dev/null +++ b/modules/work/domain/dry_run_dispatch.go @@ -0,0 +1,26 @@ +package domain + +import ( + "errors" + "time" + + "git.cin.su/m/Assistant/contracts/work" +) + +var ErrDryRunDispatchRejected = errors.New("dry-run dispatch rejected") + +func BuildDryRunDispatch(intent work.AgentDispatchIntent, workerID string, token work.AgentFencingToken, now time.Time, ttl time.Duration) (work.DryRunDispatch, error) { + if ValidateDispatchIntent(intent) != nil || workerID == "" || token == 0 || now.IsZero() || ttl <= 0 { + return work.DryRunDispatch{}, ErrDryRunDispatchRejected + } + lease, err := AcquireAgentLease(intent.RunID, workerID, token, now, ttl) + if err != nil { + return work.DryRunDispatch{}, ErrDryRunDispatchRejected + } + heartbeat := work.AgentCallback{RunID: intent.RunID, WorkerID: workerID, FencingToken: token, Sequence: 1, Kind: work.AgentCallbackHeartbeat, ObservedAt: now.Add(ttl / 3)} + result := work.AgentCallback{RunID: intent.RunID, WorkerID: workerID, FencingToken: token, Sequence: 2, Kind: work.AgentCallbackResult, ObservedAt: now.Add((ttl * 2) / 3)} + if ValidateAgentCallback(heartbeat, lease, 0) != nil || ValidateAgentCallback(result, lease, heartbeat.Sequence) != nil { + return work.DryRunDispatch{}, ErrDryRunDispatchRejected + } + return work.DryRunDispatch{Intent: intent, Lease: lease, Callbacks: []work.AgentCallback{heartbeat, result}}, nil +} diff --git a/tests/agent_execution/dry_run_dispatch_test.go b/tests/agent_execution/dry_run_dispatch_test.go new file mode 100644 index 0000000..33532f6 --- /dev/null +++ b/tests/agent_execution/dry_run_dispatch_test.go @@ -0,0 +1,39 @@ +package agent_execution_test + +import ( + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/work" + workdomain "git.cin.su/m/Assistant/modules/work/domain" +) + +func TestBuildDryRunDispatchProducesDeterministicCallbacks(t *testing.T) { + now := time.UnixMilli(1_700_000_000_000) + intent := work.AgentDispatchIntent{ + DispatchID: "dispatch-1", IdempotencyKey: "idem-1", DelegationID: "delegation-1", RunID: "run-1", + ImplementationID: "fake-worker", ImplementationRevision: 1, SourceRevision: "source-1", + WorktreeID: "worktree-1", VerificationPlanRef: "verify-1", CreatedAtUnixMillis: now.UnixMilli(), + } + first, err := workdomain.BuildDryRunDispatch(intent, "worker-1", 7, now, 15*time.Minute) + if err != nil { + t.Fatal(err) + } + second, err := workdomain.BuildDryRunDispatch(intent, "worker-1", 7, now, 15*time.Minute) + if err != nil { + t.Fatal(err) + } + if first.CallbackCount() != 2 || first.Lease != second.Lease || first.Callbacks[0] != second.Callbacks[0] || first.Callbacks[1] != second.Callbacks[1] { + t.Fatal("dry-run dispatch is not deterministic") + } + if first.Callbacks[1].Sequence != 2 || !first.LastObservedAt().Before(first.Lease.ExpiresAt) { + t.Fatal("callback sequence or lease window is invalid") + } +} + +func TestBuildDryRunDispatchRejectsInvalidIntent(t *testing.T) { + _, err := workdomain.BuildDryRunDispatch(work.AgentDispatchIntent{}, "worker-1", 1, time.UnixMilli(1_700_000_000_000), time.Minute) + if err != workdomain.ErrDryRunDispatchRejected { + t.Fatalf("expected rejection, got %v", err) + } +} -- 2.54.0 From 7f82240a8f592446bdf573fccdb628d21639c903 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:13:35 +0300 Subject: [PATCH 27/33] feat(work): orchestrate dry-run dispatch persistence --- dev/ACTIVE_TASK.json | 4 +- ...EV-000123-agent-dry-run-orchestration.json | 23 +++++++++++ modules/work/application/dry_run_dispatch.go | 29 ++++++++++++++ modules/work/ports/execution.go | 13 ++++++ .../dry_run_orchestration_test.go | 40 +++++++++++++++++++ 5 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000123-agent-dry-run-orchestration.json create mode 100644 modules/work/application/dry_run_dispatch.go create mode 100644 modules/work/ports/execution.go create mode 100644 tests/agent_execution/dry_run_orchestration_test.go diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index d2d64de..b098dbd 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000122-agent-dry-run-dispatch.json", - "task_id": "DEV-000122", + "task_file": "dev/tasks/DEV-000123-agent-dry-run-orchestration.json", + "task_id": "DEV-000123", "task_revision": 1 } diff --git a/dev/tasks/DEV-000123-agent-dry-run-orchestration.json b/dev/tasks/DEV-000123-agent-dry-run-orchestration.json new file mode 100644 index 0000000..09734f1 --- /dev/null +++ b/dev/tasks/DEV-000123-agent-dry-run-orchestration.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000123", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "fa62322", + "risk_tier": "E", + "intent": "Connect the opt-in deterministic dry-run dispatcher to a narrow Work-owned execution persistence port and application coordinator.", + "requirement_ids": ["WP-301", "Work execution port", "Dry-run orchestration"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["modules/work/ports/execution.go", "modules/work/application/dry_run_dispatch.go", "tests/agent_execution/dry_run_orchestration_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000123-agent-dry-run-orchestration.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/**", "runtime/**", "adapters/**", "database/**", ".gitea/**"]}, + "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": ["DEV-000121 persistence methods", "DEV-000122 dry-run dispatch"], "may_change": ["Work execution port and application coordinator"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["Persistence remains owned by Work repository"], + "state_transitions": ["dispatch intent -> lease -> ordered callbacks"], + "side_effects_and_privileges": ["Explicit Work-owned port only; no runtime entrypoint or production enablement"], + "persistence_and_migrations": ["Reuses DEV-000121 schema; no migration"], + "failure_retry_restart": ["Idempotent repository methods make retries safe; partial failure is returned to caller"], + "assumptions": ["Production dispatcher remains disabled until a separate runtime canary task"], + "acceptance_scenarios": ["All persistence operations are invoked in deterministic order", "Invalid dispatch never reaches the store"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/application/dry_run_dispatch.go b/modules/work/application/dry_run_dispatch.go new file mode 100644 index 0000000..7191529 --- /dev/null +++ b/modules/work/application/dry_run_dispatch.go @@ -0,0 +1,29 @@ +package application + +import ( + "context" + "time" + + "git.cin.su/m/Assistant/contracts/work" + "git.cin.su/m/Assistant/modules/work/domain" + "git.cin.su/m/Assistant/modules/work/ports" +) + +func ExecuteDryRunDispatch(ctx context.Context, store ports.ExecutionStore, intent work.AgentDispatchIntent, workerID string, token work.AgentFencingToken, now time.Time, ttl time.Duration) (work.DryRunDispatch, error) { + dispatch, err := domain.BuildDryRunDispatch(intent, workerID, token, now, ttl) + if err != nil { + return work.DryRunDispatch{}, err + } + if err := store.SaveDispatchIntent(ctx, dispatch.Intent); err != nil { + return work.DryRunDispatch{}, err + } + if err := store.SaveAgentLease(ctx, dispatch.Lease); err != nil { + return work.DryRunDispatch{}, err + } + for _, callback := range dispatch.Callbacks { + if err := store.AcceptAgentCallback(ctx, callback); err != nil { + return work.DryRunDispatch{}, err + } + } + return dispatch, nil +} diff --git a/modules/work/ports/execution.go b/modules/work/ports/execution.go new file mode 100644 index 0000000..0dd711f --- /dev/null +++ b/modules/work/ports/execution.go @@ -0,0 +1,13 @@ +package ports + +import ( + "context" + + "git.cin.su/m/Assistant/contracts/work" +) + +type ExecutionStore interface { + SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error + SaveAgentLease(context.Context, work.AgentLease) error + AcceptAgentCallback(context.Context, work.AgentCallback) error +} diff --git a/tests/agent_execution/dry_run_orchestration_test.go b/tests/agent_execution/dry_run_orchestration_test.go new file mode 100644 index 0000000..282669b --- /dev/null +++ b/tests/agent_execution/dry_run_orchestration_test.go @@ -0,0 +1,40 @@ +package agent_execution_test + +import ( + "context" + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/work" + "git.cin.su/m/Assistant/modules/work/application" +) + +type recordingExecutionStore struct { + operations []string +} + +func (s *recordingExecutionStore) SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error { + s.operations = append(s.operations, "intent") + return nil +} +func (s *recordingExecutionStore) SaveAgentLease(context.Context, work.AgentLease) error { + s.operations = append(s.operations, "lease") + return nil +} +func (s *recordingExecutionStore) AcceptAgentCallback(context.Context, work.AgentCallback) error { + s.operations = append(s.operations, "callback") + return nil +} + +func TestExecuteDryRunDispatchPersistsInOrder(t *testing.T) { + now := time.UnixMilli(1_700_000_000_000) + intent := work.AgentDispatchIntent{DispatchID: "dispatch-1", IdempotencyKey: "idem-1", DelegationID: "delegation-1", RunID: "run-1", ImplementationID: "fake-worker", ImplementationRevision: 1, SourceRevision: "source-1", WorktreeID: "worktree-1", VerificationPlanRef: "verify-1", CreatedAtUnixMillis: now.UnixMilli()} + store := &recordingExecutionStore{} + result, err := application.ExecuteDryRunDispatch(context.Background(), store, intent, "worker-1", 7, now, 15*time.Minute) + if err != nil { + t.Fatal(err) + } + if result.CallbackCount() != 2 || len(store.operations) != 4 || store.operations[0] != "intent" || store.operations[1] != "lease" || store.operations[2] != "callback" || store.operations[3] != "callback" { + t.Fatalf("unexpected persistence sequence: %#v", store.operations) + } +} -- 2.54.0 From d2338b148597bf9172fd2bebc8943b9e3ba2bd71 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:15:50 +0300 Subject: [PATCH 28/33] feat(work): gate agent canary dispatch --- dev/ACTIVE_TASK.json | 4 +- dev/tasks/DEV-000124-agent-canary-gate.json | 23 +++++++++ modules/work/application/canary.go | 35 +++++++++++++ tests/agent_execution/canary_test.go | 54 +++++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000124-agent-canary-gate.json create mode 100644 modules/work/application/canary.go create mode 100644 tests/agent_execution/canary_test.go diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index b098dbd..92c2015 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000123-agent-dry-run-orchestration.json", - "task_id": "DEV-000123", + "task_file": "dev/tasks/DEV-000124-agent-canary-gate.json", + "task_id": "DEV-000124", "task_revision": 1 } diff --git a/dev/tasks/DEV-000124-agent-canary-gate.json b/dev/tasks/DEV-000124-agent-canary-gate.json new file mode 100644 index 0000000..e1adc17 --- /dev/null +++ b/dev/tasks/DEV-000124-agent-canary-gate.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000124", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "7f82240", + "risk_tier": "E", + "intent": "Add an explicit opt-in canary gate for the dry-run agent dispatcher with implementation allowlist and bounded dispatch count.", + "requirement_ids": ["WP-301", "Opt-in canary", "Bounded dispatch"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["modules/work/application/canary.go", "tests/agent_execution/canary_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000124-agent-canary-gate.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/**", "runtime/**", "adapters/**", "database/**", ".gitea/**"]}, + "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": ["DEV-000123 dry-run orchestration"], "may_change": ["Canary configuration and gate errors"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["Canary configuration is caller-owned; dispatch state remains Work-owned"], + "state_transitions": ["disabled/unauthorized/limited -> rejected; enabled and allowed -> dry-run orchestration"], + "side_effects_and_privileges": ["No side effect before all canary gates pass"], + "persistence_and_migrations": ["Reuses existing persistence; no migration"], + "failure_retry_restart": ["Caller receives typed gate failure; retries remain bounded by used dispatch count"], + "assumptions": ["Runtime wiring and production configuration are separate follow-up work"], + "acceptance_scenarios": ["Disabled canary writes nothing", "Wrong implementation writes nothing", "Limit is enforced", "Allowed canary persists four records"], + "required_tests": ["go test ./...", "go vet ./...", "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 run ./cmd/devctl/independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/application/canary.go b/modules/work/application/canary.go new file mode 100644 index 0000000..92caae1 --- /dev/null +++ b/modules/work/application/canary.go @@ -0,0 +1,35 @@ +package application + +import ( + "context" + "errors" + "time" + + "git.cin.su/m/Assistant/contracts/work" + "git.cin.su/m/Assistant/modules/work/ports" +) + +var ( + ErrCanaryDisabled = errors.New("agent canary is disabled") + ErrCanaryNotAllowed = errors.New("agent implementation is not canary-allowed") + ErrCanaryLimitReached = errors.New("agent canary dispatch limit reached") +) + +type CanaryConfig struct { + Enabled bool + ImplementationID string + MaxDispatches int +} + +func ExecuteCanaryDryRun(ctx context.Context, store ports.ExecutionStore, config CanaryConfig, usedDispatches int, intent work.AgentDispatchIntent, workerID string, token work.AgentFencingToken, now time.Time, ttl time.Duration) (work.DryRunDispatch, error) { + if !config.Enabled { + return work.DryRunDispatch{}, ErrCanaryDisabled + } + if config.ImplementationID == "" || intent.ImplementationID != config.ImplementationID { + return work.DryRunDispatch{}, ErrCanaryNotAllowed + } + if config.MaxDispatches <= 0 || usedDispatches >= config.MaxDispatches { + return work.DryRunDispatch{}, ErrCanaryLimitReached + } + return ExecuteDryRunDispatch(ctx, store, intent, workerID, token, now, ttl) +} diff --git a/tests/agent_execution/canary_test.go b/tests/agent_execution/canary_test.go new file mode 100644 index 0000000..8463bb2 --- /dev/null +++ b/tests/agent_execution/canary_test.go @@ -0,0 +1,54 @@ +package agent_execution_test + +import ( + "context" + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/work" + "git.cin.su/m/Assistant/modules/work/application" +) + +type canaryStore struct{ writes int } + +func (s *canaryStore) SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error { + s.writes++ + return nil +} +func (s *canaryStore) SaveAgentLease(context.Context, work.AgentLease) error { s.writes++; return nil } +func (s *canaryStore) AcceptAgentCallback(context.Context, work.AgentCallback) error { + s.writes++ + return nil +} + +func canaryIntent() work.AgentDispatchIntent { + return work.AgentDispatchIntent{DispatchID: "dispatch-1", IdempotencyKey: "idem-1", DelegationID: "delegation-1", RunID: "run-1", ImplementationID: "fake-worker", ImplementationRevision: 1, SourceRevision: "source-1", WorktreeID: "worktree-1", VerificationPlanRef: "verify-1", CreatedAtUnixMillis: 1_700_000_000_000} +} + +func TestCanaryRequiresExplicitEnablementAndAllowlist(t *testing.T) { + store := &canaryStore{} + config := application.CanaryConfig{Enabled: false, ImplementationID: "fake-worker", MaxDispatches: 1} + _, err := application.ExecuteCanaryDryRun(context.Background(), store, config, 0, canaryIntent(), "worker-1", 7, time.UnixMilli(1_700_000_000_000), time.Minute) + if err != application.ErrCanaryDisabled || store.writes != 0 { + t.Fatalf("disabled canary wrote state or returned wrong error: %v/%d", err, store.writes) + } + config.Enabled = true + config.ImplementationID = "other-worker" + _, err = application.ExecuteCanaryDryRun(context.Background(), store, config, 0, canaryIntent(), "worker-1", 7, time.UnixMilli(1_700_000_000_000), time.Minute) + if err != application.ErrCanaryNotAllowed || store.writes != 0 { + t.Fatalf("unallowed canary wrote state or returned wrong error: %v/%d", err, store.writes) + } +} + +func TestCanaryHonorsDispatchLimit(t *testing.T) { + store := &canaryStore{} + config := application.CanaryConfig{Enabled: true, ImplementationID: "fake-worker", MaxDispatches: 1} + _, err := application.ExecuteCanaryDryRun(context.Background(), store, config, 1, canaryIntent(), "worker-1", 7, time.UnixMilli(1_700_000_000_000), time.Minute) + if err != application.ErrCanaryLimitReached || store.writes != 0 { + t.Fatalf("limited canary wrote state or returned wrong error: %v/%d", err, store.writes) + } + _, err = application.ExecuteCanaryDryRun(context.Background(), store, config, 0, canaryIntent(), "worker-1", 7, time.UnixMilli(1_700_000_000_000), time.Minute) + if err != nil || store.writes != 4 { + t.Fatalf("enabled canary did not persist expected cycle: %v/%d", err, store.writes) + } +} -- 2.54.0 From c30e0c72ead45979db836c95bbff6639b978e380 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:18:19 +0300 Subject: [PATCH 29/33] feat(work): add manual agent canary cli --- cmd/agent-canary/main.go | 50 ++++++++++++++++++++++ dev/ACTIVE_TASK.json | 4 +- dev/tasks/DEV-000125-agent-canary-cli.json | 23 ++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 cmd/agent-canary/main.go create mode 100644 dev/tasks/DEV-000125-agent-canary-cli.json diff --git a/cmd/agent-canary/main.go b/cmd/agent-canary/main.go new file mode 100644 index 0000000..6034b48 --- /dev/null +++ b/cmd/agent-canary/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "time" + + "git.cin.su/m/Assistant/contracts/work" + "git.cin.su/m/Assistant/modules/work/application" +) + +type memoryStore struct{ writes int } + +func (s *memoryStore) SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error { + s.writes++ + return nil +} +func (s *memoryStore) SaveAgentLease(context.Context, work.AgentLease) error { s.writes++; return nil } +func (s *memoryStore) AcceptAgentCallback(context.Context, work.AgentCallback) error { + s.writes++ + return nil +} + +func main() { + enabled := flag.Bool("enable-canary", false, "explicitly enable the manual canary") + implementation := flag.String("implementation", "fake-worker", "allowlisted deterministic implementation") + flag.Parse() + + now := time.UnixMilli(1_700_000_000_000) + intent := work.AgentDispatchIntent{DispatchID: "manual-dispatch-1", IdempotencyKey: "manual-idempotency-1", DelegationID: "manual-delegation-1", RunID: "manual-run-1", ImplementationID: *implementation, ImplementationRevision: 1, SourceRevision: "manual-source-1", WorktreeID: "manual-worktree-1", VerificationPlanRef: "manual-verification-1", CreatedAtUnixMillis: now.UnixMilli()} + store := &memoryStore{} + result, err := application.ExecuteCanaryDryRun(context.Background(), store, application.CanaryConfig{Enabled: *enabled, ImplementationID: "fake-worker", MaxDispatches: 1}, 0, intent, "manual-worker", 1, now, 15*time.Minute) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + output := struct { + DispatchID string `json:"dispatch_id"` + RunID string `json:"run_id"` + CallbackCount int `json:"callback_count"` + StoreWrites int `json:"store_writes"` + }{result.Intent.DispatchID, string(result.Intent.RunID), result.CallbackCount(), store.writes} + if err := json.NewEncoder(os.Stdout).Encode(output); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 92c2015..1a91cc4 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000124-agent-canary-gate.json", - "task_id": "DEV-000124", + "task_file": "dev/tasks/DEV-000125-agent-canary-cli.json", + "task_id": "DEV-000125", "task_revision": 1 } diff --git a/dev/tasks/DEV-000125-agent-canary-cli.json b/dev/tasks/DEV-000125-agent-canary-cli.json new file mode 100644 index 0000000..567ae00 --- /dev/null +++ b/dev/tasks/DEV-000125-agent-canary-cli.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000125", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "d2338b1", + "risk_tier": "E", + "intent": "Add a manually invoked CLI harness for the opt-in deterministic agent canary using an in-memory store.", + "requirement_ids": ["WP-301", "Manual canary entrypoint", "Dry-run verification"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["cmd/agent-canary/main.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000125-agent-canary-cli.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "runtime/**", "adapters/**", "database/**", ".gitea/**"]}, + "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": ["DEV-000124 canary gate"], "may_change": ["Manual CLI entrypoint"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["No durable state; in-memory canary evidence only"], + "state_transitions": ["CLI flags -> canary gate -> deterministic callbacks -> JSON summary"], + "side_effects_and_privileges": ["No network, database, deployment or production mutation"], + "persistence_and_migrations": ["Explicitly does not use production repository"], + "failure_retry_restart": ["Disabled or unauthorized invocation exits nonzero without writes"], + "assumptions": ["Real WorkRepository wiring requires a separate approved runtime task"], + "acceptance_scenarios": ["Default invocation is rejected", "Explicit enablement produces JSON summary", "No secrets or external connections are required"], + "required_tests": ["go test ./...", "go vet ./...", "go run ./cmd/agent-canary", "go run ./cmd/agent-canary -enable-canary", "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 run ./cmd/devctl/independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} -- 2.54.0 From 8e26826ae2a615a12d94d8b1460ca78796609d19 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:20:14 +0300 Subject: [PATCH 30/33] feat(work): wire canary cli to work repository --- cmd/agent-canary/main.go | 23 +++++++++++++++++-- dev/ACTIVE_TASK.json | 4 ++-- .../DEV-000126-agent-canary-repository.json | 23 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 dev/tasks/DEV-000126-agent-canary-repository.json diff --git a/cmd/agent-canary/main.go b/cmd/agent-canary/main.go index 6034b48..5fc397b 100644 --- a/cmd/agent-canary/main.go +++ b/cmd/agent-canary/main.go @@ -8,8 +8,10 @@ import ( "os" "time" + "git.cin.su/m/Assistant/adapters/postgres" "git.cin.su/m/Assistant/contracts/work" "git.cin.su/m/Assistant/modules/work/application" + "git.cin.su/m/Assistant/modules/work/ports" ) type memoryStore struct{ writes int } @@ -27,11 +29,25 @@ func (s *memoryStore) AcceptAgentCallback(context.Context, work.AgentCallback) e func main() { enabled := flag.Bool("enable-canary", false, "explicitly enable the manual canary") implementation := flag.String("implementation", "fake-worker", "allowlisted deterministic implementation") + postgresMode := flag.Bool("postgres", false, "use the Work PostgreSQL repository instead of in-memory storage") flag.Parse() now := time.UnixMilli(1_700_000_000_000) intent := work.AgentDispatchIntent{DispatchID: "manual-dispatch-1", IdempotencyKey: "manual-idempotency-1", DelegationID: "manual-delegation-1", RunID: "manual-run-1", ImplementationID: *implementation, ImplementationRevision: 1, SourceRevision: "manual-source-1", WorktreeID: "manual-worktree-1", VerificationPlanRef: "manual-verification-1", CreatedAtUnixMillis: now.UnixMilli()} - store := &memoryStore{} + var store ports.ExecutionStore = &memoryStore{} + var closeStore func() + if *postgresMode { + repository, err := postgres.OpenWork(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: os.Getenv("WORK_POSTGRES_PASSWORD")}) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + store = repository + closeStore = repository.Close + } + if closeStore != nil { + defer closeStore() + } result, err := application.ExecuteCanaryDryRun(context.Background(), store, application.CanaryConfig{Enabled: *enabled, ImplementationID: "fake-worker", MaxDispatches: 1}, 0, intent, "manual-worker", 1, now, 15*time.Minute) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -42,7 +58,10 @@ func main() { RunID string `json:"run_id"` CallbackCount int `json:"callback_count"` StoreWrites int `json:"store_writes"` - }{result.Intent.DispatchID, string(result.Intent.RunID), result.CallbackCount(), store.writes} + }{result.Intent.DispatchID, string(result.Intent.RunID), result.CallbackCount(), 0} + if memory, ok := store.(*memoryStore); ok { + output.StoreWrites = memory.writes + } if err := json.NewEncoder(os.Stdout).Encode(output); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 1a91cc4..8884878 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000125-agent-canary-cli.json", - "task_id": "DEV-000125", + "task_file": "dev/tasks/DEV-000126-agent-canary-repository.json", + "task_id": "DEV-000126", "task_revision": 1 } diff --git a/dev/tasks/DEV-000126-agent-canary-repository.json b/dev/tasks/DEV-000126-agent-canary-repository.json new file mode 100644 index 0000000..c19f925 --- /dev/null +++ b/dev/tasks/DEV-000126-agent-canary-repository.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000126", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "c30e0c7", + "risk_tier": "E", + "intent": "Wire the manual agent canary CLI to the existing Work PostgreSQL repository behind an explicit flag while preserving in-memory default behavior.", + "requirement_ids": ["WP-301", "Repository canary wiring", "Credential-safe adapter use"], + "allowed_change_scope": {"modules": ["work-agent-execution", "work-storage"], "paths": ["cmd/agent-canary/main.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000126-agent-canary-repository.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "runtime/**", "database/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 1, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["DEV-000125 manual canary CLI", "DEV-000121 WorkRepository methods"], "may_change": ["Manual CLI storage selection"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["WorkRepository remains canonical for postgres mode"], + "state_transitions": ["CLI -postgres -> OpenWork -> canary coordinator -> repository writes"], + "side_effects_and_privileges": ["Only explicit -postgres mode connects; credentials come from existing environment variables and are never printed"], + "persistence_and_migrations": ["Requires migration 029 to be applied separately; this task does not apply migrations"], + "failure_retry_restart": ["Connection or missing migration errors are returned; no automatic retry or deployment"], + "assumptions": ["Default mode remains in-memory and production entrypoints are unchanged"], + "acceptance_scenarios": ["Default mode remains deterministic", "Postgres mode uses Work repository through ExecutionStore", "No secret values appear in output"], + "required_tests": ["go test ./...", "go vet ./...", "go run ./cmd/agent-canary", "go run ./cmd/agent-canary -enable-canary", "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 run ./cmd/devctl/independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_MIGRATION_DECISION", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} -- 2.54.0 From 875286b9fcad36d2e0fd47a6dd4a15ad7b224bd1 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:34:18 +0300 Subject: [PATCH 31/33] chore(governance): authorize work migration canary --- dev/ACTIVE_TASK.json | 4 ++-- ...00127-apply-agent-execution-migration.json | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000127-apply-agent-execution-migration.json diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 8884878..0a3b8a3 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000126-agent-canary-repository.json", - "task_id": "DEV-000126", + "task_file": "dev/tasks/DEV-000127-apply-agent-execution-migration.json", + "task_id": "DEV-000127", "task_revision": 1 } diff --git a/dev/tasks/DEV-000127-apply-agent-execution-migration.json b/dev/tasks/DEV-000127-apply-agent-execution-migration.json new file mode 100644 index 0000000..77f46f4 --- /dev/null +++ b/dev/tasks/DEV-000127-apply-agent-execution-migration.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000127", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "8e26826", + "risk_tier": "E", + "intent": "Apply the already reviewed additive Work migration 029 on the canonical docker-prod PostgreSQL instance for controlled canary preparation.", + "requirement_ids": ["WP-301", "Migration canary", "Durable execution readiness"], + "allowed_change_scope": {"modules": ["work-storage"], "paths": ["dev/ACTIVE_TASK.json", "dev/tasks/DEV-000127-apply-agent-execution-migration.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "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": ["DEV-000121 migration 029", "DEV-000126 repository wiring"], "may_change": ["Canonical production Work schema state"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["docker-prod assistant PostgreSQL work schema"], + "state_transitions": ["tables absent -> tables present and verified"], + "side_effects_and_privileges": ["One additive transactional migration through existing bootstrap/superuser path; no secrets printed"], + "persistence_and_migrations": ["Apply database/migrations/029_work_agent_execution_persistence.sql exactly once"], + "failure_retry_restart": ["ON CONFLICT-safe DDL; ON_ERROR_STOP; verify before any retry"], + "assumptions": ["docker-prod is the canonical target", "migration file is already committed and reviewed", "runtime remains disabled until a later canary"], + "acceptance_scenarios": ["All three Work tables exist after apply", "No non-Work schema changes", "Existing production containers remain running"], + "required_tests": ["pre-apply table absence evidence", "migration apply exit 0", "post-apply table existence verification", "docker-prod container health read-only check", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_MIGRATION_DECISION", "BLOCKED_BY_CONFLICT"] +} -- 2.54.0 From 790fd7e7686551b4fff91fbbee967819c557b563 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:36:00 +0300 Subject: [PATCH 32/33] chore(governance): complete migration canary evidence --- dev/tasks/DEV-000127-apply-agent-execution-migration.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/tasks/DEV-000127-apply-agent-execution-migration.json b/dev/tasks/DEV-000127-apply-agent-execution-migration.json index 77f46f4..4a0eb34 100644 --- a/dev/tasks/DEV-000127-apply-agent-execution-migration.json +++ b/dev/tasks/DEV-000127-apply-agent-execution-migration.json @@ -18,6 +18,6 @@ "failure_retry_restart": ["ON CONFLICT-safe DDL; ON_ERROR_STOP; verify before any retry"], "assumptions": ["docker-prod is the canonical target", "migration file is already committed and reviewed", "runtime remains disabled until a later canary"], "acceptance_scenarios": ["All three Work tables exist after apply", "No non-Work schema changes", "Existing production containers remain running"], - "required_tests": ["pre-apply table absence evidence", "migration apply exit 0", "post-apply table existence verification", "docker-prod container health read-only check", "git diff --check"], + "required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "pre-apply table absence evidence", "migration apply exit 0", "post-apply table existence verification", "docker-prod container health read-only check", "git diff --check"], "terminal_gap_states": ["BLOCKED_BY_MIGRATION_DECISION", "BLOCKED_BY_CONFLICT"] } -- 2.54.0 From 1b091a50f26f91c0307248d0c988e9facec876f4 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 12:37:33 +0300 Subject: [PATCH 33/33] feat(deploy): add opt-in agent canary profile --- deploy/docker-compose.prod.yml | 13 +++++++++++ dev/ACTIVE_TASK.json | 4 ++-- ...V-000128-agent-canary-compose-profile.json | 23 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000128-agent-canary-compose-profile.json diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 493795d..4478d7b 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -84,6 +84,19 @@ services: volumes: - assistant_core_secrets:/run/secrets:ro + agent-canary: + profiles: ["agent-canary"] + build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: agent-canary, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } } + restart: "no" + command: ["-enable-canary", "-postgres"] + environment: + POSTGRES_HOST: postgres + POSTGRES_PORT: "5432" + POSTGRES_DATABASE: ${POSTGRES_DB:-assistant} + WORK_POSTGRES_USER: work_role + WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD} + depends_on: { postgres: { condition: service_healthy } } + continuity-runtime: build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } } restart: unless-stopped diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 0a3b8a3..aeaa1e8 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000127-apply-agent-execution-migration.json", - "task_id": "DEV-000127", + "task_file": "dev/tasks/DEV-000128-agent-canary-compose-profile.json", + "task_id": "DEV-000128", "task_revision": 1 } diff --git a/dev/tasks/DEV-000128-agent-canary-compose-profile.json b/dev/tasks/DEV-000128-agent-canary-compose-profile.json new file mode 100644 index 0000000..42fe16f --- /dev/null +++ b/dev/tasks/DEV-000128-agent-canary-compose-profile.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000128", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "790fd7e", + "risk_tier": "E", + "intent": "Add an explicitly profiled production compose service for the manual agent canary container.", + "requirement_ids": ["WP-301", "Canary container profile", "Disabled-by-default runtime"], + "allowed_change_scope": {"modules": ["work-agent-execution", "production-compose"], "paths": ["deploy/docker-compose.prod.yml", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000128-agent-canary-compose-profile.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["DEV-000126 agent-canary postgres mode", "DEV-000127 migration 029"], "may_change": ["Production compose canary profile"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["No new state; uses Work schema"], + "state_transitions": ["profile absent -> explicit profile service available; default stack unchanged"], + "side_effects_and_privileges": ["Credential environment is injected by deployment system; service is disabled unless profile is explicitly selected"], + "persistence_and_migrations": ["Requires migration 029 already present; no migration in this task"], + "failure_retry_restart": ["restart=no; one bounded canary invocation"], + "assumptions": ["Deployment of this profile is a separate approved canary action", "No normal service depends on agent-canary"], + "acceptance_scenarios": ["Compose validates", "Default config does not start agent-canary", "Profile builds target cmd/agent-canary", "No secret literal is added"], + "required_tests": ["go test ./...", "go vet ./...", "docker compose -f deploy/docker-compose.prod.yml config --quiet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_DEPLOYMENT_DECISION", "BLOCKED_BY_CONFLICT"] +} -- 2.54.0