Add telemetry ingest, feedback collector, pressure API and insight CLI
Some checks failed
ci / build (push) Failing after 3h11m37s

Completes FLUID-WP-0005. Normalization and redaction live on one path,
shared by the in-process emitter and the ingest endpoint: two paths with
two normalizations would eventually disagree, and the disagreement would
surface as a pressure finding that is really a pipeline bug.

Telemetry kind is inferred from event shape rather than defaulting to
"request", since an error filed as a request understates the interface's
failure rate. A malformed event in a batch does not discard the rest.

Feedback is stored as evidence and creates no pressure and no hypothesis
on its own, per API Standards 15, with the consumer recorded as the
actor so their untrusted status stays visible in the audit trail. The
feedback endpoint is the only consumer-reachable part of the control
plane.

The observation endpoints are not served at all when no pseudonymization
salt is configured, rather than served with a generated one: a salt that
changed per run would make the same consumer look new every time and
every cohort count wrong.

Adds an end-to-end test driving real traffic through the gateway and
confirming it becomes a classified pressure record, with no raw consumer
identity reaching the store.

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:
tegwick 2026-09-04 03:19:06 +02:00
parent 6e705aa0af
commit 7e0de9e5b7
13 changed files with 1307 additions and 29 deletions

View file

@ -30,6 +30,7 @@ import (
"github.com/tegwick/fluid-core/internal/control"
"github.com/tegwick/fluid-core/internal/evidence"
"github.com/tegwick/fluid-core/internal/intent"
"github.com/tegwick/fluid-core/internal/observation"
"github.com/tegwick/fluid-core/internal/policy"
"github.com/tegwick/fluid-core/internal/publish"
"github.com/tegwick/fluid-core/internal/signing"
@ -49,6 +50,8 @@ func run() error {
keyID = flag.String("key-id", envOr("FLUID_SIGNING_KEY_ID", "dev"), "signing key identifier")
keyFile = flag.String("key-file", os.Getenv("FLUID_SIGNING_KEY"), "base64 ed25519 private key file")
ephemeral = flag.Bool("ephemeral-key", false, "generate a throwaway signing key (development only)")
saltFile = flag.String("redaction-salt-file", os.Getenv("FLUID_REDACTION_SALT"),
"file holding the pseudonymization salt; required for the observation plane")
)
flag.Parse()
@ -83,9 +86,33 @@ func run() error {
return err
}
// The observation plane is optional. Without a salt there is no safe way to
// pseudonymize consumer identities, so the endpoints that would record them
// are simply not served rather than served unsafely.
var pressureAPI *control.PressureAPI
if *saltFile != "" {
salt, err := os.ReadFile(*saltFile)
if err != nil {
return fmt.Errorf("read redaction salt: %w", err)
}
policy := observation.DefaultRedactionPolicy([]byte(trimSpace(string(salt))))
ingest, err := observation.NewIngest(ev, contract.InterfaceID(*iface), policy)
if err != nil {
return err
}
pressureAPI = control.NewPressureAPI(
observation.NewPressureRegistry(ev, contract.InterfaceID(*iface)), ingest)
} else {
log.Print("no redaction salt configured: telemetry, feedback and pressure endpoints are disabled")
}
srv := &http.Server{
Addr: *addr,
Handler: control.NewServer(control.NewRevisionAPI(ev, pipeline), control.NewIntentAPI(intents, gate)).Routes(),
Addr: *addr,
Handler: control.NewServer(
control.NewRevisionAPI(ev, pipeline),
control.NewIntentAPI(intents, gate),
pressureAPI,
).Routes(),
ReadHeaderTimeout: 10 * time.Second,
}

385
cmd/fluid/insight.go Normal file
View file

@ -0,0 +1,385 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"time"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/evidence"
"github.com/tegwick/fluid-core/internal/fitness"
"github.com/tegwick/fluid-core/internal/observation"
)
// ---------- pressure ----------
func runPressure(ctx context.Context, g globals, args []string) error {
if len(args) == 0 {
return errors.New("pressure needs a subcommand: list, show, analyze, dismiss")
}
iface, err := g.requireInterface()
if err != nil {
return err
}
store, err := g.open(ctx)
if err != nil {
return err
}
defer store.Close()
reg := observation.NewPressureRegistry(store, contract.InterfaceID(iface))
switch args[0] {
case "list":
fs := newFlagSet("pressure list")
status := fs.String("status", "", "filter by status, such as OPEN or DISMISSED")
if err := fs.Parse(args[1:]); err != nil {
return err
}
list, err := reg.List(ctx, contract.FluidPressureStatus(*status))
if err != nil {
return err
}
if len(list) == 0 {
fmt.Println("no pressure recorded")
return nil
}
w := out()
fmt.Fprintln(w, "ID\tCLASS\tSEV\tCONF\tCONSUMERS\tSTATUS\tSUMMARY")
for _, p := range list {
consumers := int64(0)
if p.Frequency != nil && p.Frequency.IndependentConsumers != nil {
consumers = *p.Frequency.IndependentConsumers
}
fmt.Fprintf(w, "%s\t%s\t%.2f\t%.2f\t%d\t%s\t%s\n",
p.ID, p.Class, unitOf(p.Severity), unitOf(p.Confidence),
consumers, p.Status, truncate(oneLine(p.Summary), 60))
}
return w.Flush()
case "show":
if len(args) < 2 {
return errors.New("pressure show needs an id")
}
p, err := reg.Get(ctx, contract.PressureID(args[1]))
if err != nil {
return err
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(p)
case "analyze":
return analyzePressure(ctx, g, store, reg, args[1:])
case "dismiss":
fs := newFlagSet("pressure dismiss")
reason := fs.String("reason", "", "why this pressure will not be acted on")
if err := fs.Parse(args[1:]); err != nil {
return err
}
rest := fs.fs.Args()
if len(rest) == 0 {
return errors.New("pressure dismiss needs an id")
}
if *reason == "" {
return errors.New("dismissal requires --reason; an unexplained dismissal is not auditable")
}
actor := contract.Actor{Type: contract.ActorTypeHuman, ID: operator()}
if err := reg.SetStatus(ctx, contract.PressureID(rest[0]),
contract.FluidPressureStatusDISMISSED, actor, *reason); err != nil {
return err
}
fmt.Printf("dismissed %s\n", rest[0])
return nil
default:
return fmt.Errorf("unknown pressure subcommand %q", args[0])
}
}
// analyzePressure runs the classifier over recorded telemetry.
//
// Analysis is an explicit command rather than something that happens on ingest.
// Blueprint section 30 wants adaptive work to be budgeted and deferrable, and a
// classifier that ran on every event would be neither.
func analyzePressure(ctx context.Context, g globals, store *evidence.SQLStore, reg *observation.PressureRegistry, args []string) error {
fs := newFlagSet("pressure analyze")
since := fs.String("since", "168h", "how far back to analyze, as a Go duration")
dryRun := fs.Bool("dry-run", false, "report findings without recording them")
if err := fs.Parse(args); err != nil {
return err
}
window, err := time.ParseDuration(*since)
if err != nil {
return fmt.Errorf("invalid --since: %w", err)
}
events, err := store.Telemetry(ctx, evidence.TelemetryFilter{
InterfaceID: contract.InterfaceID(g.iface),
Since: time.Now().Add(-window),
})
if err != nil {
return err
}
if len(events) == 0 {
fmt.Println("no telemetry in the window; nothing to analyze")
return nil
}
classifier := observation.NewClassifier(
observation.DefaultClassifierOptions(), observation.NewTopologyAnalyzer())
findings := classifier.Classify(events)
if len(findings) == 0 {
fmt.Printf("analyzed %d events; no material pressure found\n", len(events))
return nil
}
w := out()
fmt.Fprintln(w, "CLASS\tSEV\tCONF\tCONSUMERS\tSUMMARY")
for _, f := range findings {
fmt.Fprintf(w, "%s\t%.2f\t%.2f\t%d\t%s\n",
f.Class, f.Severity, f.Confidence, f.Consumers, truncate(oneLine(f.Summary), 70))
}
if err := w.Flush(); err != nil {
return err
}
if *dryRun {
fmt.Printf("\n%d finding(s) from %d events; not recorded (--dry-run)\n", len(findings), len(events))
return nil
}
recorded, err := reg.RecordAll(ctx, findings)
if err != nil {
return err
}
fmt.Printf("\nrecorded %d pressure record(s) from %d events\n", len(recorded), len(events))
return nil
}
// ---------- cohorts ----------
func runCohort(ctx context.Context, g globals, args []string) error {
iface, err := g.requireInterface()
if err != nil {
return err
}
store, err := g.open(ctx)
if err != nil {
return err
}
defer store.Close()
fs := newFlagSet("cohort list")
since := fs.String("since", "168h", "how far back to summarize")
saltFile := fs.String("salt-file", os.Getenv("FLUID_REDACTION_SALT"), "pseudonymization salt file")
if err := fs.Parse(args); err != nil {
return err
}
window, err := time.ParseDuration(*since)
if err != nil {
return fmt.Errorf("invalid --since: %w", err)
}
policy, err := loadPolicy(*saltFile)
if err != nil {
return err
}
events, err := store.Telemetry(ctx, evidence.TelemetryFilter{
InterfaceID: contract.InterfaceID(iface),
Since: time.Now().Add(-window),
})
if err != nil {
return err
}
pops := observation.NewCohortEngine("unclassified", policy).Populations(events)
if len(pops) == 0 {
fmt.Println("no cohort activity in the window")
return nil
}
w := out()
fmt.Fprintln(w, "COHORT\tCONSUMERS\tEVENTS")
for _, p := range pops {
consumers := fmt.Sprint(p.Consumers)
if p.Suppressed {
// Reporting the exact count of a tiny cohort identifies individuals.
consumers = fmt.Sprintf("<%d (suppressed)", policy.CohortMinimumSize)
}
fmt.Fprintf(w, "%s\t%s\t%d\n", p.Cohort, consumers, p.Events)
}
return w.Flush()
}
// ---------- fitness ----------
func runFitness(ctx context.Context, g globals, args []string) error {
if len(args) == 0 || args[0] != "compare" {
return errors.New("usage: fluid fitness compare --control R-1 --candidate R-2")
}
iface, err := g.requireInterface()
if err != nil {
return err
}
store, err := g.open(ctx)
if err != nil {
return err
}
defer store.Close()
fs := newFlagSet("fitness compare")
control := fs.String("control", "", "control revision")
candidate := fs.String("candidate", "", "candidate revision")
since := fs.String("since", "168h", "measurement window")
target := fs.Float64("target-requests-per-task", 0, "primary target for requests per completed task")
latencyGuard := fs.Float64("guard-p95-latency-ms", 0, "p95 latency guardrail")
errorGuard := fs.Float64("guard-error-rate", 0, "error rate guardrail")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *control == "" || *candidate == "" {
return errors.New("fitness compare needs --control and --candidate")
}
window, err := time.ParseDuration(*since)
if err != nil {
return fmt.Errorf("invalid --since: %w", err)
}
start := time.Now().Add(-window)
events, err := store.Telemetry(ctx, evidence.TelemetryFilter{
InterfaceID: contract.InterfaceID(iface),
Since: start,
})
if err != nil {
return err
}
measureWindow := fitness.Window{Start: start}
observations := fitness.NewMeasurer().Measure(events, measureWindow)
// Specs are declared here rather than inferred from the data, so that the
// criteria a comparison was judged against are visible in the command that
// ran it.
var specs []fitness.MetricSpec
if *target > 0 {
specs = append(specs, fitness.MetricSpec{
Name: fitness.MetricRequestsPerTask, Role: fitness.RolePrimary,
Direction: fitness.Lower, Target: target,
})
}
if *latencyGuard > 0 {
specs = append(specs, fitness.MetricSpec{
Name: fitness.MetricP95LatencyMS, Role: fitness.RoleGuardrail,
Direction: fitness.Lower, Threshold: latencyGuard,
})
}
if *errorGuard > 0 {
specs = append(specs, fitness.MetricSpec{
Name: fitness.MetricErrorRate, Role: fitness.RoleGuardrail,
Direction: fitness.Lower, Threshold: errorGuard,
})
}
if len(specs) == 0 {
return errors.New("no criteria given: pass at least --target-requests-per-task")
}
eval := fitness.NewEvaluator().Evaluate(
contract.RevisionID(*control), contract.RevisionID(*candidate),
measureWindow, specs, observations)
fmt.Printf("Fitness: %s vs %s\nWindow: since %s\nVerdict: %s\n\n",
eval.Control, eval.Candidate, start.Format(time.RFC3339), eval.Verdict)
w := out()
fmt.Fprintln(w, "METRIC\tROLE\tBASELINE\tCURRENT\tDELTA\tOUTCOME")
for _, m := range eval.Metrics {
outcome := ""
switch {
case m.Underpowered:
outcome = "underpowered"
case m.TargetMet != nil && *m.TargetMet:
outcome = "target met"
case m.TargetMet != nil:
outcome = "target missed"
case m.GuardrailBreached != nil && *m.GuardrailBreached:
outcome = "BREACHED"
case m.GuardrailBreached != nil:
outcome = "within guardrail"
}
fmt.Fprintf(w, "%s\t%s\t%.4g\t%.4g\t%+.4g\t%s\n",
m.Name, m.Role, m.Baseline, m.Current, m.Delta, outcome)
}
if err := w.Flush(); err != nil {
return err
}
if len(eval.Reasons) > 0 {
fmt.Println()
for _, r := range eval.Reasons {
fmt.Printf(" %s\n", r)
}
}
return nil
}
// ---------- helpers ----------
// loadPolicy builds a redaction policy from a salt file.
//
// It refuses to invent a salt. A generated one would change on every
// invocation, so the same consumer would look like a new consumer each time and
// every cohort count would be wrong.
func loadPolicy(saltFile string) (observation.RedactionPolicy, error) {
if saltFile == "" {
return observation.RedactionPolicy{}, errors.New(
"no redaction salt: pass --salt-file or set FLUID_REDACTION_SALT")
}
salt, err := os.ReadFile(saltFile)
if err != nil {
return observation.RedactionPolicy{}, fmt.Errorf("read salt: %w", err)
}
policy := observation.DefaultRedactionPolicy([]byte(trimSpaceStr(string(salt))))
if err := policy.Validate(); err != nil {
return observation.RedactionPolicy{}, err
}
return policy, nil
}
func trimSpaceStr(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\n' || s[start] == '\t' || s[start] == '\r') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\n' || s[end-1] == '\t' || s[end-1] == '\r') {
end--
}
return s[start:end]
}
func unitOf(v *contract.UnitInterval) float64 {
if v == nil {
return 0
}
return float64(*v)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}

