feat(research): add guarded repository evidence connector #112

Merged
m merged 2 commits from codex/wp-102-research-orchestration into main 2026-08-22 12:33:03 +00:00
7 changed files with 129 additions and 5 deletions
+63
View File
@@ -0,0 +1,63 @@
package research
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"git.cin.su/m/Assistant/contracts/epistemics"
)
var ErrRepositoryPathRejected = errors.New("repository source path rejected")
type RepositoryConnector struct{ Root string }
func (c RepositoryConnector) Collect(ctx context.Context, request epistemics.RepositoryResearchRequest, nowUnixMillis int64) (epistemics.ResearchCandidate, error) {
if err := request.Plan.Validate(); err != nil {
return epistemics.ResearchCandidate{}, err
}
if request.SourceID == "" || strings.TrimSpace(request.Statement) == "" || nowUnixMillis < 1 {
return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected
}
root, err := filepath.Abs(c.Root)
if err != nil || root == "" {
return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected
}
path, err := filepath.Abs(filepath.Join(root, filepath.FromSlash(request.Path)))
if err != nil || !withinRoot(root, path) {
return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected
}
select {
case <-ctx.Done():
return epistemics.ResearchCandidate{}, ctx.Err()
default:
}
content, err := os.ReadFile(path)
if err != nil || len(content) == 0 {
return epistemics.ResearchCandidate{}, fmt.Errorf("read repository source: %w", err)
}
digest := sha256.Sum256(content)
digestText := "sha256:" + hex.EncodeToString(digest[:])
key := sha256.Sum256([]byte(filepath.ToSlash(path) + "\x00" + digestText))
keyText := hex.EncodeToString(key[:])
return epistemics.ResearchCandidate{
PropositionKey: request.PropositionKey,
Statement: request.Statement,
Source: epistemics.Source{ID: request.SourceID, Kind: "repository", Locator: "file://" + filepath.ToSlash(path), Title: filepath.Base(path), Publisher: "local-repository"},
Snapshot: epistemics.SourceSnapshot{ID: epistemics.SourceSnapshotID("snapshot:" + keyText), SourceID: request.SourceID, ContentDigest: digestText, CapturedAtUnixMillis: nowUnixMillis, Freshness: epistemics.FreshnessCurrent},
Excerpt: epistemics.SourceExcerpt{ID: epistemics.SourceExcerptID("excerpt:" + keyText), SnapshotID: epistemics.SourceSnapshotID("snapshot:" + keyText), Text: string(content), Start: 0, End: int64(len(content))},
Evidence: epistemics.EvidenceObserved,
Coverage: epistemics.CoverageComplete,
Conflict: epistemics.ConflictNone,
}, nil
}
func withinRoot(root, path string) bool {
rel, err := filepath.Rel(root, path)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
}
+21
View File
@@ -10,6 +10,7 @@ import (
"time"
"git.cin.su/m/Assistant/adapters/postgres"
researchadapter "git.cin.su/m/Assistant/adapters/research"
"git.cin.su/m/Assistant/contracts/epistemics"
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
"git.cin.su/m/Assistant/modules/epistemics/application"
@@ -46,6 +47,26 @@ func main() {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
})
connector := researchadapter.RepositoryConnector{Root: os.Getenv("RESEARCH_SOURCE_ROOT")}
mux.HandleFunc("POST /v1/research/repository/evaluate", func(w http.ResponseWriter, r *http.Request) {
var request epistemics.RepositoryResearchRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&request); err != nil {
http.Error(w, "invalid repository research request", http.StatusBadRequest)
return
}
candidate, err := connector.Collect(r.Context(), request, time.Now().UnixMilli())
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
result, err := application.EvaluateAndPersist(r.Context(), epistemics.ResearchEvaluationRequest{Plan: request.Plan, Candidates: []epistemics.ResearchCandidate{candidate}}, repository, time.Now().UnixMilli())
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
})
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = ":8087"
+8
View File
@@ -114,6 +114,14 @@ type ResearchEvaluationRequest struct {
Candidates []ResearchCandidate
}
type RepositoryResearchRequest struct {
Plan ResearchPlan
PropositionKey string
SourceID SourceID
Path string
Statement string
}
// 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) {
+5 -1
View File
@@ -139,6 +139,7 @@ services:
restart: unless-stopped
environment:
LISTEN_ADDR: :8087
RESEARCH_SOURCE_ROOT: /var/lib/research-sources
POSTGRES_HOST: postgres
POSTGRES_PORT: "5432"
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
@@ -149,6 +150,7 @@ services:
depends_on: { postgres: { condition: service_healthy } }
volumes:
- assistant_core_secrets:/run/secrets:ro
- assistant_research_sources:/var/lib/research-sources:ro
continuity-runtime:
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
@@ -258,5 +260,7 @@ volumes:
assistant_notification_reader_secret:
external: true
llm_local_models:
external: true
name: llm-local-models
external: true
assistant_research_sources:
name: assistant_research_sources
+1 -1
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000143-research-runtime-orchestration.json",
"task_id": "DEV-000143",
"task_revision": 1
"task_revision": 2
}
@@ -1,18 +1,18 @@
{
"task_id": "DEV-000143",
"task_revision": 1,
"task_revision": 2,
"architecture_revision": "ADR-0018",
"base_repo_revision": "f9e2173",
"risk_tier": "E",
"intent": "Run a private research runtime that evaluates immutable connector evidence and durably persists exact TruthBundle provenance.",
"requirement_ids": ["WP-102", "Truth runtime", "Research persistence orchestration"],
"allowed_change_scope": {"modules": ["epistemics", "research-runtime", "deployment"], "paths": ["contracts/epistemics/research.go", "modules/epistemics/application/**", "adapters/postgres/epistemics_repository.go", "cmd/research-runtime/**", "deploy/docker-compose.prod.yml", "tests/research_runtime/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000143-research-runtime-orchestration.json"]},
"allowed_change_scope": {"modules": ["epistemics", "research-runtime", "deployment"], "paths": ["contracts/epistemics/research.go", "modules/epistemics/application/**", "adapters/postgres/epistemics_repository.go", "adapters/research/**", "cmd/research-runtime/**", "deploy/docker-compose.prod.yml", "tests/research_runtime/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000143-research-runtime-orchestration.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "database/**", "security/**", ".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": ["ResearchEvaluationRequest, ResearchResult, TruthBundle and epistemics repository"], "may_change": ["Private persistence orchestration endpoint"]},
"semantic_owner": "epistemics",
"canonical_state": ["epistemics.truth_bundles"],
"state_transitions": ["immutable connector candidates -> evaluated findings -> persisted provenance"],
"state_transitions": ["repository file -> immutable digest/excerpt -> evaluated finding -> persisted provenance"],
"side_effects_and_privileges": ["epistemics_role writes through repository only; no source mutation or provider call"],
"persistence_and_migrations": ["Consumes migration 034 already in main"],
"failure_retry_restart": ["idempotent repository writes; runtime restart-safe"],
@@ -0,0 +1,28 @@
package research_runtime_test
import (
"context"
"os"
"path/filepath"
"testing"
researchadapter "git.cin.su/m/Assistant/adapters/research"
"git.cin.su/m/Assistant/contracts/epistemics"
)
func TestRepositoryConnectorReturnsDigestAndExactExcerpt(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "evidence.txt"), []byte("exact repository evidence"), 0600); err != nil {
t.Fatal(err)
}
candidate, err := (researchadapter.RepositoryConnector{Root: root}).Collect(context.Background(), epistemics.RepositoryResearchRequest{Plan: epistemics.ResearchPlan{ID: "plan", Question: "q", SourceClasses: []string{"repository"}, FreshnessPolicy: "current"}, PropositionKey: "p", SourceID: "s", Path: "evidence.txt", Statement: "statement"}, 10)
if err != nil || candidate.Excerpt.Text != "exact repository evidence" || candidate.Snapshot.ContentDigest[:7] != "sha256:" || candidate.Excerpt.SnapshotID != candidate.Snapshot.ID {
t.Fatalf("candidate=%#v err=%v", candidate, err)
}
}
func TestRepositoryConnectorRejectsPathTraversal(t *testing.T) {
if _, err := (researchadapter.RepositoryConnector{Root: t.TempDir()}).Collect(context.Background(), epistemics.RepositoryResearchRequest{Plan: epistemics.ResearchPlan{ID: "plan", Question: "q", SourceClasses: []string{"repository"}, FreshnessPolicy: "current"}, SourceID: "s", Path: "../secret", Statement: "statement"}, 10); err != researchadapter.ErrRepositoryPathRejected {
t.Fatalf("path traversal error=%v", err)
}
}