diff --git a/CHANGELOG.md b/CHANGELOG.md index 77493f5..1843efb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to Noema are recorded here. Format follows Keep a Changelog; - Security suite: SQL-injection effect test, static allow-list scan for assembled SQL, deceived-mind authority test, garbage model output test, self-preservation vocabulary test; threat model rewritten so every verification cites a test that asserts the claim. ### Added +- Causal timeline: `GET /api/v1/events/{id}/chain` and `/events/{id}/timeline` walk the links events actually record (causation, parents, cited evidence) backwards and forwards within bounded depth and size, label edge kinds, group by cycle as correlation only, and infer nothing. - Cognitive forks per model: minds may override model routing per role (`llm_roles`, fail-closed); experiments may declare variants that fork one snapshot under different configuration, personality or routing, receive identical stimuli, and are compared deterministically (first divergent stimulus, belief and attention overlap, score differences, memory, goal, cost and capability-request ranges). - Runtime identity: snapshots, replays and experiments record build (commit, modified tree, Go version, platform), schema version, a secret-free configuration digest, personality version, policy digest, model routing, prompt versions, deterministic subsystem versions and timezone. Replays compare identities and label themselves exact-candidate, different-build or incompatible (the last is never run), and analyse the first divergence by component. Migration 0022. - Contextual source reliability: a per-(source, domain) record updated only by prediction outcomes and operator corrections, adjusting evidence credibility in that domain once three outcomes exist; never global. Shown on belief pages. Migration 0021. diff --git a/docs/cognition.md b/docs/cognition.md index ad2df40..c884d89 100644 --- a/docs/cognition.md +++ b/docs/cognition.md @@ -1,5 +1,9 @@ # Background cognition: metacognition, reflection, consolidation, narrative, notifications +## Causal timeline + +Every event records the links it was written with: `causation_id` (the event that directly led to it), `parent_ids` (the events it was assembled from) and evidence references (the events, memories, beliefs and model calls it rests on). The causal timeline (`/events/{id}/timeline`, `GET /api/v1/events/{id}/chain`) walks those links backwards towards causes and forwards towards consequences, up to six hops and two hundred events, and draws each edge with its kind: `caused`, `parent` or `cited`. Events are grouped by the cognitive cycle they belong to, and that grouping is labelled as correlation, not causation. Nothing on the page is inferred; where the system does not know why something happened, no arrow is drawn. Questions the walk answers directly: why a belief exists (walk back to the observations and assertions it cites), what changed it (the `belief_updated` events downstream of an outcome), which memories were retrieved and which thoughts won attention in the cycle, which model call contributed (an artefact on the node), and which later decisions depended on it (walk forward). + These subsystems run beside the cognitive cycle. Each is deterministic in what it concludes; a language model may only word things, and only within a configured budget. ## Metacognition (per cycle) diff --git a/docs/openapi.json b/docs/openapi.json index 6c7e276..9e208c9 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -537,6 +537,64 @@ ] } }, + "/api/v1/events/{id}/chain": { + "get": { + "operationId": "eventChain", + "summary": "bounded causal chain around an event (recorded links only)", + "x-required-role": "observer", + "tags": [ + "events" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "direction", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "back", + "forward", + "both" + ] + } + }, + { + "name": "depth", + "in": "query", + "schema": { + "type": "integer", + "maximum": 6 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/api/v1/events/{id}/context": { "get": { "operationId": "eventContext", diff --git a/internal/api/events.go b/internal/api/events.go index 6929d8f..20f5196 100644 --- a/internal/api/events.go +++ b/internal/api/events.go @@ -25,6 +25,7 @@ func (s *Server) eventRoutes() { m.Handle("GET /api/v1/events/stream", require(auth.RoleObserver, s.eventsStream)) m.Handle("GET /api/v1/events/{id}", require(auth.RoleObserver, s.eventGet)) m.Handle("GET /api/v1/events/{id}/context", require(auth.RoleObserver, s.eventContext)) + m.Handle("GET /api/v1/events/{id}/chain", require(auth.RoleObserver, s.eventChain)) m.Handle("GET /api/v1/events/types", require(auth.RoleObserver, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"types": events.AllTypes()}) })) @@ -333,3 +334,20 @@ func (s *Server) eventsStream(w http.ResponseWriter, r *http.Request) { } } } + +// eventChain returns the bounded causal chain around an event: only the +// links events actually record, in the requested direction and depth. +func (s *Server) eventChain(w http.ResponseWriter, r *http.Request) { + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + fail(w, r, ValidationError("bad id")) + return + } + depth, _ := strconv.Atoi(r.URL.Query().Get("depth")) + c, err := s.deps.Events.Chain(r.Context(), id, r.URL.Query().Get("direction"), depth) + if err != nil { + fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, c) +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 6c7e276..9e208c9 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -537,6 +537,64 @@ ] } }, + "/api/v1/events/{id}/chain": { + "get": { + "operationId": "eventChain", + "summary": "bounded causal chain around an event (recorded links only)", + "x-required-role": "observer", + "tags": [ + "events" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "direction", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "back", + "forward", + "both" + ] + } + }, + { + "name": "depth", + "in": "query", + "schema": { + "type": "integer", + "maximum": 6 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/api/v1/events/{id}/context": { "get": { "operationId": "eventContext", diff --git a/internal/events/chain.go b/internal/events/chain.go new file mode 100644 index 0000000..588a6b5 --- /dev/null +++ b/internal/events/chain.go @@ -0,0 +1,204 @@ +package events + +import ( + "context" + "sort" + + "github.com/andydixon/noema/pkg/events" + "github.com/andydixon/noema/pkg/ids" +) + +// Causal chains (review pass §7.2 item 11). A chain is a bounded walk over +// the provenance links events actually record: causation_id ("caused"), +// parent_ids ("parent") and evidence references to other events ("cited"). +// Membership of the same cognitive cycle is reported on each node but is +// never an edge: it is correlation, and the interface must say so. + +// Edge kinds. +const ( + EdgeCaused = "caused" // to.causation_id = from + EdgeParent = "parent" // from ∈ to.parent_ids + EdgeCited = "cited" // to.evidence references from +) + +// Chain limits. Depth counts hops from the origin; nodes caps the whole walk. +const ( + MaxChainDepth = 6 + MaxChainNodes = 200 +) + +// ChainNode is one event in a chain with its distance from the origin. +type ChainNode struct { + Event events.Event `json:"event"` + Depth int `json:"depth"` // negative = upstream (cause side), positive = downstream + CycleID ids.ID `json:"cycle_id"` // correlation id; grouping only, not causation + Artefacts []events.EvidenceRef `json:"artefacts"` // non-event references the node cites (beliefs, memories, calls) +} + +// ChainEdge is one recorded relationship between two events in the chain. +type ChainEdge struct { + From ids.ID `json:"from"` + To ids.ID `json:"to"` + Kind string `json:"kind"` +} + +// Chain is the result of a bounded walk. +type Chain struct { + Origin ids.ID `json:"origin"` + Direction string `json:"direction"` + Depth int `json:"depth"` + Nodes []ChainNode `json:"nodes"` + Edges []ChainEdge `json:"edges"` + Truncated bool `json:"truncated"` // the node cap stopped the walk +} + +// Chain walks back (towards causes), forward (towards consequences) or both +// from an event, up to depth hops and MaxChainNodes nodes. Only recorded +// links are followed; nothing is inferred. Visited sets make cycles harmless. +func (s *Store) Chain(ctx context.Context, origin ids.ID, direction string, depth int) (Chain, error) { + if depth <= 0 || depth > MaxChainDepth { + depth = MaxChainDepth + } + if direction != "back" && direction != "forward" { + direction = "both" + } + start, err := s.Get(ctx, origin) + if err != nil { + return Chain{}, err + } + c := Chain{Origin: origin, Direction: direction, Depth: depth} + seen := map[ids.ID]bool{origin: true} + edgeSeen := map[[3]string]bool{} + nodes := map[ids.ID]ChainNode{origin: node(start, 0)} + addEdge := func(from, to ids.ID, kind string) { + k := [3]string{string(from), string(to), kind} + if edgeSeen[k] { + return + } + edgeSeen[k] = true + c.Edges = append(c.Edges, ChainEdge{From: from, To: to, Kind: kind}) + } + type item struct { + ev events.Event + d int + } + // Upstream: what this event says it came from. + if direction != "forward" { + queue := []item{{start, 0}} + for len(queue) > 0 && len(nodes) < MaxChainNodes { + cur := queue[0] + queue = queue[1:] + if -cur.d >= depth { + continue + } + var upstream []ids.ID + kinds := map[ids.ID]string{} + if !cur.ev.CausationID.IsZero() { + upstream = append(upstream, cur.ev.CausationID) + kinds[cur.ev.CausationID] = EdgeCaused + } + for _, p := range cur.ev.ParentIDs { + if _, ok := kinds[p]; !ok { + upstream = append(upstream, p) + kinds[p] = EdgeParent + } + } + for _, ref := range cur.ev.Evidence { + if ref.Kind != "event" { + continue + } + if id, err := ids.Parse(ref.ID); err == nil { + if _, ok := kinds[id]; !ok { + upstream = append(upstream, id) + kinds[id] = EdgeCited + } + } + } + if len(upstream) == 0 { + continue + } + evs, err := s.GetMany(ctx, upstream) + if err != nil { + return c, err + } + for _, e := range evs { + addEdge(e.ID, cur.ev.ID, kinds[e.ID]) + if seen[e.ID] { + continue + } + if len(nodes) >= MaxChainNodes { + c.Truncated = true + break + } + seen[e.ID] = true + nodes[e.ID] = node(e, cur.d-1) + queue = append(queue, item{e, cur.d - 1}) + } + } + } + // Downstream: what later events say came from this one. + if direction != "back" { + queue := []item{{start, 0}} + for len(queue) > 0 && len(nodes) < MaxChainNodes { + cur := queue[0] + queue = queue[1:] + if cur.d >= depth { + continue + } + children, err := s.Children(ctx, cur.ev.ID, 50) + if err != nil { + return c, err + } + cited, err := s.DerivedFrom(ctx, "event", string(cur.ev.ID), 50) + if err != nil { + return c, err + } + for _, e := range children { + kind := EdgeParent + if e.CausationID == cur.ev.ID { + kind = EdgeCaused + } + addEdge(cur.ev.ID, e.ID, kind) + } + for _, e := range cited { + addEdge(cur.ev.ID, e.ID, EdgeCited) + } + for _, e := range append(children, cited...) { + if seen[e.ID] { + continue + } + if len(nodes) >= MaxChainNodes { + c.Truncated = true + break + } + seen[e.ID] = true + nodes[e.ID] = node(e, cur.d+1) + queue = append(queue, item{e, cur.d + 1}) + } + } + } + for _, n := range nodes { + c.Nodes = append(c.Nodes, n) + } + sort.Slice(c.Nodes, func(i, j int) bool { return c.Nodes[i].Event.Seq < c.Nodes[j].Event.Seq }) + sort.Slice(c.Edges, func(i, j int) bool { + if c.Edges[i].From != c.Edges[j].From { + return c.Edges[i].From < c.Edges[j].From + } + return c.Edges[i].To < c.Edges[j].To + }) + if c.Edges == nil { + c.Edges = []ChainEdge{} + } + return c, nil +} + +func node(e events.Event, depth int) ChainNode { + n := ChainNode{Event: e, Depth: depth, CycleID: e.CorrelationID, Artefacts: []events.EvidenceRef{}} + for _, ref := range e.Evidence { + if ref.Kind != "event" { + n.Artefacts = append(n.Artefacts, ref) + } + } + return n +} diff --git a/internal/events/events_test.go b/internal/events/events_test.go index ef9c4e3..8283255 100644 --- a/internal/events/events_test.go +++ b/internal/events/events_test.go @@ -167,3 +167,71 @@ func TestCursorsToleratePlaceholderGaps(t *testing.T) { t.Fatalf("stats: %+v", st) } } + +// TestChainFollowsOnlyRecordedLinks: the causal chain walks causation, +// parent and cited-event links in both directions, reports cycle membership +// without turning it into an edge, and stays within its bounds. +func TestChainFollowsOnlyRecordedLinks(t *testing.T) { + pool := storage.TestPool(t, migrations.FS) + ctx := context.Background() + m, _ := minds.NewStore(pool).Create(ctx, minds.CreateParams{Slug: "chain"}) + s := NewStore(pool, NewBus(8), nil) + cycle := ids.New() + obs, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.Observation, Source: "test", Summary: "disk alert", CorrelationID: cycle}) + perc, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.Perception, Source: "brain.perceive", Summary: "perceived alert", CausationID: obs[0].ID, ParentIDs: []ids.ID{obs[0].ID}, CorrelationID: cycle}) + recall, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.MemoryRetrieved, Source: "brain.retrieve", Summary: "recalled last outage", CorrelationID: cycle}) // same cycle, no recorded link + belief, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.BeliefCreated, Source: "beliefs", Summary: "belief: disk is failing", CausationID: perc[0].ID, CorrelationID: cycle, + Evidence: []events.EvidenceRef{{Kind: "event", ID: string(obs[0].ID)}, {Kind: "memory", ID: "mem-1"}}}) + goal, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.GoalCreated, Source: "brain.curiosity", Summary: "investigate the disk", CausationID: belief[0].ID}) + + back, err := s.Chain(ctx, belief[0].ID, "back", 6) + if err != nil { + t.Fatal(err) + } + ids3 := map[ids.ID]bool{} + for _, n := range back.Nodes { + ids3[n.Event.ID] = true + } + if !ids3[obs[0].ID] || !ids3[perc[0].ID] || ids3[recall[0].ID] || ids3[goal[0].ID] { + t.Fatalf("back chain nodes wrong: %+v", back.Nodes) + } + kinds := map[string]int{} + for _, e := range back.Edges { + kinds[e.Kind]++ + } + if kinds[EdgeCaused] != 2 || kinds[EdgeCited] != 1 || kinds[EdgeParent] != 0 { + // obs→perc is both causation and parent; causation wins for the same pair. + t.Fatalf("edge kinds: %+v", back.Edges) + } + for _, n := range back.Nodes { + if n.CycleID != cycle && n.Event.ID != goal[0].ID { + t.Fatalf("cycle membership not reported: %+v", n) + } + if n.Event.ID == belief[0].ID && (len(n.Artefacts) != 1 || n.Artefacts[0].Kind != "memory") { + t.Fatalf("artefacts: %+v", n.Artefacts) + } + } + forward, _ := s.Chain(ctx, obs[0].ID, "forward", 6) + fw := map[ids.ID]bool{} + for _, n := range forward.Nodes { + fw[n.Event.ID] = true + } + if !fw[perc[0].ID] || !fw[belief[0].ID] || !fw[goal[0].ID] || fw[recall[0].ID] { + t.Fatalf("forward chain wrong: %+v", forward.Nodes) + } + // Depth bounds the walk: one hop back from the goal reaches the belief only. + one, _ := s.Chain(ctx, goal[0].ID, "back", 1) + if len(one.Nodes) != 2 { + t.Fatalf("depth 1 should give origin plus one: %d", len(one.Nodes)) + } + // A self-referencing evidence link cannot loop the walk. + loop, _ := s.Append(ctx, events.New{MindID: m.ID, Type: events.Metacognition, Source: "brain", Summary: "loop", CausationID: goal[0].ID}) + // Append-only rows cannot be edited to point forward, so emulate a cycle through evidence back to an ancestor and re-walk. + if _, err := s.Append(ctx, events.New{MindID: m.ID, Type: events.Metacognition, Source: "brain", Summary: "loop2", CausationID: loop[0].ID, Evidence: []events.EvidenceRef{{Kind: "event", ID: string(obs[0].ID)}}}); err != nil { + t.Fatal(err) + } + both, err := s.Chain(ctx, obs[0].ID, "both", 6) + if err != nil || len(both.Nodes) > MaxChainNodes || both.Truncated { + t.Fatalf("cyclic references must terminate: %v %d", err, len(both.Nodes)) + } +} diff --git a/internal/ui/events.go b/internal/ui/events.go index 426e9e4..e472be2 100644 --- a/internal/ui/events.go +++ b/internal/ui/events.go @@ -8,6 +8,7 @@ import ( "github.com/andydixon/noema/internal/api" "github.com/andydixon/noema/internal/auth" + evstore "github.com/andydixon/noema/internal/events" "github.com/andydixon/noema/pkg/events" "github.com/andydixon/noema/pkg/ids" ) @@ -16,6 +17,7 @@ func (u *UI) eventRoutes(m *http.ServeMux) { m.Handle("GET /minds/{mind}/thoughts", u.require(auth.RoleObserver, u.thoughts)) m.Handle("POST /minds/{mind}/observe", u.require(auth.RoleOperator, u.observe)) m.Handle("GET /events/{id}", u.require(auth.RoleObserver, u.event)) + m.Handle("GET /events/{id}/timeline", u.require(auth.RoleObserver, u.timeline)) } var sinceWindows = map[string]string{"15m": "15 minutes", "1h": "1 hour", "24h": "24 hours", "168h": "7 days"} @@ -118,3 +120,50 @@ func prettyAny(v any) string { b, _ := json.MarshalIndent(v, "", " ") return string(b) } + +// timeline renders the causal chain around an event grouped by cognitive +// cycle. Edges are the recorded links; cycle grouping is shown as such. +func (u *UI) timeline(w http.ResponseWriter, r *http.Request) { + id, err := ids.Parse(r.PathValue("id")) + if err != nil { + u.errorPage(w, r, 404, "No such event.") + return + } + depth, _ := strconv.Atoi(r.URL.Query().Get("depth")) + if depth <= 0 { + depth = 4 + } + direction := r.URL.Query().Get("direction") + c, err := u.deps.Events.Chain(r.Context(), id, direction, depth) + if err != nil { + u.errorPage(w, r, 404, "No such event.") + return + } + origin, _ := u.deps.Events.Get(r.Context(), id) + m, _ := u.deps.Minds.Get(r.Context(), origin.MindID) + // Group nodes by cycle in sequence order; edges into a lookup for rendering. + type group struct { + Cycle ids.ID + Nodes []evstore.ChainNode + } + var groups []*group + byCycle := map[ids.ID]*group{} + for _, n := range c.Nodes { + g := byCycle[n.CycleID] + if g == nil { + g = &group{Cycle: n.CycleID} + byCycle[n.CycleID] = g + groups = append(groups, g) + } + g.Nodes = append(g.Nodes, n) + } + inbound := map[ids.ID][]evstore.ChainEdge{} + names := map[ids.ID]string{} + for _, n := range c.Nodes { + names[n.Event.ID] = n.Event.Summary + } + for _, e := range c.Edges { + inbound[e.To] = append(inbound[e.To], e) + } + u.render(w, r, "timeline", "Timeline · "+truncateStr(origin.Summary, 60), map[string]any{"Mind": m, "Origin": origin, "Chain": c, "Groups": groups, "Inbound": inbound, "Names": names, "Depth": depth, "Direction": c.Direction}) +} diff --git a/tests/m1/m1_test.go b/tests/m1/m1_test.go index 3e5cc30..fc06424 100644 --- a/tests/m1/m1_test.go +++ b/tests/m1/m1_test.go @@ -229,6 +229,14 @@ func TestFirstMilestone(t *testing.T) { if len(detail.Assertions) != 1 || detail.Assertions[0].AttributedTo != "admin" || detail.Assertions[0].SourceEvent != ids.ID(detail.Evidence[0].RootIDs[0]) { t.Fatalf("assertion: %+v", detail.Assertions) } + // The causal chain walks recorded links only: the message leads to the belief. + var chain struct { + Nodes []struct{ Depth int } + Edges []struct{ Kind string } + } + if code := h.do("GET", "/api/v1/events/"+detail.Evidence[0].RootIDs[0]+"/chain?direction=forward&depth=4", nil, &chain); code != 200 || len(chain.Nodes) < 2 || len(chain.Edges) == 0 { + t.Fatalf("chain: %d %+v", code, chain) + } var evCtx api.EventContext h.do("GET", "/api/v1/events/"+detail.Evidence[0].RootIDs[0]+"/context", nil, &evCtx) if evCtx.Event.Type != events.Observation || len(evCtx.Children) == 0 { diff --git a/web/static/app.css b/web/static/app.css index 7d45a30..9c4d75a 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -231,3 +231,6 @@ canvas.graph { width: 100%; height: 560px; display: block; border-radius: 8px; b /* Study: replay & experiments */ .replay-step { padding: .6rem 0; border-top: 1px solid var(--border); } .replay-step:first-child { border-top: 0; } .replay-step ul { margin: .2rem 0 0 1rem; padding: 0; } .strong { font-weight: 600; } .break { word-break: break-all; } + +/* Causal timeline */ +.tl-cycle { border-left: 2px solid var(--border); margin: .6rem 0 .6rem .4rem; padding: .2rem 0 .2rem .9rem; } .tl-node { padding: .35rem 0 .35rem .6rem; border-left: 2px solid transparent; } .tl-node.current { border-left-color: var(--accent); background: var(--surface-2); border-radius: 4px; } .tl-when { font-family: var(--mono); } diff --git a/web/templates/pages/event.html b/web/templates/pages/event.html index 13f847a..0cfef8d 100644 --- a/web/templates/pages/event.html +++ b/web/templates/pages/event.html @@ -8,7 +8,7 @@
-