View file

@ -30,6 +30,11 @@ Commands:
revision show Show one revision descriptor
policy put Install a routing policy
policy show Show the current routing policy
pressure list List recorded interface pressure
pressure analyze Classify recent telemetry into pressure records
pressure dismiss Record that a pressure will not be acted on
cohort Summarize cohort populations
fitness compare Compare a candidate revision against its control
events Show audit events
telemetry Show recorded telemetry
audit trace Reconstruct the history behind a revision
@ -113,6 +118,12 @@ func run(args []string) error {
return runEvents(ctx, g, rest)
case "telemetry":
return runTelemetry(ctx, g, rest)
case "pressure":
return runPressure(ctx, g, rest)
case "cohort":
return runCohort(ctx, g, rest)
case "fitness":
return runFitness(ctx, g, rest)
case "audit":
return runAudit(ctx, g, rest)
default:

View file

@ -0,0 +1,154 @@
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/evidence"
"github.com/tegwick/fluid-core/internal/observation"
"github.com/tegwick/fluid-core/internal/runtime"
)
// TestInsightLoopEndToEnd drives real traffic through the gateway and confirms
// the observation plane turns it into a pressure record.
//
// This is the Blueprint section 33 shape end to end: consumers repeatedly
// listing a collection to find one entry, observed at the gateway, redacted on
// ingest, and classified as inefficient usage without anyone naming it as such.
func TestInsightLoopEndToEnd(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
store, err := evidence.OpenSQLite(ctx, filepath.Join(dir, "e.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
policy := observation.DefaultRedactionPolicy([]byte("a-stable-salt-for-this-test-only"))
ingest, err := observation.NewIngest(store, "hall-publishing", policy)
if err != nil {
t.Fatal(err)
}
adapter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[{"id":1},{"id":2}]`))
}))
defer adapter.Close()
reg := runtime.NewRegistry("hall-publishing")
d := contract.Revision{
SchemaVersion: "0.1", ID: "R-1", Interface: "hall-publishing",
State: contract.RevisionStateStable,
Contract: contract.RevisionContract{Type: contract.RevisionContractTypeOpenapi, Digest: contract.Digest("sha256:" + repeat("1", 64))},
Runtime: contract.RevisionRuntime{Upstream: adapter.URL},
Intent: contract.RevisionIntent{Version: "IEI-1"},
Policy: contract.RevisionPolicy{
Compatibility: contract.RevisionPolicyCompatibilityAdditive,
SecurityCheck: contract.RevisionPolicySecurityCheckPassed,
},
}
if err := reg.PutRevision(d); err != nil {
t.Fatal(err)
}
if err := reg.PutPolicy(contract.RoutingPolicy{
SchemaVersion: "0.1", Interface: "hall-publishing",
Generation: 1, DefaultRevision: "R-1",
}); err != nil {
t.Fatal(err)
}
emitter := runtime.NewEmitter(ingest, runtime.EmitterOptions{Buffer: 4096, Workers: 2})
gw, err := runtime.NewGateway(runtime.GatewayOptions{
Interface: "hall-publishing",
Registry: reg,
Resolver: runtime.NewResolver(reg, false),
Connector: runtime.NewConnector(),
Emitter: emitter,
Cohorts: observation.NewCohortEngine("coding-agents", policy),
Response: runtime.ResponsePolicy{FeedbackPath: "/v1/feedback"},
})
if err != nil {
t.Fatal(err)
}
// Four independent consumers, each listing the collection four times per
// task to find one entry: the Blueprint section 33 shape.
for c := 0; c < 4; c++ {
for chain := 0; chain < 3; chain++ {
for i := 0; i < 4; i++ {
req := httptest.NewRequest(http.MethodGet, "/v1/hall-entries", nil)
req.Header.Set("X-FLUID-Consumer", fmt.Sprintf("agent-%d", c))
req.Header.Set("X-FLUID-Correlation", fmt.Sprintf("c-%d-%d", c, chain))
rec := httptest.NewRecorder()
gw.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("request failed: %d", rec.Code)
}
}
}
}
emitter.Close()
events, err := store.Telemetry(ctx, evidence.TelemetryFilter{InterfaceID: "hall-publishing"})
if err != nil {
t.Fatal(err)
}
if len(events) != 48 {
t.Fatalf("recorded %d telemetry events, want 48", len(events))
}
// The raw consumer header must never have reached the store.
for _, ev := range events {
if ev.ConsumerRef == "agent-0" {
t.Fatal("a raw consumer identity reached the evidence store")
}
}
classifier := observation.NewClassifier(
observation.DefaultClassifierOptions(), observation.NewTopologyAnalyzer())
findings := classifier.Classify(events)
if len(findings) == 0 {
t.Fatal("no pressure found in clearly inefficient traffic")
}
registry := observation.NewPressureRegistry(store, "hall-publishing")
recorded, err := registry.RecordAll(ctx, findings)
if err != nil {
t.Fatal(err)
}
if len(recorded) == 0 {
t.Fatal("no pressure recorded")
}
var inefficient *contract.FluidPressure
for i := range recorded {
if recorded[i].Class == contract.PressureClassSuccessfulButInefficientUsage {
inefficient = &recorded[i]
}
}
if inefficient == nil {
t.Fatalf("inefficient usage not recorded; got %v", recorded)
}
if inefficient.Frequency == nil || *inefficient.Frequency.IndependentConsumers != 4 {
t.Errorf("independent consumers not recorded correctly: %+v", inefficient.Frequency)
}
if len(inefficient.EvidenceRefs) == 0 {
t.Error("pressure recorded with no evidence references")
}
t.Logf("pressure %s: %s (severity %.2f, confidence %.2f, %d consumers)",
inefficient.ID, inefficient.Summary,
float64(*inefficient.Severity), float64(*inefficient.Confidence),
*inefficient.Frequency.IndependentConsumers)
}
func repeat(s string, n int) string { return strings.Repeat(s, n) }

View file

@ -10,6 +10,7 @@ const (
FluidEventEntityTypeHypothesis FluidEventEntityType = "hypothesis"
FluidEventEntityTypeRevision FluidEventEntityType = "revision"
FluidEventEntityTypeExperiment FluidEventEntityType = "experiment"
FluidEventEntityTypeFeedback FluidEventEntityType = "feedback"
FluidEventEntityTypeBackendRequirement FluidEventEntityType = "backend_requirement"
FluidEventEntityTypeIntent FluidEventEntityType = "intent"
FluidEventEntityTypeDecision FluidEventEntityType = "decision"
@ -19,7 +20,7 @@ const (
// Valid reports whether v is a defined FluidEventEntityType.
func (v FluidEventEntityType) Valid() bool {
switch v {
case FluidEventEntityTypePressure, FluidEventEntityTypeHypothesis, FluidEventEntityTypeRevision, FluidEventEntityTypeExperiment, FluidEventEntityTypeBackendRequirement, FluidEventEntityTypeIntent, FluidEventEntityTypeDecision, FluidEventEntityTypeRoutingPolicy:
case FluidEventEntityTypePressure, FluidEventEntityTypeHypothesis, FluidEventEntityTypeRevision, FluidEventEntityTypeExperiment, FluidEventEntityTypeFeedback, FluidEventEntityTypeBackendRequirement, FluidEventEntityTypeIntent, FluidEventEntityTypeDecision, FluidEventEntityTypeRoutingPolicy:
return true
}
return false

View file

@ -23,11 +23,13 @@ import (
type Server struct {
revisions *RevisionAPI
intents *IntentAPI
pressure *PressureAPI
}
// NewServer wires the control APIs.
func NewServer(rev *RevisionAPI, in *IntentAPI) *Server {
return &Server{revisions: rev, intents: in}
// NewServer wires the control APIs. The pressure API may be nil where an
// interface runs without an observation plane.
func NewServer(rev *RevisionAPI, in *IntentAPI, p *PressureAPI) *Server {
return &Server{revisions: rev, intents: in, pressure: p}
}
// Routes returns the control-plane mux.
@ -40,6 +42,14 @@ func (s *Server) Routes() *http.ServeMux {
mux.HandleFunc("/control/v1/intents/", s.intents.handleItem)
mux.HandleFunc("/control/v1/intents/active", s.intents.handleActive)
if s.pressure != nil {
mux.HandleFunc("/control/v1/pressure", s.pressure.handleCollection)
mux.HandleFunc("/control/v1/pressure/", s.pressure.handleItem)
mux.HandleFunc("/control/v1/telemetry", s.pressure.handleTelemetry)
// Consumer-reachable, unlike the rest of this surface.
mux.HandleFunc("/v1/feedback", s.pressure.handleFeedback)
}
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})

View file

@ -57,7 +57,7 @@ func newServer(t *testing.T) (*http.ServeMux, *evidence.SQLStore) {
t.Fatal(err)
}
srv := NewServer(NewRevisionAPI(store, pipeline), NewIntentAPI(intents, gate))
srv := NewServer(NewRevisionAPI(store, pipeline), NewIntentAPI(intents, gate), nil)
return srv.Routes(), store
}

View file

@ -0,0 +1,171 @@
package control
import (
"encoding/json"
"errors"
"net/http"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/observation"
)
// PressureAPI implements ArchitectureBlueprint.md section 44.2: record
// pressure, aggregate, link evidence, link hypothesis, close or dismiss.
type PressureAPI struct {
registry *observation.PressureRegistry
ingest *observation.Ingest
}
// NewPressureAPI returns the pressure API.
func NewPressureAPI(r *observation.PressureRegistry, in *observation.Ingest) *PressureAPI {
return &PressureAPI{registry: r, ingest: in}
}
func (a *PressureAPI) handleCollection(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
status := contract.FluidPressureStatus(r.URL.Query().Get("status"))
if status != "" && !status.Valid() {
writeError(w, http.StatusBadRequest, "unknown status filter")
return
}
list, err := a.registry.List(r.Context(), status)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not list pressure")
return
}
writeJSON(w, http.StatusOK, map[string]any{"pressures": list})
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (a *PressureAPI) handleItem(w http.ResponseWriter, r *http.Request) {
id := pathTail(r.URL.Path, "/control/v1/pressure")
if id == "" {
writeError(w, http.StatusNotFound, "no pressure named")
return
}
switch r.Method {
case http.MethodGet:
p, err := a.registry.Get(r.Context(), contract.PressureID(id))
if err != nil {
writeError(w, statusForStoreError(err), "pressure not found")
return
}
writeJSON(w, http.StatusOK, p)
case http.MethodPatch:
a.patch(w, r, contract.PressureID(id))
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
// PatchPressureRequest changes a pressure's disposition.
type PatchPressureRequest struct {
Status contract.FluidPressureStatus `json:"status,omitempty"`
Reason string `json:"reason,omitempty"`
Actor contract.Actor `json:"actor"`
Hypothesis contract.HypothesisID `json:"link_hypothesis,omitempty"`
}
func (a *PressureAPI) patch(w http.ResponseWriter, r *http.Request, id contract.PressureID) {
var req PatchPressureRequest
if err := decodeBody(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
return
}
if req.Actor.ID == "" {
// A disposition change with no actor cannot be audited, and dismissals
// are exactly the decisions worth attributing.
writeError(w, http.StatusBadRequest, "a disposition change must name its actor")
return
}
if req.Hypothesis != "" {
if err := a.registry.LinkHypothesis(r.Context(), id, req.Hypothesis); err != nil {
writeError(w, statusForStoreError(err), err.Error())
return
}
}
if req.Status != "" {
if err := a.registry.SetStatus(r.Context(), id, req.Status, req.Actor, req.Reason); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
p, err := a.registry.Get(r.Context(), id)
if err != nil {
writeError(w, statusForStoreError(err), "pressure not found")
return
}
writeJSON(w, http.StatusOK, p)
}
// handleTelemetry accepts telemetry from out-of-process adapters and consumers.
func (a *PressureAPI) handleTelemetry(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var batch struct {
Events []contract.FluidTelemetry `json:"events"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&batch); err != nil {
writeError(w, http.StatusBadRequest, "could not decode telemetry", err.Error())
return
}
accepted, rejected := a.ingest.WriteBatch(r.Context(), batch.Events)
causes := make([]string, 0, len(rejected))
for _, err := range rejected {
causes = append(causes, err.Error())
}
// Partial acceptance is reported rather than failed: telemetry is
// best-effort evidence and the good events are worth keeping.
writeJSON(w, http.StatusAccepted, map[string]any{
"accepted": accepted,
"rejected": len(rejected),
"causes": causes,
})
}
// handleFeedback accepts explicit consumer feedback.
//
// This endpoint is reachable by consumers, unlike the rest of the control
// plane. What it accepts is evidence, never authority: recording feedback
// creates no pressure and no hypothesis on its own.
func (a *PressureAPI) handleFeedback(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var f contract.FluidFeedback
if err := decodeBody(r, &f); err != nil {
writeError(w, http.StatusBadRequest, "could not decode feedback", err.Error())
return
}
stored, err := a.ingest.RecordFeedback(r.Context(), f)
if err != nil {
if errors.Is(err, observation.ErrWrongInterface) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"id": stored.ID,
"note": "recorded as evidence; feedback does not itself authorize an interface change",
})
}

