Some checks failed
ci / build (push) Has been cancelled
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
214 lines
6.3 KiB
Go
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")
|
|
}
|
|
}
|