Files
noema/internal/planning/validate.go
T
Andy Dixon 813b067842 feat(planning): plans, validation, planner and a restart-safe executor
Plans turn intentions into revisable dependency graphs (migration 0027):
objective, assumptions, ordered or dependent steps, capabilities,
expected observations, preconditions, risk, estimates, budget,
completion criteria, abort conditions and the alternatives considered.

- Validation owns structure: acyclic dependencies, registered and
  unlocked capabilities only, schema-valid parameters, targets that agree
  with parameters, read-only checks, a closed set of step kinds and
  internal actions. Model proposals are parsed into the same structure;
  fields that could carry authority or claim progress are reported as
  ignored, and an invalid draft is stored as a draft that never runs.
- The planner prefers deterministic strategies: a direct action verified
  by observation, a read-only diagnosis that requests temporary scoped log
  access with risk and fallback stated, and an escalation that diagnoses
  and informs the operator instead of repeating a failed action. Failed
  strategies are not reused; a model is consulted only when none applies.
- The executor holds no state in memory: it claims a step with a
  conditional update, acts only through Invoke and RequestCapability, and
  records the outcome. Waiting for approval or access forms a subordinate
  intention and blocks the parent with the reason. Expectations become
  verification predictions resolved from a read-only observation, so an
  HTTP-level success is never taken as the outcome; a miss is a prediction
  error that weakens the supporting belief and triggers reconsideration.
  Replanning creates a new revision, withdraws open requests of the old
  one and keeps it as history, within a replan limit. Checks never raise
  approval prompts. Budgets stop runaway plans. Preconditions skip actions
  when the world already changed. Dream plans and disabled sandbox minds
  never act. An interrupted non-idempotent action with no record is
  blocked for verification instead of repeated; recorded outcomes are
  adopted. Completed plans are stored as procedural memories; a completed
  escalation does not count as fulfilling the intention.
- Executions and approvals record their origin; minds may withdraw their
  own pending approval requests; Preview reports a decision without side
  effects; service.status is allowed by default as a bounded read.
2026-09-14 23:38:44 +01:00

