From 38f8f816d5901c7e89a4786e91c9bf5c8f767c07 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 23 Aug 2026 01:43:35 +0300 Subject: [PATCH] feat: gate graph synthesis on independent verification --- contracts/work/graph.go | 22 ++++++++ dev/ACTIVE_TASK.json | 4 +- ...-graph-synthesis-independent-verifier.json | 23 +++++++++ modules/work/domain/graph_scheduler.go | 51 ++++++++++++++++++- tests/agent_execution/graph_test.go | 15 ++++++ 5 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 dev/tasks/DEV-000188-graph-synthesis-independent-verifier.json diff --git a/contracts/work/graph.go b/contracts/work/graph.go index f6b58bf..19cbd15 100644 --- a/contracts/work/graph.go +++ b/contracts/work/graph.go @@ -71,3 +71,25 @@ type ConflictSet struct { CandidateIDs []string Resolved bool } + +type GraphSynthesisState string + +const GraphSynthesisVerified GraphSynthesisState = "VERIFIED" + +// GraphSynthesisCandidate retains every competing result while recording the +// independently verified selection. It is a candidate, not an external action +// or authority grant. +type GraphSynthesisCandidate struct { + ID string + GraphID string + GraphRevision int64 + NodeID GraphNodeID + CandidateIDs []string + SelectedCandidateID string + DissentCandidateIDs []string + VerifierRunID AgentRunID + VerifierIdentity string + EvidenceRefs []EvidenceRef + Summary string + State GraphSynthesisState +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index ac653cb..b3729c7 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000187-scaffold-task-packet-candidate.json", - "task_id": "DEV-000187", + "task_file": "dev/tasks/DEV-000188-graph-synthesis-independent-verifier.json", + "task_id": "DEV-000188", "task_revision": 1 } diff --git a/dev/tasks/DEV-000188-graph-synthesis-independent-verifier.json b/dev/tasks/DEV-000188-graph-synthesis-independent-verifier.json new file mode 100644 index 0000000..5adb0fa --- /dev/null +++ b/dev/tasks/DEV-000188-graph-synthesis-independent-verifier.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000188", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "4d9d1b8", + "risk_tier": "E", + "intent": "Add typed Work Graph synthesis and independent-verifier gating so conflicting node results remain attributable and cannot be resolved by the producing run.", + "requirement_ids": ["WP-403", "ResultCandidate conflict retention", "Synthesis candidate", "Independent graph verifier"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["contracts/work/graph.go", "modules/work/domain/graph_scheduler.go", "tests/agent_execution/graph_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000188-graph-synthesis-independent-verifier.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "cmd/**", "adapters/**", "deploy/**", "contracts/authority/**", ".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 WorkGraph, ResultCandidate and ConflictSet contracts", "IndependentVerification semantics"], "may_change": ["typed GraphSynthesisCandidate contract"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["No new persistence; typed graph synthesis candidate only"], + "state_transitions": ["conflicting candidates -> attributable synthesis candidate", "verified independent synthesis -> resolved conflict", "failed/forged/self-verification -> rejected"], + "side_effects_and_privileges": ["No external mutation, provider call, deployment, or authority expansion"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Dissent and candidate lineage are retained; verifier must differ from every producer; rejected synthesis does not resolve conflict"], + "assumptions": ["Durable persistence and runtime orchestration already exist and are unchanged", "A later runtime task may persist this typed result"], + "acceptance_scenarios": ["conflicting candidates remain attributable", "same producer cannot verify synthesis", "unknown candidate or missing evidence fails closed", "successful independent verification retains dissent and resolves conflict"], + "required_tests": ["go test ./tests/agent_execution", "go vet ./...", "go run ./cmd/devctl preflight -task dev/tasks/DEV-000188-graph-synthesis-independent-verifier.json", "go run ./cmd/devctl check-diff -task dev/tasks/DEV-000188-graph-synthesis-independent-verifier.json", "go run ./cmd/devctl check-architecture", "go run ./cmd/devctl independent", "git diff --check", "CI"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/domain/graph_scheduler.go b/modules/work/domain/graph_scheduler.go index ea5b4ca..25b2f39 100644 --- a/modules/work/domain/graph_scheduler.go +++ b/modules/work/domain/graph_scheduler.go @@ -8,10 +8,57 @@ import ( ) var ( - ErrGraphBudgetExceeded = errors.New("work graph budget exceeded") - ErrConflictUnresolved = errors.New("conflict resolution is not explicit") + ErrGraphBudgetExceeded = errors.New("work graph budget exceeded") + ErrConflictUnresolved = errors.New("conflict resolution is not explicit") + ErrGraphSynthesisRejected = errors.New("graph synthesis rejected") ) +// BuildVerifiedGraphSynthesis resolves a conflict only after an independent +// verifier has evaluated all candidates. The verifier may not be any result +// producer, and every candidate remains in the returned lineage. +func BuildVerifiedGraphSynthesis(conflict work.ConflictSet, candidates []work.ResultCandidate, selectedCandidate string, verifierRunID work.AgentRunID, verifierIdentity string, evidence []work.EvidenceRef, summary string, verified bool) (work.ConflictSet, work.GraphSynthesisCandidate, error) { + if conflict.ID == "" || conflict.GraphID == "" || conflict.GraphRevision < 1 || conflict.NodeID == "" || conflict.Resolved || len(conflict.CandidateIDs) < 2 || selectedCandidate == "" || verifierRunID == "" || verifierIdentity == "" || len(evidence) == 0 || summary == "" || !verified { + return work.ConflictSet{}, work.GraphSynthesisCandidate{}, ErrGraphSynthesisRejected + } + candidateByID := make(map[string]work.ResultCandidate, len(candidates)) + for _, candidate := range candidates { + if candidate.ID == "" || candidate.GraphID != conflict.GraphID || candidate.GraphRevision != conflict.GraphRevision || candidate.NodeID != conflict.NodeID || candidate.AuthorRunID == "" { + return work.ConflictSet{}, work.GraphSynthesisCandidate{}, ErrGraphSynthesisRejected + } + candidateByID[candidate.ID] = candidate + if candidate.AuthorRunID == verifierRunID { + return work.ConflictSet{}, work.GraphSynthesisCandidate{}, ErrGraphSynthesisRejected + } + } + ordered := append([]string(nil), conflict.CandidateIDs...) + for _, id := range ordered { + if _, ok := candidateByID[id]; !ok { + return work.ConflictSet{}, work.GraphSynthesisCandidate{}, ErrGraphSynthesisRejected + } + } + if !containsString(ordered, selectedCandidate) { + return work.ConflictSet{}, work.GraphSynthesisCandidate{}, ErrGraphSynthesisRejected + } + dissent := make([]string, 0, len(ordered)-1) + for _, id := range ordered { + if id != selectedCandidate { + dissent = append(dissent, id) + } + } + conflict.Resolved = true + synthesis := work.GraphSynthesisCandidate{ID: conflict.ID + ":synthesis", GraphID: conflict.GraphID, GraphRevision: conflict.GraphRevision, NodeID: conflict.NodeID, CandidateIDs: ordered, SelectedCandidateID: selectedCandidate, DissentCandidateIDs: dissent, VerifierRunID: verifierRunID, VerifierIdentity: verifierIdentity, EvidenceRefs: append([]work.EvidenceRef(nil), evidence...), Summary: summary, State: work.GraphSynthesisVerified} + return conflict, synthesis, nil +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + func ScheduleReadyNodes(graph work.WorkGraph, active int) ([]work.GraphNodeID, error) { if err := ValidateWorkGraph(graph); err != nil { return nil, err diff --git a/tests/agent_execution/graph_test.go b/tests/agent_execution/graph_test.go index cc959fd..13bd1a5 100644 --- a/tests/agent_execution/graph_test.go +++ b/tests/agent_execution/graph_test.go @@ -56,3 +56,18 @@ func TestGraphSchedulerEnforcesParallelBudgetAndExplicitConflict(t *testing.T) { t.Fatalf("unknown candidate was accepted: %v", err) } } + +func TestGraphSynthesisRequiresIndependentVerifierAndRetainsDissent(t *testing.T) { + conflict := work.ConflictSet{ID: "conflict-1", GraphID: "graph-1", GraphRevision: 1, NodeID: "node-1", CandidateIDs: []string{"candidate-a", "candidate-b"}} + candidates := []work.ResultCandidate{{ID: "candidate-a", GraphID: "graph-1", GraphRevision: 1, NodeID: "node-1", AuthorRunID: "producer-a", EvidenceSummary: "a"}, {ID: "candidate-b", GraphID: "graph-1", GraphRevision: 1, NodeID: "node-1", AuthorRunID: "producer-b", EvidenceSummary: "b"}} + resolved, synthesis, err := workdomain.BuildVerifiedGraphSynthesis(conflict, candidates, "candidate-a", "verifier-1", "independent-verifier", []work.EvidenceRef{"tests:1"}, "candidate a is supported", true) + if err != nil || !resolved.Resolved || synthesis.SelectedCandidateID != "candidate-a" || len(synthesis.DissentCandidateIDs) != 1 || synthesis.VerifierRunID == candidates[0].AuthorRunID { + t.Fatalf("unexpected synthesis: conflict=%+v synthesis=%+v err=%v", resolved, synthesis, err) + } + if _, _, err := workdomain.BuildVerifiedGraphSynthesis(conflict, candidates, "candidate-a", "producer-a", "producer", []work.EvidenceRef{"tests:1"}, "forged", true); err != workdomain.ErrGraphSynthesisRejected { + t.Fatalf("producer must not verify its own result, got %v", err) + } + if _, _, err := workdomain.BuildVerifiedGraphSynthesis(conflict, candidates, "candidate-a", "verifier-1", "independent-verifier", []work.EvidenceRef{"tests:1"}, "failed", false); err != workdomain.ErrGraphSynthesisRejected { + t.Fatalf("failed verification must not resolve conflict, got %v", err) + } +} -- 2.54.0