Files
noema/internal/api/study.go
T
Andy Dixon 38c5dde21f feat(security): forks without inherited authority, dreams that only want
Part H invariants, enforced and tested:

- Snapshots now carry wants, want conflicts, commitments, intentions,
  plans, plan steps and the volition decision ledger.
- A fork no longer copies the source mind's policies unless the operator
  asks for it explicitly (inherit_policies in the API, a checkbox in the
  UI; audited), and the fork's history records the omission. Imports
  never restore policies, as before. Replays and experiments never
  inherit.
- Work inherited mid-flight by a fork or import is quiesced in the
  restore transaction: open steps are blocked with their approval and
  request ids cleared, open plans blocked and live intentions suspended,
  each with the reason. Approvals, grants and executions stay with the
  source mind.
- Dreams may leave a want to test a hypothesis they proposed; they never
  request authority, and dream-origin plans never run.
- Snapshot column discovery now reads the current schema instead of
  "public", which only worked in tests because the development database
  happened to hold the same tables.
- Security tests: a static scan that only the capability service runs
  executors and that no cognitive package can set or delete policy,
  approve, deny, grant or revoke; forged wants, commitments, intentions,
  model plans and model output end in refusal or a pending approval with
  policy and grants unchanged; component names cannot decide requests or
  approvals; no capability names an operator control; the registry cannot
  grow at runtime; audit, event and decision history cannot be altered;
  forks and dreams as above.
2026-09-16 22:42:01 +01:00

