diff --git a/cmd/agent-execution-bridge/main.go b/cmd/agent-execution-bridge/main.go index a1aba34..df8c29e 100644 --- a/cmd/agent-execution-bridge/main.go +++ b/cmd/agent-execution-bridge/main.go @@ -135,9 +135,20 @@ func (b bridge) executeGenericCognitiveWork(ctx context.Context, request work.Ag if response.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(response.AdvisoryText) == "" { return work.AgentExecutionResult{}, fmt.Errorf("generic cognitive work did not complete: %s", response.FinishReason) } + candidateID := "candidate:" + request.Intent.DispatchID + artifactRef := work.EvidenceRef("agent-output:" + request.Intent.DispatchID) + verification, err := postJSON[work.GenericCandidateVerificationResponse](ctx, b.client, b.verifierURL+"/v1/work-graphs/candidates/verify", work.GenericCandidateVerificationRequest{ + GraphID: execution.GraphID, GraphRevision: execution.GraphRevision, NodeID: execution.NodeID, AuthorRunID: request.Intent.RunID, CandidateID: candidateID, Summary: response.AdvisoryText, EvidenceRefs: []work.EvidenceRef{artifactRef}, + }) + if err != nil { + return work.AgentExecutionResult{}, err + } + if !verification.Verified || verification.VerificationEvidenceRef == "" || verification.VerifierIdentity == "" { + return work.AgentExecutionResult{State: work.AgentExecutionWaiting, Summary: "generic candidate envelope was not independently verified; work remains waiting", ObservedAtUnixMillis: time.Now().UnixMilli()}, nil + } candidate := work.GraphResultCallback{GraphID: execution.GraphID, Revision: execution.GraphRevision, Candidate: work.ResultCandidate{ - ID: "candidate:" + request.Intent.DispatchID, GraphID: execution.GraphID, GraphRevision: execution.GraphRevision, NodeID: execution.NodeID, - AuthorRunID: request.Intent.RunID, ArtifactRefs: []work.EvidenceRef{work.EvidenceRef("agent-output:" + request.Intent.DispatchID)}, EvidenceSummary: "cognitive gateway output recorded as an unverified candidate", + ID: candidateID, GraphID: execution.GraphID, GraphRevision: execution.GraphRevision, NodeID: execution.NodeID, + AuthorRunID: request.Intent.RunID, ArtifactRefs: []work.EvidenceRef{artifactRef, work.EvidenceRef(verification.VerificationEvidenceRef)}, EvidenceSummary: "candidate attribution and evidence envelope independently verified; semantic content remains unverified", Summary: response.AdvisoryText, }} if _, err := postJSON[json.RawMessage](ctx, b.client, b.callbackURL+"/v1/work-graphs/result-callback", candidate); err != nil { diff --git a/cmd/agent-execution-bridge/main_test.go b/cmd/agent-execution-bridge/main_test.go index 7f8c3b8..5c653ed 100644 --- a/cmd/agent-execution-bridge/main_test.go +++ b/cmd/agent-execution-bridge/main_test.go @@ -70,17 +70,19 @@ func TestBridgeRunsGenericCognitiveWorkAsUnverifiedGraphCandidate(t *testing.T) t.Fatal(err) } w.WriteHeader(http.StatusNoContent) + case "/v1/work-graphs/candidates/verify": + _ = json.NewEncoder(w).Encode(work.GenericCandidateVerificationResponse{Verified: true, VerificationEvidenceRef: "verification:generic-1", VerifierIdentity: "deterministic-generic-envelope-verifier-v1", Summary: "envelope verified"}) default: http.NotFound(w, r) } })) defer server.Close() - b := bridge{cognitiveURL: server.URL, callbackURL: server.URL, client: server.Client()} + b := bridge{cognitiveURL: server.URL, callbackURL: server.URL, verifierURL: server.URL, client: server.Client()} result, err := b.execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{ DispatchID: "dispatch-generic", RunID: "run-generic", TaskEvaluationExecution: &work.TaskEvaluationDispatchContext{GraphID: "graph-1", GraphRevision: 2, NodeID: "node-1", ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1", GenericMessage: "find the bounded solution", GenericPurpose: "investigate", GenericDataPolicy: string(capability.DataPolicyLocalOnly)}, }}) - if err != nil || result.State != work.AgentExecutionCompleted || callback.Candidate.Summary != "candidate answer" || callback.Candidate.AuthorRunID != "run-generic" { + if err != nil || result.State != work.AgentExecutionCompleted || callback.Candidate.Summary != "candidate answer" || callback.Candidate.AuthorRunID != "run-generic" || len(callback.Candidate.ArtifactRefs) != 2 { t.Fatalf("result=%+v callback=%+v err=%v", result, callback, err) } } diff --git a/cmd/task-verifier/main.go b/cmd/task-verifier/main.go index ddfb906..236fa99 100644 --- a/cmd/task-verifier/main.go +++ b/cmd/task-verifier/main.go @@ -10,9 +10,11 @@ import ( "strings" "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/work" ) const verifierIdentity = "deterministic-task-verifier-v1" +const genericVerifierIdentity = "deterministic-generic-envelope-verifier-v1" func main() { mux := http.NewServeMux() @@ -21,6 +23,7 @@ func main() { _, _ = w.Write([]byte(`{"runtime":"task-verifier","status":"ok"}`)) }) mux.HandleFunc("POST /v1/task-evaluations/verify", verifyHandler) + mux.HandleFunc("POST /v1/work-graphs/candidates/verify", genericCandidateVerifyHandler) addr := ":8091" if value := strings.TrimSpace(os.Getenv("LISTEN_ADDR")); value != "" { addr = value @@ -30,6 +33,27 @@ func main() { } } +func genericCandidateVerifyHandler(w http.ResponseWriter, r *http.Request) { + var request work.GenericCandidateVerificationRequest + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil || request.GraphID == "" || request.GraphRevision < 1 || request.NodeID == "" || request.AuthorRunID == "" || request.CandidateID == "" || strings.TrimSpace(request.Summary) == "" || len(request.EvidenceRefs) == 0 { + http.Error(w, "invalid generic candidate envelope", http.StatusBadRequest) + return + } + payload := fmt.Sprintf("%s|%s|%d|%s|%s|%s|%s", genericVerifierIdentity, request.GraphID, request.GraphRevision, request.NodeID, request.AuthorRunID, request.CandidateID, strings.Join(evidenceRefStrings(request.EvidenceRefs), "|")) + digest := sha256.Sum256([]byte(payload)) + writeJSON(w, work.GenericCandidateVerificationResponse{Verified: true, VerificationEvidenceRef: "verification:" + hex.EncodeToString(digest[:]), VerifierIdentity: genericVerifierIdentity, Summary: "candidate attribution and evidence envelope verified; semantic content remains unverified"}) +} + +func evidenceRefStrings(values []work.EvidenceRef) []string { + result := make([]string, len(values)) + for i, value := range values { + result[i] = string(value) + } + return result +} + func verifyHandler(w http.ResponseWriter, r *http.Request) { var request capability.TaskCaseVerificationRequest decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)) diff --git a/cmd/task-verifier/main_test.go b/cmd/task-verifier/main_test.go index 0662415..fe28d39 100644 --- a/cmd/task-verifier/main_test.go +++ b/cmd/task-verifier/main_test.go @@ -5,9 +5,11 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/work" ) func TestVerifyExactTextProducesIndependentEvidence(t *testing.T) { @@ -39,3 +41,13 @@ func TestVerifyJSONFieldEquals(t *testing.T) { t.Fatalf("unexpected JSON verification response: code=%d body=%s value=%+v", recorder.Code, recorder.Body.String(), response) } } + +func TestVerifyGenericCandidateEnvelopeDoesNotClaimTruth(t *testing.T) { + payload, _ := json.Marshal(work.GenericCandidateVerificationRequest{GraphID: "graph-1", GraphRevision: 2, NodeID: "node-1", AuthorRunID: "run-1", CandidateID: "candidate-1", Summary: "model proposal", EvidenceRefs: []work.EvidenceRef{"agent-output:1"}}) + recorder := httptest.NewRecorder() + genericCandidateVerifyHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/work-graphs/candidates/verify", bytes.NewReader(payload))) + var response work.GenericCandidateVerificationResponse + if recorder.Code != http.StatusOK || json.NewDecoder(recorder.Body).Decode(&response) != nil || !response.Verified || response.VerifierIdentity == "" || response.VerificationEvidenceRef == "" || !strings.Contains(response.Summary, "semantic content remains unverified") { + t.Fatalf("unexpected generic verification response: code=%d body=%s value=%+v", recorder.Code, recorder.Body.String(), response) + } +} diff --git a/contracts/work/graph.go b/contracts/work/graph.go index cabea08..bd656e7 100644 --- a/contracts/work/graph.go +++ b/contracts/work/graph.go @@ -113,6 +113,26 @@ type ResultCandidate struct { Summary string `json:"summary"` } +// GenericCandidateVerificationRequest asks the independent deterministic +// verifier to validate attribution and evidence-envelope integrity. It does +// not assert that the candidate's semantic content is true. +type GenericCandidateVerificationRequest struct { + GraphID string `json:"graph_id"` + GraphRevision int64 `json:"graph_revision"` + NodeID GraphNodeID `json:"node_id"` + AuthorRunID AgentRunID `json:"author_run_id"` + CandidateID string `json:"candidate_id"` + Summary string `json:"summary"` + EvidenceRefs []EvidenceRef `json:"evidence_refs"` +} + +type GenericCandidateVerificationResponse struct { + Verified bool `json:"verified"` + VerificationEvidenceRef string `json:"verification_evidence_ref"` + VerifierIdentity string `json:"verifier_identity"` + Summary string `json:"summary"` +} + // GraphResultCallback carries one agent's attributable candidate to Work. // Recording a candidate never makes the node successful; the node enters // VERIFYING until an independent synthesis/verifier accepts it. diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 266063e..5bf8f7d 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000282-final-auto-deploy-evidence.json", - "task_id": "DEV-000282", + "task_file": "dev/tasks/DEV-000283-generic-candidate-verifier.json", + "task_id": "DEV-000283", "task_revision": 1 } diff --git a/dev/tasks/DEV-000283-generic-candidate-verifier.json b/dev/tasks/DEV-000283-generic-candidate-verifier.json new file mode 100644 index 0000000..97ef73d --- /dev/null +++ b/dev/tasks/DEV-000283-generic-candidate-verifier.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000283", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "94baf98", + "risk_tier": "E", + "intent": "Add an independent deterministic envelope verifier before a generic local Work Graph candidate is recorded.", + "requirement_ids": ["independent candidate envelope verification", "model output is not truth", "graph success still requires synthesis"], + "allowed_change_scope": {"modules": ["work"], "paths": ["dev/ACTIVE_TASK.json", "dev/tasks/DEV-000283-generic-candidate-verifier.json", "contracts/work/graph.go", "cmd/task-verifier/**", "cmd/agent-execution-bridge/**"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "modules/identity/**", "modules/authority/**", "LLM-local/**", "deploy/**"]}, + "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": ["contracts/work@v1", "task-verifier internal endpoint", "work graph result callback"], "may_change": ["contracts/work graph candidate verification payload"]}, + "semantic_owner": "work", + "canonical_state": ["verification evidence is an integrity envelope only; candidate remains unverified semantically"], + "state_transitions": ["generic model output -> deterministic envelope verification -> candidate recording -> VERIFYING"], + "side_effects_and_privileges": ["local verifier call and work graph callback; no external mutation"], + "persistence_and_migrations": ["reuse existing candidate evidence refs; no migration"], + "failure_retry_restart": ["verifier unavailable returns retryable failure", "invalid envelope returns WAITING", "graph success remains gated by independent synthesis"], + "assumptions": ["deterministic verification proves attribution/integrity only, not factual truth", "task-verifier is an independently running local service"], + "acceptance_scenarios": ["valid generic candidate obtains verifier evidence", "invalid candidate is not recorded as completed", "model output remains unverified", "existing task evaluation path remains unchanged"], + "required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY"] +}