fluid-core/internal/observation/registry.go
tegwick 0ac35892a5 Add pressure classifier and pressure registry
FLUID-WP-0005 T05-T06. The classifier is deterministic: its output is
what a hypothesis will later cite as its observation, and a first
implementation that reached for a model could not be audited.

Two mappings are deliberate. An authorization failure is not pressure --
the interface working as designed is not a defect, and classifying it as
one would send the Daimon after a change that must not happen. Backend
failures and timeouts are reported at any rate, while consumer-side
errors must be a meaningful share of traffic first, since a handful says
more about one confused client than about the interface.

Pressure ids are derived from a finding fingerprint rather than
allocated, which makes ingest idempotent: re-analysing a window updates
one record instead of minting another. first_seen is never overwritten,
because how long the interface has had a problem is the evidence that
makes an old pressure worth prioritizing. A dismissed pressure is not
silently reopened, or the operator's decision would mean nothing.

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 03:11:49 +02:00

326 lines
10 KiB
Go

package observation
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"time"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/evidence"
)
// PressureRegistry is the durable inventory of material interface pressure.
//
// ArchitectureBlueprint.md section 9 requires deduplication, aggregation,
// cohort segmentation, frequency tracking, severity, confidence, linked
// hypotheses and disposition — and says plainly that pressure may remain
// unresolved on purpose. Not every observed mismatch deserves adaptation.
type PressureRegistry struct {
store evidence.Store
iface contract.InterfaceID
now func() time.Time
}
// NewPressureRegistry returns a registry backed by the evidence store.
func NewPressureRegistry(store evidence.Store, iface contract.InterfaceID) *PressureRegistry {
return &PressureRegistry{store: store, iface: iface, now: time.Now}
}
// ErrDismissed reports an attempt to reopen a deliberately closed pressure.
var ErrDismissed = errors.New("pressure was dismissed and will not be reopened automatically")
// pressureID derives a stable identifier from a finding's fingerprint.
//
// Deriving rather than allocating is what makes ingest idempotent: re-running
// analysis over the same window updates one record instead of minting a new one
// on every pass.
func pressureID(iface contract.InterfaceID, fingerprint string) contract.PressureID {
sum := sha256.Sum256([]byte(string(iface) + "\x00" + fingerprint))
return contract.PressureID("P-" + hex.EncodeToString(sum[:6]))
}
// Record folds a finding into the registry, creating or updating one pressure.
//
// A finding that matches an existing record extends its window and refreshes
// its counts rather than replacing it: first_seen is evidence about how long
// the interface has had this problem, and overwriting it would erase the age
// that makes a pressure worth prioritizing.
func (r *PressureRegistry) Record(ctx context.Context, f Finding) (contract.FluidPressure, error) {
id := pressureID(r.iface, f.Fingerprint)
existing, err := r.Get(ctx, id)
switch {
case err == nil:
if existing.Status == contract.FluidPressureStatusDISMISSED {
// An operator decided this is not worth acting on. Silently
// resurrecting it would make the dismissal meaningless.
return existing, fmt.Errorf("%w: %s", ErrDismissed, id)
}
return r.update(ctx, existing, f)
case errors.Is(err, evidence.ErrNotFound):
return r.create(ctx, id, f)
default:
return contract.FluidPressure{}, err
}
}
func (r *PressureRegistry) create(ctx context.Context, id contract.PressureID, f Finding) (contract.FluidPressure, error) {
observations := int64(f.Occurrences)
consumers := int64(f.Consumers)
severity := contract.UnitInterval(f.Severity)
confidence := contract.UnitInterval(f.Confidence)
p := contract.FluidPressure{
SchemaVersion: "0.1",
ID: id,
InterfaceID: r.iface,
Class: f.Class,
FirstSeen: f.FirstSeen,
LastSeen: f.LastSeen,
AffectedCohorts: f.Cohorts,
Frequency: &contract.FluidPressureFrequency{
Observations: &observations,
IndependentConsumers: &consumers,
},
Severity: &severity,
Confidence: &confidence,
Summary: f.Summary,
EvidenceRefs: f.Evidence,
Status: contract.FluidPressureStatusOPEN,
}
if err := r.put(ctx, p); err != nil {
return contract.FluidPressure{}, err
}
if err := r.event(ctx, p, "PRESSURE_OPENED", f.Summary); err != nil {
return contract.FluidPressure{}, err
}
return p, nil
}
func (r *PressureRegistry) update(ctx context.Context, p contract.FluidPressure, f Finding) (contract.FluidPressure, error) {
if f.FirstSeen.Before(p.FirstSeen) {
p.FirstSeen = f.FirstSeen
}
if f.LastSeen.After(p.LastSeen) {
p.LastSeen = f.LastSeen
}
observations := int64(f.Occurrences)
consumers := int64(f.Consumers)
p.Frequency = &contract.FluidPressureFrequency{
Observations: &observations,
IndependentConsumers: &consumers,
}
severity := contract.UnitInterval(f.Severity)
confidence := contract.UnitInterval(f.Confidence)
p.Severity = &severity
p.Confidence = &confidence
p.Summary = f.Summary
p.AffectedCohorts = mergeCohorts(p.AffectedCohorts, f.Cohorts)
p.EvidenceRefs = mergeRefs(p.EvidenceRefs, f.Evidence)
// Fresh evidence for something previously explained means it is back.
if p.Status == contract.FluidPressureStatusADDRESSED {
p.Status = contract.FluidPressureStatusOPEN
if err := r.put(ctx, p); err != nil {
return contract.FluidPressure{}, err
}
return p, r.event(ctx, p, "PRESSURE_REOPENED",
"new evidence observed after the pressure was marked addressed")
}
if err := r.put(ctx, p); err != nil {
return contract.FluidPressure{}, err
}
return p, r.event(ctx, p, "PRESSURE_OBSERVED", f.Summary)
}
// RecordAll folds a batch of findings into the registry.
//
// Dismissed pressures are skipped rather than treated as errors: hitting one
// is the expected outcome of re-analysing a window an operator has already
// triaged.
func (r *PressureRegistry) RecordAll(ctx context.Context, findings []Finding) ([]contract.FluidPressure, error) {
var out []contract.FluidPressure
for _, f := range findings {
p, err := r.Record(ctx, f)
if errors.Is(err, ErrDismissed) {
continue
}
if err != nil {
return out, err
}
out = append(out, p)
}
return out, nil
}
// Get returns one pressure record.
func (r *PressureRegistry) Get(ctx context.Context, id contract.PressureID) (contract.FluidPressure, error) {
body, err := r.store.Record(ctx, contract.KindPressure, string(id))
if err != nil {
return contract.FluidPressure{}, err
}
var doc contract.PressureDocument
if err := json.Unmarshal(body, &doc); err != nil {
return contract.FluidPressure{}, fmt.Errorf("decode pressure %s: %w", id, err)
}
return doc.FluidPressure, nil
}
// List returns pressures, optionally filtered by status.
func (r *PressureRegistry) List(ctx context.Context, status contract.FluidPressureStatus) ([]contract.FluidPressure, error) {
records, err := r.store.Records(ctx, contract.KindPressure)
if err != nil {
return nil, err
}
out := make([]contract.FluidPressure, 0, len(records))
for _, body := range records {
var doc contract.PressureDocument
if err := json.Unmarshal(body, &doc); err != nil {
continue
}
if status != "" && doc.FluidPressure.Status != status {
continue
}
out = append(out, doc.FluidPressure)
}
// Most severe first: the registry is a work queue as much as an inventory.
sort.Slice(out, func(i, j int) bool {
si, sj := unit(out[i].Severity), unit(out[j].Severity)
if si != sj {
return si > sj
}
return out[i].ID < out[j].ID
})
return out, nil
}
// SetStatus moves a pressure through its lifecycle.
//
// Transitions are recorded as events rather than only as a field, so a
// dismissal can be traced to whoever made it and why.
func (r *PressureRegistry) SetStatus(ctx context.Context, id contract.PressureID, status contract.FluidPressureStatus, actor contract.Actor, reason string) error {
if !status.Valid() {
return fmt.Errorf("unknown pressure status %q", status)
}
if reason == "" {
// A status change with no reason is not auditable, and dismissals
// without a reason are how a registry quietly loses its evidence.
return errors.New("a status change requires a reason")
}
p, err := r.Get(ctx, id)
if err != nil {
return err
}
previous := p.Status
p.Status = status
if err := r.put(ctx, p); err != nil {
return err
}
return r.eventBy(ctx, p, "PRESSURE_STATUS_CHANGED", actor,
fmt.Sprintf("%s -> %s: %s", previous, status, reason))
}
// LinkHypothesis records that a hypothesis addresses this pressure.
func (r *PressureRegistry) LinkHypothesis(ctx context.Context, id contract.PressureID, h contract.HypothesisID) error {
if err := contract.RequireKind(string(h), contract.KindHypothesis); err != nil {
return err
}
p, err := r.Get(ctx, id)
if err != nil {
return err
}
for _, existing := range p.LinkedHypotheses {
if existing == h {
return nil
}
}
p.LinkedHypotheses = append(p.LinkedHypotheses, h)
if p.Status == contract.FluidPressureStatusOPEN {
p.Status = contract.FluidPressureStatusANALYZING
}
if err := r.put(ctx, p); err != nil {
return err
}
return r.event(ctx, p, "PRESSURE_HYPOTHESIS_LINKED", fmt.Sprintf("linked %s", h))
}
func (r *PressureRegistry) put(ctx context.Context, p contract.FluidPressure) error {
body, err := json.Marshal(contract.PressureDocument{FluidPressure: p})
if err != nil {
return err
}
return r.store.PutRecord(ctx, contract.KindPressure, string(p.ID), body)
}
func (r *PressureRegistry) event(ctx context.Context, p contract.FluidPressure, kind, reason string) error {
return r.eventBy(ctx, p, kind,
contract.Actor{Type: contract.ActorTypeSystem, ID: "fluid-pressure-engine"}, reason)
}
func (r *PressureRegistry) eventBy(ctx context.Context, p contract.FluidPressure, kind string, actor contract.Actor, reason string) error {
return r.store.AppendEvent(ctx, contract.FluidEvent{
SchemaVersion: "0.1",
ID: contract.EventID(fmt.Sprintf("EV-%s-%d", p.ID, r.now().UnixNano())),
OccurredAt: r.now().UTC(),
EntityType: contract.FluidEventEntityTypePressure,
EntityID: string(p.ID),
EventType: kind,
Actor: actor,
Reason: reason,
EvidenceRefs: p.EvidenceRefs,
})
}
func unit(v *contract.UnitInterval) float64 {
if v == nil {
return 0
}
return float64(*v)
}
func mergeCohorts(a, b []contract.CohortID) []contract.CohortID {
seen := map[contract.CohortID]struct{}{}
var out []contract.CohortID
for _, list := range [][]contract.CohortID{a, b} {
for _, c := range list {
if _, ok := seen[c]; ok {
continue
}
seen[c] = struct{}{}
out = append(out, c)
}
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func mergeRefs(a, b []contract.EvidenceRef) []contract.EvidenceRef {
seen := map[contract.EvidenceRef]struct{}{}
var out []contract.EvidenceRef
for _, list := range [][]contract.EvidenceRef{a, b} {
for _, s := range list {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}