fluid-core/internal/observation/topology.go
tegwick e779d1f8b9 Add redaction filter, cohort engine and topology analyzer
FLUID-WP-0005 T02-T04. Redaction is deterministic configuration rather
than a heuristic: the evidence store is append-only, so anything it
accepts cannot be taken back out. Consumer identities are pseudonymized
with HMAC rather than a bare hash, since a plain digest of a short
identifier is reversible by enumeration.

Applied rules are recorded on each event, so later analysis knows what
it cannot see instead of mistaking an absence of evidence for evidence
of absence. Redacted query parameters keep their key: which parameters a
consumer sent is itself interface evidence.

The topology analyzer requires several independent consumers before
calling a shape a pattern. One client looping is a client bug; several
converging on the same workaround is interface pressure. It also
distinguishes a recovered error from a hard failure, because those two
deserve different hypotheses.

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:08:05 +02:00

306 lines
8.4 KiB
Go

package observation
import (
"sort"
"strings"
"time"
"github.com/tegwick/fluid-core/internal/contract"
)
// Interaction is one consumer's ordered call chain.
//
// ArchitectureBlueprint.md section 6.4: the analyzer looks beyond individual
// requests, because interaction topologies are often more informative than
// simple error counts. A single 200 tells you nothing; the same 200 fetched
// forty times to find one record tells you the interface is missing a concept.
type Interaction struct {
ConsumerRef string
Cohort contract.CohortID
Started time.Time
Ended time.Time
Steps []Step
}
// Step is one call within an interaction.
type Step struct {
Route string
Method string
Status int64
Error contract.FluidTelemetryErrorClass
}
// Signature renders an interaction as a comparable shape.
//
// Routes are used rather than concrete URLs so that two consumers doing the
// same thing to different resources produce the same signature. Without that
// normalization every chain is unique and no pattern is ever detected twice.
func (i Interaction) Signature() string {
parts := make([]string, 0, len(i.Steps))
for _, s := range i.Steps {
part := s.Method + " " + s.Route
if s.Error != "" {
part += " !" + string(s.Error)
}
parts = append(parts, part)
}
return strings.Join(parts, " -> ")
}
// Pattern is a recurring interaction shape observed across consumers.
type Pattern struct {
Signature string `json:"signature"`
Steps int `json:"steps"`
Count int `json:"occurrences"`
Consumers int `json:"independent_consumers"`
Cohorts []contract.CohortID `json:"cohorts"`
// RepeatedStep names a route called more than once in the same chain, which
// is the usual shape of a consumer compensating for a missing capability.
RepeatedStep string `json:"repeated_step,omitempty"`
// MaxRepeats is how many times that route appeared in the worst chain.
MaxRepeats int `json:"max_repeats,omitempty"`
// RecoveredError names an error the consumer hit and then worked past,
// which distinguishes a recoverable misunderstanding from a hard failure.
RecoveredError contract.FluidTelemetryErrorClass `json:"recovered_error,omitempty"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
}
// TopologyAnalyzer groups telemetry into interactions and finds patterns.
type TopologyAnalyzer struct {
// ChainGap is the idle time after which a consumer's next call starts a new
// interaction rather than continuing the previous one.
ChainGap time.Duration
// MinOccurrences is how often a shape must appear before it is a pattern.
MinOccurrences int
// MinConsumers is how many independent consumers must show the shape.
// One consumer repeating itself is a client bug; several independent
// consumers converging on the same workaround is interface pressure.
MinConsumers int
}
// NewTopologyAnalyzer returns an analyzer with workable defaults.
func NewTopologyAnalyzer() *TopologyAnalyzer {
return &TopologyAnalyzer{
ChainGap: 30 * time.Second,
MinOccurrences: 3,
MinConsumers: 2,
}
}
// Interactions groups events into per-consumer call chains.
//
// Grouping prefers an explicit chain id when the consumer supplied one, and
// falls back to time-bounded sessions per consumer. The fallback is a heuristic
// and is why chain ids are worth asking agentic consumers for.
func (a *TopologyAnalyzer) Interactions(events []contract.FluidTelemetry) []Interaction {
ordered := make([]contract.FluidTelemetry, len(events))
copy(ordered, events)
sort.SliceStable(ordered, func(i, j int) bool {
return ordered[i].OccurredAt.Before(ordered[j].OccurredAt)
})
type key struct{ consumer, chain string }
open := map[key]*Interaction{}
var done []Interaction
for _, ev := range ordered {
if ev.Request == nil && ev.Error == nil {
continue
}
consumer := ev.ConsumerRef
if consumer == "" {
consumer = ev.CorrelationID
}
if consumer == "" {
continue
}
chain := ""
if ev.Sequence != nil {
chain = ev.Sequence.ChainID
}
k := key{consumer: consumer, chain: chain}
current, ok := open[k]
// With no explicit chain id, an idle gap ends the interaction.
if ok && chain == "" && ev.OccurredAt.Sub(current.Ended) > a.ChainGap {
done = append(done, *current)
ok = false
}
if !ok {
cohort := contract.CohortID("")
if ev.Cohort != nil {
cohort = *ev.Cohort
}
current = &Interaction{
ConsumerRef: consumer,
Cohort: cohort,
Started: ev.OccurredAt,
}
open[k] = current
}
current.Ended = ev.OccurredAt
current.Steps = append(current.Steps, stepOf(ev))
}
for _, in := range open {
done = append(done, *in)
}
sort.Slice(done, func(i, j int) bool { return done[i].Started.Before(done[j].Started) })
return done
}
func stepOf(ev contract.FluidTelemetry) Step {
var s Step
if ev.Request != nil {
s.Route = ev.Request.Route
s.Method = ev.Request.Method
if ev.Request.Status != nil {
s.Status = *ev.Request.Status
}
}
if ev.Error != nil {
s.Error = ev.Error.Class
}
if s.Route == "" {
s.Route = "(unknown)"
}
return s
}
// Patterns reports recurring interaction shapes.
func (a *TopologyAnalyzer) Patterns(events []contract.FluidTelemetry) []Pattern {
interactions := a.Interactions(events)
type acc struct {
count int
consumers map[string]struct{}
cohorts map[contract.CohortID]struct{}
steps int
repeated string
maxRepeats int
recovered contract.FluidTelemetryErrorClass
first time.Time
last time.Time
}
groups := map[string]*acc{}
for _, in := range interactions {
if len(in.Steps) == 0 {
continue
}
sig := in.Signature()
g, ok := groups[sig]
if !ok {
g = &acc{
consumers: map[string]struct{}{},
cohorts: map[contract.CohortID]struct{}{},
steps: len(in.Steps),
first: in.Started,
last: in.Ended,
}
groups[sig] = g
}
g.count++
g.consumers[in.ConsumerRef] = struct{}{}
if in.Cohort != "" {
g.cohorts[in.Cohort] = struct{}{}
}
if in.Started.Before(g.first) {
g.first = in.Started
}
if in.Ended.After(g.last) {
g.last = in.Ended
}
if route, n := repeatedRoute(in); n > g.maxRepeats {
g.repeated, g.maxRepeats = route, n
}
if class, ok := recoveredError(in); ok {
g.recovered = class
}
}
out := make([]Pattern, 0, len(groups))
for sig, g := range groups {
if g.count < a.MinOccurrences || len(g.consumers) < a.MinConsumers {
continue
}
cohorts := make([]contract.CohortID, 0, len(g.cohorts))
for c := range g.cohorts {
cohorts = append(cohorts, c)
}
sort.Slice(cohorts, func(i, j int) bool { return cohorts[i] < cohorts[j] })
p := Pattern{
Signature: sig,
Steps: g.steps,
Count: g.count,
Consumers: len(g.consumers),
Cohorts: cohorts,
FirstSeen: g.first,
LastSeen: g.last,
}
if g.maxRepeats > 1 {
p.RepeatedStep, p.MaxRepeats = g.repeated, g.maxRepeats
}
p.RecoveredError = g.recovered
out = append(out, p)
}
// Most frequent first: an analyst reading this wants the biggest signal at
// the top, and a stable tiebreak keeps the output diffable.
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Signature < out[j].Signature
})
return out
}
// repeatedRoute finds the most-repeated route within one interaction.
func repeatedRoute(in Interaction) (string, int) {
counts := map[string]int{}
for _, s := range in.Steps {
counts[s.Method+" "+s.Route]++
}
best, bestN := "", 0
routes := make([]string, 0, len(counts))
for r := range counts {
routes = append(routes, r)
}
sort.Strings(routes)
for _, r := range routes {
if counts[r] > bestN {
best, bestN = r, counts[r]
}
}
return best, bestN
}
// recoveredError reports an error the consumer hit and then got past.
//
// This is the shape Blueprint 6.4 calls out as a recoverable misunderstanding:
// invalid request, schema lookup, retry with a corrected request. It is a
// different problem from a chain that simply fails, and conflating the two
// would send the wrong hypothesis to the Daimon.
func recoveredError(in Interaction) (contract.FluidTelemetryErrorClass, bool) {
var seen contract.FluidTelemetryErrorClass
for _, s := range in.Steps {
if s.Error != "" {
seen = s.Error
continue
}
if seen != "" && s.Status >= 200 && s.Status < 300 {
return seen, true
}
}
return "", false
}