View file

@ -0,0 +1,22 @@
package observation
import (
"encoding/json"
"github.com/tegwick/fluid-core/internal/contract"
)
// consumerLabel names the source of a piece of feedback for the audit trail.
//
// It prefers the cohort over anything consumer-specific: the trail needs to
// know what kind of consumer said this, not which one.
func consumerLabel(f contract.FluidFeedback) string {
if f.Cohort != nil && *f.Cohort != "" {
return string(*f.Cohort)
}
return "unclassified"
}
func marshalFeedback(f contract.FluidFeedback) ([]byte, error) {
return json.Marshal(contract.FeedbackDocument{FluidFeedback: f})
}

View file

@ -0,0 +1,191 @@
package observation
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/evidence"
)
// Ingest normalizes, redacts and stores telemetry.
//
// Redaction happens here rather than at the query side because the evidence
// store is append-only: anything written unredacted stays unredacted forever.
// The filter belongs on the way in, where there is still a decision to make.
type Ingest struct {
store evidence.Store
policy RedactionPolicy
iface contract.InterfaceID
now func() time.Time
}
// NewIngest returns an ingest pipeline.
func NewIngest(store evidence.Store, iface contract.InterfaceID, policy RedactionPolicy) (*Ingest, error) {
if err := policy.Validate(); err != nil {
return nil, err
}
return &Ingest{store: store, policy: policy, iface: iface, now: time.Now}, nil
}
// ErrWrongInterface reports telemetry submitted for another interface.
var ErrWrongInterface = errors.New("telemetry belongs to a different interface")
// Normalize fills in defaults and applies redaction.
//
// It is separated from Write so the same normalization runs whether an event
// arrives from the in-process emitter or over the ingest endpoint. Two paths
// with two normalizations would eventually disagree, and the disagreement would
// surface as a pressure finding that is really a bug in the pipeline.
func (i *Ingest) Normalize(ev contract.FluidTelemetry) (contract.FluidTelemetry, error) {
if ev.InterfaceID == "" {
ev.InterfaceID = i.iface
}
if ev.InterfaceID != i.iface {
return ev, fmt.Errorf("%w: event is for %q, ingest serves %q",
ErrWrongInterface, ev.InterfaceID, i.iface)
}
if ev.SchemaVersion == "" {
ev.SchemaVersion = "0.1"
}
if ev.ID == "" {
ev.ID = newID("tl-")
}
if ev.OccurredAt.IsZero() {
ev.OccurredAt = i.now().UTC()
}
ev.OccurredAt = ev.OccurredAt.UTC()
if ev.Kind == "" {
// Infer from shape rather than defaulting to "request": an error event
// filed as a request would understate the interface's failure rate.
switch {
case ev.Error != nil:
ev.Kind = contract.FluidTelemetryKindError
case ev.Adoption != nil:
ev.Kind = contract.FluidTelemetryKindAdoption
case ev.Sequence != nil:
ev.Kind = contract.FluidTelemetryKindSequence
default:
ev.Kind = contract.FluidTelemetryKindRequest
}
}
if !ev.Kind.Valid() {
return ev, fmt.Errorf("unknown telemetry kind %q", ev.Kind)
}
// Raw payload capture is not the default (Blueprint 6.2). Where a policy
// forbids it, error detail is the only free-text field that survives, and
// it is scrubbed below.
if !i.policy.AllowRawPayload && ev.Error != nil && ev.Error.Detail != "" {
if len(ev.Error.Detail) > 512 {
ev.Error.Detail = ev.Error.Detail[:512] + "…"
}
}
i.policy.Apply(&ev)
return ev, nil
}
// Write normalizes and stores one event.
func (i *Ingest) Write(ctx context.Context, ev contract.FluidTelemetry) error {
normalized, err := i.Normalize(ev)
if err != nil {
return err
}
return i.store.WriteTelemetry(ctx, normalized)
}
// WriteBatch stores several events, reporting how many landed.
//
// A malformed event in a batch does not discard the rest. Telemetry is
// best-effort evidence, and dropping a hundred good events because one was
// wrong would lose more than it protects.
func (i *Ingest) WriteBatch(ctx context.Context, events []contract.FluidTelemetry) (accepted int, rejected []error) {
for _, ev := range events {
if err := i.Write(ctx, ev); err != nil {
rejected = append(rejected, err)
continue
}
accepted++
}
return accepted, rejected
}
// RecordFeedback stores explicit consumer feedback.
//
// FluidAPIStandards.md section 15: feedback is evidence and must not itself
// authorize interface changes. It is stored as a record and an event, and
// nothing here creates pressure or a hypothesis from it — that stays a
// deliberate step someone takes.
func (i *Ingest) RecordFeedback(ctx context.Context, f contract.FluidFeedback) (contract.FluidFeedback, error) {
if f.Goal == "" {
// Feedback with no stated goal cannot be interpreted later; the goal is
// the part that says what the consumer was actually trying to do.
return f, errors.New("feedback must state a goal")
}
if f.SchemaVersion == "" {
f.SchemaVersion = "0.1"
}
if f.ID == "" {
f.ID = contract.FeedbackID(newID("F-"))
}
if err := contract.RequireKind(string(f.ID), contract.KindFeedback); err != nil {
return f, err
}
if f.InterfaceID == "" {
f.InterfaceID = i.iface
}
if f.InterfaceID != i.iface {
return f, fmt.Errorf("%w: feedback is for %q", ErrWrongInterface, f.InterfaceID)
}
if f.ReceivedAt.IsZero() {
f.ReceivedAt = i.now().UTC()
}
// Consumers write free text; it passes the same filter as everything else.
f.Goal, _ = i.policy.Scrub(f.Goal)
f.Outcome, _ = i.policy.Scrub(f.Outcome)
f.Attempt, _ = i.policy.Scrub(f.Attempt)
f.MissingCapability, _ = i.policy.Scrub(f.MissingCapability)
body, err := marshalFeedback(f)
if err != nil {
return f, err
}
if err := i.store.PutRecord(ctx, contract.KindFeedback, string(f.ID), body); err != nil {
return f, err
}
if err := i.store.AppendEvent(ctx, contract.FluidEvent{
SchemaVersion: "0.1",
ID: contract.EventID(fmt.Sprintf("EV-%s-%d", f.ID, i.now().UnixNano())),
OccurredAt: f.ReceivedAt,
EntityType: contract.FluidEventEntityTypeFeedback,
EntityID: string(f.ID),
EventType: "FEEDBACK_RECEIVED",
// The consumer is the actor, and a consumer is untrusted (Blueprint 47).
// Recording them as the actor keeps that visible in the audit trail.
Actor: contract.Actor{Type: contract.ActorTypeConsumer, ID: consumerLabel(f)},
Reason: f.Goal,
}); err != nil {
return f, err
}
return f, nil
}
// newID generates an identifier with the given prefix.
func newID(prefix string) string {
var b [10]byte
if _, err := rand.Read(b[:]); err != nil {
return prefix + "0"
}
return prefix + hex.EncodeToString(b[:])
}

View file

@ -0,0 +1,264 @@
package observation
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
_ "modernc.org/sqlite"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/evidence"
)
func newIngest(t *testing.T) (*Ingest, *evidence.SQLStore) {
t.Helper()
store, err := evidence.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "e.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
in, err := NewIngest(store, "hall-publishing", testPolicy())
if err != nil {
t.Fatal(err)
}
return in, store
}
func TestIngestRejectsUnsaltedPolicy(t *testing.T) {
store, err := evidence.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "e.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
if _, err := NewIngest(store, "x", RedactionPolicy{}); !errors.Is(err, ErrNoSalt) {
t.Errorf("an unsalted ingest was constructed: %v", err)
}
}
// TestRedactionHappensOnTheWayIn: the store is append-only, so anything written
// unredacted stays that way forever.
func TestRedactionHappensOnTheWayIn(t *testing.T) {
ctx := context.Background()
in, store := newIngest(t)
err := in.Write(ctx, contract.FluidTelemetry{
ConsumerRef: "bernd@example.com",
Request: &contract.FluidTelemetryRequest{Route: "/v1/entries?token=hunter2", Method: "GET"},
})
if err != nil {
t.Fatal(err)
}
rows, err := store.Telemetry(ctx, evidence.TelemetryFilter{})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("stored %d rows, want 1", len(rows))
}
stored := rows[0]
if strings.Contains(stored.ConsumerRef, "@") {
t.Errorf("raw consumer identity reached the store: %q", stored.ConsumerRef)
}
if strings.Contains(stored.Request.Route, "hunter2") {
t.Errorf("credential reached the store: %q", stored.Request.Route)
}
if stored.Redaction == nil || !stored.Redaction.Applied {
t.Error("redaction was applied but not recorded on the stored event")
}
}
// TestKindIsInferredFromShape: an error event filed as a request would
// understate the interface's failure rate.
func TestKindIsInferredFromShape(t *testing.T) {
in, _ := newIngest(t)
got, err := in.Normalize(contract.FluidTelemetry{
Error: &contract.FluidTelemetryError{Class: contract.FluidTelemetryErrorClassTimeout},
})
if err != nil {
t.Fatal(err)
}
if got.Kind != contract.FluidTelemetryKindError {
t.Errorf("kind = %s, want error", got.Kind)
}
got, err = in.Normalize(contract.FluidTelemetry{
Adoption: &contract.FluidTelemetryAdoption{Event: contract.FluidTelemetryAdoptionEventFirstUse},
})
if err != nil {
t.Fatal(err)
}
if got.Kind != contract.FluidTelemetryKindAdoption {
t.Errorf("kind = %s, want adoption", got.Kind)
}
}
func TestNormalizeFillsDefaultsAndGuardsInterface(t *testing.T) {
in, _ := newIngest(t)
got, err := in.Normalize(contract.FluidTelemetry{})
if err != nil {
t.Fatal(err)
}
if got.ID == "" || got.OccurredAt.IsZero() || got.SchemaVersion == "" {
t.Errorf("defaults not filled: %+v", got)
}
if got.InterfaceID != "hall-publishing" {
t.Errorf("interface = %q", got.InterfaceID)
}
// Another interface's telemetry must not land in this evidence store.
if _, err := in.Normalize(contract.FluidTelemetry{InterfaceID: "some-other-api"}); !errors.Is(err, ErrWrongInterface) {
t.Errorf("foreign telemetry accepted: %v", err)
}
}
// TestBatchKeepsGoodEventsWhenOneIsBad: dropping a hundred good events because
// one was malformed loses more than it protects.
func TestBatchKeepsGoodEventsWhenOneIsBad(t *testing.T) {
ctx := context.Background()
in, store := newIngest(t)
accepted, rejected := in.WriteBatch(ctx, []contract.FluidTelemetry{
{Request: &contract.FluidTelemetryRequest{Route: "/a"}},
{InterfaceID: "wrong-interface"},
{Request: &contract.FluidTelemetryRequest{Route: "/b"}},
})
if accepted != 2 {
t.Errorf("accepted = %d, want 2", accepted)
}
if len(rejected) != 1 {
t.Errorf("rejected = %d, want 1", len(rejected))
}
rows, _ := store.Telemetry(ctx, evidence.TelemetryFilter{})
if len(rows) != 2 {
t.Errorf("stored %d rows, want 2", len(rows))
}
}
func TestErrorDetailIsBoundedWhenRawPayloadIsOff(t *testing.T) {
in, _ := newIngest(t)
long := strings.Repeat("x", 2000)
got, err := in.Normalize(contract.FluidTelemetry{
Error: &contract.FluidTelemetryError{
Class: contract.FluidTelemetryErrorClassBackendFailure,
Detail: long,
},
})
if err != nil {
t.Fatal(err)
}
if len(got.Error.Detail) > 600 {
t.Errorf("error detail was not bounded: %d chars", len(got.Error.Detail))
}
}
func TestFeedbackIsStoredAsEvidence(t *testing.T) {
ctx := context.Background()
in, store := newIngest(t)
cohort := contract.CohortID("coding-agents")
got, err := in.RecordFeedback(ctx, contract.FluidFeedback{
Cohort: &cohort,
Goal: "publish a hall entry without splitting it by hand",
MissingCapability: "long-form serialization",
Outcome: "capability unavailable, contact bernd@example.com",
})
if err != nil {
t.Fatal(err)
}
if got.ID == "" || !strings.HasPrefix(string(got.ID), "F-") {
t.Errorf("feedback id = %q, want an F- prefix", got.ID)
}
// Consumers write free text; it passes the same filter as everything else.
if strings.Contains(got.Outcome, "bernd@example.com") {
t.Errorf("an address survived in feedback: %q", got.Outcome)
}
if _, err := store.Record(ctx, contract.KindFeedback, string(got.ID)); err != nil {
t.Errorf("feedback was not persisted: %v", err)
}
events, err := store.Events(ctx, evidence.EventFilter{EntityID: string(got.ID)})
if err != nil {
t.Fatal(err)
}
if len(events) != 1 {
t.Fatalf("feedback left %d events, want 1", len(events))
}
// A consumer is untrusted; recording them as the actor keeps that visible.
if events[0].Actor.Type != contract.ActorTypeConsumer {
t.Errorf("actor type = %s, want consumer", events[0].Actor.Type)
}
}
func TestFeedbackNeedsAGoal(t *testing.T) {
in, _ := newIngest(t)
// Without a goal there is nothing to interpret later.
if _, err := in.RecordFeedback(context.Background(), contract.FluidFeedback{
Outcome: "it did not work",
}); err == nil {
t.Error("feedback with no goal was accepted")
}
}
// TestFeedbackDoesNotCreatePressure is the section 15 boundary: feedback is
// evidence and must not itself authorize a change.
func TestFeedbackDoesNotCreatePressure(t *testing.T) {
ctx := context.Background()
in, store := newIngest(t)
if _, err := in.RecordFeedback(ctx, contract.FluidFeedback{
Goal: "I need a bulk publish endpoint",
MissingCapability: "bulk publish",
}); err != nil {
t.Fatal(err)
}
records, err := store.Records(ctx, contract.KindPressure)
if err != nil {
t.Fatal(err)
}
if len(records) != 0 {
t.Errorf("feedback created %d pressure records on its own", len(records))
}
}
func TestIngestIsUsableAsARuntimeSink(t *testing.T) {
ctx := context.Background()
in, store := newIngest(t)
// The gateway's emitter writes through this interface; redaction must apply
// on that path too, not only to events arriving over the endpoint.
var sink interface {
Write(context.Context, contract.FluidTelemetry) error
} = in
if err := sink.Write(ctx, contract.FluidTelemetry{
ConsumerRef: "raw-identity",
OccurredAt: time.Now(),
Request: &contract.FluidTelemetryRequest{Route: "/v1/x"},
}); err != nil {
t.Fatal(err)
}
rows, _ := store.Telemetry(ctx, evidence.TelemetryFilter{})
if len(rows) != 1 {
t.Fatalf("stored %d rows", len(rows))
}
if rows[0].ConsumerRef == "raw-identity" {
t.Error("the emitter path bypassed redaction")
}
}

View file

@ -8,28 +8,70 @@
"fluid_event": {
"type": "object",
"properties": {
"schema_version": { "$ref": "common.schema.json#/$defs/schemaVersion" },
"id": { "$ref": "common.schema.json#/$defs/eventId" },
"occurred_at": { "$ref": "common.schema.json#/$defs/timestamp" },
"entity_type": { "enum": ["pressure", "hypothesis", "revision", "experiment", "backend_requirement", "intent", "decision", "routing_policy"] },
"entity_id": { "type": "string", "minLength": 1 },
"event_type": { "type": "string", "minLength": 1 },
"actor": { "$ref": "common.schema.json#/$defs/actor" },
"schema_version": {
"$ref": "common.schema.json#/$defs/schemaVersion"
},
"id": {
"$ref": "common.schema.json#/$defs/eventId"
},
"occurred_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"entity_type": {
"enum": [
"pressure",
"hypothesis",
"revision",
"experiment",
"feedback",
"backend_requirement",
"intent",
"decision",
"routing_policy"
]
},
"entity_id": {
"type": "string",
"minLength": 1
},
"event_type": {
"type": "string",
"minLength": 1
},
"actor": {
"$ref": "common.schema.json#/$defs/actor"
},
"inputs": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"description": "Identifiers of the records this transition drew on."
},
"reason": { "type": "string" },
"reason": {
"type": "string"
},
"evidence_refs": {
"type": "array",
"items": { "$ref": "common.schema.json#/$defs/evidenceRef" }
"items": {
"$ref": "common.schema.json#/$defs/evidenceRef"
}
}
},
"required": ["schema_version", "id", "occurred_at", "entity_type", "entity_id", "event_type", "actor"],
"required": [
"schema_version",
"id",
"occurred_at",
"entity_type",
"entity_id",
"event_type",
"actor"
],
"additionalProperties": false
}
},
"required": ["fluid_event"],
"required": [
"fluid_event"
],
"additionalProperties": false
}

