Files
noema/internal/volition/store.go
T
Andy Dixon 03e2d7890a feat(cognition): wanting, intending and acting inside the cognitive cycle
Volition and planning now run as part of cognition (PLAN §8.2 item 3):

- A volition hook derives wants after each cycle from what it perceived
  and produced, satisfies answer wants when a reply is sent, raises due,
  overdue, expired and conflicting commitments, and expires faded wants.
  Events produced by earlier cycles are not re-derived.
- An intend process proposes deciding about the strongest undecided
  wants; the candidate competes for the workspace, so attention gates
  what the mind decides to pursue. The handler makes value tensions
  explicit (caution against disruptive writes, privacy against reading
  personal data), resolves them with recorded scores, runs deterministic
  formation with commitments, policy preview, goals and values, records
  the decision whatever it is, and plans a formed intention in the same
  transaction. Operator requests carry their own weight in formation.
- Explicit requests in conversation ("restart <unit>", "logs for <unit>",
  "fetch <url>") become wants with proposed means instead of direct
  invocations; the reply says the request was recorded and that acting
  depends on commitments and policy.
- The executor is woken after commits, runs in a scheduler-owned loop and
  reacts to the event bus: a new commitment tightens or abandons affected
  intentions (withdrawing their pending approvals), a weakened assumption
  or a finished goal triggers reconsideration. A tightened plan may reuse
  its strategy; a failed one may not.
- Metacognition gains volition checks: strong wants repeatedly set aside,
  intentions with no recorded reason, intentions a commitment forbids,
  commitments that cannot be kept, churn, repeated failed execution,
  cost outweighing value, and intentions that outlived their goal.
- A plan model role and prompt let a model propose decompositions that
  the planner validates. Ablations are a fork-only configuration field.
- Approvals required by an intention are described as coming from a
  commitment or value constraint. The expectation after an approved
  action is linked to the execution event, not the request.
- The wants.values column of the unreleased migration 0026 is renamed
  value_keys: VALUES is reserved and the generic snapshot restore does
  not quote identifiers.
- The hostile-output security test now uses a message that asks for no
  action, because an operator's "status of <unit>" is legitimately a
  read-only status check.
2026-09-15 21:04:07 +01:00

876 lines
38 KiB
Go

