Files
noema/internal/volition/store.go
T
Andy Dixon 9db188ffe6 feat(volition): wants, intentions, commitments and their decisions
A model of volition that keeps wanting, intending and committing as
separate states (PLAN §8.2 items 1–6, 14):

- Wants (migration 0026) carry origin, strength with a six-hour
  half-life, urgency, valence, drives, values, goal, commitment and an
  optional proposed means. Derivation is deterministic and selective:
  failed predictions, contradictions, operator goals, stalled goals,
  questions and requests, and due commitments; drives and configured
  values modulate strength with recorded terms; candidates below 0.35 are
  not stored; identical subjects reinforce; faded and excess wants expire.
- Conflicts are first-class and resolved by explicit scores with no fixed
  hierarchy: urgency scales strength, goals add priority, commitments add
  normative weight; an approach opposed by a value-derived avoid want wins
  outright only when clearly stronger, otherwise with a constraint or is
  deferred. Neither side is discarded.
- Commitments (do/refrain with capability, target, access and risk
  matchers, optional "unless approval", due, expiry, revocability) are
  checked for due, overdue, impossible, conflicting and superseded states.
  They constrain means and never grant authority.
- Formation weighs strength, urgency, goal, commitment, expected utility,
  risk scaled by caution, cost, privacy and autonomy; it returns formed or
  not formed with a reason (forbidden by commitment, denied, unavailable,
  no possible plan, below threshold, capacity, duplicate, deferred), and a
  commitment requiring approval turns an action into a request for
  approval.
- Reconsideration maps triggers (denied or expired approval, capability or
  commitment change, failed step or expectation, weakened belief, budget,
  goal completion, world change, operator correction) to keep, block,
  suspend, resume, replan, require approval, abandon or fulfil, with
  bounded replanning.
- The store records every transition as an event and every choice in the
  append-only volition_decisions ledger; fulfilling an intention satisfies
  its wants and fulfils its commitment.
- Event vocabulary gains volition and plan categories.
2026-09-13 21:03:23 +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, "values", 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, "values", 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()
}