a turn hands back its trace id, and one wire op corrects it (V-630)

The correction is the only supervised signal in the box, so the cost of
giving one has to be near zero. That means the surface needs the trace id of
the turn it is showing, which it had no way to learn: handleText returns one
string and the trace was written after the reply left.

The id rides back on ChatReply through the same context sink querySource
uses, so the mic, telegram and the web keep the one signature they share.
CorrectTurn takes a trace id and an optional target, which is deliberately
reach-agnostic: nothing about it assumes a browser.

store.ErrNoSuchTrace gets a wire twin. A turn past the retention bound is
gone, and that is the expected outcome of correcting an old turn, not a
broken database.
This commit is contained in:
2026-08-06 19:45:37 +04:00
parent e5ec4abe04
commit 4d97280d74
11 changed files with 162 additions and 7 deletions
+48 -2
View File
@@ -16,6 +16,7 @@ import (
"encoding/json"
"log"
"strings"
"sync"
"time"
"github.com/kami/maven/internal/decision"
@@ -38,6 +39,44 @@ func traceSink(s *store.Store) traceWriter {
return s
}
// The trace id rides the context, the same seam querysource.go uses and for the
// same reason: handleText answers every reach through one string, and threading
// a second value through the whole action dispatch would change a signature the
// mic, telegram and the web all share. A caller that wants the id asks for a
// sink; the mic path does not, and pays nothing.
type traceIDKey struct{}
type traceIDSink struct {
mu sync.Mutex
id int64
}
func (s *traceIDSink) note(id int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.id = id
}
// ID is the persisted trace for the turn, or 0 when nothing was persisted.
func (s *traceIDSink) ID() int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.id
}
// withTraceIDSink returns a context that collects the persisted trace id, and
// the sink to read after the turn has answered.
func withTraceIDSink(ctx context.Context) (context.Context, *traceIDSink) {
sink := &traceIDSink{}
return context.WithValue(ctx, traceIDKey{}, sink), sink
}
func noteTraceID(ctx context.Context, id int64) {
if sink, ok := ctx.Value(traceIDKey{}).(*traceIDSink); ok {
sink.note(id)
}
}
// pruneTracesOnStart enforces retention once at wiring time. Pruning on write
// alone is not enough: a box that goes quiet for a month keeps every row until
// the next sixty-fourth turn, and "kept for fourteen days" would then be true
@@ -58,7 +97,8 @@ func pruneTracesOnStart(s *store.Store, now time.Time) {
//
// Errors are logged and swallowed. A trace is diagnostic and training data, and
// a failed insert must never change what the owner hears.
func (h *reactiveHandler) persistDecision(ctx context.Context, rec *decision.Record, src turnSource) {
func (h *reactiveHandler) persistDecision(turnCtx context.Context, rec *decision.Record, src turnSource) {
ctx := turnCtx
if h.traces == nil || rec == nil || strings.TrimSpace(rec.Utterance) == "" {
return
}
@@ -85,9 +125,15 @@ func (h *reactiveHandler) persistDecision(ctx context.Context, rec *decision.Rec
Outcome: wonAt(rec, decision.StageAction),
Claims: claims,
}
if _, err := h.traces.WriteRoutingTrace(ctx, tr); err != nil {
id, err := h.traces.WriteRoutingTrace(ctx, tr)
if err != nil {
log.Printf("routing trace: write: %v", err)
return
}
// The id goes back to whoever asked for it, so /chat can offer a correction
// on the turn it is already showing (V-630). Noted on the ORIGINAL context,
// not the detached one above: the sink belongs to the caller's turn.
noteTraceID(turnCtx, id)
}
// wonIntent — what the winning claimant made the turn. Read from the claim