package volition
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Store persists volitional state. Every change goes through a Recorder so
// its event and, where a choice was made, its decision row are written in
// the same transaction.
type Store struct{ db *pgxpool.Pool }
// NewStore creates a store.
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
// DB exposes the pool for callers that record outside a transaction.
func (s *Store) DB() *pgxpool.Pool { return s.db }
// ErrInvalid marks bad input; ErrClosed marks a transition from a closed state.
var (
ErrInvalid = errors.New("volition: invalid")
ErrClosed = errors.New("volition: intention is closed")
)
// DirectEmitter appends and publishes immediately. Use it only with the pool,
// never inside a transaction (subscribers must not see uncommitted events).
func DirectEmitter(es *evstore.Store) Emitter {
return func(ctx context.Context, q storage.Querier, n events.New) (events.Event, error) {
evs, err := es.AppendIn(ctx, q, n)
if err != nil {
return events.Event{}, err
}
es.Publish(evs...)
return evs[0], nil
}
}
// Direct returns a recorder on the pool that publishes as it goes.
func (s *Store) Direct(es *evstore.Store, source string) Recorder {
return Recorder{Q: s.db, Emit: DirectEmitter(es), Source: source}
}
func (r Recorder) emit(ctx context.Context, mind ids.ID, typ events.Type, summary string, salience float64, evidence []events.EvidenceRef, meta map[string]any, payload any) (ids.ID, error) {
if r.Emit == nil {
return ids.Nil, nil
}
ev, err := r.Emit(ctx, r.Q, events.New{MindID: mind, Type: typ, Source: firstNonEmpty(r.Source, "volition"), Summary: summary, Salience: salience, Confidence: 1,
CausationID: r.Cause, CorrelationID: r.Correlation, Evidence: evidence, Metadata: meta, Payload: payload, OccurredAt: r.Now})
if err != nil {
return ids.Nil, err
}
return ev.ID, nil
}
func nullID(id ids.ID) *string {
if id.IsZero() {
return nil
}
v := string(id)
return &v
}
func idStrings(in []ids.ID) []string {
out := make([]string, 0, len(in))
for _, id := range in {
if !id.IsZero() {
out = append(out, string(id))
}
}
return out
}
func toIDs(in []string) []ids.ID {
out := make([]ids.ID, len(in))
for i, s := range in {
out[i] = ids.ID(s)
}
return out
}
func setID(dst *ids.ID, src *string) {
if src != nil {
*dst = ids.ID(*src)
}
}
func jsonOr(v any, empty string) []byte {
b, err := json.Marshal(v)
if err != nil || string(b) == "null" {
return []byte(empty)
}
return b
}
// ---- wants ----
const wantCols = `id, mind_id, subject, description, origin_type, origin_event, origin_ref, strength, valence, urgency, status, drives, value_keys, goal_id, commitment_id, proposal, terms, reinforcements, suppressions, last_reinforced_at, resolution, resolved_at, created_at, updated_at`
func scanWant(row pgx.Row) (Want, error) {
var w Want
var origin, status string
var originEv, goal, commitment *string
var proposal, terms []byte
if err := row.Scan(&w.ID, &w.MindID, &w.Subject, &w.Description, &origin, &originEv, &w.OriginRef, &w.Strength, &w.Valence, &w.Urgency, &status, &w.Drives, &w.Values, &goal, &commitment, &proposal, &terms,
&w.Reinforcements, &w.Suppressions, &w.LastReinforcedAt, &w.Resolution, &w.ResolvedAt, &w.CreatedAt, &w.UpdatedAt); err != nil {
return w, storage.Translate(err)
}
w.Origin, w.Status = Origin(origin), WantStatus(status)
setID(&w.OriginEvent, originEv)
setID(&w.GoalID, goal)
setID(&w.CommitmentID, commitment)
if len(proposal) > 0 && string(proposal) != "null" {
w.Proposal = &Proposal{}
json.Unmarshal(proposal, w.Proposal) //nolint:errcheck
}
json.Unmarshal(terms, &w.Terms) //nolint:errcheck
return w, nil
}
// UpsertWant creates a want from a candidate or, when an active want with
// the same subject exists, reinforces it: identical wants refresh rather
// than accumulate. It returns the want and whether it was new.
func (s *Store) UpsertWant(ctx context.Context, r Recorder, mind ids.ID, c Candidate) (Want, bool, error) {
now := r.now()
c.Subject, c.Description = strings.TrimSpace(c.Subject), strings.TrimSpace(c.Description)
if mind.IsZero() || c.Subject == "" || c.Description == "" || len(c.Subject) > 300 {
return Want{}, false, fmt.Errorf("%w: want needs a subject (≤300 chars) and a description", ErrInvalid)
}
c.Strength, c.Urgency = clamp01(c.Strength), clamp01(c.Urgency)
if c.Valence != "avoid" {
c.Valence = "approach"
}
existing, err := scanWant(r.Q.QueryRow(ctx, `SELECT `+wantCols+` FROM wants WHERE mind_id=$1 AND subject=$2 AND status='active'`, mind, c.Subject))
switch {
case err == nil:
eff := existing.Effective(now)
next := round3(clamp01(maxf(eff, c.Strength) + 0.1*(1-maxf(eff, c.Strength))))
urgency := maxf(existing.Urgency, c.Urgency)
if _, err := r.Q.Exec(ctx, `UPDATE wants SET strength=$2, urgency=$3, reinforcements=reinforcements+1, last_reinforced_at=$4, updated_at=$4 WHERE id=$1`, existing.ID, next, urgency, now); err != nil {
return Want{}, false, err
}
if next-eff >= 0.05 {
if _, err := r.emit(ctx, mind, events.WantReinforced, fmt.Sprintf("want reinforced (%.2f → %.2f): %s", eff, next, existing.Description), 0.2,
[]events.EvidenceRef{{Kind: "want", ID: string(existing.ID)}}, map[string]any{"want_id": string(existing.ID), "from": eff, "to": next, "origin_event": string(c.OriginEvent)}, map[string]any{"candidate_terms": c.Terms}); err != nil {
return Want{}, false, err
}
}
existing.Strength, existing.Urgency, existing.Reinforcements, existing.LastReinforcedAt = next, urgency, existing.Reinforcements+1, now
return existing, false, nil
case !errors.Is(err, storage.ErrNotFound):
return Want{}, false, err
}
w := Want{ID: ids.New(), MindID: mind, Subject: c.Subject, Description: trunc(c.Description, 500), Origin: c.Origin, OriginEvent: c.OriginEvent, OriginRef: c.OriginRef, Strength: c.Strength, Valence: c.Valence, Urgency: c.Urgency,
Status: WantActive, Drives: nonNil(c.Drives), Values: nonNil(c.Values), GoalID: c.GoalID, CommitmentID: c.CommitmentID, Proposal: c.Proposal, Terms: c.Terms, LastReinforcedAt: now, CreatedAt: now, UpdatedAt: now}
if w.Terms == nil {
w.Terms = map[string]float64{}
}
var proposal []byte
if w.Proposal != nil {
proposal, _ = json.Marshal(w.Proposal)
}
if _, err := r.Q.Exec(ctx, `INSERT INTO wants (id, mind_id, subject, description, origin_type, origin_event, origin_ref, strength, valence, urgency, status, drives, value_keys, goal_id, commitment_id, proposal, terms, last_reinforced_at, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'active',$11,$12,$13,$14,$15,$16,$17,$17,$17)`,
w.ID, mind, w.Subject, w.Description, string(w.Origin), nullID(w.OriginEvent), w.OriginRef, w.Strength, w.Valence, w.Urgency, w.Drives, w.Values, nullID(w.GoalID), nullID(w.CommitmentID), proposal, jsonOr(w.Terms, "{}"), now); err != nil {
return Want{}, false, storage.Translate(err)
}
evidence := []events.EvidenceRef{{Kind: "want", ID: string(w.ID)}}
if !w.OriginEvent.IsZero() {
evidence = append(evidence, events.EvidenceRef{Kind: "event", ID: string(w.OriginEvent), Note: "origin"})
}
if !w.GoalID.IsZero() {
evidence = append(evidence, events.EvidenceRef{Kind: "goal", ID: string(w.GoalID)})
}
if _, err := r.emit(ctx, mind, events.WantCreated, fmt.Sprintf("wants (%.2f, %s): %s", w.Strength, w.Origin, w.Description), 0.25+0.3*w.Strength, evidence,
map[string]any{"want_id": string(w.ID), "origin": string(w.Origin), "subject": w.Subject, "valence": w.Valence, "strength": w.Strength}, w); err != nil {
return Want{}, false, err
}
return w, true, nil
}
func nonNil(xs []string) []string {
if xs == nil {
return []string{}
}
return xs
}
// Wants lists a mind's wants by status (empty = all), newest first.
func (s *Store) Wants(ctx context.Context, q storage.Querier, mind ids.ID, statuses []WantStatus, limit int) ([]Want, error) {
if q == nil {
q = s.db
}
if limit <= 0 || limit > 1000 {
limit = 200
}
st := make([]string, len(statuses))
for i, x := range statuses {
st[i] = string(x)
}
rows, err := q.Query(ctx, `SELECT `+wantCols+` FROM wants WHERE mind_id=$1 AND (cardinality($2::text[])=0 OR status=ANY($2)) ORDER BY created_at DESC LIMIT $3`, mind, st, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Want
for rows.Next() {
w, err := scanWant(rows)
if err != nil {
return nil, err
}
out = append(out, w)
}
return out, rows.Err()
}
// GetWant fetches one want of a mind.
func (s *Store) GetWant(ctx context.Context, q storage.Querier, mind, id ids.ID) (Want, error) {
if q == nil {
q = s.db
}
return scanWant(q.QueryRow(ctx, `SELECT `+wantCols+` FROM wants WHERE id=$1 AND mind_id=$2`, id, mind))
}
// ResolveWant closes an active want (satisfied, abandoned, expired, superseded).
func (s *Store) ResolveWant(ctx context.Context, r Recorder, mind, id ids.ID, status WantStatus, resolution string) error {
if status == WantActive {
return fmt.Errorf("%w: resolving to active", ErrInvalid)
}
now := r.now()
tag, err := r.Q.Exec(ctx, `UPDATE wants SET status=$3, resolution=$4, resolved_at=$5, updated_at=$5 WHERE id=$1 AND mind_id=$2 AND status='active'`, id, mind, string(status), trunc(resolution, 500), now)
if err != nil || tag.RowsAffected() == 0 {
return err
}
return nil
}
// SuppressWant records that an active want was not pursued, and why. The
// want stays active and represented; its suppression count rises.
func (s *Store) SuppressWant(ctx context.Context, r Recorder, w Want, reason string, commitments []ids.ID) error {
if _, err := r.Q.Exec(ctx, `UPDATE wants SET suppressions=suppressions+1, updated_at=$2 WHERE id=$1`, w.ID, r.now()); err != nil {
return err
}
evidence := []events.EvidenceRef{{Kind: "want", ID: string(w.ID)}}
for _, c := range commitments {
evidence = append(evidence, events.EvidenceRef{Kind: "commitment", ID: string(c)})
}
_, err := r.emit(ctx, w.MindID, events.WantSuppressed, "want not pursued: "+w.Description+" — "+reason, 0.35, evidence,
map[string]any{"want_id": string(w.ID), "suppressions": w.Suppressions + 1, "commitments": idStrings(commitments)}, map[string]any{"reason": reason})
return err
}
// ExpireWants closes active wants whose strength decayed below the floor
// and, beyond the cap, the weakest ones. It returns how many were closed.
func (s *Store) ExpireWants(ctx context.Context, r Recorder, mind ids.ID, maxActive int) (int, error) {
if maxActive <= 0 {
maxActive = 40
}
now := r.now()
ws, err := s.Wants(ctx, r.Q, mind, []WantStatus{WantActive}, 1000)
if err != nil {
return 0, err
}
SortWants(ws, now)
n := 0
for i, w := range ws {
why := ""
switch {
case w.Effective(now) < 0.1:
why = "faded: not reinforced for " + now.Sub(w.LastReinforcedAt).Truncate(time.Minute).String()
case i >= maxActive:
why = "displaced by stronger wants (more than " + fmt.Sprint(maxActive) + " active)"
}
if why == "" {
continue
}
if err := s.ResolveWant(ctx, r, mind, w.ID, WantExpired, why); err != nil {
return n, err
}
n++
}
return n, nil
}
// ---- conflicts ----
const conflictCols = `id, mind_id, want_a, want_b, kind, status, preferred, resolution, rationale, detected_event, resolved_at, created_at`
func scanConflict(row pgx.Row) (Conflict, error) {
var c Conflict
var pref, det *string
var rat []byte
if err := row.Scan(&c.ID, &c.MindID, &c.WantA, &c.WantB, &c.Kind, &c.Status, &pref, &c.Resolution, &rat, &det, &c.ResolvedAt, &c.CreatedAt); err != nil {
return c, storage.Translate(err)
}
setID(&c.Preferred, pref)
setID(&c.DetectedEvent, det)
json.Unmarshal(rat, &c.Rationale) //nolint:errcheck
return c, nil
}
// RecordConflict stores a resolved conflict between two wants once per open
// pair, with the decision and a want_conflict_detected event. Neither want is
// discarded. It returns the conflict and whether it was new.
func (s *Store) RecordConflict(ctx context.Context, r Recorder, a, b Want, kind string, res Resolution) (Conflict, bool, error) {
existing, err := scanConflict(r.Q.QueryRow(ctx, `SELECT `+conflictCols+` FROM want_conflicts WHERE mind_id=$1 AND status='open' AND least(want_a,want_b)=least($2::uuid,$3::uuid) AND greatest(want_a,want_b)=greatest($2::uuid,$3::uuid)`, a.MindID, a.ID, b.ID))
if err == nil {
return existing, false, nil
}
if !errors.Is(err, storage.ErrNotFound) {
return Conflict{}, false, err
}
now := r.now()
c := Conflict{ID: ids.New(), MindID: a.MindID, WantA: a.ID, WantB: b.ID, Kind: kind, Status: "open", Preferred: res.Preferred, Resolution: res.Outcome, Rationale: res.Rationale, CreatedAt: now}
preferred, other := a, b
if res.Preferred == b.ID {
preferred, other = b, a
}
summary := fmt.Sprintf("wants conflict (%s): %q vs %q — %s %q", kind, a.Description, b.Description, map[string]string{"preferred": "preferred", "preferred_with_constraint": "preferred, with constraint,", "deferred": "deferred in favour of"}[res.Outcome], preferred.Description)
evID, err := r.emit(ctx, a.MindID, events.WantConflictDetected, summary, 0.5, []events.EvidenceRef{{Kind: "want", ID: string(a.ID)}, {Kind: "want", ID: string(b.ID)}},
map[string]any{"conflict_id": string(c.ID), "want_a": string(a.ID), "want_b": string(b.ID), "preferred": string(res.Preferred), "outcome": res.Outcome, "kind": kind}, c)
if err != nil {
return Conflict{}, false, err
}
c.DetectedEvent = evID
if _, err := r.Q.Exec(ctx, `INSERT INTO want_conflicts (id, mind_id, want_a, want_b, kind, status, preferred, resolution, rationale, detected_event, created_at) VALUES ($1,$2,$3,$4,$5,'open',$6,$7,$8,$9,$10)`,
c.ID, c.MindID, a.ID, b.ID, kind, nullID(c.Preferred), c.Resolution, jsonOr(c.Rationale, "{}"), nullID(evID), now); err != nil {
return Conflict{}, false, storage.Translate(err)
}
rule, _ := res.Rationale["rule"].(string)
if _, err := s.RecordDecision(ctx, r.Q, Decision{MindID: a.MindID, Kind: "conflict_resolution", Outcome: res.Outcome, Reason: kind, Rationale: rule, WantID: preferred.ID, ConflictID: c.ID, EventID: evID,
Inputs: map[string]any{"preferred": preferred.ID, "other": other.ID, "rationale": res.Rationale}, CreatedAt: now}); err != nil {
return Conflict{}, false, err
}
return c, true, nil
}
// SettleConflicts marks open conflicts involving a closed want as dissolved.
func (s *Store) SettleConflicts(ctx context.Context, q storage.Querier, mind ids.ID, now time.Time) error {
_, err := q.Exec(ctx, `UPDATE want_conflicts c SET status='dissolved', resolved_at=$2 WHERE c.mind_id=$1 AND c.status='open'
AND EXISTS (SELECT 1 FROM wants w WHERE w.id IN (c.want_a, c.want_b) AND w.status <> 'active')`, mind, now)
return err
}
// Conflicts lists a mind's want conflicts (status empty = all), newest first.
func (s *Store) Conflicts(ctx context.Context, mind ids.ID, status string, limit int) ([]Conflict, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.db.Query(ctx, `SELECT `+conflictCols+` FROM want_conflicts WHERE mind_id=$1 AND ($2='' OR status=$2) ORDER BY created_at DESC LIMIT $3`, mind, status, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Conflict
for rows.Next() {
c, err := scanConflict(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// ---- commitments ----
// NewCommitment describes a commitment to create.
type NewCommitment struct {
MindID ids.ID
Subject string
Description string
Kind CommitmentKind
Polarity string
Matcher Matcher
Unless string
Beneficiary string
Origin string
OriginEvent ids.ID
OriginRef string
Strength float64
Priority float64
Conditions string
DueAt *time.Time
ExpiresAt *time.Time
Revocable *bool
GoalID ids.ID
IntentionID ids.ID
Evidence []events.EvidenceRef
CreatedBy string
}
const commitmentCols = `id, mind_id, subject, description, kind, polarity, matcher, unless, beneficiary, origin_type, origin_event, origin_ref, strength, priority, conditions, due_at, expires_at, revocable, status, superseded_by, goal_id, intention_id, evidence, due_notified_at, created_by, status_reason, closed_at, created_at, updated_at`
func scanCommitment(row pgx.Row) (Commitment, error) {
var c Commitment
var kind string
var matcher, evidence []byte
var originEv, sup, goal, intention *string
if err := row.Scan(&c.ID, &c.MindID, &c.Subject, &c.Description, &kind, &c.Polarity, &matcher, &c.Unless, &c.Beneficiary, &c.Origin, &originEv, &c.OriginRef, &c.Strength, &c.Priority, &c.Conditions,
&c.DueAt, &c.ExpiresAt, &c.Revocable, &c.Status, &sup, &goal, &intention, &evidence, &c.DueNotifiedAt, &c.CreatedBy, &c.StatusReason, &c.ClosedAt, &c.CreatedAt, &c.UpdatedAt); err != nil {
return c, storage.Translate(err)
}
c.Kind = CommitmentKind(kind)
setID(&c.OriginEvent, originEv)
setID(&c.SupersededBy, sup)
setID(&c.GoalID, goal)
setID(&c.IntentionID, intention)
json.Unmarshal(matcher, &c.Matcher) //nolint:errcheck
json.Unmarshal(evidence, &c.Evidence) //nolint:errcheck
if c.Evidence == nil {
c.Evidence = []events.EvidenceRef{}
}
return c, nil
}
// CreateCommitment validates and stores a commitment. A new commitment with
// the same subject, kind and polarity supersedes the older one, which is kept
// as history.
func (s *Store) CreateCommitment(ctx context.Context, r Recorder, n NewCommitment) (Commitment, error) {
now := r.now()
n.Subject, n.Description = strings.TrimSpace(n.Subject), strings.TrimSpace(n.Description)
if n.MindID.IsZero() || n.Description == "" || len(n.Description) > 1000 || len(n.Subject) > 300 {
return Commitment{}, fmt.Errorf("%w: a commitment needs a description (≤1000 chars)", ErrInvalid)
}
if n.Subject == "" {
n.Subject = strings.ToLower(trunc(n.Description, 120))
}
if !ValidKind(n.Kind) {
return Commitment{}, fmt.Errorf("%w: unknown commitment kind %q", ErrInvalid, n.Kind)
}
if n.Polarity != "do" && n.Polarity != "refrain" {
return Commitment{}, fmt.Errorf("%w: polarity must be do or refrain", ErrInvalid)
}
if n.Unless != "" && (n.Unless != "approval" || n.Polarity != "refrain") {
return Commitment{}, fmt.Errorf("%w: only refrain commitments may have unless=approval", ErrInvalid)
}
if n.Matcher.Access != "" && n.Matcher.Access != "read" && n.Matcher.Access != "write" {
return Commitment{}, fmt.Errorf("%w: matcher access must be read, write or empty", ErrInvalid)
}
if n.Matcher.MinRisk != "" && riskLevel[n.Matcher.MinRisk] == 0 {
return Commitment{}, fmt.Errorf("%w: unknown min_risk", ErrInvalid)
}
if n.Strength <= 0 || n.Strength > 1 {
n.Strength = 0.8
}
if n.Priority <= 0 || n.Priority > 1 {
n.Priority = 0.5
}
revocable := true
if n.Revocable != nil {
revocable = *n.Revocable
}
if n.Origin == "" {
n.Origin = "operator"
}
if n.Evidence == nil {
n.Evidence = []events.EvidenceRef{}
}
c := Commitment{ID: ids.New(), MindID: n.MindID, Subject: n.Subject, Description: n.Description, Kind: n.Kind, Polarity: n.Polarity, Matcher: n.Matcher, Unless: n.Unless, Beneficiary: n.Beneficiary, Origin: n.Origin,
OriginEvent: n.OriginEvent, OriginRef: n.OriginRef, Strength: round3(n.Strength), Priority: round3(n.Priority), Conditions: n.Conditions, DueAt: n.DueAt, ExpiresAt: n.ExpiresAt, Revocable: revocable, Status: "active",
GoalID: n.GoalID, IntentionID: n.IntentionID, Evidence: n.Evidence, CreatedBy: n.CreatedBy, CreatedAt: now, UpdatedAt: now}
if _, err := r.Q.Exec(ctx, `INSERT INTO commitments (id, mind_id, subject, description, kind, polarity, matcher, unless, beneficiary, origin_type, origin_event, origin_ref, strength, priority, conditions, due_at, expires_at, revocable, status, goal_id, intention_id, evidence, created_by, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,'active',$19,$20,$21,$22,$23,$23)`,
c.ID, c.MindID, c.Subject, c.Description, string(c.Kind), c.Polarity, jsonOr(c.Matcher, "{}"), c.Unless, c.Beneficiary, c.Origin, nullID(c.OriginEvent), c.OriginRef, c.Strength, c.Priority, c.Conditions, c.DueAt, c.ExpiresAt, c.Revocable,
nullID(c.GoalID), nullID(c.IntentionID), jsonOr(c.Evidence, "[]"), c.CreatedBy, now); err != nil {
return Commitment{}, storage.Translate(err)
}
tag, err := r.Q.Exec(ctx, `UPDATE commitments SET status='superseded', superseded_by=$1, status_reason='superseded by a newer commitment on the same subject', closed_at=$2, updated_at=$2
WHERE mind_id=$3 AND id<>$1 AND status='active' AND lower(subject)=lower($4) AND kind=$5 AND polarity=$6`, c.ID, now, c.MindID, c.Subject, string(c.Kind), c.Polarity)
if err != nil {
return Commitment{}, err
}
verb := map[string]string{"do": "committed to", "refrain": "committed not to"}[c.Polarity]
summary := fmt.Sprintf("%s: %s (%s, weight %.2f)", verb, c.Description, c.Kind, c.Weight())
if tag.RowsAffected() > 0 {
summary += fmt.Sprintf("; supersedes %d earlier commitment(s)", tag.RowsAffected())
}
if _, err := r.emit(ctx, c.MindID, events.CommitmentCreated, summary, 0.4+0.3*c.Weight(), append([]events.EvidenceRef{{Kind: "commitment", ID: string(c.ID)}}, c.Evidence...),
map[string]any{"commitment_id": string(c.ID), "kind": string(c.Kind), "polarity": c.Polarity, "unless": c.Unless, "created_by": c.CreatedBy, "superseded": tag.RowsAffected()}, c); err != nil {
return Commitment{}, err
}
return c, nil
}
// Commitments lists a mind's commitments by status (empty = all).
func (s *Store) Commitments(ctx context.Context, q storage.Querier, mind ids.ID, statuses []string, limit int) ([]Commitment, error) {
if q == nil {
q = s.db
}
if limit <= 0 || limit > 1000 {
limit = 200
}
rows, err := q.Query(ctx, `SELECT `+commitmentCols+` FROM commitments WHERE mind_id=$1 AND (cardinality($2::text[])=0 OR status=ANY($2)) ORDER BY created_at DESC LIMIT $3`, mind, nonNil(statuses), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Commitment
for rows.Next() {
c, err := scanCommitment(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// GetCommitment fetches one commitment of a mind.
func (s *Store) GetCommitment(ctx context.Context, q storage.Querier, mind, id ids.ID) (Commitment, error) {
if q == nil {
q = s.db
}
return scanCommitment(q.QueryRow(ctx, `SELECT `+commitmentCols+` FROM commitments WHERE id=$1 AND mind_id=$2`, id, mind))
}
// ErrNotRevocable is returned when cognition tries to revoke a commitment
// that only an operator may revoke.
var ErrNotRevocable = errors.New("volition: this commitment can only be revoked by an operator")
// CloseCommitment ends an active commitment as fulfilled, broken, revoked or
// expired. Non-revocable commitments can be revoked only by an operator.
func (s *Store) CloseCommitment(ctx context.Context, r Recorder, mind, id ids.ID, status, reason string, byOperator bool) (Commitment, error) {
typ := map[string]events.Type{"fulfilled": events.CommitmentFulfilled, "broken": events.CommitmentBroken, "revoked": events.CommitmentRevoked, "expired": events.CommitmentRevoked}[status]
if typ == "" {
return Commitment{}, fmt.Errorf("%w: status must be fulfilled, broken, revoked or expired", ErrInvalid)
}
c, err := s.GetCommitment(ctx, r.Q, mind, id)
if err != nil {
return c, err
}
if c.Status != "active" {
return c, fmt.Errorf("%w: commitment is %s", ErrInvalid, c.Status)
}
if status == "revoked" && !c.Revocable && !byOperator {
return c, ErrNotRevocable
}
now := r.now()
if _, err := r.Q.Exec(ctx, `UPDATE commitments SET status=$3, status_reason=$4, closed_at=$5, updated_at=$5 WHERE id=$1 AND mind_id=$2 AND status='active'`, id, mind, status, trunc(reason, 500), now); err != nil {
return c, err
}
c.Status, c.StatusReason, c.ClosedAt = status, reason, &now
if _, err := r.emit(ctx, mind, typ, fmt.Sprintf("commitment %s: %s — %s", status, c.Description, reason), map[string]float64{"broken": 0.7}[status]+0.3, []events.EvidenceRef{{Kind: "commitment", ID: string(id)}},
map[string]any{"commitment_id": string(id), "status": status}, map[string]any{"reason": reason}); err != nil {
return c, err
}
return c, nil
}
// MarkDue records that a commitment entered its due window and emits commitment_due once.
func (s *Store) MarkDue(ctx context.Context, r Recorder, c Commitment) error {
now := r.now()
tag, err := r.Q.Exec(ctx, `UPDATE commitments SET due_notified_at=$2, updated_at=$2 WHERE id=$1 AND due_notified_at IS NULL`, c.ID, now)
if err != nil || tag.RowsAffected() == 0 {
return err
}
_, err = r.emit(ctx, c.MindID, events.CommitmentDue, fmt.Sprintf("commitment due %s: %s", c.DueAt.Format(time.RFC3339), c.Description), 0.6, []events.EvidenceRef{{Kind: "commitment", ID: string(c.ID)}},
map[string]any{"commitment_id": string(c.ID), "due_at": c.DueAt}, nil)
return err
}
// ---- intentions ----
const intentionCols = `id, mind_id, parent_id, description, desired_outcome, kind, source_wants, goal_id, commitment_id, belief_ids, expected_result, confidence, priority, status, blocked_kind, blocked_reason, required_capabilities, require_approval, proposal, plan_id, superseded_by, formed_event, last_event, formation, reconsiderations, status_reason, closed_at, created_at, updated_at`
func scanIntention(row pgx.Row) (Intention, error) {
var it Intention
var parent, goal, commitment, plan, sup, formed, last *string
var kind, status string
var wants, beliefs []string
var proposal, formation []byte
if err := row.Scan(&it.ID, &it.MindID, &parent, &it.Description, &it.DesiredOutcome, &kind, &wants, &goal, &commitment, &beliefs, &it.ExpectedResult, &it.Confidence, &it.Priority, &status, &it.BlockedKind, &it.BlockedReason,
&it.RequiredCapabilities, &it.RequireApproval, &proposal, &plan, &sup, &formed, &last, &formation, &it.Reconsiderations, &it.StatusReason, &it.ClosedAt, &it.CreatedAt, &it.UpdatedAt); err != nil {
return it, storage.Translate(err)
}
it.Kind, it.Status = IntentionKind(kind), IntentionStatus(status)
it.SourceWants, it.BeliefIDs = toIDs(wants), toIDs(beliefs)
for _, x := range []struct {
dst *ids.ID
src *string
}{{&it.ParentID, parent}, {&it.GoalID, goal}, {&it.CommitmentID, commitment}, {&it.PlanID, plan}, {&it.SupersededBy, sup}, {&it.FormedEvent, formed}, {&it.LastEvent, last}} {
setID(x.dst, x.src)
}
if len(proposal) > 0 && string(proposal) != "null" {
it.Proposal = &Proposal{}
json.Unmarshal(proposal, it.Proposal) //nolint:errcheck
}
json.Unmarshal(formation, &it.Formation) //nolint:errcheck
return it, nil
}
// CreateIntention stores a formed intention and emits intention_formed with
// its provenance: source wants, goal, commitment, beliefs and the formation
// terms.
func (s *Store) CreateIntention(ctx context.Context, r Recorder, it Intention) (Intention, error) {
now := r.now()
it.Description = strings.TrimSpace(it.Description)
if it.MindID.IsZero() || it.Description == "" {
return Intention{}, fmt.Errorf("%w: intention needs a mind and a description", ErrInvalid)
}
if it.Kind == "" {
it.Kind = IntentPursue
}
if it.Status == "" {
it.Status = IntentionActive
}
it.ID, it.CreatedAt, it.UpdatedAt = ids.New(), now, now
it.Confidence, it.Priority = clamp01(it.Confidence), clamp01(it.Priority)
if it.Formation == nil {
it.Formation = map[string]any{}
}
if it.RequiredCapabilities == nil {
it.RequiredCapabilities = []string{}
}
evidence := []events.EvidenceRef{{Kind: "intention", ID: string(it.ID)}}
for _, w := range it.SourceWants {
evidence = append(evidence, events.EvidenceRef{Kind: "want", ID: string(w), Note: "source want"})
}
if !it.GoalID.IsZero() {
evidence = append(evidence, events.EvidenceRef{Kind: "goal", ID: string(it.GoalID)})
}
if !it.CommitmentID.IsZero() {
evidence = append(evidence, events.EvidenceRef{Kind: "commitment", ID: string(it.CommitmentID)})
}
for _, b := range it.BeliefIDs {
evidence = append(evidence, events.EvidenceRef{Kind: "belief", ID: string(b), Note: "supporting belief"})
}
if !it.ParentID.IsZero() {
evidence = append(evidence, events.EvidenceRef{Kind: "intention", ID: string(it.ParentID), Note: "parent intention"})
}
summary := "intends to " + it.Description
if it.Status == IntentionBlocked {
summary += " (blocked: " + it.BlockedReason + ")"
}
evID, err := r.emit(ctx, it.MindID, events.IntentionFormed, summary, 0.4+0.4*it.Priority, evidence,
map[string]any{"intention_id": string(it.ID), "kind": string(it.Kind), "priority": it.Priority, "parent_id": string(it.ParentID), "require_approval": it.RequireApproval, "want_ids": idStrings(it.SourceWants)}, it)
if err != nil {
return Intention{}, err
}
it.FormedEvent, it.LastEvent = evID, evID
var proposal []byte
if it.Proposal != nil {
proposal, _ = json.Marshal(it.Proposal)
}
if _, err := r.Q.Exec(ctx, `INSERT INTO intentions (id, mind_id, parent_id, description, desired_outcome, kind, source_wants, goal_id, commitment_id, belief_ids, expected_result, confidence, priority, status, blocked_kind, blocked_reason,
required_capabilities, require_approval, proposal, plan_id, formed_event, last_event, formation, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$21,$22,$23,$23)`,
it.ID, it.MindID, nullID(it.ParentID), trunc(it.Description, 500), trunc(it.DesiredOutcome, 500), string(it.Kind), idStrings(it.SourceWants), nullID(it.GoalID), nullID(it.CommitmentID), idStrings(it.BeliefIDs), trunc(it.ExpectedResult, 500),
it.Confidence, it.Priority, string(it.Status), it.BlockedKind, it.BlockedReason, it.RequiredCapabilities, it.RequireApproval, proposal, nullID(it.PlanID), nullID(evID), jsonOr(it.Formation, "{}"), now); err != nil {
return Intention{}, storage.Translate(err)
}
return it, nil
}
// Intentions lists a mind's intentions by status (empty = all).
func (s *Store) Intentions(ctx context.Context, q storage.Querier, mind ids.ID, statuses []IntentionStatus, limit int) ([]Intention, error) {
if q == nil {
q = s.db
}
if limit <= 0 || limit > 1000 {
limit = 200
}
st := make([]string, len(statuses))
for i, x := range statuses {
st[i] = string(x)
}
rows, err := q.Query(ctx, `SELECT `+intentionCols+` FROM intentions WHERE mind_id=$1 AND (cardinality($2::text[])=0 OR status=ANY($2)) ORDER BY created_at DESC LIMIT $3`, mind, st, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Intention
for rows.Next() {
it, err := scanIntention(rows)
if err != nil {
return nil, err
}
out = append(out, it)
}
return out, rows.Err()
}
// Open lists live intentions (proposed, active, suspended, blocked).
func (s *Store) Open(ctx context.Context, q storage.Querier, mind ids.ID) ([]Intention, error) {
return s.Intentions(ctx, q, mind, []IntentionStatus{IntentionProposed, IntentionActive, IntentionSuspended, IntentionBlocked}, 500)
}
// GetIntention fetches one intention of a mind.
func (s *Store) GetIntention(ctx context.Context, q storage.Querier, mind, id ids.ID) (Intention, error) {
if q == nil {
q = s.db
}
return scanIntention(q.QueryRow(ctx, `SELECT `+intentionCols+` FROM intentions WHERE id=$1 AND mind_id=$2`, id, mind))
}
// GetIntentionAny fetches an intention by id alone (forensic lookups).
func (s *Store) GetIntentionAny(ctx context.Context, id ids.ID) (Intention, error) {
return scanIntention(s.db.QueryRow(ctx, `SELECT `+intentionCols+` FROM intentions WHERE id=$1`, id))
}
// SetPlan links an intention to its current plan.
func (s *Store) SetPlan(ctx context.Context, q storage.Querier, mind, id, plan ids.ID) error {
_, err := q.Exec(ctx, `UPDATE intentions SET plan_id=$3, updated_at=now() WHERE id=$1 AND mind_id=$2`, id, mind, nullID(plan))
return err
}
// Apply records a reconsideration outcome as a status transition with its
// event and decision. History is never rewritten: the earlier state stays in
// the event log and the decision ledger.
func (s *Store) Apply(ctx context.Context, r Recorder, mind, id ids.ID, rec Reconsideration, t Trigger) (Intention, error) {
it, err := s.GetIntention(ctx, r.Q, mind, id)
if err != nil {
return it, err
}
if !it.Status.Open() {
return it, fmt.Errorf("%w (%s)", ErrClosed, it.Status)
}
now := r.now()
next, typ := it.Status, events.IntentionReconsidered
blockedKind, blockedReason := it.BlockedKind, it.BlockedReason
requireApproval, kind := it.RequireApproval, it.Kind
var closed *time.Time
switch rec.Outcome {
case Keep:
return it, nil
case Block:
next, typ, blockedKind, blockedReason = IntentionBlocked, events.IntentionBlocked, firstNonEmpty(rec.BlockedKind, BlockPrereq), rec.Reason
case Suspend:
next, typ = IntentionSuspended, events.IntentionSuspended
if rec.BlockedKind != "" {
blockedKind, blockedReason = rec.BlockedKind, rec.Reason
}
case Resume:
next, typ, blockedKind, blockedReason = IntentionActive, events.IntentionResumed, "", ""
case Abandon:
next, typ, closed = IntentionAbandoned, events.IntentionAbandoned, &now
case Fulfil:
next, typ, closed, blockedKind, blockedReason = IntentionFulfilled, events.IntentionFulfilled, &now, "", ""
case Replan:
next, blockedKind, blockedReason = IntentionActive, "", ""
case Tighten:
requireApproval, kind = true, IntentRequestApproval
default:
return it, fmt.Errorf("%w: unknown outcome %q", ErrInvalid, rec.Outcome)
}
verb := map[events.Type]string{events.IntentionBlocked: "blocked", events.IntentionSuspended: "suspended", events.IntentionResumed: "resumed", events.IntentionAbandoned: "abandoned", events.IntentionFulfilled: "fulfilled", events.IntentionReconsidered: "reconsidering (" + rec.Outcome + ")"}[typ]
evidence := []events.EvidenceRef{{Kind: "intention", ID: string(it.ID)}}
if !t.EventID.IsZero() {
evidence = append(evidence, events.EvidenceRef{Kind: "event", ID: string(t.EventID), Note: "trigger: " + t.Kind})
}
rr := r
if !t.EventID.IsZero() {
rr.Cause = t.EventID
}
evID, err := rr.emit(ctx, mind, typ, fmt.Sprintf("intention %s: %s — %s", verb, it.Description, rec.Reason), 0.45, evidence,
map[string]any{"intention_id": string(it.ID), "from": string(it.Status), "to": string(next), "outcome": rec.Outcome, "trigger": t.Kind, "blocked_kind": blockedKind, "plan_id": string(it.PlanID)}, map[string]any{"reason": rec.Reason, "trigger": t})
if err != nil {
return it, err
}
if _, err := r.Q.Exec(ctx, `UPDATE intentions SET status=$3, blocked_kind=$4, blocked_reason=$5, require_approval=$6, kind=$7, reconsiderations=reconsiderations+1, status_reason=$8, last_event=$9, closed_at=coalesce($10, closed_at), updated_at=$11 WHERE id=$1 AND mind_id=$2`,
id, mind, string(next), blockedKind, trunc(blockedReason, 500), requireApproval, string(kind), trunc(rec.Reason, 500), nullID(evID), closed, now); err != nil {
return it, err
}
if _, err := s.RecordDecision(ctx, r.Q, Decision{MindID: mind, Kind: "reconsideration", Outcome: rec.Outcome, Reason: t.Kind, Rationale: rec.Reason, IntentionID: id, EventID: evID, CreatedAt: now,
Inputs: map[string]any{"from": it.Status, "to": next, "trigger": t}}); err != nil {
return it, err
}
if next == IntentionFulfilled {
for _, w := range it.SourceWants {
if err := s.ResolveWant(ctx, r, mind, w, WantSatisfied, "intention fulfilled: "+it.Description); err != nil {
return it, err
}
}
if !it.CommitmentID.IsZero() {
if _, err := s.CloseCommitment(ctx, r, mind, it.CommitmentID, "fulfilled", "intention fulfilled: "+it.Description, false); err != nil && !errors.Is(err, ErrInvalid) {
return it, err
}
}
}
return s.GetIntention(ctx, r.Q, mind, id)
}
// Supersede closes an intention in favour of another.
func (s *Store) Supersede(ctx context.Context, r Recorder, mind, old, by ids.ID, reason string) error {
it, err := s.GetIntention(ctx, r.Q, mind, old)
if err != nil {
return err
}
if !it.Status.Open() {
return nil
}
now := r.now()
evID, err := r.emit(ctx, mind, events.IntentionSuperseded, "intention superseded: "+it.Description+" — "+reason, 0.4, []events.EvidenceRef{{Kind: "intention", ID: string(old)}, {Kind: "intention", ID: string(by), Note: "replacement"}},
map[string]any{"intention_id": string(old), "superseded_by": string(by)}, map[string]any{"reason": reason})
if err != nil {
return err
}
_, err = r.Q.Exec(ctx, `UPDATE intentions SET status='superseded', superseded_by=$3, status_reason=$4, last_event=$5, closed_at=$6, updated_at=$6 WHERE id=$1 AND mind_id=$2`, old, mind, by, reason, nullID(evID), now)
return err
}
// ---- decisions ----
// RecordDecision appends a decision.
func (s *Store) RecordDecision(ctx context.Context, q storage.Querier, d Decision) (Decision, error) {
if q == nil {
q = s.db
}
d.ID = ids.New()
if d.CreatedAt.IsZero() {
d.CreatedAt = time.Now()
}
if d.Terms == nil {
d.Terms = map[string]float64{}
}
if d.Inputs == nil {
d.Inputs = map[string]any{}
}
_, err := q.Exec(ctx, `INSERT INTO volition_decisions (id, mind_id, kind, outcome, reason, rationale, want_id, intention_id, commitment_id, conflict_id, terms, inputs, event_id, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
d.ID, d.MindID, d.Kind, d.Outcome, d.Reason, trunc(d.Rationale, 2000), nullID(d.WantID), nullID(d.IntentionID), nullID(d.CommitmentID), nullID(d.ConflictID), jsonOr(d.Terms, "{}"), jsonOr(d.Inputs, "{}"), nullID(d.EventID), d.CreatedAt)
return d, err
}
// DecisionFilter narrows a decision listing.
type DecisionFilter struct {
WantID ids.ID
IntentionID ids.ID
CommitmentID ids.ID
Kind string
Limit int
}
// Decisions lists decisions, newest first.
func (s *Store) Decisions(ctx context.Context, mind ids.ID, f DecisionFilter) ([]Decision, error) {
if f.Limit <= 0 || f.Limit > 1000 {
f.Limit = 200
}
rows, err := s.db.Query(ctx, `SELECT id, mind_id, kind, outcome, reason, rationale, want_id, intention_id, commitment_id, conflict_id, terms, inputs, event_id, created_at FROM volition_decisions
WHERE mind_id=$1 AND ($2::uuid IS NULL OR want_id=$2) AND ($3::uuid IS NULL OR intention_id=$3) AND ($4::uuid IS NULL OR commitment_id=$4) AND ($5='' OR kind=$5) ORDER BY created_at DESC LIMIT $6`,
mind, nullID(f.WantID), nullID(f.IntentionID), nullID(f.CommitmentID), f.Kind, f.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Decision
for rows.Next() {
var d Decision
var want, intention, commitment, conflict, ev *string
var terms, inputs []byte
if err := rows.Scan(&d.ID, &d.MindID, &d.Kind, &d.Outcome, &d.Reason, &d.Rationale, &want, &intention, &commitment, &conflict, &terms, &inputs, &ev, &d.CreatedAt); err != nil {
return nil, err
}
setID(&d.WantID, want)
setID(&d.IntentionID, intention)
setID(&d.CommitmentID, commitment)
setID(&d.ConflictID, conflict)
setID(&d.EventID, ev)
json.Unmarshal(terms, &d.Terms) //nolint:errcheck
json.Unmarshal(inputs, &d.Inputs) //nolint:errcheck
out = append(out, d)
}
return out, rows.Err()
}