feat(research): run private evidence persistence runtime #111

Merged
m merged 1 commits from codex/wp-102-research-orchestration into main 2026-08-22 12:24:52 +00:00
8 changed files with 196 additions and 2 deletions
@@ -21,6 +21,10 @@ func OpenEpistemics(ctx context.Context, config ContinuityConfig) (*EpistemicsRe
func (r *EpistemicsRepository) Close() { r.pool.Close() }
func (r *EpistemicsRepository) Ping(ctx context.Context) error {
return r.pool.Ping(ctx)
}
func (r *EpistemicsRepository) SaveSystemExpectation(ctx context.Context, value epistemics.SystemExpectation) error {
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.system_expectations (
expectation_id, original_intent_id, source_utterance_id, expected_outcome, expected_effect,
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
"git.cin.su/m/Assistant/adapters/postgres"
"git.cin.su/m/Assistant/contracts/epistemics"
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
"git.cin.su/m/Assistant/modules/epistemics/application"
"git.cin.su/m/Assistant/platform/secrets"
"git.cin.su/m/Assistant/runtime/health"
)
func main() {
ctx := context.Background()
password, err := resolveDatabasePassword()
if err != nil {
log.Fatal(err)
}
repository, err := postgres.OpenEpistemics(ctx, postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("EPISTEMICS_POSTGRES_USER"), Password: password})
if err != nil {
log.Fatal(err)
}
defer repository.Close()
mux := http.NewServeMux()
mux.Handle("/healthz", health.Handler{Runtime: "research-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "research-runtime", Probes: []health.Probe{{Name: "epistemics-postgres", Check: func(ctx context.Context) error { return repository.Ping(ctx) }}}})
mux.HandleFunc("POST /v1/research/evaluate", func(w http.ResponseWriter, r *http.Request) {
var request epistemics.ResearchEvaluationRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&request); err != nil {
http.Error(w, "invalid research evidence", http.StatusBadRequest)
return
}
result, err := application.EvaluateAndPersist(r.Context(), request, 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"
}
log.Printf("research-runtime listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
func resolveDatabasePassword() (string, error) {
if _, ok := os.LookupEnv("EPISTEMICS_POSTGRES_PASSWORD"); ok {
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD direct environment secret is forbidden")
}
path, ref := os.Getenv("EPISTEMICS_POSTGRES_PASSWORD_FILE"), os.Getenv("EPISTEMICS_POSTGRES_CREDENTIAL_REF")
if path == "" || ref == "" {
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD_FILE and EPISTEMICS_POSTGRES_CREDENTIAL_REF are required")
}
root := os.Getenv("SECRET_BACKEND_ROOT")
if root == "" {
root = "/run/secrets"
}
resolver, err := secrets.NewFileResolver(root)
if err != nil {
return "", err
}
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
}
+8
View File
@@ -106,6 +106,14 @@ type ResearchResult struct {
EvaluatedAtUnixMillis int64
}
// ResearchEvaluationRequest carries immutable evidence from a read-only
// connector to the evaluator. The runtime never fetches or mutates a source
// from this contract.
type ResearchEvaluationRequest struct {
Plan ResearchPlan
Candidates []ResearchCandidate
}
// 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) {
+16
View File
@@ -134,6 +134,22 @@ services:
volumes:
- assistant_core_secrets:/run/secrets:ro
research-runtime:
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: research-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
restart: unless-stopped
environment:
LISTEN_ADDR: :8087
POSTGRES_HOST: postgres
POSTGRES_PORT: "5432"
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
EPISTEMICS_POSTGRES_USER: epistemics_role
EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1
EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password
SECRET_BACKEND_ROOT: /run/secrets
depends_on: { postgres: { condition: service_healthy } }
volumes:
- assistant_core_secrets:/run/secrets:ro
continuity-runtime:
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
restart: unless-stopped
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000142-truth-bundle-persistence.json",
"task_id": "DEV-000142",
"task_file": "dev/tasks/DEV-000143-research-runtime-orchestration.json",
"task_id": "DEV-000143",
"task_revision": 1
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000143",
"task_revision": 1,
"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"]},
"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"],
"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"],
"assumptions": ["source fetching connectors are read-only callers and remain a separate package"],
"acceptance_scenarios": ["runtime ready with epistemics DB", "valid evidence is persisted", "invalid evidence is rejected", "no public compose port is exposed"],
"required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "docker compose config"],
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
}
@@ -0,0 +1,40 @@
package application
import (
"context"
"git.cin.su/m/Assistant/contracts/epistemics"
domain "git.cin.su/m/Assistant/modules/epistemics/domain"
)
type TruthBundleStore interface {
SaveTruthBundle(context.Context, epistemics.TruthBundle, int64) error
}
// EvaluateAndPersist evaluates only supplied immutable connector evidence and
// persists the exact source chain used by each candidate. It never performs a
// network fetch and never promotes a belief to verified state.
func EvaluateAndPersist(ctx context.Context, request epistemics.ResearchEvaluationRequest, store TruthBundleStore, evaluatedAtUnixMillis int64) (epistemics.ResearchResult, error) {
result, err := (ResearchService{Connector: staticConnector{candidates: request.Candidates}}).Evaluate(ctx, request.Plan, evaluatedAtUnixMillis)
if err != nil {
return epistemics.ResearchResult{}, err
}
for _, candidate := range request.Candidates {
bundle, err := domain.CompileObservedClaim(candidate.Source, candidate.Snapshot, candidate.Excerpt, candidate.Statement, candidate.Snapshot.CapturedAtUnixMillis, candidate.Conflict)
if err != nil {
return epistemics.ResearchResult{}, err
}
if err := store.SaveTruthBundle(ctx, bundle, evaluatedAtUnixMillis); err != nil {
return epistemics.ResearchResult{}, err
}
}
return result, nil
}
type staticConnector struct {
candidates []epistemics.ResearchCandidate
}
func (c staticConnector) Collect(context.Context, epistemics.ResearchPlan) ([]epistemics.ResearchCandidate, error) {
return c.candidates, nil
}
@@ -0,0 +1,29 @@
package research_runtime_test
import (
"context"
"testing"
"git.cin.su/m/Assistant/contracts/epistemics"
"git.cin.su/m/Assistant/modules/epistemics/application"
)
type store struct{ saved []epistemics.TruthBundle }
func (s *store) SaveTruthBundle(_ context.Context, value epistemics.TruthBundle, _ int64) error {
s.saved = append(s.saved, value)
return nil
}
func TestEvaluateAndPersistStoresExactCandidateChain(t *testing.T) {
candidate := epistemics.ResearchCandidate{
PropositionKey: "p1", Statement: "fact", Source: epistemics.Source{ID: "source-1", Kind: "repo", Locator: "file:///repo/doc", Title: "doc"},
Snapshot: epistemics.SourceSnapshot{ID: "snapshot-1", SourceID: "source-1", ContentDigest: "sha256:x", CapturedAtUnixMillis: 10, Freshness: epistemics.FreshnessCurrent},
Excerpt: epistemics.SourceExcerpt{ID: "excerpt-1", SnapshotID: "snapshot-1", Text: "fact", Start: 0, End: 4}, Evidence: epistemics.EvidenceObserved, Coverage: epistemics.CoverageComplete, Conflict: epistemics.ConflictNone,
}
store := &store{}
result, err := application.EvaluateAndPersist(context.Background(), epistemics.ResearchEvaluationRequest{Plan: epistemics.ResearchPlan{ID: "plan-1", Question: "q", SourceClasses: []string{"repo"}, FreshnessPolicy: "current"}, Candidates: []epistemics.ResearchCandidate{candidate}}, store, 20)
if err != nil || len(result.Findings) != 1 || len(store.saved) != 1 || store.saved[0].Excerpt.ID != candidate.Excerpt.ID || store.saved[0].Source.Locator != candidate.Source.Locator {
t.Fatalf("result=%#v saved=%#v err=%v", result, store.saved, err)
}
}