ecosystem: assign one correlation ID per action and fail closed on scope

The act path minted IDs per hop and trusted whatever Praxis returned
for a scoped attention query. A service that ignored the entity filter
would have had its unrelated items read back to the owner as his. The
handler now assigns one correlation ID at the top of the action and
passes it down, and drops any item the response did not tag with the
requested entity. Traces are written to the trace table with the
causation ID and HTTP status hoisted into columns, the duplicate
legacy Hexis trace is gone, truncated lists say so, and a rejected
credential gets its own reply instead of looking like an outage.

Found in review of #83 and #84.
This commit is contained in:
kami
2026-08-01 14:15:33 +04:00
parent f432eb0b25
commit 252f773223
+167 -69
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
@@ -10,8 +9,8 @@ import (
"time"
hexisclient "github.com/kami/hexis/pkg/client"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
@@ -84,6 +83,12 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
if h.ecosystem == nil || h.ecosystem.praxis == nil {
return ""
}
// Every hop of this action shares one correlation ID, assigned here, so a
// digest that calls attention once and surface N times reads as one turn
// on the Praxis side instead of N+1 unrelated request ids.
if correlationIDFromCtx(ctx) == "" {
ctx = withCorrelationID(ctx, newCorrelationID())
}
px := h.ecosystem.praxis
for _, capability := range praxisCapabilities {
for _, alias := range capability.aliases() {
@@ -114,11 +119,14 @@ func (a praxisItemAction) handle(ctx context.Context, h *reactiveHandler, px *pr
if id == "" {
return a.ask
}
started := h.now()
if err := a.call(ctx, px, id); err != nil {
log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err)
h.recordEcosystemTrace(ctx, "praxis", a.op, traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"item_id": id}))
return a.failure
}
h.recordPraxisTrace(ctx, a.op, map[string]any{"item_id": id})
h.recordPraxisTrace(ctx, a.op, started, map[string]any{"item_id": id})
return a.success
}
@@ -130,15 +138,18 @@ func (listAttentionCapability) aliases() []string {
}
func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
started := h.now()
items, err := px.ListAttention(ctx, 20)
if err != nil {
log.Printf("ecosystem: praxis attention: %v", err)
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
started, traceErrorFields(err))
return "не могу сейчас узнать, что требует внимания."
}
if len(items) == 0 {
return "ничего не требует внимания."
}
h.recordPraxisTrace(ctx, "list_attention", map[string]any{"count": len(items)})
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
var parts []string
for _, item := range items {
title, _ := item["title"].(string)
@@ -175,15 +186,18 @@ func (listChangesCapability) aliases() []string {
}
func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
started := h.now()
changes, err := px.ListChanges(ctx, 20)
if err != nil {
log.Printf("ecosystem: praxis changes: %v", err)
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
started, traceErrorFields(err))
return "не могу сейчас узнать об изменениях."
}
if len(changes) == 0 {
return "нет изменений."
}
h.recordPraxisTrace(ctx, "list_changes", map[string]any{"count": len(changes)})
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
var parts []string
for _, c := range changes {
title, _ := c["title"].(string)
@@ -204,8 +218,10 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
// gets one answer across both stores.
type entityAttentionCapability struct{}
// aliases are matched against Slots.Fn, which carries a function slot from the
// act grammar and never free Russian, so only grammar names belong here.
func (entityAttentionCapability) aliases() []string {
return []string{"entity_attention", "что с", "как дела у", "статус"}
return []string{"entity_attention", "entity_status"}
}
func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string {
@@ -222,9 +238,18 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
return "не могу связать это с сущностью — Nexus не настроен."
}
started := h.now()
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
if err != nil {
log.Printf("ecosystem: entity attention resolve %q: %v", subject, err)
// The subject is his words, so the log gets the same redaction the
// trace gets. A trace that stores a rune count next to a log line
// storing the runes is not redacted at all.
log.Printf("ecosystem: entity attention resolve %s: %v", redactSubject(subject), err)
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен."
}
return "экосистема недоступна, попробуй ещё раз."
}
if len(ambiguous) > 0 {
@@ -237,12 +262,26 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
displayName = subject
}
queried := h.now()
items, err := px.ListAttentionForEntity(ctx, entityID, 20)
if err != nil {
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
}
h.recordPraxisTrace(ctx, "entity_attention", map[string]any{
items, scoped := scopedToEntity(items, entityID)
if !scoped {
// A Praxis old enough to ignore an unknown query parameter answers the
// scoped question with the unscoped list. Reading that back as "по
// «X»: ..." is the exact fabrication the entity ref exists to prevent,
// so refuse the answer instead of relabelling someone else's items.
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
}
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
"entity_id": entityID, "count": len(items),
})
@@ -269,6 +308,40 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
return "по «" + displayName + "»: " + strings.Join(parts, "; ")
}
// scopedToEntity drops items that carry an entity_id other than the one asked
// about, and reports whether the response can be trusted as scoped at all. An
// item without an entity_id is kept only when at least one sibling carries the
// matching id: a whole page with no entity_id is a Praxis that ignored the
// scope, not a page of untagged items.
func scopedToEntity(items []map[string]any, entityID string) ([]map[string]any, bool) {
if len(items) == 0 {
return items, true
}
var kept []map[string]any
var sawMatch, sawMismatch bool
for _, item := range items {
id, _ := item["entity_id"].(string)
switch {
case id == entityID:
sawMatch = true
kept = append(kept, item)
case id != "":
sawMismatch = true
default:
kept = append(kept, item)
}
}
if sawMatch {
return kept, true
}
if sawMismatch {
// Some items were tagged and none matched: the far side answered about
// other entities, so nothing here belongs to this one.
return nil, true
}
return nil, false
}
// localFactsForEntity summarises Maven's own facts already resolved to this
// canonical entity. Empty when the store is unavailable or nothing matched —
// entity-scoped memory is an enrichment of the answer, never a precondition.
@@ -276,11 +349,18 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
if h.dataStore == nil || entityID == "" {
return ""
}
facts, err := h.dataStore.FactsByEntity(ctx, entityID, 3)
const spoken = 3
// One over the spoken limit, so a truncation can be named rather than
// passed off as everything she knows.
facts, err := h.dataStore.FactsByEntity(ctx, entityID, spoken+1)
if err != nil {
log.Printf("ecosystem: facts by entity %s: %v", entityID, err)
return ""
}
more := false
if len(facts) > spoken {
facts, more = facts[:spoken], true
}
var parts []string
for _, f := range facts {
if f.Value != "" {
@@ -290,7 +370,11 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
if len(parts) == 0 {
return ""
}
return "я помню: " + strings.Join(parts, ", ")
out := "я помню: " + strings.Join(parts, ", ")
if more {
out += ", и это не всё"
}
return out
}
// mergeFields overlays b onto a and returns a.
@@ -301,30 +385,11 @@ func mergeFields(a, b map[string]any) map[string]any {
return a
}
// recordPraxisTrace — writes a fact recording a cross-service ecosystem call.
// The fact is stored with source "praxis:trace" so the proactive loop can
// reference it and the dashboard can display recent ecosystem activity.
func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, details map[string]any) {
now := h.now()
value := operation
if len(details) > 0 {
if b, err := json.Marshal(details); err == nil {
value = operation + " " + string(b)
}
}
// Kind must be one of the store's allowed kinds ('self','env','config');
// "system" was silently rejected by the CHECK constraint, so no praxis
// trace was ever persisted.
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: now,
Kind: "env",
Key: "praxis:" + operation,
Value: value,
Source: "praxis:trace",
Confidence: 1.0,
}); err != nil {
log.Printf("ecosystem: record praxis trace %s: %v", operation, err)
}
// recordPraxisTrace — records a completed Praxis call. Thin wrapper over
// recordEcosystemTrace so every ecosystem hop lands in one table with one
// shape.
func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, started time.Time, details map[string]any) {
h.recordEcosystemTrace(ctx, "praxis", operation, traceOK, started, details)
}
// traceStatus classifies an ecosystem call for the trace record. Kept coarse
@@ -332,12 +397,25 @@ func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation strin
// it take", not to re-derive the error.
const (
traceOK = "ok"
traceFailed = "failed"
traceFailed = "failed" // the call never got an answer
traceRefused = "refused" // the far side answered, and said no
traceAmbig = "ambiguous"
traceNotFound = "not_found"
tracePending = "pending" // deliberately not done yet, awaiting a confirm
)
// traceStatusForError distinguishes "I could not reach it" from "it answered
// and refused". Both degrade the same way for him and not at all the same way
// for whoever reads the trace: one is a network or a dead service, the other
// is a token, a version or a rejected argument.
func traceStatusForError(err error) string {
var ee *ecosystemError
if errors.As(err, &ee) && !ee.Unreachable() {
return traceRefused
}
return traceFailed
}
// redactSubject reduces a user utterance to something safe to persist in a
// trace: its length only. Traces are diagnostics, and his words are not
// diagnostics — the correlation ID is what ties a trace to the turn.
@@ -347,38 +425,55 @@ func redactSubject(s string) string {
// recordEcosystemTrace writes one hop of a cross-service call: which service,
// which operation, the outcome, how long it took, and the correlation ID that
// stitches the hops together. Unlike recordPraxisTrace it is written for every
// outcome, not only success — an unrecorded failure is exactly the hop you
// need when something went wrong at 3am.
// stitches the hops together. It is written for every outcome, not only
// success — an unrecorded failure is exactly the hop you need when something
// went wrong at 3am.
//
// Traces go to their own store table, never to facts. One act turn produces
// three or four of them, at machine rate, while facts arrive at human rate:
// sharing the table meant the habit profile's 2000-row window, memeval's
// prompt snapshot and the /dash and /history pages all filled with traces and
// stopped seeing his actual facts.
func (h *reactiveHandler) recordEcosystemTrace(ctx context.Context, service, op, status string, started time.Time, fields map[string]any) {
details := map[string]any{
"service": service,
"operation": op,
"status": status,
"duration_ms": h.now().Sub(started).Milliseconds(),
if h.dataStore == nil {
return
}
if id := correlationIDFromCtx(ctx); id != "" {
details["correlation_id"] = id
tr := store.EcosystemTrace{
Ts: h.now(),
Service: service,
Operation: op,
Status: status,
DurationMs: h.now().Sub(started).Milliseconds(),
CorrelationID: correlationIDFromCtx(ctx),
Fields: map[string]any{},
}
for k, v := range fields {
details[k] = v
switch k {
case "causation_id":
tr.CausationID, _ = v.(string)
case "http_status":
if n, ok := v.(int); ok {
tr.HTTPStatus = n
continue
}
tr.Fields[k] = v
default:
tr.Fields[k] = v
}
}
value := service + ":" + op + " " + status
if b, err := json.Marshal(details); err == nil {
value = value + " " + string(b)
}
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: h.now(),
Kind: "env",
Key: "ecosystem:" + service + ":" + op,
Value: value,
Source: "ecosystem:trace",
Confidence: 1.0,
}); err != nil {
if _, err := h.dataStore.WriteEcosystemTrace(ctx, tr); err != nil {
log.Printf("ecosystem: record trace %s:%s: %v", service, op, err)
}
}
// unauthorizedEcosystemError reports a credential the far side rejected. It
// gets its own reply: a missing or wrong token looks exactly like an outage to
// him, and "try again" is advice that will never work.
func unauthorizedEcosystemError(err error) bool {
var ee *ecosystemError
return errors.As(err, &ee) && ee.Unauthorized()
}
// traceErrorFields describes an ecosystemError for a trace without leaking the
// payload: the HTTP status and the failure class, nothing else.
func traceErrorFields(err error) map[string]any {
@@ -422,8 +517,11 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
started := h.now()
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
if err != nil {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceFailed, started,
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен."
}
// A genuine Nexus dependency failure, not "no such entity" — stop here
// and report degradation rather than silently falling through to the
// local command executor (ECOSYSTEM-SPEC.md: services degrade
@@ -449,8 +547,11 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
discovered := h.now()
caps, err := h.ecosystem.discoverCapabilities(ctx, entityID)
if err != nil {
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceFailed, discovered,
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен."
}
return "экосистема недоступна, попробуй ещё раз."
}
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
@@ -499,7 +600,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
expiry: h.now().Add(confirmTTL),
}
h.mu.Unlock()
h.recordEcosystemTrace(ctx, "hexis", "confirmation", "pending", h.now(),
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
map[string]any{"entity_id": entityID, "capability": matched.Name})
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
}
@@ -517,20 +618,17 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
traced := withCorrelationID(ctx, correlationID)
if err != nil {
log.Printf("ecosystem: hexis execute error (cor=%s): %v", correlationID, err)
h.recordEcosystemTrace(traced, "hexis", "execute", traceFailed, started,
h.recordEcosystemTrace(traced, "hexis", "execute", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{
"entity_id": entityID, "capability": capName, "causation_id": causationID,
}))
return "не получилось выполнить команду для " + displayName + "."
}
// One record per hop: the second write this used to make said the same
// thing under a different key, in a different shape.
h.recordEcosystemTrace(traced, "hexis", "execute", traceOK, started, map[string]any{
"entity_id": entityID, "capability": capName, "causation_id": causationID,
})
h.recordPraxisTrace(ctx, "hexis:"+capName, map[string]any{
"entity_id": entityID,
"entity_name": displayName,
"capability": capName,
"correlation_id": correlationID,
"entity_id": entityID, "entity_name": displayName,
"capability": capName, "causation_id": causationID,
})
return "команда выполнена для " + displayName + "."
}