diff --git a/adapters/postgres/epistemics_repository.go b/adapters/postgres/epistemics_repository.go index 2297c38..4cbb635 100644 --- a/adapters/postgres/epistemics_repository.go +++ b/adapters/postgres/epistemics_repository.go @@ -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, diff --git a/cmd/research-runtime/main.go b/cmd/research-runtime/main.go new file mode 100644 index 0000000..e95811e --- /dev/null +++ b/cmd/research-runtime/main.go @@ -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}) +} diff --git a/contracts/epistemics/research.go b/contracts/epistemics/research.go index 05f85cb..9a8eb9b 100644 --- a/contracts/epistemics/research.go +++ b/contracts/epistemics/research.go @@ -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) { diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 356a638..db834d6 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -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 diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index bfb2664..99035a9 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -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 } diff --git a/dev/tasks/DEV-000143-research-runtime-orchestration.json b/dev/tasks/DEV-000143-research-runtime-orchestration.json new file mode 100644 index 0000000..a8049a8 --- /dev/null +++ b/dev/tasks/DEV-000143-research-runtime-orchestration.json @@ -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"] +} diff --git a/modules/epistemics/application/persisted_research.go b/modules/epistemics/application/persisted_research.go new file mode 100644 index 0000000..6f15041 --- /dev/null +++ b/modules/epistemics/application/persisted_research.go @@ -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 +} diff --git a/tests/research_runtime/persisted_research_test.go b/tests/research_runtime/persisted_research_test.go new file mode 100644 index 0000000..7de6c7b --- /dev/null +++ b/tests/research_runtime/persisted_research_test.go @@ -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) + } +}