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
326 lines
10 KiB
Go
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
|
|
}
|