feat: gate graph synthesis on independent verification #161
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user