feat(epistemics): record assertions between observations and beliefs

A statement made to a mind is now three things kept apart: the observation
that it arrived, an append-only assertion (attributed actor, stance,
certainty, modality, negation, reported or quoted speech, hypothetical
status, interpretation confidence and notes), and, only for factual
assertions, one piece of evidence for a proposition belief attributed to
whoever actually stands behind it. Reported speech is attributed to the
person reported and weighed as second-hand; hypotheticals, questions and
imperatives create no belief; expressed doubt counts against; a possibly
sarcastic remark is recorded with low interpretation confidence rather
than guessed at. Interpretation is deterministic code; a model may enrich
the claim list but never sets stance or confidence.

Association no longer treats conversational turns as world events:
hypotheticals, questions, requests and consecutive turns by one speaker
cannot seed causal hypotheses.

Adds internal/epistemics, migration 0018, the assertions API, assertions
on belief pages and in belief detail, a shared end-to-end test harness
(tests/harness), end-to-end tests for eight speech patterns, and updates
the milestone test to the new provenance.
This commit is contained in:
Andy Dixon
2026-08-05 19:22:48 +01:00
parent 0071658c93
commit 9b2521bffd
28 changed files with 1447 additions and 325 deletions
+17 -15
View File
@@ -10,6 +10,8 @@ import (
"strings"
"testing"
"github.com/andydixon/noema/tests/harness"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/snapshots"
@@ -45,20 +47,20 @@ var sqlPayloads = []string{
// counts across sensitive tables are unchanged, seeded content is intact and
// the payloads are stored verbatim as data.
func TestSQLInjectionHasNoEffect(t *testing.T) {
h := start(t)
h := harness.Start(t)
ctx := context.Background()
m := h.mind("sqli")
m := h.Mind("sqli")
seed := "the launch window opens at 09:00"
code, body := h.do(h.admin, "POST", "/api/v1/minds/sqli/memories", map[string]any{"kind": "semantic", "content": seed, "epistemic": "fact", "confidence": 0.9, "importance": 0.9})
code, body := h.Do(h.Admin, "POST", "/api/v1/minds/sqli/memories", map[string]any{"kind": "semantic", "content": seed, "epistemic": "fact", "confidence": 0.9, "importance": 0.9})
if code != 201 {
t.Fatalf("seed: %d %s", code, body)
}
var seeded struct{ ID string }
json.Unmarshal(body, &seeded) //nolint:errcheck
h.do(h.admin, "POST", "/api/v1/minds/sqli/beliefs", map[string]any{"proposition": "the launch is on schedule", "epistemic": "claim", "weight": 0.5})
h.Do(h.Admin, "POST", "/api/v1/minds/sqli/beliefs", map[string]any{"proposition": "the launch is on schedule", "epistemic": "claim", "weight": 0.5})
count := func(table string) int {
var n int
h.deps.DB.QueryRow(ctx, `SELECT count(*) FROM `+table).Scan(&n) //nolint:errcheck
h.Deps.DB.QueryRow(ctx, `SELECT count(*) FROM `+table).Scan(&n) //nolint:errcheck
return n
}
before := map[string]int{}
@@ -66,7 +68,7 @@ func TestSQLInjectionHasNoEffect(t *testing.T) {
before[tb] = count(tb)
}
var adminRoles int
h.deps.DB.QueryRow(ctx, `SELECT count(*) FROM users WHERE role='administrator'`).Scan(&adminRoles) //nolint:errcheck
h.Deps.DB.QueryRow(ctx, `SELECT count(*) FROM users WHERE role='administrator'`).Scan(&adminRoles) //nolint:errcheck
reads := []string{
"/api/v1/minds/sqli/memories?q=%s", "/api/v1/minds/sqli/memories?q=%s&mode=list", "/api/v1/minds/sqli/beliefs?q=%s", "/api/v1/minds/sqli/beliefs?subject=%s",
@@ -78,28 +80,28 @@ func TestSQLInjectionHasNoEffect(t *testing.T) {
esc := url.QueryEscape(p)
for _, tpl := range reads {
path := strings.ReplaceAll(tpl, "%s", esc)
if code, _ := h.do(h.admin, "GET", path, nil); code >= 500 {
if code, _ := h.Do(h.Admin, "GET", path, nil); code >= 500 {
t.Errorf("GET %s → %d", tpl, code)
}
}
// Free-text writes: the payload must land as data.
if code, body := h.do(h.admin, "POST", "/api/v1/minds/sqli/observations", map[string]any{"text": p}); code >= 300 {
if code, body := h.Do(h.Admin, "POST", "/api/v1/minds/sqli/observations", map[string]any{"text": p}); code >= 300 {
t.Errorf("observe %q → %d %s", p, code, body)
}
if code, _ := h.do(h.admin, "POST", "/api/v1/minds/sqli/memories", map[string]any{"kind": "episodic", "content": p, "epistemic": "observation", "confidence": 0.5, "importance": 0.5}); code != 201 {
if code, _ := h.Do(h.Admin, "POST", "/api/v1/minds/sqli/memories", map[string]any{"kind": "episodic", "content": p, "epistemic": "observation", "confidence": 0.5, "importance": 0.5}); code != 201 {
t.Errorf("memory %q → %d", p, code)
}
if code, _ := h.do(h.admin, "POST", "/api/v1/minds/sqli/goals", map[string]any{"title": p, "kind": "task"}); code != 201 {
if code, _ := h.Do(h.Admin, "POST", "/api/v1/minds/sqli/goals", map[string]any{"title": p, "kind": "task"}); code != 201 {
t.Errorf("goal %q → %d", p, code)
}
}
// NUL bytes are refused at the edge with a deliberate 400, never a 500.
for _, path := range []string{"/api/v1/minds/sqli/memories?q=a%00b", "/api/v1/minds/sql%00i"} {
if code, _ := h.do(h.admin, "GET", path, nil); code != 400 && code != 404 {
if code, _ := h.Do(h.Admin, "GET", path, nil); code != 400 && code != 404 {
t.Errorf("NUL in %s → %d", path, code)
}
}
if code, _ := h.do(h.admin, "POST", "/api/v1/minds/sqli/observations", map[string]any{"text": "a\x00b"}); code != 400 {
if code, _ := h.Do(h.Admin, "POST", "/api/v1/minds/sqli/observations", map[string]any{"text": "a\x00b"}); code != 400 {
t.Errorf("NUL in body → %d", code)
}
// Effects: nothing beyond the rows this test deliberately created.
@@ -109,18 +111,18 @@ func TestSQLInjectionHasNoEffect(t *testing.T) {
}
}
var adminAfter int
h.deps.DB.QueryRow(ctx, `SELECT count(*) FROM users WHERE role='administrator'`).Scan(&adminAfter) //nolint:errcheck
h.Deps.DB.QueryRow(ctx, `SELECT count(*) FROM users WHERE role='administrator'`).Scan(&adminAfter) //nolint:errcheck
if adminAfter != adminRoles {
t.Fatal("role escalation through injected string")
}
if count("beliefs") < before["beliefs"] {
t.Error("beliefs lost")
}
mem, err := h.deps.Memory.Get(ctx, m.ID, ids.ID(seeded.ID))
mem, err := h.Deps.Memory.Get(ctx, m.ID, ids.ID(seeded.ID))
if err != nil || mem.Content != seed {
t.Fatalf("seeded memory altered: %+v %v", mem, err)
}
stored, _ := h.deps.Memory.List(ctx, m.ID, memory.Filter{Limit: 50})
stored, _ := h.Deps.Memory.List(ctx, m.ID, memory.Filter{Limit: 50})
found := 0
for _, s := range stored {
for _, p := range sqlPayloads {