Some checks failed
ci / build (push) Has been cancelled
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
128 lines
3.6 KiB
Go
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,
|
|
}
|
|
}
|