diff --git a/adapters/postgres/epistemics_repository.go b/adapters/postgres/epistemics_repository.go index 2992978..2297c38 100644 --- a/adapters/postgres/epistemics_repository.go +++ b/adapters/postgres/epistemics_repository.go @@ -161,3 +161,21 @@ func (r *EpistemicsRepository) SaveReflectionCandidate(ctx context.Context, valu value.Summary, value.EvidenceRefs, value.RequiresCognition, value.RecordedAtUnixMillis, value.Revision) return err } + +func (r *EpistemicsRepository) SaveTruthBundle(ctx context.Context, value epistemics.TruthBundle, recordedAtUnixMillis int64) error { + if value.Source.ID == "" || value.Snapshot.ID == "" || value.Excerpt.ID == "" || value.Claim.ID == "" || value.Belief.ID == "" || value.Citation.ID == "" || recordedAtUnixMillis < 1 { + return fmt.Errorf("invalid truth bundle") + } + _, err := r.pool.Exec(ctx, `INSERT INTO epistemics.truth_bundles ( + bundle_id,source_id,source_kind,source_locator,source_title,source_publisher,snapshot_id,snapshot_source_id,snapshot_content_digest,snapshot_captured_at_unix_millis,snapshot_freshness, + excerpt_id,excerpt_snapshot_id,excerpt_text,excerpt_start,excerpt_end,observation_id,observation_observed_at_unix_millis,claim_id,claim_statement,claim_observation_id,claim_citation_id,claim_verification, + belief_id,belief_claim_id,belief_state,citation_id,citation_excerpt_id,citation_evidence,citation_coverage,citation_conflict,citation_resolved_at_unix_millis,recorded_at_unix_millis) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33) + ON CONFLICT (bundle_id) DO UPDATE SET recorded_at_unix_millis=EXCLUDED.recorded_at_unix_millis`, + "bundle:"+string(value.Citation.ID), value.Source.ID, value.Source.Kind, value.Source.Locator, value.Source.Title, value.Source.Publisher, + value.Snapshot.ID, value.Snapshot.SourceID, value.Snapshot.ContentDigest, value.Snapshot.CapturedAtUnixMillis, value.Snapshot.Freshness, + value.Excerpt.ID, value.Excerpt.SnapshotID, value.Excerpt.Text, value.Excerpt.Start, value.Excerpt.End, + value.Observation.ID, value.Observation.ObservedAt, value.Claim.ID, value.Claim.Statement, value.Claim.ObservationID, value.Claim.CitationID, value.Claim.Verification, + value.Belief.ID, value.Belief.ClaimID, value.Belief.State, value.Citation.ID, value.Citation.ExcerptID, value.Citation.Evidence, value.Citation.Coverage, value.Citation.Conflict, value.Citation.ResolvedAtUnixMillis, recordedAtUnixMillis) + return err +} diff --git a/contracts/epistemics/truth.go b/contracts/epistemics/truth.go index b597c61..b791643 100644 --- a/contracts/epistemics/truth.go +++ b/contracts/epistemics/truth.go @@ -31,6 +31,9 @@ type Belief struct { } type TruthBundle struct { + Source Source + Snapshot SourceSnapshot + Excerpt SourceExcerpt Observation Observation Claim Claim Belief Belief diff --git a/database/migrations/034_epistemics_truth_bundles.sql b/database/migrations/034_epistemics_truth_bundles.sql new file mode 100644 index 0000000..4089fc4 --- /dev/null +++ b/database/migrations/034_epistemics_truth_bundles.sql @@ -0,0 +1,49 @@ +BEGIN; + +SET ROLE epistemics_role; + +CREATE TABLE IF NOT EXISTS epistemics.truth_bundles ( + bundle_id TEXT PRIMARY KEY, + source_id TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_locator TEXT NOT NULL, + source_title TEXT NOT NULL, + source_publisher TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + snapshot_source_id TEXT NOT NULL, + snapshot_content_digest TEXT NOT NULL, + snapshot_captured_at_unix_millis BIGINT NOT NULL, + snapshot_freshness TEXT NOT NULL, + excerpt_id TEXT NOT NULL, + excerpt_snapshot_id TEXT NOT NULL, + excerpt_text TEXT NOT NULL, + excerpt_start BIGINT NOT NULL, + excerpt_end BIGINT NOT NULL, + observation_id TEXT NOT NULL, + observation_observed_at_unix_millis BIGINT NOT NULL, + claim_id TEXT NOT NULL, + claim_statement TEXT NOT NULL, + claim_observation_id TEXT NOT NULL, + claim_citation_id TEXT NOT NULL, + claim_verification TEXT NOT NULL, + belief_id TEXT NOT NULL, + belief_claim_id TEXT NOT NULL, + belief_state TEXT NOT NULL, + citation_id TEXT NOT NULL, + citation_excerpt_id TEXT NOT NULL, + citation_evidence TEXT NOT NULL, + citation_coverage TEXT NOT NULL, + citation_conflict TEXT NOT NULL, + citation_resolved_at_unix_millis BIGINT NOT NULL, + recorded_at_unix_millis BIGINT NOT NULL, + UNIQUE (snapshot_id, excerpt_id, claim_id), + CHECK (bundle_id <> ''), CHECK (source_id <> ''), CHECK (source_locator <> ''), + CHECK (excerpt_start >= 0), CHECK (excerpt_end >= excerpt_start), + CHECK (recorded_at_unix_millis > 0) +); + +CREATE INDEX IF NOT EXISTS truth_bundles_source_time_idx + ON epistemics.truth_bundles (source_id, recorded_at_unix_millis DESC); + +RESET ROLE; +COMMIT; diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index b5c9a73..bfb2664 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000141-optional-fleet-readiness.json", - "task_id": "DEV-000141", - "task_revision": 2 + "task_file": "dev/tasks/DEV-000142-truth-bundle-persistence.json", + "task_id": "DEV-000142", + "task_revision": 1 } diff --git a/dev/tasks/DEV-000142-truth-bundle-persistence.json b/dev/tasks/DEV-000142-truth-bundle-persistence.json new file mode 100644 index 0000000..8777d6b --- /dev/null +++ b/dev/tasks/DEV-000142-truth-bundle-persistence.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000142", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "0b321fe", + "risk_tier": "E", + "intent": "Persist typed Truth/Research bundles with exact source, snapshot, excerpt and citation provenance in the epistemics owner schema.", + "requirement_ids": ["WP-102", "Truth runtime", "Research provenance persistence"], + "allowed_change_scope": {"modules": ["epistemics", "database"], "paths": ["contracts/epistemics/truth.go", "modules/epistemics/domain/**", "adapters/postgres/epistemics_repository.go", "database/migrations/034_epistemics_truth_bundles.sql", "tests/research_contracts/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000142-truth-bundle-persistence.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "deploy/**", "security/**", "cmd/**", ".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": 1}, + "contracts": {"consume": ["TruthBundle, Source, SourceSnapshot, SourceExcerpt and Citation contracts"], "may_change": ["TruthBundle carries exact source evidence needed for durable provenance"]}, + "semantic_owner": "epistemics", + "canonical_state": ["epistemics.truth_bundles"], + "state_transitions": ["compiled truth bundle -> durable append/update by exact citation identity"], + "side_effects_and_privileges": ["epistemics_role writes only epistemics.truth_bundles"], + "persistence_and_migrations": ["Migration 034; idempotent table/index creation"], + "failure_retry_restart": ["bundle write is idempotent by citation-derived bundle id"], + "assumptions": ["external connectors remain read-only and are a separate follow-up"], + "acceptance_scenarios": ["exact source chain is persisted", "duplicate bundle write is idempotent", "cross-owner schemas are untouched"], + "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/domain/research.go b/modules/epistemics/domain/research.go index ef62e7b..436e6ff 100644 --- a/modules/epistemics/domain/research.go +++ b/modules/epistemics/domain/research.go @@ -106,5 +106,5 @@ func compileResearchCandidate(candidate epistemics.ResearchCandidate) (epistemic } observation := epistemics.Observation{ID: epistemics.ObservationID("observation:" + string(candidate.Excerpt.ID)), SourceSnapshot: candidate.Snapshot.ID, Excerpt: candidate.Excerpt.ID, Text: candidate.Excerpt.Text, ObservedAt: candidate.Snapshot.CapturedAtUnixMillis} claim := epistemics.Claim{ID: epistemics.ClaimID("claim:" + string(candidate.Excerpt.ID)), Statement: candidate.Statement, ObservationID: observation.ID, CitationID: citation.ID, Verification: epistemics.VerificationUnverified} - return epistemics.TruthBundle{Observation: observation, Claim: claim, Belief: epistemics.Belief{ID: epistemics.BeliefID("belief:" + string(candidate.Excerpt.ID)), ClaimID: claim.ID, State: epistemics.BeliefProvisional}, Citation: citation}, nil + return epistemics.TruthBundle{Source: candidate.Source, Snapshot: candidate.Snapshot, Excerpt: candidate.Excerpt, Observation: observation, Claim: claim, Belief: epistemics.Belief{ID: epistemics.BeliefID("belief:" + string(candidate.Excerpt.ID)), ClaimID: claim.ID, State: epistemics.BeliefProvisional}, Citation: citation}, nil } diff --git a/modules/epistemics/domain/truth.go b/modules/epistemics/domain/truth.go index 3b9a1a0..17790c9 100644 --- a/modules/epistemics/domain/truth.go +++ b/modules/epistemics/domain/truth.go @@ -23,7 +23,7 @@ func CompileObservedClaim(source epistemics.Source, snapshot epistemics.SourceSn if conflict == epistemics.ConflictPresent || conflict == epistemics.ConflictUnknown { state = epistemics.BeliefConflicted } - bundle := epistemics.TruthBundle{Observation: observation, Claim: claim, Belief: epistemics.Belief{ID: epistemics.BeliefID("belief:" + string(excerpt.ID)), ClaimID: claim.ID, State: state}, Citation: citation} + bundle := epistemics.TruthBundle{Source: source, Snapshot: snapshot, Excerpt: excerpt, Observation: observation, Claim: claim, Belief: epistemics.Belief{ID: epistemics.BeliefID("belief:" + string(excerpt.ID)), ClaimID: claim.ID, State: state}, Citation: citation} if bundle.Observation.SourceSnapshot != snapshot.ID || bundle.Claim.ObservationID != bundle.Observation.ID || bundle.Claim.CitationID != bundle.Citation.ID || bundle.Belief.ClaimID != bundle.Claim.ID { return epistemics.TruthBundle{}, ErrInvalidTruthBundle } diff --git a/tests/research_contracts/truth_persistence_sql_test.go b/tests/research_contracts/truth_persistence_sql_test.go new file mode 100644 index 0000000..c52186d --- /dev/null +++ b/tests/research_contracts/truth_persistence_sql_test.go @@ -0,0 +1,26 @@ +package research_contracts_test + +import ( + "os" + "strings" + "testing" +) + +func TestTruthBundlePersistenceMigrationIsOwnedAndIdempotent(t *testing.T) { + payload, err := os.ReadFile("../../database/migrations/034_epistemics_truth_bundles.sql") + if err != nil { + t.Fatal(err) + } + script := string(payload) + for _, required := range []string{ + "SET ROLE epistemics_role", + "CREATE TABLE IF NOT EXISTS epistemics.truth_bundles", + "UNIQUE (snapshot_id, excerpt_id, claim_id)", + "CREATE INDEX IF NOT EXISTS truth_bundles_source_time_idx", + "RESET ROLE", + } { + if !strings.Contains(script, required) { + t.Fatalf("migration missing %q", required) + } + } +}