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", }) }