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
105 lines
3.3 KiB
Go
105 lines
3.3 KiB
Go
// Package control implements the FLUID control-plane APIs.
|
|
//
|
|
// ArchitectureBlueprint.md section 44 asks for a small set of internal control
|
|
// APIs. They are internal on purpose: this is the surface that publishes
|
|
// revisions and records governance decisions, and it must never be reachable by
|
|
// the consumers whose behaviour it evolves in response to.
|
|
//
|
|
// Nothing here is on the request path. The data plane keeps serving when this
|
|
// server is down (invariant 2), which is also why the CLI reads the evidence
|
|
// store directly rather than through these endpoints.
|
|
package control
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/tegwick/fluid-core/internal/evidence"
|
|
)
|
|
|
|
// Server exposes the control APIs over HTTP.
|
|
type Server struct {
|
|
revisions *RevisionAPI
|
|
intents *IntentAPI
|
|
pressure *PressureAPI
|
|
}
|
|
|
|
// 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.
|
|
func (s *Server) Routes() *http.ServeMux {
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("/control/v1/revisions", s.revisions.handleCollection)
|
|
mux.HandleFunc("/control/v1/revisions/", s.revisions.handleItem)
|
|
mux.HandleFunc("/control/v1/intents", s.intents.handleCollection)
|
|
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"})
|
|
})
|
|
|
|
return mux
|
|
}
|
|
|
|
// apiError is the control-plane error shape.
|
|
type apiError struct {
|
|
Error string `json:"error"`
|
|
Detail string `json:"detail,omitempty"`
|
|
Causes []string `json:"causes,omitempty"`
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string, causes ...string) {
|
|
writeJSON(w, status, apiError{Error: msg, Causes: causes})
|
|
}
|
|
|
|
// statusForStoreError maps store failures onto HTTP without leaking detail.
|
|
func statusForStoreError(err error) int {
|
|
if errors.Is(err, evidence.ErrNotFound) {
|
|
return http.StatusNotFound
|
|
}
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
// pathTail returns the segment after prefix, or "" when there is none.
|
|
func pathTail(path, prefix string) string {
|
|
rest := strings.TrimPrefix(path, prefix)
|
|
rest = strings.Trim(rest, "/")
|
|
if rest == "" {
|
|
return ""
|
|
}
|
|
if i := strings.Index(rest, "/"); i >= 0 {
|
|
return rest[:i]
|
|
}
|
|
return rest
|
|
}
|
|
|
|
func decodeBody(r *http.Request, into any) error {
|
|
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(into); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|