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
191 lines
5.9 KiB
Go
191 lines
5.9 KiB
Go
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[:])
|
|
}
|