418 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package planning
import (
"errors"
"fmt"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"github.com/andydixon/noema/internal/capabilities"
)
// Validation is the result of checking a draft.
type Validation struct {
Valid bool `json:"valid"`
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
Order []string `json:"order"` // a topological order of step keys
}
// Limits on plans.
const (
MaxSteps = 30
MaxWaitSeconds = 7 * 24 * 3600
MaxMonitorSeconds = 7 * 24 * 3600
)
var keyRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,16}$`)
// Validate checks structure, dependency integrity and the capability
// vocabulary. Nothing a draft says is trusted: capability names must be
// registered and not locked, parameters must satisfy the schema, targets
// must agree with parameters, checks must use read-only capabilities, and
// the dependency graph must be acyclic.
func Validate(d *Draft, reg *capabilities.Registry) Validation {
v := Validation{Errors: []string{}, Warnings: []string{}, Order: []string{}}
fail := func(format string, args ...any) { v.Errors = append(v.Errors, fmt.Sprintf(format, args...)) }
d.Objective = strings.TrimSpace(d.Objective)
if d.Objective == "" || len(d.Objective) > 500 {
fail("objective is required (at most 500 characters)")
}
switch d.Origin {
case "template", "model", "operator", "dream", "replan":
default:
fail("origin %q is not recognised", d.Origin)
}
if len(d.Steps) == 0 || len(d.Steps) > MaxSteps {
fail("a plan needs 1–%d steps, not %d", MaxSteps, len(d.Steps))
}
if d.Budget.MaxInvocations <= 0 {
d.Budget.MaxInvocations = 20
}
keys := map[string]int{}
for i := range d.Steps {
st := &d.Steps[i]
st.Title = strings.TrimSpace(st.Title)
if !keyRe.MatchString(st.Key) {
fail("step %d: key %q must be 1–16 letters, digits, '-' or '_'", i+1, st.Key)
continue
}
if _, dup := keys[st.Key]; dup {
fail("step %s: duplicate key", st.Key)
}
keys[st.Key] = i
if st.Title == "" || len(st.Title) > 200 {
fail("step %s: title is required (at most 200 characters)", st.Key)
}
if st.MaxAttempts <= 0 {
st.MaxAttempts = 1
}
if st.MaxAttempts > 5 {
st.MaxAttempts = 5
}
}
for i := range d.Steps {
st := &d.Steps[i]
for _, dep := range st.DependsOn {
if dep == st.Key {
fail("step %s depends on itself", st.Key)
} else if _, ok := keys[dep]; !ok {
fail("step %s depends on %q, which is not a step of this plan", st.Key, dep)
}
}
validateStep(st, d.Steps, keys, reg, fail, &v)
}
if len(v.Errors) == 0 {
order, err := topo(d.Steps)
if err != nil {
fail("%v", err)
} else {
v.Order = order
}
}
for _, a := range d.Assumptions {
if a.MinConfidence < 0 || a.MinConfidence > 1 {
fail("assumption %q: min_confidence must be in [0,1]", a.Proposition)
}
}
v.Valid = len(v.Errors) == 0
return v
}
func validateStep(st *StepSpec, all []StepSpec, keys map[string]int, reg *capabilities.Registry, fail func(string, ...any), v *Validation) {
registered := func(name string) (capabilities.Capability, bool) {
c, ok := reg.Get(name)
if !ok {
fail("step %s: %q is not a registered capability", st.Key, name)
return c, false
}
if c.Locked {
fail("step %s: %s is locked and can never run", st.Key, name)
return c, false
}
return c, true
}
checkCheck := func(label string, ck *Check) {
c, ok := registered(ck.Capability)
if !ok {
return
}
if c.Access != capabilities.AccessRead {
fail("step %s: %s check must use a read-only capability, not %s", st.Key, label, c.Name)
}
p, err := c.ValidateParams(ck.Params)
if err != nil {
fail("step %s: %s check parameters: %v", st.Key, label, err)
return
}
ck.Params = p
if ck.Target, err = c.ResolveTarget(ck.Target, p); err != nil {
fail("step %s: %s check target: %v", st.Key, label, err)
}
switch ck.Op {
case "eq", "ne", "lt", "lte", "gt", "gte", "contains", "exists":
default:
fail("step %s: %s check operator %q is not recognised", st.Key, label, ck.Op)
}
if ck.Path == "" {
fail("step %s: %s check needs a result path", st.Key, label)
}
}
populated := 0
for _, x := range []bool{st.Request != nil, st.Verify != nil, st.Monitor != nil, st.Wait != nil, st.Internal != nil} {
if x {
populated++
}
}
if populated > 1 {
fail("step %s: more than one kind-specific section", st.Key)
}
switch st.Kind {
case KindCapability:
c, ok := registered(st.Capability)
if !ok {
return
}
p, err := c.ValidateParams(st.Params)
if err != nil {
fail("step %s: parameters: %v", st.Key, err)
return
}
st.Params = p
if st.Target, err = c.ResolveTarget(st.Target, p); err != nil {
fail("step %s: target: %v", st.Key, err)
}
if c.Access == capabilities.AccessWrite && !c.Idempotent && st.MaxAttempts > 1 {
v.Warnings = append(v.Warnings, fmt.Sprintf("step %s: %s is not idempotent; retries reduced to one attempt", st.Key, c.Name))
st.MaxAttempts = 1
}
if st.Expectation != nil {
if st.Expectation.Confidence <= 0 || st.Expectation.Confidence >= 1 {
st.Expectation.Confidence = 0.7
}
if st.Expectation.WithinSeconds <= 0 || st.Expectation.WithinSeconds > 86400 {
st.Expectation.WithinSeconds = 300
}
if strings.TrimSpace(st.Expectation.Description) == "" {
fail("step %s: an expectation needs a description", st.Key)
}
} else if c.Access == capabilities.AccessWrite {
v.Warnings = append(v.Warnings, fmt.Sprintf("step %s: %s changes the world but states no expected outcome", st.Key, c.Name))
}
if st.Precondition != nil {
checkCheck("precondition", &st.Precondition.Check)
}
case KindRequestCapability:
if st.Request == nil {
fail("step %s: request_capability needs a request", st.Key)
return
}
if _, ok := registered(st.Request.Capability); !ok {
return
}
if st.Request.DurationSeconds <= 0 || st.Request.DurationSeconds > int(capabilities.MaxGrantDuration.Seconds()) {
fail("step %s: request duration must be 1 second to 7 days", st.Key)
}
if strings.TrimSpace(st.Request.Reason) == "" {
fail("step %s: a capability request needs a reason", st.Key)
}
case KindVerify:
if st.Verify == nil {
fail("step %s: verify needs a verify section", st.Key)
return
}
i, ok := keys[st.Verify.Of]
if !ok {
fail("step %s: verifies %q, which is not a step", st.Key, st.Verify.Of)
return
}
if all[i].Expectation == nil {
fail("step %s: step %s has no expectation to verify", st.Key, st.Verify.Of)
}
if !dependsOn(all, keys, st.Key, st.Verify.Of) {
fail("step %s: must depend on %s, the step it verifies", st.Key, st.Verify.Of)
}
checkCheck("verify", &st.Verify.Check)
if st.Verify.IntervalSeconds <= 0 {
st.Verify.IntervalSeconds = 10
}
if st.Verify.WithinSeconds <= 0 {
st.Verify.WithinSeconds = 60
}
case KindMonitor:
if st.Monitor == nil {
fail("step %s: monitor needs a monitor section", st.Key)
return
}
checkCheck("monitor", &st.Monitor.Check)
m := st.Monitor
if m.IntervalSeconds < 5 {
m.IntervalSeconds = 5
}
if m.DeadlineSeconds <= 0 || m.DeadlineSeconds > MaxMonitorSeconds || m.StableSeconds < 0 || m.StableSeconds > m.DeadlineSeconds {
fail("step %s: monitor needs 0 ≤ stable ≤ deadline ≤ 7 days", st.Key)
}
case KindWait:
if st.Wait == nil || st.Wait.Seconds <= 0 || st.Wait.Seconds > MaxWaitSeconds {
fail("step %s: wait needs 1 second to 7 days", st.Key)
}
case KindInternal:
if st.Internal == nil || InternalActions[st.Internal.Action] == "" {
fail("step %s: internal action must be one of %s", st.Key, strings.Join(sortedKeys(InternalActions), ", "))
}
default:
fail("step %s: kind %q is not recognised", st.Key, st.Kind)
}
if st.Kind != KindCapability && (st.Capability != "" || st.Expectation != nil) {
v.Warnings = append(v.Warnings, fmt.Sprintf("step %s: capability and expectation are ignored on %s steps", st.Key, st.Kind))
st.Capability, st.Target, st.Params, st.Expectation = "", "", nil, nil
}
}
func dependsOn(all []StepSpec, keys map[string]int, from, to string) bool {
seen := map[string]bool{}
var walk func(k string) bool
walk = func(k string) bool {
if seen[k] {
return false
}
seen[k] = true
i, ok := keys[k]
if !ok {
return false
}
for _, d := range all[i].DependsOn {
if d == to || walk(d) {
return true
}
}
return false
}
return walk(from)
}
// ErrCycle marks cyclic dependencies.
var ErrCycle = errors.New("dependencies form a cycle")
func topo(steps []StepSpec) ([]string, error) {
indeg := map[string]int{}
next := map[string][]string{}
for _, s := range steps {
indeg[s.Key] += 0
for _, d := range s.DependsOn {
indeg[s.Key]++
next[d] = append(next[d], s.Key)
}
}
var queue, order []string
for _, s := range steps {
if indeg[s.Key] == 0 {
queue = append(queue, s.Key)
}
}
for len(queue) > 0 {
k := queue[0]
queue = queue[1:]
order = append(order, k)
for _, n := range next[k] {
indeg[n]--
if indeg[n] == 0 {
queue = append(queue, n)
}
}
}
if len(order) != len(steps) {
var stuck []string
for _, s := range steps {
if indeg[s.Key] > 0 {
stuck = append(stuck, s.Key)
}
}
sort.Strings(stuck)
return nil, fmt.Errorf("%w among %s", ErrCycle, strings.Join(stuck, ", "))
}
return order, nil
}
func sortedKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// Evaluate applies a check to a capability result's data. It returns whether
// the check holds and the observed value.
func Evaluate(ck Check, data map[string]any) (bool, any, error) {
val, ok := lookup(data, ck.Path)
if ck.Op == "exists" {
return ok, val, nil
}
if !ok {
return false, nil, fmt.Errorf("result has no %q", ck.Path)
}
switch ck.Op {
case "eq":
return equal(val, ck.Value), val, nil
case "ne":
return !equal(val, ck.Value), val, nil
case "contains":
return strings.Contains(strings.ToLower(fmt.Sprint(val)), strings.ToLower(fmt.Sprint(ck.Value))), val, nil
case "lt", "lte", "gt", "gte":
a, aok := number(val)
b, bok := number(ck.Value)
if !aok || !bok {
return false, val, fmt.Errorf("%q is not numeric", ck.Path)
}
switch ck.Op {
case "lt":
return a < b, val, nil
case "lte":
return a <= b, val, nil
case "gt":
return a > b, val, nil
}
return a >= b, val, nil
}
return false, val, fmt.Errorf("unknown operator %q", ck.Op)
}
func lookup(data map[string]any, path string) (any, bool) {
var cur any = data
for _, part := range strings.Split(path, ".") {
switch m := cur.(type) {
case map[string]any:
v, ok := m[part]
if !ok {
return nil, false
}
cur = v
case map[string]string:
v, ok := m[part]
if !ok {
return nil, false
}
cur = v
default:
return nil, false
}
}
return cur, true
}
func number(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case float32:
return float64(x), true
case int:
return float64(x), true
case int64:
return float64(x), true
case uint64:
return float64(x), true
case string:
f, err := strconv.ParseFloat(x, 64)
return f, err == nil
}
return 0, false
}
func equal(a, b any) bool {
if fa, ok := number(a); ok {
if fb, ok := number(b); ok {
return fa == fb
}
}
if reflect.DeepEqual(a, b) {
return true
}
return fmt.Sprint(a) == fmt.Sprint(b)
}