Add telemetry ingest, feedback collector, pressure API and insight CLI
Some checks failed
ci / build (push) Failing after 3h11m37s
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:
parent
6e705aa0af
commit
7e0de9e5b7
13 changed files with 1307 additions and 29 deletions
|
|
@ -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"})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
171
internal/control/pressure.go
Normal file
171
internal/control/pressure.go
Normal 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",
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue