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.
This commit is contained in:
Andy Dixon
2026-09-14 23:38:44 +01:00
parent 9db188ffe6
commit 813b067842
14 changed files with 3464 additions and 20 deletions
+1 -1
View File
@@ -170,7 +170,7 @@ func Capabilities(o Options) []capabilities.Capability {
return capabilities.Result{Summary: fmt.Sprintf("%d processes", len(procs)), Data: map[string]any{"processes": procs}}, nil
}},
{Name: "service.status", Description: "Report a systemd unit's load, active and sub state and main PID.", Risk: capabilities.RiskLow, TargetKind: "service", Access: capabilities.AccessRead, Idempotent: true, Timeout: 10 * time.Second,
SideEffects: "none", DefaultEffect: capabilities.Ask, Params: svc, TargetFrom: unitTarget,
SideEffects: "none", DefaultEffect: capabilities.Allow, Params: svc, TargetFrom: unitTarget,
Executor: func(ctx context.Context, inv capabilities.Invocation) (capabilities.Result, error) {
unit, err := unitTarget(inv.Params)
if err != nil || run == nil {
+93 -15
View File
@@ -40,6 +40,7 @@ type Approval struct {
Error string `json:"error"`
EventID ids.ID `json:"event_id,omitempty"`
CorrelationID ids.ID `json:"correlation_id,omitempty"`
Origin string `json:"origin,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
@@ -56,6 +57,7 @@ type Execution struct {
DurationMS int `json:"duration_ms"`
ApprovalID ids.ID `json:"approval_id,omitempty"`
EventID ids.ID `json:"event_id,omitempty"`
Origin string `json:"origin,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -323,6 +325,33 @@ func (s *Service) Invoke(ctx context.Context, inv Invocation) (Outcome, error) {
return s.execute(ctx, inv, c, d, decisionEv, ids.Nil)
}
// Preview reports what Invoke would decide for a request without recording
// or executing anything: parameters validated, target derived, policy,
// session and scoped grants and approval requirements applied. Planning uses
// it to choose routes; it confers nothing.
func (s *Service) Preview(ctx context.Context, inv Invocation) (Decision, error) {
c, ok := s.Registry.Get(inv.Capability)
if !ok {
return Decision{Effect: Deny, Reason: "unknown capability"}, fmt.Errorf("%w: %s", ErrUnknown, inv.Capability)
}
if c.Locked {
return Decision{Effect: Deny, Locked: true, Reason: "capability is locked to DENY by design"}, nil
}
params, err := c.ValidateParams(inv.Params)
if err != nil {
return Decision{Effect: Deny, Reason: err.Error()}, err
}
inv.Params = params
if inv.Target, err = c.ResolveTarget(inv.Target, params); err != nil {
return Decision{Effect: Deny, Reason: err.Error()}, err
}
action, _ := params["action"].(string)
if action == "" {
action = "*"
}
return s.decideInvocation(ctx, inv, c, action)
}
// decideInvocation is Decide for a concrete request: scoped grants may match
// on parameters and are consulted only when policy says ASK, and a request
// that requires approval can never be allowed without one.
@@ -349,21 +378,21 @@ func (s *Service) createApproval(ctx context.Context, inv Invocation, c Capabili
if ttl <= 0 {
ttl = 24 * time.Hour
}
a := Approval{ID: ids.New(), MindID: inv.MindID, Capability: c.Name, Target: inv.Target, Params: inv.Params, RequestHash: hash, Reason: inv.Reason, Risk: c.Risk, Evidence: inv.Evidence, Status: "pending", CorrelationID: inv.Correlation, ExpiresAt: s.now().Add(ttl), CreatedAt: s.now()}
a := Approval{ID: ids.New(), MindID: inv.MindID, Capability: c.Name, Target: inv.Target, Params: inv.Params, RequestHash: hash, Reason: inv.Reason, Risk: c.Risk, Evidence: inv.Evidence, Status: "pending", CorrelationID: inv.Correlation, Origin: inv.Origin, ExpiresAt: s.now().Add(ttl), CreatedAt: s.now()}
if a.Evidence == nil {
a.Evidence = []EvidenceRef{}
}
ev, err := s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolRequested, Source: "capabilities", Summary: fmt.Sprintf("approval requested: %s on %q — %s", c.Name, inv.Target, firstNonEmpty(inv.Reason, "no reason given")),
Salience: 0.6, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence),
Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "approval_id": string(a.ID), "risk": c.Risk}, Payload: map[string]any{"approval_id": a.ID, "capability": c.Name, "target": inv.Target, "params": inv.Params, "risk": c.Risk, "reason": inv.Reason, "expires_at": a.ExpiresAt}})
Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "approval_id": string(a.ID), "risk": c.Risk, "origin": inv.Origin}, Payload: map[string]any{"approval_id": a.ID, "capability": c.Name, "target": inv.Target, "params": inv.Params, "risk": c.Risk, "reason": inv.Reason, "expires_at": a.ExpiresAt}})
if err != nil {
return Approval{}, err
}
a.EventID = ev[0].ID
params, _ := json.Marshal(inv.Params)
evid, _ := json.Marshal(a.Evidence)
_, err = s.DB.Exec(ctx, `INSERT INTO approval_requests (id, mind_id, capability, target, params, request_hash, reason, risk, evidence, status, event_id, correlation_id, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending',$10,$11,$12)`, a.ID, a.MindID, a.Capability, a.Target, params, a.RequestHash, a.Reason, string(a.Risk), evid, string(a.EventID), nullID(a.CorrelationID), a.ExpiresAt)
_, err = s.DB.Exec(ctx, `INSERT INTO approval_requests (id, mind_id, capability, target, params, request_hash, reason, risk, evidence, status, event_id, correlation_id, expires_at, origin)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending',$10,$11,$12,$13)`, a.ID, a.MindID, a.Capability, a.Target, params, a.RequestHash, a.Reason, string(a.Risk), evid, string(a.EventID), nullID(a.CorrelationID), a.ExpiresAt, a.Origin)
return a, err
}
@@ -386,20 +415,20 @@ func (s *Service) execute(ctx context.Context, inv Invocation, c Capability, d D
res, execErr = c.Executor(ectx, inv)
}()
dur := time.Since(start)
exec := Execution{ID: ids.New(), MindID: inv.MindID, Capability: c.Name, Target: inv.Target, Params: inv.Params, DurationMS: int(dur.Milliseconds()), ApprovalID: approvalID, CreatedAt: s.now()}
payload := map[string]any{"capability": c.Name, "target": inv.Target, "params": inv.Params, "approval_id": approvalID, "duration_ms": exec.DurationMS}
exec := Execution{ID: ids.New(), MindID: inv.MindID, Capability: c.Name, Target: inv.Target, Params: inv.Params, DurationMS: int(dur.Milliseconds()), ApprovalID: approvalID, Origin: inv.Origin, CreatedAt: s.now()}
payload := map[string]any{"capability": c.Name, "target": inv.Target, "params": inv.Params, "approval_id": approvalID, "duration_ms": exec.DurationMS, "origin": inv.Origin}
var ev []events.Event
if execErr != nil {
exec.Error = execErr.Error()
payload["error"] = exec.Error
ev, _ = s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolFailed, Source: "capabilities", Summary: fmt.Sprintf("%s on %q failed: %s", c.Name, inv.Target, truncate(exec.Error, 160)),
Salience: 0.6, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID)}, Payload: payload})
Salience: 0.6, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID), "origin": inv.Origin, "approval_id": string(approvalID)}, Payload: payload})
} else {
rj, _ := json.Marshal(res)
exec.Result = rj
payload["result"] = res
ev, _ = s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.ToolExecuted, Source: "capabilities", Summary: fmt.Sprintf("%s on %q: %s", c.Name, inv.Target, truncate(res.Summary, 160)),
Salience: 0.5, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence), Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID), "trust": c.OutputTrust}, Payload: payload})
Salience: 0.5, Confidence: 1, CausationID: cause, CorrelationID: inv.Correlation, Evidence: toEvidence(inv.Evidence), Metadata: map[string]any{"capability": c.Name, "target": inv.Target, "execution_id": string(exec.ID), "trust": c.OutputTrust, "origin": inv.Origin, "approval_id": string(approvalID)}, Payload: payload})
// Tool output re-enters cognition as an observation with tool trust.
if strings.TrimSpace(res.Output) != "" {
s.Events.Append(ctx, events.New{MindID: inv.MindID, Type: events.Observation, Source: "tool:" + c.Name, Summary: "tool output from " + c.Name + ": " + truncate(oneLine(res.Output), 140), Salience: 0.6, Confidence: 0.9, CausationID: firstID(ev), CorrelationID: inv.Correlation, //nolint:errcheck
@@ -409,8 +438,8 @@ func (s *Service) execute(ctx context.Context, inv Invocation, c Capability, d D
}
exec.EventID = firstID(ev)
params, _ := json.Marshal(inv.Params)
_, err := s.DB.Exec(ctx, `INSERT INTO tool_executions (id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
exec.ID, exec.MindID, exec.Capability, exec.Target, params, nullRaw(exec.Result), exec.Error, exec.DurationMS, nullID(approvalID), nullID(exec.EventID))
_, err := s.DB.Exec(ctx, `INSERT INTO tool_executions (id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, origin) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
exec.ID, exec.MindID, exec.Capability, exec.Target, params, nullRaw(exec.Result), exec.Error, exec.DurationMS, nullID(approvalID), nullID(exec.EventID), exec.Origin)
if err != nil {
s.Log.Error("record execution failed", "err", err)
}
@@ -422,7 +451,7 @@ func (s *Service) execute(ctx context.Context, inv Invocation, c Capability, d D
// ---- approvals ----
const approvalCols = `id, mind_id, capability, target, params, request_hash, reason, risk, evidence, status, decision_by, decision_note, decided_at, session_grant, modified_params, result, error, event_id, correlation_id, expires_at, created_at`
const approvalCols = `id, mind_id, capability, target, params, request_hash, reason, risk, evidence, status, decision_by, decision_note, decided_at, session_grant, modified_params, result, error, event_id, correlation_id, expires_at, created_at, origin`
func scanApproval(row pgx.Row) (Approval, error) {
var a Approval
@@ -430,7 +459,7 @@ func scanApproval(row pgx.Row) (Approval, error) {
var modified, result *json.RawMessage
var risk string
var ev, corr *string
if err := row.Scan(&a.ID, &a.MindID, &a.Capability, &a.Target, &params, &a.RequestHash, &a.Reason, &risk, &evid, &a.Status, &a.DecisionBy, &a.DecisionNote, &a.DecidedAt, &a.SessionGrant, &modified, &result, &a.Error, &ev, &corr, &a.ExpiresAt, &a.CreatedAt); err != nil {
if err := row.Scan(&a.ID, &a.MindID, &a.Capability, &a.Target, &params, &a.RequestHash, &a.Reason, &risk, &evid, &a.Status, &a.DecisionBy, &a.DecisionNote, &a.DecidedAt, &a.SessionGrant, &modified, &result, &a.Error, &ev, &corr, &a.ExpiresAt, &a.CreatedAt, &a.Origin); err != nil {
return a, storage.Translate(err)
}
a.Risk = Risk(risk)
@@ -531,7 +560,7 @@ func (s *Service) Approve(ctx context.Context, id ids.ID, by, note string, forSe
}
ev, _ := s.Events.Append(ctx, events.New{MindID: a.MindID, Type: events.ToolAuthorised, Source: "operator", Summary: fmt.Sprintf("operator %s approved %s on %q%s", by, a.Capability, a.Target, map[bool]string{true: " for this session", false: ""}[forSession]),
Salience: 0.5, Confidence: 1, CausationID: a.EventID, CorrelationID: a.CorrelationID, Metadata: map[string]any{"approval_id": string(a.ID), "actor": by, "capability": a.Capability, "for_session": forSession}, Payload: details})
inv := Invocation{MindID: a.MindID, Capability: a.Capability, Target: a.Target, Params: params, Reason: a.Reason, Evidence: a.Evidence, Correlation: a.CorrelationID, ApprovalID: a.ID}
inv := Invocation{MindID: a.MindID, Capability: a.Capability, Target: a.Target, Params: params, Reason: a.Reason, Evidence: a.Evidence, Correlation: a.CorrelationID, ApprovalID: a.ID, Origin: a.Origin}
out, err := s.execute(ctx, inv, c, Decision{Effect: Allow, Reason: "approved by " + by}, firstID(ev), a.ID)
if err != nil {
return out, err
@@ -622,7 +651,56 @@ func (s *Service) Executions(ctx context.Context, mind ids.ID, limit int) ([]Exe
if limit <= 0 || limit > 500 {
limit = 50
}
rows, err := s.DB.Query(ctx, `SELECT id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, created_at FROM tool_executions WHERE ($1::uuid IS NULL OR mind_id=$1) ORDER BY created_at DESC LIMIT $2`, nullID(mind), limit)
return s.queryExecutions(ctx, `SELECT id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, created_at, origin FROM tool_executions WHERE ($1::uuid IS NULL OR mind_id=$1) ORDER BY created_at DESC LIMIT $2`, nullID(mind), limit)
}
// ExecutionsByOrigin lists executions a cognitive object asked for, oldest first.
func (s *Service) ExecutionsByOrigin(ctx context.Context, mind ids.ID, origin string) ([]Execution, error) {
return s.queryExecutions(ctx, `SELECT id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, created_at, origin FROM tool_executions WHERE mind_id=$1 AND origin=$2 ORDER BY created_at LIMIT 100`, mind, origin)
}
// GetExecution fetches one execution.
func (s *Service) GetExecution(ctx context.Context, id ids.ID) (Execution, error) {
out, err := s.queryExecutions(ctx, `SELECT id, mind_id, capability, target, params, result, error, duration_ms, approval_id, event_id, created_at, origin FROM tool_executions WHERE id=$1`, id)
if err != nil {
return Execution{}, err
}
if len(out) == 0 {
return Execution{}, storage.ErrNotFound
}
return out[0], nil
}
// ApprovalsByOrigin lists approval requests a cognitive object raised, oldest first.
func (s *Service) ApprovalsByOrigin(ctx context.Context, mind ids.ID, origin string) ([]Approval, error) {
rows, err := s.DB.Query(ctx, `SELECT `+approvalCols+` FROM approval_requests WHERE mind_id=$1 AND origin=$2 ORDER BY created_at LIMIT 100`, mind, origin)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Approval
for rows.Next() {
a, err := scanApproval(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// WithdrawApproval lets the requesting mind withdraw a pending approval
// request it no longer needs (its plan changed). It never approves anything.
func (s *Service) WithdrawApproval(ctx context.Context, mind, id ids.ID, reason string) error {
tag, err := s.DB.Exec(ctx, `UPDATE approval_requests SET status='withdrawn', decision_note=$3 WHERE id=$1 AND mind_id=$2 AND status='pending'`, id, mind, reason)
if err == nil && tag.RowsAffected() == 0 {
return storage.ErrNotFound
}
return err
}
func (s *Service) queryExecutions(ctx context.Context, sql string, args ...any) ([]Execution, error) {
rows, err := s.DB.Query(ctx, sql, args...)
if err != nil {
return nil, err
}
@@ -633,7 +711,7 @@ func (s *Service) Executions(ctx context.Context, mind ids.ID, limit int) ([]Exe
var params []byte
var result *json.RawMessage
var appr, ev *string
if err := rows.Scan(&e.ID, &e.MindID, &e.Capability, &e.Target, &params, &result, &e.Error, &e.DurationMS, &appr, &ev, &e.CreatedAt); err != nil {
if err := rows.Scan(&e.ID, &e.MindID, &e.Capability, &e.Target, &params, &result, &e.Error, &e.DurationMS, &appr, &ev, &e.CreatedAt, &e.Origin); err != nil {
return nil, err
}
json.Unmarshal(params, &e.Params) //nolint:errcheck