feat: gate graph synthesis on independent verification #161

Merged
m merged 1 commits from codex/wp-403-conflict-synthesis into main 2026-08-22 22:45:19 +00:00
5 changed files with 111 additions and 4 deletions
+22
View File
@@ -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
}
+2 -2
View File
@@ -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
}
@@ -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"]
}
+49 -2
View File
@@ -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
+15
View File
@@ -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)
}
}