diff --git a/adapters/postgres/epistemics_repository.go b/adapters/postgres/epistemics_repository.go index 4cbb635..2befe1b 100644 --- a/adapters/postgres/epistemics_repository.go +++ b/adapters/postgres/epistemics_repository.go @@ -11,6 +11,14 @@ import ( type EpistemicsRepository struct{ pool *pgxpool.Pool } +type TruthRuntimeStatus struct { + BundleCount int64 `json:"bundle_count"` + ClaimsByVerification map[string]int64 `json:"claims_by_verification"` + BeliefsByState map[string]int64 `json:"beliefs_by_state"` + SourcesByFreshness map[string]int64 `json:"sources_by_freshness"` + ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"` +} + func OpenEpistemics(ctx context.Context, config ContinuityConfig) (*EpistemicsRepository, error) { pool, err := openPool(ctx, config) if err != nil { @@ -25,6 +33,49 @@ func (r *EpistemicsRepository) Ping(ctx context.Context) error { return r.pool.Ping(ctx) } +// TruthRuntimeStatus is a read-only projection of durable truth bundles. +// It does not promote claims, revise beliefs, or mutate epistemic state. +func (r *EpistemicsRepository) TruthRuntimeStatus(ctx context.Context) (TruthRuntimeStatus, error) { + status := TruthRuntimeStatus{ + ClaimsByVerification: map[string]int64{}, + BeliefsByState: map[string]int64{}, + SourcesByFreshness: map[string]int64{}, + ObservedAtUnixMillis: time.Now().UnixMilli(), + } + if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM epistemics.truth_bundles`).Scan(&status.BundleCount); err != nil { + return TruthRuntimeStatus{}, err + } + queries := []struct { + query string + destination map[string]int64 + }{ + {`SELECT claim_verification, count(*) FROM epistemics.truth_bundles GROUP BY claim_verification`, status.ClaimsByVerification}, + {`SELECT belief_state, count(*) FROM epistemics.truth_bundles GROUP BY belief_state`, status.BeliefsByState}, + {`SELECT snapshot_freshness, count(*) FROM epistemics.truth_bundles GROUP BY snapshot_freshness`, status.SourcesByFreshness}, + } + for _, item := range queries { + rows, err := r.pool.Query(ctx, item.query) + if err != nil { + return TruthRuntimeStatus{}, err + } + for rows.Next() { + var state string + var count int64 + if err := rows.Scan(&state, &count); err != nil { + rows.Close() + return TruthRuntimeStatus{}, err + } + item.destination[state] = count + } + if err := rows.Err(); err != nil { + rows.Close() + return TruthRuntimeStatus{}, err + } + rows.Close() + } + return status, nil +} + 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 index ef5705c..d2b165b 100644 --- a/cmd/research-runtime/main.go +++ b/cmd/research-runtime/main.go @@ -34,6 +34,7 @@ func main() { 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.Handle("/v1/research/status", truthRuntimeStatusHandler(repository)) 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 { diff --git a/cmd/research-runtime/status.go b/cmd/research-runtime/status.go new file mode 100644 index 0000000..c94afc6 --- /dev/null +++ b/cmd/research-runtime/status.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + + "git.cin.su/m/Assistant/adapters/postgres" +) + +type truthRuntimeStatusReader interface { + TruthRuntimeStatus(context.Context) (postgres.TruthRuntimeStatus, error) +} + +type truthRuntimeStatusResponse struct { + Runtime string `json:"runtime"` + Status string `json:"status"` + BundleCount int64 `json:"bundle_count"` + ClaimsByVerification map[string]int64 `json:"claims_by_verification"` + BeliefsByState map[string]int64 `json:"beliefs_by_state"` + SourcesByFreshness map[string]int64 `json:"sources_by_freshness"` + ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"` +} + +func truthRuntimeStatusHandler(reader truthRuntimeStatusReader) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/research/status" { + http.NotFound(w, r) + return + } + status, err := reader.TruthRuntimeStatus(r.Context()) + if err != nil { + http.Error(w, "research status unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(truthRuntimeStatusResponse{ + Runtime: "research-runtime", + Status: "ready", + BundleCount: status.BundleCount, + ClaimsByVerification: status.ClaimsByVerification, + BeliefsByState: status.BeliefsByState, + SourcesByFreshness: status.SourcesByFreshness, + ObservedAtUnixMillis: status.ObservedAtUnixMillis, + }) + }) +} diff --git a/cmd/research-runtime/status_test.go b/cmd/research-runtime/status_test.go new file mode 100644 index 0000000..daf9dbd --- /dev/null +++ b/cmd/research-runtime/status_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.cin.su/m/Assistant/adapters/postgres" +) + +type fakeTruthRuntimeStatusReader struct { + status postgres.TruthRuntimeStatus + err error +} + +func (f fakeTruthRuntimeStatusReader) TruthRuntimeStatus(context.Context) (postgres.TruthRuntimeStatus, error) { + return f.status, f.err +} + +func TestTruthRuntimeStatusReportsEvidenceCounts(t *testing.T) { + response := httptest.NewRecorder() + truthRuntimeStatusHandler(fakeTruthRuntimeStatusReader{status: postgres.TruthRuntimeStatus{ + BundleCount: 4, + ClaimsByVerification: map[string]int64{"UNVERIFIED": 3, "VERIFIED": 1}, + BeliefsByState: map[string]int64{"PROVISIONAL": 4}, + SourcesByFreshness: map[string]int64{"FRESH": 4}, + ObservedAtUnixMillis: 1700000000000, + }}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/research/status", nil)) + if response.Code != http.StatusOK { + t.Fatalf("status code = %d", response.Code) + } + for _, expected := range []string{"\"status\":\"ready\"", "\"bundle_count\":4", "\"VERIFIED\":1", "\"PROVISIONAL\":4", "1700000000000"} { + if !strings.Contains(response.Body.String(), expected) { + t.Fatalf("response missing %q: %s", expected, response.Body.String()) + } + } +} + +func TestTruthRuntimeStatusFailsClosedOnDatabaseError(t *testing.T) { + response := httptest.NewRecorder() + truthRuntimeStatusHandler(fakeTruthRuntimeStatusReader{err: errors.New("database unavailable")}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/research/status", nil)) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("status code = %d", response.Code) + } +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 0bc3418..4a69794 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000246-work-graph-runtime-status.json", - "task_id": "DEV-000246", + "task_file": "dev/tasks/DEV-000247-research-runtime-status.json", + "task_id": "DEV-000247", "task_revision": 1 } diff --git a/dev/tasks/DEV-000247-research-runtime-status.json b/dev/tasks/DEV-000247-research-runtime-status.json new file mode 100644 index 0000000..fe6b989 --- /dev/null +++ b/dev/tasks/DEV-000247-research-runtime-status.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000247", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "e70be0103a03c31f7ecea851fd1aeabcb7abcc55", + "risk_tier": "E", + "intent": "Expose read-only Truth/Research evidence counts from durable truth bundles without changing epistemic evaluation or belief semantics.", + "requirement_ids": ["Truth/Research runtime", "Observation-Claim-Belief evidence visibility", "fast operational verification"], + "allowed_change_scope": {"modules": ["epistemics"], "paths": ["adapters/postgres/epistemics_repository.go", "cmd/research-runtime/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000247-research-runtime-status.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "contracts/**", "LLM-local/**", "modules/work/secrets/**"]}, + "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": ["epistemics.truth_bundles", "existing research health conventions"], "may_change": ["internal read-only status response"]}, + "semantic_owner": "Epistemics Truth/Research runtime observability", + "canonical_state": ["none; counts are derived projections"], + "state_transitions": ["none"], + "side_effects_and_privileges": ["read-only aggregate queries only"], + "persistence_and_migrations": ["none"], + "failure_retry_restart": ["status fails closed with 503 when epistemics database is unavailable"], + "assumptions": ["truth bundle migration is present in production"], + "acceptance_scenarios": ["readyz remains database-backed", "status reports bundle, claim verification, belief and freshness counts", "status does not mutate epistemic state"], + "required_tests": ["go test ./cmd/research-runtime ./adapters/postgres", "go vet ./cmd/research-runtime ./adapters/postgres", "go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-architecture", "git diff --check", "independent", "CI", "production health smoke"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"] +}