fluid-core/internal/evidence/store_test.go
tegwick d52dcc92a9
Some checks failed
ci / build (push) Has been cancelled
Add evidence store, intent store and the fluid CLI
Completes FLUID-WP-0003. The evidence store is append-only at the
database, not by convention in Go: a trigger blocks UPDATE and DELETE on
fluid_events in both SQLite and Postgres, so the guarantee binds a psql
session and the CLI equally, not just callers who go through the Go API.
Records are derived summary state and may be superseded; the event log
stays the authority.

The intent store makes a recorded version immutable and content
addressed, since reassigning what governed a revision after the fact
would break the one audit question Blueprint 27 exists to answer. The
unfilled InterfaceEvolutionIntent template is rejected rather than
defaulted, because a template still listing every FLUID-N mode has not
been completed and defaulting it would pick a permissive authority by
accident.

The CLI reads the evidence store directly rather than through the
control-plane API, so an operator can reconstruct history when the
control plane is down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 02:26:23 +02:00

214 lines
6.3 KiB
Go

package evidence
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"time"
_ "modernc.org/sqlite"
"github.com/tegwick/fluid-core/internal/contract"
)
func newStore(t *testing.T) *SQLStore {
t.Helper()
path := filepath.Join(t.TempDir(), "evidence.db")
s, err := OpenSQLite(context.Background(), path)
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func event(id string, at time.Time, entity contract.EntityKind, entityID, kind string) contract.FluidEvent {
return contract.FluidEvent{
SchemaVersion: "0.1",
ID: contract.EventID(id),
OccurredAt: at,
EntityType: contract.FluidEventEntityType(entity),
EntityID: entityID,
EventType: kind,
Actor: contract.Actor{Type: contract.ActorTypeSystem, ID: "test"},
}
}
func TestAppendAndQueryEvents(t *testing.T) {
s := newStore(t)
ctx := context.Background()
base := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
events := []contract.FluidEvent{
event("EV-3", base.Add(2*time.Minute), contract.KindRevision, "R-1", "PUBLISHED"),
event("EV-1", base, contract.KindRevision, "R-1", "CREATED"),
event("EV-2", base.Add(time.Minute), contract.KindRevision, "R-1", "VERIFIED"),
event("EV-4", base.Add(3*time.Minute), contract.KindHypothesis, "H-1", "CREATED"),
}
for _, ev := range events {
if err := s.AppendEvent(ctx, ev); err != nil {
t.Fatalf("append %s: %v", ev.ID, err)
}
}
got, err := s.Events(ctx, EventFilter{EntityType: contract.KindRevision, EntityID: "R-1"})
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("got %d events, want 3", len(got))
}
// Insertion order was scrambled; occurrence order must come back sorted,
// because an audit trail read out of order is worse than none.
want := []string{"CREATED", "VERIFIED", "PUBLISHED"}
for i, ev := range got {
if ev.EventType != want[i] {
t.Errorf("position %d = %s, want %s", i, ev.EventType, want[i])
}
}
}
// TestEventsAreAppendOnly checks the invariant at the database, not in Go.
// Blueprint 28.1 gives the evidence writer permission to append and not to
// revise; a Go-level convention would not bind a CLI or a psql session.
func TestEventsAreAppendOnly(t *testing.T) {
s := newStore(t)
ctx := context.Background()
ev := event("EV-1", time.Now(), contract.KindRevision, "R-1", "CREATED")
if err := s.AppendEvent(ctx, ev); err != nil {
t.Fatal(err)
}
if _, err := s.db.ExecContext(ctx, `UPDATE fluid_events SET event_type = 'REWRITTEN'`); err == nil {
t.Error("UPDATE on fluid_events succeeded; history is rewritable")
} else if !strings.Contains(err.Error(), "append-only") {
t.Errorf("UPDATE failed for the wrong reason: %v", err)
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM fluid_events`); err == nil {
t.Error("DELETE on fluid_events succeeded; history is erasable")
} else if !strings.Contains(err.Error(), "append-only") {
t.Errorf("DELETE failed for the wrong reason: %v", err)
}
// The original event must still be there and unchanged.
got, err := s.Events(ctx, EventFilter{EntityID: "R-1"})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].EventType != "CREATED" {
t.Errorf("event was altered: %+v", got)
}
}
func TestDuplicateEventIDRefused(t *testing.T) {
s := newStore(t)
ctx := context.Background()
ev := event("EV-1", time.Now(), contract.KindRevision, "R-1", "CREATED")
if err := s.AppendEvent(ctx, ev); err != nil {
t.Fatal(err)
}
if err := s.AppendEvent(ctx, ev); err == nil {
t.Error("duplicate event id accepted; an event was silently replayed")
}
}
func TestTelemetryRoundTripAndFilters(t *testing.T) {
s := newStore(t)
ctx := context.Background()
base := time.Date(2026, 9, 5, 8, 0, 0, 0, time.UTC)
r1 := contract.RevisionID("R-1")
r2 := contract.RevisionID("R-2")
exp := contract.ExperimentID("E-1")
cohort := contract.CohortID("coding-agents")
for i, rev := range []*contract.RevisionID{&r1, &r1, &r2} {
ev := contract.FluidTelemetry{
SchemaVersion: "0.1",
ID: "tl-" + string(rune('a'+i)),
OccurredAt: base.Add(time.Duration(i) * time.Minute),
InterfaceID: "hall-publishing",
Kind: contract.FluidTelemetryKindRequest,
Revision: rev,
Experiment: &exp,
Cohort: &cohort,
}
if err := s.WriteTelemetry(ctx, ev); err != nil {
t.Fatalf("write %d: %v", i, err)
}
}
all, err := s.Telemetry(ctx, TelemetryFilter{InterfaceID: "hall-publishing"})
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Fatalf("got %d telemetry rows, want 3", len(all))
}
only1, err := s.Telemetry(ctx, TelemetryFilter{Revision: "R-1"})
if err != nil {
t.Fatal(err)
}
if len(only1) != 2 {
t.Errorf("revision filter returned %d rows, want 2", len(only1))
}
// The measurement window is what makes a fitness comparison honest, so
// bounded queries have to be exact.
windowed, err := s.Telemetry(ctx, TelemetryFilter{
InterfaceID: "hall-publishing",
Since: base.Add(time.Minute),
})
if err != nil {
t.Fatal(err)
}
if len(windowed) != 2 {
t.Errorf("windowed query returned %d rows, want 2", len(windowed))
}
}
func TestRecordsAreSupersedable(t *testing.T) {
s := newStore(t)
ctx := context.Background()
first, _ := json.Marshal(map[string]string{"status": "OPEN"})
if err := s.PutRecord(ctx, contract.KindPressure, "P-1", first); err != nil {
t.Fatal(err)
}
// Records are derived summary state and may be rewritten; the event log
// remains the authority for how they got there.
second, _ := json.Marshal(map[string]string{"status": "ADDRESSED"})
if err := s.PutRecord(ctx, contract.KindPressure, "P-1", second); err != nil {
t.Fatal(err)
}
got, err := s.Record(ctx, contract.KindPressure, "P-1")
if err != nil {
t.Fatal(err)
}
var decoded map[string]string
if err := json.Unmarshal(got, &decoded); err != nil {
t.Fatal(err)
}
if decoded["status"] != "ADDRESSED" {
t.Errorf("record not superseded: %v", decoded)
}
if _, err := s.Record(ctx, contract.KindPressure, "P-missing"); !errors.Is(err, ErrNotFound) {
t.Errorf("missing record returned %v, want ErrNotFound", err)
}
}
func TestPutRecordRejectsNonJSON(t *testing.T) {
s := newStore(t)
if err := s.PutRecord(context.Background(), contract.KindPressure, "P-1", []byte("not json")); err == nil {
t.Error("non-JSON record body accepted")
}
}