View file

@ -4,7 +4,7 @@ type: workplan
title: "FLUID Insight - observation plane (Blueprint Phase B)"
domain: infotech
repo: fluid-core
status: active
status: done
owner: worsch
topic_slug: fluid-core
created: "2026-09-04"
@ -25,7 +25,7 @@ model inference anywhere in this workplan.
```task
id: FLUID-WP-0005-T01
status: todo
status: done
priority: high
state_hub_task_id: "0c074022-031b-5049-832f-1fa8b1ff8b4f"
```
@ -37,7 +37,7 @@ normalized interaction event (Blueprint §6.1).
```task
id: FLUID-WP-0005-T02
status: todo
status: done
priority: high
state_hub_task_id: "13086f4a-217c-5999-a039-90f5d84fb53b"
```
@ -50,7 +50,7 @@ FLUID learns about the interface, not about people.
```task
id: FLUID-WP-0005-T03
status: todo
status: done
priority: medium
state_hub_task_id: "815765d4-8d20-5c6f-87ac-e085778cd58c"
```
@ -62,7 +62,7 @@ not be more specific than the analysis requires.
```task
id: FLUID-WP-0005-T04
status: todo
status: done
priority: high
state_hub_task_id: "27726efa-e054-56c2-a6b8-3cce8f2e85e7"
```
@ -74,7 +74,7 @@ error counting.
```task
id: FLUID-WP-0005-T05
status: todo
status: done
priority: high
state_hub_task_id: "d2e4f2df-809f-5817-9b74-e1533f8f71d9"
```
@ -86,7 +86,7 @@ in every case.
```task
id: FLUID-WP-0005-T06
status: todo
status: done
priority: high
state_hub_task_id: "50bd30a6-ae29-5e2b-9618-a3efe6a362b3"
```
@ -99,7 +99,7 @@ deserves adaptation.
```task
id: FLUID-WP-0005-T07
status: todo
status: done
priority: high
state_hub_task_id: "ebecef59-2ff8-59c0-8cff-cde53030d0c8"
```
@ -111,7 +111,7 @@ distinct. Baseline and measurement window are retained, never recomputed.
```task
id: FLUID-WP-0005-T08
status: todo
status: done
priority: medium
state_hub_task_id: "48f6bbce-2ae8-5b72-8fdc-e962c6ea1f54"
```
@ -123,7 +123,7 @@ treated as authority to change anything.
```task
id: FLUID-WP-0005-T09
status: todo
status: done
priority: medium
state_hub_task_id: "ba413808-346b-5026-bfce-aec0eae0c410"
```