Add evidence store, intent store and the fluid CLI
Some checks failed
ci / build (push) Has been cancelled
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
This commit is contained in:
parent
791e419973
commit
d52dcc92a9
11 changed files with 1836 additions and 3 deletions
283
internal/intent/store.go
Normal file
283
internal/intent/store.go
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
// Package intent implements the FLUID intent store.
|
||||
//
|
||||
// ArchitectureBlueprint.md section 27: the system must retain the exact
|
||||
// interface evolution intent governing each revision, so that a later audit can
|
||||
// answer whether a change was valid under the intent that existed when it was
|
||||
// made. An intent document is therefore versioned, content-addressed and
|
||||
// immutable once recorded.
|
||||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tegwick/fluid-core/internal/contract"
|
||||
"github.com/tegwick/fluid-core/internal/evidence"
|
||||
)
|
||||
|
||||
// AuthorityMode is the operational authority granted to the Daimon.
|
||||
//
|
||||
// FluidAPIStandards.md section 26 is explicit that these describe authority,
|
||||
// not maturity: a high-assurance system may deliberately remain at FLUID-2.
|
||||
type AuthorityMode int
|
||||
|
||||
const (
|
||||
ModeInstrumented AuthorityMode = iota // FLUID-0
|
||||
ModeAnalytical // FLUID-1
|
||||
ModeAdvisory // FLUID-2
|
||||
ModeConstructive // FLUID-3
|
||||
ModeExperimental // FLUID-4
|
||||
ModeBoundedAutonomous // FLUID-5
|
||||
ModeEvolutionary // FLUID-6
|
||||
)
|
||||
|
||||
// String renders the canonical FLUID-N form.
|
||||
func (m AuthorityMode) String() string { return fmt.Sprintf("FLUID-%d", int(m)) }
|
||||
|
||||
// Valid reports whether m is a defined mode.
|
||||
func (m AuthorityMode) Valid() bool { return m >= ModeInstrumented && m <= ModeEvolutionary }
|
||||
|
||||
// Allows reports whether this mode permits at least the authority of want.
|
||||
func (m AuthorityMode) Allows(want AuthorityMode) bool { return m >= want }
|
||||
|
||||
var modePattern = regexp.MustCompile(`FLUID-([0-6])\b`)
|
||||
|
||||
// Version is one recorded interface evolution intent.
|
||||
type Version struct {
|
||||
// Version is the human-facing label, such as "IEI-7".
|
||||
Version string `json:"version"`
|
||||
// Digest content-addresses the document. Two intent versions with the same
|
||||
// digest are the same intent, whatever they were called.
|
||||
Digest contract.Digest `json:"digest"`
|
||||
// Document is the full text, retained verbatim. A summary would not settle
|
||||
// an audit question about what the intent actually said.
|
||||
Document string `json:"document"`
|
||||
// Mode is the operational authority the document declares.
|
||||
Mode AuthorityMode `json:"mode"`
|
||||
// RecordedAt is when this version entered the store.
|
||||
RecordedAt time.Time `json:"recorded_at"`
|
||||
}
|
||||
|
||||
// Store holds intent versions and which one is currently active.
|
||||
type Store struct {
|
||||
ev evidence.Store
|
||||
iface contract.InterfaceID
|
||||
}
|
||||
|
||||
// New returns a store backed by ev.
|
||||
func New(ev evidence.Store, iface contract.InterfaceID) *Store {
|
||||
return &Store{ev: ev, iface: iface}
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrNotFound reports an unknown intent version.
|
||||
ErrNotFound = evidence.ErrNotFound
|
||||
// ErrImmutable reports an attempt to change a recorded intent version.
|
||||
ErrImmutable = errors.New("a recorded intent version cannot be changed")
|
||||
// ErrNoActive reports that no intent has been made active yet.
|
||||
ErrNoActive = errors.New("no active interface evolution intent")
|
||||
)
|
||||
|
||||
// activeKey is the record id holding the active-version pointer.
|
||||
const activeKey = "__active__"
|
||||
|
||||
// Digest computes the content address of an intent document.
|
||||
func Digest(document string) contract.Digest {
|
||||
sum := sha256.Sum256([]byte(document))
|
||||
return contract.Digest("sha256:" + hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
// ParseMode extracts the declared authority mode from an intent document.
|
||||
//
|
||||
// The template writes the mode as a fenced list of alternatives before it is
|
||||
// filled in; a document that still contains every mode has not been completed,
|
||||
// and defaulting it to something permissive would be exactly the wrong failure.
|
||||
func ParseMode(document string) (AuthorityMode, error) {
|
||||
matches := modePattern.FindAllStringSubmatch(document, -1)
|
||||
if len(matches) == 0 {
|
||||
return 0, errors.New("intent document declares no FLUID-N authority mode")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, m := range matches {
|
||||
seen[m[1]] = true
|
||||
}
|
||||
if len(seen) > 1 {
|
||||
return 0, fmt.Errorf(
|
||||
"intent document declares %d different authority modes; the template placeholder has not been resolved",
|
||||
len(seen))
|
||||
}
|
||||
|
||||
return AuthorityMode(matches[0][1][0] - '0'), nil
|
||||
}
|
||||
|
||||
// Put records an intent version.
|
||||
//
|
||||
// Recording the same version twice with identical content is a no-op, which
|
||||
// makes startup idempotent. Recording it with different content is refused:
|
||||
// changing what a revision was governed by, after the fact, would break the
|
||||
// audit question the store exists to answer.
|
||||
func (s *Store) Put(ctx context.Context, version, document string) (Version, error) {
|
||||
if version == "" {
|
||||
return Version{}, errors.New("intent version label is required")
|
||||
}
|
||||
|
||||
mode, err := ParseMode(document)
|
||||
if err != nil {
|
||||
return Version{}, fmt.Errorf("intent %s: %w", version, err)
|
||||
}
|
||||
|
||||
v := Version{
|
||||
Version: version,
|
||||
Digest: Digest(document),
|
||||
Document: document,
|
||||
Mode: mode,
|
||||
RecordedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if existing, err := s.Get(ctx, version); err == nil {
|
||||
if existing.Digest != v.Digest {
|
||||
return Version{}, fmt.Errorf("%w: %s already recorded with digest %s",
|
||||
ErrImmutable, version, existing.Digest)
|
||||
}
|
||||
return existing, nil
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return Version{}, err
|
||||
}
|
||||
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return Version{}, err
|
||||
}
|
||||
if err := s.ev.PutRecord(ctx, contract.KindIntent, s.key(version), body); err != nil {
|
||||
return Version{}, err
|
||||
}
|
||||
|
||||
if err := s.ev.AppendEvent(ctx, contract.FluidEvent{
|
||||
SchemaVersion: "0.1",
|
||||
ID: contract.EventID("EV-intent-" + string(v.Digest[7:19])),
|
||||
OccurredAt: v.RecordedAt,
|
||||
EntityType: contract.FluidEventEntityTypeIntent,
|
||||
EntityID: version,
|
||||
EventType: "INTENT_RECORDED",
|
||||
Actor: contract.Actor{Type: contract.ActorTypeSystem, ID: "fluid-intent-store"},
|
||||
Reason: fmt.Sprintf("recorded %s at authority mode %s", version, mode),
|
||||
}); err != nil {
|
||||
return Version{}, err
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Get returns a recorded intent version.
|
||||
func (s *Store) Get(ctx context.Context, version string) (Version, error) {
|
||||
body, err := s.ev.Record(ctx, contract.KindIntent, s.key(version))
|
||||
if err != nil {
|
||||
return Version{}, err
|
||||
}
|
||||
var v Version
|
||||
if err := json.Unmarshal(body, &v); err != nil {
|
||||
return Version{}, fmt.Errorf("decode intent %s: %w", version, err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// SetActive makes a recorded version the governing intent.
|
||||
func (s *Store) SetActive(ctx context.Context, version string) error {
|
||||
v, err := s.Get(ctx, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot activate %s: %w", version, err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"version": v.Version, "digest": string(v.Digest)})
|
||||
if err := s.ev.PutRecord(ctx, contract.KindIntent, s.key(activeKey), body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.ev.AppendEvent(ctx, contract.FluidEvent{
|
||||
SchemaVersion: "0.1",
|
||||
ID: contract.EventID("EV-intent-active-" + string(v.Digest[7:19])),
|
||||
OccurredAt: time.Now().UTC(),
|
||||
EntityType: contract.FluidEventEntityTypeIntent,
|
||||
EntityID: version,
|
||||
EventType: "INTENT_ACTIVATED",
|
||||
Actor: contract.Actor{Type: contract.ActorTypeSystem, ID: "fluid-intent-store"},
|
||||
Reason: fmt.Sprintf("%s is now the governing intent", version),
|
||||
})
|
||||
}
|
||||
|
||||
// Active returns the currently governing intent version.
|
||||
func (s *Store) Active(ctx context.Context) (Version, error) {
|
||||
body, err := s.ev.Record(ctx, contract.KindIntent, s.key(activeKey))
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Version{}, ErrNoActive
|
||||
}
|
||||
if err != nil {
|
||||
return Version{}, err
|
||||
}
|
||||
|
||||
var ptr struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &ptr); err != nil {
|
||||
return Version{}, err
|
||||
}
|
||||
return s.Get(ctx, ptr.Version)
|
||||
}
|
||||
|
||||
// Bind records that a revision is governed by an intent version.
|
||||
//
|
||||
// This is the link Blueprint section 27 requires. It is stored as an event
|
||||
// rather than as a mutable field so that a revision's governing intent cannot
|
||||
// be quietly reassigned later.
|
||||
func (s *Store) Bind(ctx context.Context, rev contract.RevisionID, version string) error {
|
||||
v, err := s.Get(ctx, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot bind %s: %w", rev, err)
|
||||
}
|
||||
|
||||
return s.ev.AppendEvent(ctx, contract.FluidEvent{
|
||||
SchemaVersion: "0.1",
|
||||
ID: contract.EventID("EV-bind-" + string(rev) + "-" + version),
|
||||
OccurredAt: time.Now().UTC(),
|
||||
EntityType: contract.FluidEventEntityTypeRevision,
|
||||
EntityID: string(rev),
|
||||
EventType: "INTENT_BOUND",
|
||||
Actor: contract.Actor{Type: contract.ActorTypeSystem, ID: "fluid-intent-store"},
|
||||
Inputs: []string{version},
|
||||
Reason: fmt.Sprintf("%s is governed by %s (%s)", rev, version, v.Digest),
|
||||
})
|
||||
}
|
||||
|
||||
// GoverningIntent returns the intent version a revision was bound to.
|
||||
func (s *Store) GoverningIntent(ctx context.Context, rev contract.RevisionID) (Version, error) {
|
||||
events, err := s.ev.Events(ctx, evidence.EventFilter{
|
||||
EntityType: contract.KindRevision,
|
||||
EntityID: string(rev),
|
||||
EventType: "INTENT_BOUND",
|
||||
})
|
||||
if err != nil {
|
||||
return Version{}, err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return Version{}, fmt.Errorf("%w: no intent bound to %s", ErrNotFound, rev)
|
||||
}
|
||||
// The first binding governs. A later one would be a reassignment, which the
|
||||
// audit trail records but must not silently win.
|
||||
first := events[0]
|
||||
if len(first.Inputs) == 0 {
|
||||
return Version{}, fmt.Errorf("binding event %s names no intent version", first.ID)
|
||||
}
|
||||
return s.Get(ctx, first.Inputs[0])
|
||||
}
|
||||
|
||||
func (s *Store) key(id string) string {
|
||||
return string(s.iface) + "/" + strings.TrimSpace(id)
|
||||
}
|
||||
166
internal/intent/store_test.go
Normal file
166
internal/intent/store_test.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
package intent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/tegwick/fluid-core/internal/contract"
|
||||
"github.com/tegwick/fluid-core/internal/evidence"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
ev, err := evidence.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "e.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ev.Close() })
|
||||
return New(ev, "hall-publishing")
|
||||
}
|
||||
|
||||
const filledIntent = `# Interface Evolution Intent
|
||||
|
||||
**Current operational authority mode:**
|
||||
FLUID-2
|
||||
|
||||
## Mission
|
||||
Publish hall-of-helix entries to a Telegram channel.
|
||||
`
|
||||
|
||||
func TestPutIsIdempotentAndImmutable(t *testing.T) {
|
||||
s := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := s.Put(ctx, "IEI-1", filledIntent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Mode != ModeAdvisory {
|
||||
t.Errorf("mode = %s, want FLUID-2", first.Mode)
|
||||
}
|
||||
|
||||
// Recording the same content again must be a no-op, so startup is safe to
|
||||
// repeat.
|
||||
again, err := s.Put(ctx, "IEI-1", filledIntent)
|
||||
if err != nil {
|
||||
t.Fatalf("re-recording identical intent failed: %v", err)
|
||||
}
|
||||
if again.Digest != first.Digest {
|
||||
t.Error("identical documents produced different digests")
|
||||
}
|
||||
|
||||
// Changing what a version says, after revisions may already be bound to it,
|
||||
// would break the audit question the store exists to answer.
|
||||
changed := strings.Replace(filledIntent, "FLUID-2", "FLUID-5", 1)
|
||||
if _, err := s.Put(ctx, "IEI-1", changed); !errors.Is(err, ErrImmutable) {
|
||||
t.Errorf("mutating a recorded version returned %v, want ErrImmutable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModeRejectsUnresolvedTemplate(t *testing.T) {
|
||||
// The shipped template lists every mode as alternatives. Accepting that and
|
||||
// defaulting to something permissive is exactly the wrong failure.
|
||||
template, err := os.ReadFile(filepath.Join("..", "..", "spec", "InterfaceEvolutionIntent.md"))
|
||||
if err != nil {
|
||||
t.Skipf("template not readable: %v", err)
|
||||
}
|
||||
if _, err := ParseMode(string(template)); err == nil {
|
||||
t.Error("the unfilled template was accepted as a governing intent")
|
||||
}
|
||||
|
||||
if _, err := ParseMode("no mode declared here"); err == nil {
|
||||
t.Error("a document with no authority mode was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorityModeOrdering(t *testing.T) {
|
||||
if !ModeExperimental.Allows(ModeAdvisory) {
|
||||
t.Error("FLUID-4 should permit advisory authority")
|
||||
}
|
||||
if ModeAdvisory.Allows(ModeExperimental) {
|
||||
t.Error("FLUID-2 must not permit experimental authority")
|
||||
}
|
||||
if got := ModeBoundedAutonomous.String(); got != "FLUID-5" {
|
||||
t.Errorf("String() = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveIntent(t *testing.T) {
|
||||
s := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.Active(ctx); !errors.Is(err, ErrNoActive) {
|
||||
t.Errorf("Active with nothing recorded returned %v, want ErrNoActive", err)
|
||||
}
|
||||
|
||||
if _, err := s.Put(ctx, "IEI-1", filledIntent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetActive(ctx, "IEI-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
active, err := s.Active(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if active.Version != "IEI-1" {
|
||||
t.Errorf("active = %s, want IEI-1", active.Version)
|
||||
}
|
||||
|
||||
if err := s.SetActive(ctx, "IEI-missing"); err == nil {
|
||||
t.Error("activating an unrecorded version succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindingIsRecordedAndFirstWins(t *testing.T) {
|
||||
s := newStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.Put(ctx, "IEI-1", filledIntent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v2 := strings.Replace(filledIntent, "FLUID-2", "FLUID-4", 1)
|
||||
if _, err := s.Put(ctx, "IEI-2", v2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rev := contract.RevisionID("R-1")
|
||||
if err := s.Bind(ctx, rev, "IEI-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := s.GoverningIntent(ctx, rev)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Version != "IEI-1" {
|
||||
t.Errorf("governing intent = %s, want IEI-1", got.Version)
|
||||
}
|
||||
|
||||
// A later reassignment is recorded in the audit trail but must not silently
|
||||
// become the answer to "what governed this revision".
|
||||
if err := s.Bind(ctx, rev, "IEI-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = s.GoverningIntent(ctx, rev)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Version != "IEI-1" {
|
||||
t.Errorf("a later binding overrode the original: got %s", got.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnboundRevisionHasNoIntent(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.GoverningIntent(context.Background(), "R-unbound"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("got %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue