fluid-core/internal/control/intent.go
tegwick 03ff7a8ad7
Some checks failed
ci / build (push) Has been cancelled
Add control APIs, control-plane binary, and close the CLI gate bypass
Completes FLUID-WP-0004. The Revision and Intent APIs (Blueprint 44.1
and 44.5) are served by fluid-control, which is deliberately off the
request path and must never be reachable by interface consumers: it is
the mechanism that evolves the interface in response to their behaviour.

Intent amendment is a proposal, never an edit. Rewriting a recorded
version returns 409, because changing what a version says would change
what already-published revisions were governed by.

A rejected candidate comes back as 422 with its full stage report rather
than as a server fault. Rejection is a normal outcome (invariant 14) and
the reasons are the evidence a later hypothesis needs.

Also closes a real hole this workplan opened: `fluid revision publish`
previously wrote straight into the evidence store, which was a way
around the deterministic policy gate for anyone with shell access. It
now runs the same pipeline the control plane does and requires a signing
key.

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
2026-09-04 03:00:17 +02:00

128 lines
3.6 KiB
Go

package control
import (
"errors"
"net/http"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/intent"
"github.com/tegwick/fluid-core/internal/policy"
)
// IntentAPI implements ArchitectureBlueprint.md section 44.5: read active
// intent, read historical intent, validate a candidate against intent, propose
// an amendment.
//
// Amendment is deliberately a proposal and not an edit. Blueprint section 22 is
// blunt about it: the Daimon must not silently expand its own mission, and
// intent changes require a separate governance process. This API records that
// an amendment was proposed; it never enacts one.
type IntentAPI struct {
store *intent.Store
gate *policy.Gate
}
// NewIntentAPI returns the intent API.
func NewIntentAPI(store *intent.Store, gate *policy.Gate) *IntentAPI {
return &IntentAPI{store: store, gate: gate}
}
// IntentResponse is a recorded intent version.
type IntentResponse struct {
Version string `json:"version"`
Digest contract.Digest `json:"digest"`
Mode string `json:"mode"`
Document string `json:"document"`
}
func (a *IntentAPI) handleCollection(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
a.record(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (a *IntentAPI) handleActive(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
v, err := a.store.Active(r.Context())
if err != nil {
if errors.Is(err, intent.ErrNoActive) {
writeError(w, http.StatusNotFound, "no active interface evolution intent")
return
}
writeError(w, http.StatusInternalServerError, "could not read active intent")
return
}
writeJSON(w, http.StatusOK, toResponse(v))
}
func (a *IntentAPI) handleItem(w http.ResponseWriter, r *http.Request) {
version := pathTail(r.URL.Path, "/control/v1/intents")
if version == "" || version == "active" {
a.handleActive(w, r)
return
}
switch r.Method {
case http.MethodGet:
v, err := a.store.Get(r.Context(), version)
if err != nil {
writeError(w, statusForStoreError(err), "intent version not found")
return
}
writeJSON(w, http.StatusOK, toResponse(v))
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
// RecordIntentRequest records a new intent version.
type RecordIntentRequest struct {
Version string `json:"version"`
Document string `json:"document"`
Activate bool `json:"activate,omitempty"`
}
func (a *IntentAPI) record(w http.ResponseWriter, r *http.Request) {
var req RecordIntentRequest
if err := decodeBody(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
return
}
v, err := a.store.Put(r.Context(), req.Version, req.Document)
if err != nil {
if errors.Is(err, intent.ErrImmutable) {
// Rewriting a recorded version would change what already-published
// revisions were governed by, so it is a conflict, not a bad request.
writeError(w, http.StatusConflict, err.Error())
return
}
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.Activate {
if err := a.store.SetActive(r.Context(), v.Version); err != nil {
writeError(w, http.StatusInternalServerError, "recorded but not activated", err.Error())
return
}
}
writeJSON(w, http.StatusCreated, toResponse(v))
}
func toResponse(v intent.Version) IntentResponse {
return IntentResponse{
Version: v.Version,
Digest: v.Digest,
Mode: v.Mode.String(),
Document: v.Document,
}
}