the store keeps a routing trace for fourteen days (V-629)

Migration #23 adds routing_traces, and internal/store/routingtraces.go writes,
lists and prunes it. Nothing calls it yet; the daemon side is the next commit.

The utterance is stored in clear. A 384-dimension vector of a short sentence is
substantially recoverable, so storing vectors instead would be a privacy claim
we cannot support. Retention is 14 days, enforced on write, and an age rather
than a row count so a busy Tuesday cannot push last Friday out. Store.Wipe
already deletes it with everything else, so explicit deletion needs no new
surface.

A correction is not covered by that bound. When the owner corrects a turn the
pair is promoted out into a seed-shaped row and kept, because a label is not a
transcript. What stays here is the transcript, and the transcript expires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
This commit is contained in:
2026-08-06 19:13:04 +04:00
parent 799cf5587d
commit 034d4b4359
3 changed files with 223 additions and 0 deletions
+30
View File
@@ -300,6 +300,36 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
// here and no caller has to tell them apart.
`ALTER TABLE tasks ADD COLUMN done_when TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN blocked_on TEXT NOT NULL DEFAULT '';`,
// #23 — the routing trace (V-629). internal/decision kept a 25-turn ring and
// persisted nothing, on the argument that a turn record is read minutes later
// or never. The owner reversed that on 06-08-2026: mode discovery and distance
// calibration need real utterances, and there is no other source of them.
// docs/plans/21-persisting-the-routing-trace.md carries the
// reversal.
//
// utterance holds his words in clear. A 384-dimension vector of a short
// sentence is substantially recoverable, so storing vectors instead would be a
// privacy claim we cannot support. What makes it safe is the same thing that
// makes the fact store safe: it never leaves the box, retention is bounded at
// routingTraceRetention, and Wipe drops it with everything else.
//
// correction is empty until the owner corrects a turn on /chat (V-630). A
// corrected pair is promoted out of here into a seed-shaped row and kept, so
// this column is a queue, not the durable label.
`CREATE TABLE IF NOT EXISTS routing_traces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
utterance TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
winner TEXT NOT NULL DEFAULT '',
intent TEXT NOT NULL DEFAULT '',
claimed_before_head INTEGER NOT NULL DEFAULT 0,
encoder_id TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT '',
correction TEXT NOT NULL DEFAULT '',
claims TEXT NOT NULL DEFAULT '[]'
);
CREATE INDEX IF NOT EXISTS idx_routing_traces_ts ON routing_traces (ts DESC);`,
}
// migrate applies every migration with a number greater than the DB's current
+118
View File
@@ -0,0 +1,118 @@
package store
import (
"context"
"encoding/json"
"fmt"
"time"
)
// routingTraceRetention is how long a raw trace lives (owner's call,
// 06-08-2026). A trace is read within a day or two of the turn that produced it,
// or never, so two weeks is diagnosis with room for a weekend. It is deliberately
// an age and not a row count: the useful question is "what did she do this week",
// and a busy Tuesday must not push last Friday out.
//
// A correction is not covered by this bound. The moment the owner corrects a
// turn, the pair is promoted out of the trace into a seed-shaped row and kept
// indefinitely, because a label is not a transcript. Keeping the transcript that
// carried it would defeat the point of the bound.
const routingTraceRetention = 14 * 24 * time.Hour
// RoutingTrace is one turn's arbitration, persisted. It is internal/decision's
// Record plus the four things the ring never had to carry: which reach the
// utterance arrived on, whether stage 0 answered before the classifier was
// consulted, which encoder body was live, and what the turn actually did.
type RoutingTrace struct {
ID int64 `json:"id"`
Ts time.Time `json:"ts"`
Utterance string `json:"utterance"`
Source string `json:"source"`
Winner string `json:"winner"`
Intent string `json:"intent"`
// ClaimedBeforeHead — stage 0 or a pre-route resolver answered, so the turn
// teaches nothing about the classifier. It is a large share of real traffic,
// and counting those turns as training signal would fit the head to the
// grammars rather than to him.
ClaimedBeforeHead bool `json:"claimed_before_head"`
// EncoderID names the encoder body that was live. A fitted distance means
// nothing under another body, and V-546 trains a copy of the weights.
EncoderID string `json:"encoder_id"`
// Outcome is what happened, not what was routed: a route that reached a gap
// and a route that ran are different turns.
Outcome string `json:"outcome"`
// Correction is the owner's label, empty until he gives one (V-630).
Correction string `json:"correction"`
// Claims is internal/decision's per-claimant detail, stored as JSON because
// nothing queries inside it: it is read whole, beside the turn it explains.
Claims json.RawMessage `json:"claims"`
}
// WriteRoutingTrace appends one turn and drops the ones past retention.
func (s *Store) WriteRoutingTrace(ctx context.Context, tr RoutingTrace) (int64, error) {
claims := "[]"
if len(tr.Claims) > 0 {
claims = string(tr.Claims)
}
res, err := s.db.ExecContext(ctx, `
INSERT INTO routing_traces
(ts, utterance, source, winner, intent, claimed_before_head, encoder_id, outcome, correction, claims)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
tr.Ts.UnixMilli(), tr.Utterance, tr.Source, tr.Winner, tr.Intent,
tr.ClaimedBeforeHead, tr.EncoderID, tr.Outcome, tr.Correction, claims)
if err != nil {
return 0, fmt.Errorf("write routing trace: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("last insert id: %w", err)
}
// Prune rarely. Turns arrive at human rate, so the bound is a ceiling and
// paying for a delete on every one of them buys nothing. 64 turns is hours.
if id%64 == 0 {
if err := s.PruneRoutingTraces(ctx, tr.Ts.Add(-routingTraceRetention)); err != nil {
return id, err
}
}
return id, nil
}
// PruneRoutingTraces deletes every trace older than before. A corrected turn is
// deleted with the rest: the label was promoted out when the owner wrote it, so
// what is left here is the transcript, and the transcript is what expires.
func (s *Store) PruneRoutingTraces(ctx context.Context, before time.Time) error {
if _, err := s.db.ExecContext(ctx,
`DELETE FROM routing_traces WHERE ts < ?`, before.UnixMilli()); err != nil {
return fmt.Errorf("prune routing traces: %w", err)
}
return nil
}
// RecentRoutingTraces returns the newest n turns, newest first.
func (s *Store) RecentRoutingTraces(ctx context.Context, n int) ([]RoutingTrace, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, ts, utterance, source, winner, intent, claimed_before_head,
encoder_id, outcome, correction, claims
FROM routing_traces
ORDER BY id DESC
LIMIT ?`, n)
if err != nil {
return nil, fmt.Errorf("recent routing traces: %w", err)
}
defer rows.Close()
var out []RoutingTrace
for rows.Next() {
var tr RoutingTrace
var tsMilli int64
var claims string
if err := rows.Scan(&tr.ID, &tsMilli, &tr.Utterance, &tr.Source, &tr.Winner,
&tr.Intent, &tr.ClaimedBeforeHead, &tr.EncoderID, &tr.Outcome,
&tr.Correction, &claims); err != nil {
return nil, err
}
tr.Ts = time.UnixMilli(tsMilli).UTC()
tr.Claims = json.RawMessage(claims)
out = append(out, tr)
}
return out, rows.Err()
}
+75
View File
@@ -0,0 +1,75 @@
package store
import (
"context"
"testing"
"time"
)
func TestRoutingTraceRoundTrip(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC)
in := RoutingTrace{
Ts: now,
Utterance: "напомни в 11:00 позвонить маме",
Source: "tap:voice",
Winner: "stage0:reminder-grammar",
Intent: "reminder",
ClaimedBeforeHead: true,
EncoderID: "e5-small",
Outcome: "reminder",
Claims: []byte(`[{"stage":"stage0","claimant":"reminder-grammar","outcome":"won"}]`),
}
if _, err := s.WriteRoutingTrace(ctx, in); err != nil {
t.Fatal(err)
}
got, err := s.RecentRoutingTraces(ctx, 10)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("got %d traces, want 1", len(got))
}
// The utterance is stored in clear on purpose: a vector is not redaction.
if got[0].Utterance != in.Utterance {
t.Errorf("utterance %q, want %q", got[0].Utterance, in.Utterance)
}
if !got[0].ClaimedBeforeHead {
t.Error("claimed_before_head lost, and V-632 needs exactly that share")
}
if got[0].EncoderID != in.EncoderID {
t.Errorf("encoder_id %q, want %q", got[0].EncoderID, in.EncoderID)
}
if string(got[0].Claims) != string(in.Claims) {
t.Errorf("claims %s, want %s", got[0].Claims, in.Claims)
}
if got[0].Correction != "" {
t.Errorf("correction %q on an uncorrected turn", got[0].Correction)
}
}
// The bound is an age, not a row count: the useful question is what she did this
// week, and a busy Tuesday must not push last Friday out.
func TestPruneRoutingTracesByAge(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC)
for _, age := range []time.Duration{0, 13 * 24 * time.Hour, 15 * 24 * time.Hour} {
if _, err := s.WriteRoutingTrace(ctx, RoutingTrace{Ts: now.Add(-age), Utterance: "привет"}); err != nil {
t.Fatal(err)
}
}
if err := s.PruneRoutingTraces(ctx, now.Add(-routingTraceRetention)); err != nil {
t.Fatal(err)
}
got, err := s.RecentRoutingTraces(ctx, 10)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("kept %d traces, want the two inside 14 days", len(got))
}
}