diff --git a/cmd/mavend/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go index 1f5438a..4c7a852 100644 --- a/cmd/mavend/ecosystem_acts.go +++ b/cmd/mavend/ecosystem_acts.go @@ -29,6 +29,17 @@ const ( // serviceVars — the one-key map the eco_down and eco_denied lines take. func serviceVars(name string) map[string]string { return map[string]string{"name": name} } +// ecosystemGap names the service that failed. A rejected credential gets its +// own line, because a wrong token looks exactly like an outage to him and +// "try again" is advice that will never work. Every degrade path reads through +// here, so all of them name the service and none of them guesses instead. +func ecosystemGap(service string, err error) string { + if unauthorizedEcosystemError(err) { + return phraser.A(phraser.EcoDenied, serviceVars(service)) + } + return phraser.A(phraser.EcoDown, serviceVars(service)) +} + // praxisCapability is one arm of the Praxis act dispatch. This is an interface // rather than a map[string]func because each arm carries its own state: the // verb aliases it answers to, the trace name it records, and its own reply @@ -199,16 +210,10 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p } parts = append(parts, s) - // Speaking an item surfaces it, it does not acknowledge it - // (ECOSYSTEM-SPEC.md §2.3: surfaced != acknowledged). Best-effort: - // a failed surface call must not block delivering the digest. - if id, ok := item["id"].(string); ok && id != "" { - // Recorded in the order she says them, and only for items she could - // say: an item skipped above has no position in what he heard (#516). + // Recorded in the order she says them, and only for items she could + // say: an item skipped above has no position in what he heard (#516). + if id := surfaceSpoken(ctx, px, item); id != "" { spoken = append(spoken, id) - if _, err := px.Surface(ctx, id); err != nil { - log.Printf("ecosystem: praxis surface %s: %v", id, err) - } } } h.rememberSurfaced(spoken) @@ -298,12 +303,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, // 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 phraser.A(phraser.EcoDenied, serviceVars(serviceNexus)) - } - return phraser.A(phraser.EcoDown, serviceVars(serviceNexus)) + return h.nexusResolveFailed(ctx, subject, started, err) } if len(ambiguous) > 0 { return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")}) @@ -345,12 +345,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, continue } parts = append(parts, title) - // Same surfaced != acknowledged rule as the unscoped digest. - if id, ok := item["id"].(string); ok && id != "" { - if _, err := px.Surface(ctx, id); err != nil { - log.Printf("ecosystem: praxis surface %s: %v", id, err) - } - } + surfaceSpoken(ctx, px, item) } if known := h.localFactsForEntity(ctx, entityID); known != "" { parts = append(parts, known) @@ -366,6 +361,22 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")}) } +// surfaceSpoken marks an item she just read out as surfaced. Speaking an item +// surfaces it, it does not acknowledge it (ECOSYSTEM-SPEC.md §2.3: surfaced != +// acknowledged), so this calls Surface and nothing else. Best effort: a failed +// surface call must not block delivering the digest. Returns the item id, or "" +// when the item carried none. +func surfaceSpoken(ctx context.Context, px *praxisClient, item map[string]any) string { + id, _ := item["id"].(string) + if id == "" { + return "" + } + if _, err := px.Surface(ctx, id); err != nil { + log.Printf("ecosystem: praxis surface %s: %v", id, err) + } + return id +} + // 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 @@ -474,6 +485,14 @@ func traceStatusForError(err error) string { return traceFailed } +// nexusResolveFailed records a resolve that failed and returns the named gap. +// The subject is his words, so the trace keeps a rune count and not the runes. +func (h *reactiveHandler) nexusResolveFailed(ctx context.Context, subject string, started time.Time, err error) string { + h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started, + mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)})) + return ecosystemGap(serviceNexus, err) +} + // 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. @@ -535,23 +554,20 @@ func unauthorizedEcosystemError(err error) bool { // 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 { - fields := map[string]any{} + fields := map[string]any{"class": "error"} var ee *ecosystemError - if errors.As(err, &ee) { - fields["http_status"] = ee.Status - switch { - case ee.Unauthorized(): - fields["class"] = "unauthorized" - case ee.ContractMismatch(): - fields["class"] = "contract_mismatch" - case ee.Unreachable(): - fields["class"] = "unreachable" - default: - fields["class"] = "error" - } + if !errors.As(err, &ee) { return fields } - fields["class"] = "error" + fields["http_status"] = ee.Status + switch { + case ee.Unauthorized(): + fields["class"] = "unauthorized" + case ee.ContractMismatch(): + fields["class"] = "contract_mismatch" + case ee.Unreachable(): + fields["class"] = "unreachable" + } return fields } @@ -636,16 +652,11 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio res := h.resolveEntityCandidates(ctx, entityReferences(dec)) subject, entityID, displayName, ambiguous, err := res.subject, res.entityID, res.displayName, res.ambiguous, res.err if err != nil { - h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started, - mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)})) - if unauthorizedEcosystemError(err) { - return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus)) - } // 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 // independently, never a silent all-clear). - return phraser.A(phraser.EcoDown, serviceVars(serviceNexus)) + return h.nexusResolveFailed(ctx, subject, started, err) } if len(ambiguous) > 0 { h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started, @@ -668,10 +679,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio if err != nil { h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID})) - if unauthorizedEcosystemError(err) { - return phraser.A(phraser.EcoDenied, serviceVars(serviceHexis)) - } - return phraser.A(phraser.EcoDown, serviceVars(serviceHexis)) + return ecosystemGap(serviceHexis, err) } h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered, map[string]any{"entity_id": entityID, "count": len(caps)})