Causal chain

+

Causal chain full timeline →

{{if $c.Cause}}
caused by
{{$c.Cause.Type}} {{$c.Cause.Summary}}
↓
{{end}}
{{$e.Type}} {{$e.Summary}}
diff --git a/web/templates/pages/timeline.html b/web/templates/pages/timeline.html new file mode 100644 index 0000000..9e9e15e --- /dev/null +++ b/web/templates/pages/timeline.html @@ -0,0 +1,19 @@ +{{define "content"}} +{{$o := .Data.Origin}}{{$c := .Data.Chain}} +
+

{{.Data.Mind.DisplayName}} › event #{{$o.Seq}} › timeline

+

Causal timeline

+

Around {{$o.Type}} {{$o.Summary}}. Arrows are the links events recorded when they were written: caused (causation), parent (parent ids) and cited (evidence references). Grouping by cycle is correlation, shown for orientation and not a causal claim. Nothing here is inferred.

+
{{if $c.Truncated}} truncated at {{len $c.Nodes}} events{{end}}
+
+
+ {{range .Data.Groups}}
cycle {{if .Cycle}}{{trunc (printf "%s" .Cycle) 8}}{{else}}—{{end}} (same-cycle grouping; correlation, not causation)
+ {{range .Nodes}}
+
#{{.Event.Seq}} · {{.Event.OccurredAt.Local.Format "15:04:05.000"}} · {{if lt .Depth 0}}{{.Depth}} (upstream){{else if gt .Depth 0}}+{{.Depth}} (downstream){{else}}origin{{end}}
+
{{.Event.Type}} {{.Event.Summary}} · {{.Event.Source}}
+ {{with index $.Data.Inbound .Event.ID}}
{{range .}}← {{.Kind}} {{trunc (index $.Data.Names .From) 70}} {{end}}
{{end}} + {{if .Artefacts}}
rests on: {{range .Artefacts}}{{if eq .Kind "belief"}}belief{{else if eq .Kind "memory"}}memory{{else if eq .Kind "llm_call"}}model call{{else}}{{.Kind}}{{end}}{{with .Note}} ({{.}}){{end}} · {{end}}
{{end}} +
{{end}} +
{{end}} +
+{{end}}