440 lines
15 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/experiments"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/replay"
"github.com/andydixon/noema/internal/snapshots"
"github.com/andydixon/noema/pkg/ids"
)
// studyRoutes: snapshots, forks, export/import, comparison, replay, experiments.
func (s *Server) studyRoutes() {
m := s.mux
m.Handle("GET /api/v1/minds/{mind}/snapshots", require(auth.RoleObserver, s.snapshotsList))
m.Handle("POST /api/v1/minds/{mind}/snapshots", require(auth.RoleOperator, s.snapshotCreate))
m.Handle("GET /api/v1/snapshots/{id}", require(auth.RoleObserver, s.snapshotGet))
m.Handle("GET /api/v1/snapshots/{id}/archive", require(auth.RoleOperator, s.snapshotArchive))
m.Handle("POST /api/v1/snapshots/{id}/fork", require(auth.RoleAdministrator, s.snapshotFork))
m.Handle("DELETE /api/v1/snapshots/{id}", require(auth.RoleAdministrator, s.snapshotDelete))
m.Handle("GET /api/v1/minds/{mind}/export", require(auth.RoleOperator, s.mindExport))
m.Handle("POST /api/v1/minds/import-archive", require(auth.RoleAdministrator, s.mindImportArchive))
m.Handle("GET /api/v1/minds/{mind}/compare/{other}", require(auth.RoleObserver, s.mindCompare))
m.Handle("GET /api/v1/minds/{mind}/replays", require(auth.RoleObserver, s.replaysList))
m.Handle("POST /api/v1/minds/{mind}/replays", require(auth.RoleOperator, s.replayStart))
m.Handle("GET /api/v1/replays/{id}", require(auth.RoleObserver, s.replayGet))
m.Handle("GET /api/v1/experiments", require(auth.RoleObserver, s.experimentsList))
m.Handle("POST /api/v1/experiments", require(auth.RoleOperator, s.experimentCreate))
m.Handle("GET /api/v1/experiments/{id}", require(auth.RoleObserver, s.experimentGet))
m.Handle("POST /api/v1/experiments/{id}/run", require(auth.RoleOperator, s.experimentRun))
m.Handle("GET /api/v1/experiments/{id}/export", require(auth.RoleObserver, s.experimentExport))
m.Handle("DELETE /api/v1/experiments/{id}", require(auth.RoleAdministrator, s.experimentDelete))
}
func (s *Server) snapshotsList(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
list, err := s.deps.Snapshots.List(r.Context(), m.ID, 100)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []snapshots.Snapshot{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": list})
}
// CreateSnapshot captures a mind; shared with the UI.
func (s *Server) CreateSnapshot(ctx context.Context, m minds.Mind, name, note, by string) (snapshots.Snapshot, error) {
sn, err := s.deps.Snapshots.Create(ctx, m, name, note, by)
if err != nil {
return sn, err
}
s.deps.Audit.Log(ctx, "snapshot.create", "snapshot", sn.ID.String(), map[string]any{"mind": m.Slug, "name": sn.Name, "event_seq": sn.EventSeq})
s.deps.Events.OperatorAction(ctx, m.ID, by, "snapshot.create", "Operator "+by+" took snapshot "+sn.Name, map[string]any{"snapshot_id": sn.ID, "event_seq": sn.EventSeq})
return sn, nil
}
func (s *Server) snapshotCreate(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var in struct{ Name, Note string }
decode(r, &in) //nolint:errcheck // both optional
sn, err := s.CreateSnapshot(r.Context(), m, in.Name, in.Note, actor(r))
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusCreated, sn)
}
func (s *Server) snapshotByPath(w http.ResponseWriter, r *http.Request) (snapshots.Snapshot, bool) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad snapshot id"))
return snapshots.Snapshot{}, false
}
sn, err := s.deps.Snapshots.Get(r.Context(), id)
if err != nil {
fail(w, r, err)
return sn, false
}
return sn, true
}
func (s *Server) snapshotGet(w http.ResponseWriter, r *http.Request) {
if sn, ok := s.snapshotByPath(w, r); ok {
writeJSON(w, http.StatusOK, sn)
}
}
func (s *Server) snapshotArchive(w http.ResponseWriter, r *http.Request) {
sn, ok := s.snapshotByPath(w, r)
if !ok {
return
}
a, err := s.deps.Snapshots.Archive(r.Context(), sn.ID)
if err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "snapshot.export", "snapshot", sn.ID.String(), nil)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="`+a.Mind.Slug+"-"+sn.ID.String()[len(sn.ID.String())-8:]+`.noema.json"`)
json.NewEncoder(w).Encode(a) //nolint:errcheck
}
// ForkSnapshot creates a mind from a snapshot; shared with the UI. The fork
// receives no policies unless inheritPolicies is set explicitly.
func (s *Server) ForkSnapshot(ctx context.Context, sn snapshots.Snapshot, slug, display, by string, inheritPolicies bool) (minds.Mind, error) {
m, err := s.deps.Snapshots.Fork(ctx, sn.ID, snapshots.ForkOptions{Slug: slug, DisplayName: display, By: by, InheritPolicies: inheritPolicies})
if err != nil {
return m, mindErr(err)
}
s.deps.Audit.Log(ctx, "mind.fork", "mind", m.ID.String(), map[string]any{"snapshot": sn.ID, "source": sn.MindID, "slug": m.Slug, "inherit_policies": inheritPolicies})
if !inheritPolicies {
s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.fork.policies", "Capability policies were not inherited from the source mind; this mind has only global policy until an operator adds rules", map[string]any{"inherit_policies": false})
}
s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.fork", "Mind forked from snapshot "+sn.Name+" (history before event "+strconv.FormatInt(sn.EventSeq, 10)+" is shared with the source)", map[string]any{"snapshot_id": sn.ID, "source_mind": sn.MindID, "event_seq": sn.EventSeq})
s.deps.Events.OperatorAction(ctx, sn.MindID, by, "mind.fork", "Operator "+by+" forked this mind into "+m.Slug+" from snapshot "+sn.Name, map[string]any{"snapshot_id": sn.ID, "fork_mind": m.ID})
s.deps.Brain.Reconcile(ctx) //nolint:errcheck
return m, nil
}
func (s *Server) snapshotFork(w http.ResponseWriter, r *http.Request) {
sn, ok := s.snapshotByPath(w, r)
if !ok {
return
}
var in struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
InheritPolicies bool `json:"inherit_policies"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
m, err := s.ForkSnapshot(r.Context(), sn, in.Slug, in.DisplayName, actor(r), in.InheritPolicies)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusCreated, m)
}
func (s *Server) snapshotDelete(w http.ResponseWriter, r *http.Request) {
sn, ok := s.snapshotByPath(w, r)
if !ok {
return
}
if err := s.deps.Snapshots.Delete(r.Context(), sn.ID); err != nil {
fail(w, r, ValidationError("snapshot is referenced by a replay or experiment"))
return
}
s.deps.Audit.Log(r.Context(), "snapshot.delete", "snapshot", sn.ID.String(), map[string]any{"name": sn.Name})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) mindExport(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
a, err := snapshots.Capture(r.Context(), s.deps.DB, s.deps.Events, m)
if err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "mind.export", "mind", m.ID.String(), map[string]any{"event_seq": a.Source.EventSeq})
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="`+m.Slug+`.noema.json"`)
json.NewEncoder(w).Encode(a) //nolint:errcheck
}
// ImportArchive restores a hostile archive into a new mind; shared with the UI.
func (s *Server) ImportArchive(ctx context.Context, raw []byte, slug, display, by string) (minds.Mind, error) {
if len(raw) > snapshots.MaxArchiveBytes {
return minds.Mind{}, ValidationError("archive too large")
}
var a snapshots.Archive
dec := json.NewDecoder(bytesReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&a); err != nil {
return minds.Mind{}, ValidationError("invalid archive: " + err.Error())
}
m, omitted, err := s.deps.Snapshots.ImportReport(ctx, a, snapshots.ForkOptions{Slug: slug, DisplayName: display, By: by, Notes: "imported archive of " + a.Source.Slug})
if err != nil {
if errors.Is(err, snapshots.ErrInvalid) {
return m, ValidationError(err.Error())
}
return m, mindErr(err)
}
s.deps.Audit.Log(ctx, "mind.import", "mind", m.ID.String(), map[string]any{"source": a.Source.Slug, "checksum": a.Checksum, "counts": a.Counts(), "omitted": omitted})
summary := "Mind imported from archive of " + a.Source.Slug + " (" + a.Checksum + ")"
if n := omitted["policies"]; n > 0 {
summary += fmt.Sprintf("; %d policy rule(s) in the archive were not imported: policies are set by this instance's operators", n)
}
s.deps.Events.OperatorAction(ctx, m.ID, by, "mind.import", summary, map[string]any{"counts": a.Counts(), "omitted": omitted})
s.deps.Brain.Reconcile(ctx) //nolint:errcheck
return m, nil
}
func (s *Server) mindImportArchive(w http.ResponseWriter, r *http.Request) {
var in struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Archive json.RawMessage `json:"archive"`
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, snapshots.MaxArchiveBytes+4096))
if err := dec.Decode(&in); err != nil {
fail(w, r, ValidationError("invalid request: "+err.Error()))
return
}
m, err := s.ImportArchive(r.Context(), in.Archive, in.Slug, in.DisplayName, actor(r))
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusCreated, m)
}
func (s *Server) mindCompare(w http.ResponseWriter, r *http.Request) {
a, ok := s.mind(w, r)
if !ok {
return
}
b, err := s.deps.Minds.Resolve(r.Context(), r.PathValue("other"))
if err != nil {
fail(w, r, err)
return
}
c, err := s.deps.Snapshots.Compare(r.Context(), a, b)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"a": a.Slug, "b": b.Slug, "comparison": c})
}
func (s *Server) replaysList(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
list, err := s.deps.Replay.List(r.Context(), m.ID, 50)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []replay.Replay{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": list})
}
// StartReplay records a replay and executes it in the background; shared with the UI.
func (s *Server) StartReplay(ctx context.Context, m minds.Mind, snapshotID ids.ID, from, to int64, by string) (replay.Replay, error) {
rp, err := s.deps.Replay.Begin(ctx, m, snapshotID, from, to, by)
if err != nil {
return rp, ValidationError(err.Error())
}
s.deps.Audit.Log(ctx, "replay.start", "replay", rp.ID.String(), map[string]any{"mind": m.Slug, "snapshot": snapshotID, "from": rp.FromSeq, "to": rp.ToSeq})
bg := context.WithoutCancel(ctx)
go func() {
if _, err := s.deps.Replay.Execute(bg, rp); err != nil {
s.deps.Log.Warn("replay failed", "replay", rp.ID, "err", err)
}
}()
return rp, nil
}
func (s *Server) replayStart(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var in struct {
SnapshotID ids.ID `json:"snapshot_id"`
FromSeq int64 `json:"from_seq"`
ToSeq int64 `json:"to_seq"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
rp, err := s.StartReplay(r.Context(), m, in.SnapshotID, in.FromSeq, in.ToSeq, actor(r))
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusAccepted, rp)
}
func (s *Server) replayGet(w http.ResponseWriter, r *http.Request) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad replay id"))
return
}
rp, err := s.deps.Replay.Get(r.Context(), id)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusOK, rp)
}
func (s *Server) experimentsList(w http.ResponseWriter, r *http.Request) {
list, err := s.deps.Experiments.List(r.Context(), 100)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []experiments.Experiment{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": list})
}
// ExperimentInput is the create body (API and UI).
type ExperimentInput struct {
Name string `json:"name"`
Hypothesis string `json:"hypothesis"`
SnapshotID ids.ID `json:"snapshot_id"`
Definition experiments.Definition `json:"definition"`
}
// CreateExperiment stores a draft; shared with the UI.
func (s *Server) CreateExperiment(ctx context.Context, in ExperimentInput, by string) (experiments.Experiment, error) {
e, err := s.deps.Experiments.Create(ctx, in.Name, in.Hypothesis, in.SnapshotID, in.Definition, by)
if err != nil {
return e, ValidationError(err.Error())
}
s.deps.Audit.Log(ctx, "experiment.create", "experiment", e.ID.String(), map[string]any{"name": e.Name, "snapshot": e.SnapshotID})
return e, nil
}
func (s *Server) experimentCreate(w http.ResponseWriter, r *http.Request) {
var in ExperimentInput
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
e, err := s.CreateExperiment(r.Context(), in, actor(r))
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusCreated, e)
}
func (s *Server) experimentByPath(w http.ResponseWriter, r *http.Request) (experiments.Experiment, bool) {
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad experiment id"))
return experiments.Experiment{}, false
}
e, err := s.deps.Experiments.Get(r.Context(), id)
if err != nil {
fail(w, r, err)
return e, false
}
return e, true
}
func (s *Server) experimentGet(w http.ResponseWriter, r *http.Request) {
if e, ok := s.experimentByPath(w, r); ok {
writeJSON(w, http.StatusOK, e)
}
}
// RunExperiment starts a run in the background; shared with the UI.
func (s *Server) RunExperiment(ctx context.Context, e experiments.Experiment, by string) error {
if e.Status == "running" {
return ValidationError("experiment is already running")
}
s.deps.Audit.Log(ctx, "experiment.run", "experiment", e.ID.String(), map[string]any{"name": e.Name})
bg := context.WithoutCancel(ctx)
go func() {
if _, err := s.deps.Experiments.Run(bg, e.ID, by); err != nil {
s.deps.Log.Warn("experiment failed", "experiment", e.ID, "err", err)
}
}()
return nil
}
func (s *Server) experimentRun(w http.ResponseWriter, r *http.Request) {
e, ok := s.experimentByPath(w, r)
if !ok {
return
}
if err := s.RunExperiment(r.Context(), e, actor(r)); err != nil {
fail(w, r, err)
return
}
e.Status = "running"
writeJSON(w, http.StatusAccepted, e)
}
func (s *Server) experimentExport(w http.ResponseWriter, r *http.Request) {
e, ok := s.experimentByPath(w, r)
if !ok {
return
}
out, err := s.deps.Experiments.Export(r.Context(), e.ID)
if err != nil {
fail(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="experiment-`+e.ID.String()[len(e.ID.String())-8:]+`.json"`)
json.NewEncoder(w).Encode(out) //nolint:errcheck
}
func (s *Server) experimentDelete(w http.ResponseWriter, r *http.Request) {
e, ok := s.experimentByPath(w, r)
if !ok {
return
}
if err := s.deps.Experiments.Delete(r.Context(), e.ID); err != nil {
fail(w, r, err)
return
}
s.deps.Audit.Log(r.Context(), "experiment.delete", "experiment", e.ID.String(), map[string]any{"name": e.Name})
w.WriteHeader(http.StatusNoContent)
}