validatord: coordinator session JSONL for offline analysis (B follow-up)

Closes the second half of J's 2026-05-02 multi-call observability
concern. Trace-id propagation (commit d6d2fdf) gave us the *live*
view in Langfuse; this gives us the *longitudinal* view for ad-hoc
DuckDB queries over thousands of sessions:

  "show me every session where the model produced a real candidate
   without ever needing a retry"
  "find sessions where validation rejected three times in a row"
  "first-shot success rate per model — did we feed it enough corpus?"

## What's in

internal/validator/session_log.go:
  - SessionRecord type (schema=session.iterate.v1)
  - SessionLogger writer — mutex-guarded append, best-effort posture,
    nil-safe (NewSessionLogger("") = nil = no-op on Append)
  - BuildSessionRecord helper — assembles a row from any
    iterate response/failure/infra-error combination, callable from
    other daemons that wrap iterate (cross-daemon shared schema)
  - 7 unit tests including concurrent-append safety + the three
    code paths (success / max_iter_exhausted / infra_error)

cmd/validatord/main.go:
  - handlers.sessionLog field + wiring from cfg.Validatord.SessionLogPath
  - Iterate handler: build + append a SessionRecord on every call
  - rosterCheckFor("fill") closure stamps grounded_in_roster — the
    load-bearing forensic property J flagged ("we can never
    hallucinate available staff members to contracts")

internal/shared/config.go + lakehouse.toml:
  - [validatord].session_log_path field; empty = disabled
  - Production: /var/lib/lakehouse/validator/sessions.jsonl

scripts/validatord_smoke.sh:
  - Adds a probe verifying validatord announces session log path on
    startup. Smoke is now 6/6 (was 5/5).

docs/SESSION_LOG.md:
  - Schema reference + 5 worked DuckDB query examples including the
    "alarm" query (sessions where grounded_in_roster=false on an
    accepted fill — should always be empty; if not, something is
    bypassing FillValidator).

## What this is NOT

This is NOT a duplicate of replay_runs.jsonl. They're siblings:
  - replay_runs.jsonl: replay tool's per-task retrieval+model output
  - sessions.jsonl: validatord's per-iterate full retry chain +
    grounded-in-roster verdict

A single coordinator session can produce rows in both streams; the
session_id (= Langfuse trace_id) is the join key.

## Layered observability now in place

  Live view:  Langfuse trace tree (X-Lakehouse-Trace-Id propagation)
              `iterate.attempt[N]` spans with prompt/raw/verdict
  Offline:    coordinator_sessions.jsonl (this commit)
              DuckDB-queryable; longitudinal forensics
  Hard gate:  FillValidator + WorkerLookup (existing)
              phantom IDs structurally rejected, never reach
              session log's grounded_in_roster=true bucket

Per the architecture invariant in STATE_OF_PLAY's DO NOT RELITIGATE
section — these layers are wired; future work targets the data, not
the wiring.

## Verification

- internal/validator: 7 new tests (session_log_test.go) — all PASS
- cmd/validatord: 3 new integration tests covering the success,
  failure, and grounded=false paths — all PASS
- validatord_smoke.sh: 6/6 PASS through gateway :3110
- Full go test ./... green across 33 packages

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-02 05:22:09 -05:00
co-authored by Claude Opus 4.7
parent d6d2fdf81f
commit 1a3a82aedb
9 changed files with 848 additions and 7 deletions
+7
View File
@@ -165,6 +165,12 @@ type ValidatordConfig struct {
DefaultMaxTokens int `toml:"default_max_tokens"`
// Per-call timeout for the chat hop in seconds. 0 = 240s.
ChatTimeoutSecs int `toml:"chat_timeout_secs"`
// SessionLogPath: where to append SessionRecord JSONL rows for
// offline analysis (DuckDB queries, scrum review tooling). Empty
// = disabled. Production sets a stable path under
// /var/lib/lakehouse/validator/sessions.jsonl. Append-only,
// best-effort; see internal/validator/session_log.go.
SessionLogPath string `toml:"session_log_path"`
}
// ObserverdConfig drives the observer service (cmd/observerd).
@@ -393,6 +399,7 @@ func DefaultConfig() Config {
DefaultMaxIterations: 3,
DefaultMaxTokens: 4096,
ChatTimeoutSecs: 240,
SessionLogPath: "", // empty = no session JSONL. Operators set under /var/lib/lakehouse/validator/sessions.jsonl.
},
Chatd: ChatdConfig{
Bind: "127.0.0.1:3220",
+199
View File
@@ -0,0 +1,199 @@
package validator
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
)
// SessionRecordSchema versions the on-wire JSON shape. Bump when the
// schema changes incompatibly. Consumers (e.g. duckdb queries over
// coordinator_sessions.jsonl, scrum review tooling) check this field
// to decide whether they understand the row.
const SessionRecordSchema = "session.iterate.v1"
// SessionRecord is one row in coordinator_sessions.jsonl. Captures the
// full retry chain of a single /v1/iterate session for offline
// forensics: "show me all sessions where the validator caught a
// phantom worker" / "show me all sessions where retrieval missed."
//
// The Langfuse trace tree (see X-Lakehouse-Trace-Id propagation
// 2026-05-02) is the live view; this JSONL is the longitudinal view
// for ad-hoc DuckDB queries over thousands of sessions.
type SessionRecord struct {
Schema string `json:"schema"`
SessionID string `json:"session_id"` // = Langfuse trace_id
Timestamp string `json:"timestamp"` // ISO 8601
Daemon string `json:"daemon"`
Kind string `json:"kind"` // fill | email | playbook
Model string `json:"model"`
Provider string `json:"provider"`
Prompt string `json:"prompt"` // truncated to 4000
Iterations int `json:"iterations"`
MaxIterations int `json:"max_iterations"`
FinalVerdict string `json:"final_verdict"` // accepted | max_iter_exhausted | infra_error
Attempts []SessionAttemptRecord `json:"attempts"`
Artifact map[string]any `json:"artifact,omitempty"` // present on success
GroundedInRoster *bool `json:"grounded_in_roster,omitempty"`
DurationMs int64 `json:"duration_ms"`
}
// SessionAttemptRecord mirrors IterateAttempt but stores only the
// stable signals (iteration, verdict, error, span id). The raw model
// output is intentionally NOT captured here — it lives in the
// Langfuse span (queryable by span_id) and the iterate response. Two
// copies would let the JSONL grow unbounded on long sessions.
type SessionAttemptRecord struct {
Iteration int `json:"iteration"`
VerdictKind string `json:"verdict_kind"` // no_json | validation_failed | accepted
Error string `json:"error,omitempty"`
SpanID string `json:"span_id,omitempty"`
}
// SessionLogger appends SessionRecord rows to a JSONL file. Best-effort:
// errors are logged via slog and never returned to the caller (per the
// rest of the observability stack — never block a request because the
// session log is unhappy).
//
// nil is a valid value: NewSessionLogger("") returns nil; Append on
// a nil receiver is a no-op. Lets validatord skip the wiring entirely
// when no session log is configured.
type SessionLogger struct {
path string
mu sync.Mutex
}
// NewSessionLogger constructs a logger writing to `path`. Empty path
// disables logging. Creates parent dirs lazily on first write so the
// constructor doesn't panic when the path is on a not-yet-mounted
// volume (e.g. systemd unit ordering).
func NewSessionLogger(path string) *SessionLogger {
if path == "" {
return nil
}
return &SessionLogger{path: path}
}
// Append writes one JSONL row. Returns nil on success or after a
// best-effort failure. The caller doesn't need to handle errors;
// logging is an observability witness, not a correctness gate.
func (l *SessionLogger) Append(rec SessionRecord) {
if l == nil {
return
}
if rec.Schema == "" {
rec.Schema = SessionRecordSchema
}
if rec.Timestamp == "" {
rec.Timestamp = time.Now().UTC().Format(time.RFC3339Nano)
}
if rec.Daemon == "" {
rec.Daemon = "validatord"
}
body, err := json.Marshal(rec)
if err != nil {
slog.Warn("session_log: marshal", "err", err, "session_id", rec.SessionID)
return
}
body = append(body, '\n')
l.mu.Lock()
defer l.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil {
slog.Warn("session_log: mkdir", "err", err, "path", l.path)
return
}
f, err := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
slog.Warn("session_log: open", "err", err, "path", l.path)
return
}
defer f.Close()
if _, err := f.Write(body); err != nil {
slog.Warn("session_log: write", "err", err, "session_id", rec.SessionID)
}
}
// BuildSessionRecord assembles a SessionRecord from an iterate
// response/failure pair. Exactly one of resp/fail must be non-nil
// (or both nil for an infrastructure failure). Centralized here so
// validatord and any future iterate-using daemon emit the same shape.
//
// rosterCheck is called per accepted artifact to populate
// GroundedInRoster — set to nil to skip the check (e.g. for
// non-fill kinds that don't have worker IDs). Returns the shaped
// record; pass to logger.Append on the caller side so failed
// daemons don't block on their own observability layer.
func BuildSessionRecord(
req IterateRequest,
resp *IterateResponse,
fail *IterateFailure,
infraErr error,
rosterCheck func(map[string]any) *bool,
durationMs int64,
) SessionRecord {
rec := SessionRecord{
SessionID: req.TraceID,
Kind: req.Kind,
Model: req.Model,
Provider: req.Provider,
Prompt: trim(req.Prompt, 4000),
MaxIterations: req.MaxIterations,
DurationMs: durationMs,
}
switch {
case resp != nil:
rec.Iterations = resp.Iterations
rec.FinalVerdict = "accepted"
rec.Attempts = sessionAttemptsFromHistory(resp.History)
rec.Artifact = resp.Artifact
if rosterCheck != nil {
rec.GroundedInRoster = rosterCheck(resp.Artifact)
}
// Keep the trace id authoritative even if the request didn't
// supply one — the iterate response carries the resolved id.
if rec.SessionID == "" {
rec.SessionID = resp.TraceID
}
case fail != nil:
rec.Iterations = fail.Iterations
rec.FinalVerdict = "max_iter_exhausted"
rec.Attempts = sessionAttemptsFromHistory(fail.History)
if rec.SessionID == "" {
rec.SessionID = fail.TraceID
}
default:
// Infrastructure failure (chat hop crashed mid-loop, etc.).
// We still emit a row so the failure is forensically visible —
// otherwise long debugging sessions get harder.
rec.FinalVerdict = "infra_error"
if infraErr != nil {
rec.Attempts = []SessionAttemptRecord{{
Iteration: 0,
VerdictKind: "infra_error",
Error: trim(fmt.Sprintf("%v", infraErr), 800),
}}
}
}
return rec
}
func sessionAttemptsFromHistory(h []IterateAttempt) []SessionAttemptRecord {
out := make([]SessionAttemptRecord, len(h))
for i, a := range h {
out[i] = SessionAttemptRecord{
Iteration: a.Iteration,
VerdictKind: a.Status.Kind,
Error: a.Status.Error,
SpanID: a.SpanID,
}
}
return out
}
+224
View File
@@ -0,0 +1,224 @@
package validator
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
)
func TestSessionLogger_AppendsSchemaTaggedRows(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sessions.jsonl")
logger := NewSessionLogger(path)
if logger == nil {
t.Fatal("expected non-nil logger for a non-empty path")
}
rec := SessionRecord{
SessionID: "trace-1",
Kind: "fill",
Model: "qwen3.5:latest",
Provider: "ollama",
Prompt: "produce a fill artifact",
Iterations: 1,
FinalVerdict: "accepted",
}
logger.Append(rec)
rows := readJSONL(t, path)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0]["schema"] != SessionRecordSchema {
t.Errorf("schema autopopulation: %v", rows[0]["schema"])
}
if rows[0]["session_id"] != "trace-1" {
t.Errorf("session_id round-trip: %v", rows[0]["session_id"])
}
if rows[0]["timestamp"] == "" || rows[0]["timestamp"] == nil {
t.Errorf("timestamp autopopulation: %v", rows[0]["timestamp"])
}
if rows[0]["daemon"] != "validatord" {
t.Errorf("daemon autopopulation: %v", rows[0]["daemon"])
}
}
func TestSessionLogger_NilIsNoOp(t *testing.T) {
var logger *SessionLogger // nil
logger.Append(SessionRecord{SessionID: "trace-1"})
// No panic, no error — the test passing IS the assertion.
}
func TestSessionLogger_EmptyPathReturnsNil(t *testing.T) {
if NewSessionLogger("") != nil {
t.Error("empty path should disable logging (nil logger)")
}
}
// TestSessionLogger_ConcurrentAppends locks the per-row atomicity: N
// goroutines each appending must produce N well-formed JSONL rows
// with no torn writes. Mutex lives at the logger level, not OS-level,
// so this also documents that concurrent logger instances would NOT
// be safe (one logger per daemon — that's fine).
func TestSessionLogger_ConcurrentAppends(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sessions.jsonl")
logger := NewSessionLogger(path)
const N = 64
var wg sync.WaitGroup
for i := 0; i < N; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
logger.Append(SessionRecord{
SessionID: "trace-" + string(rune('a'+i%26)),
Kind: "fill",
FinalVerdict: "accepted",
Iterations: i,
})
}(i)
}
wg.Wait()
rows := readJSONL(t, path)
if len(rows) != N {
t.Fatalf("expected %d rows after concurrent appends, got %d", N, len(rows))
}
// Every row must be parseable JSON — a torn write would throw
// a decode error, which readJSONL t.Fatal's on.
}
func TestBuildSessionRecord_SuccessPathCapturesArtifact(t *testing.T) {
req := IterateRequest{
TraceID: "trace-x",
Kind: "fill",
Prompt: "produce fill",
Provider: "ollama",
Model: "qwen3.5:latest",
MaxIterations: 3,
}
resp := &IterateResponse{
Artifact: map[string]any{"fills": []any{
map[string]any{"candidate_id": "W-1"},
}},
Validation: Report{},
Iterations: 1,
History: []IterateAttempt{{
Iteration: 0,
Raw: `{"fills":[{"candidate_id":"W-1"}]}`,
Status: AttemptStatus{Kind: "accepted"},
SpanID: "span-0",
}},
TraceID: "trace-x",
}
roster := func(_ map[string]any) *bool {
t := true
return &t
}
rec := BuildSessionRecord(req, resp, nil, nil, roster, 1234)
if rec.FinalVerdict != "accepted" || rec.Iterations != 1 {
t.Errorf("verdict/iterations: %+v", rec)
}
if rec.GroundedInRoster == nil || !*rec.GroundedInRoster {
t.Errorf("grounded_in_roster: expected true, got %v", rec.GroundedInRoster)
}
if rec.Artifact == nil {
t.Errorf("artifact missing on success path")
}
if len(rec.Attempts) != 1 || rec.Attempts[0].SpanID != "span-0" {
t.Errorf("attempts shape: %+v", rec.Attempts)
}
if rec.SessionID != "trace-x" {
t.Errorf("session_id: %s", rec.SessionID)
}
if rec.DurationMs != 1234 {
t.Errorf("duration: %d", rec.DurationMs)
}
}
func TestBuildSessionRecord_FailurePathOmitsArtifact(t *testing.T) {
req := IterateRequest{
TraceID: "trace-y",
Kind: "fill",
Provider: "ollama",
Model: "qwen",
MaxIterations: 3,
}
fail := &IterateFailure{
Error: "max iterations reached (3)",
Iterations: 3,
History: []IterateAttempt{
{Iteration: 0, Status: AttemptStatus{Kind: "no_json"}},
{Iteration: 1, Status: AttemptStatus{Kind: "validation_failed", Error: "phantom W-X"}},
{Iteration: 2, Status: AttemptStatus{Kind: "validation_failed", Error: "phantom W-Y"}},
},
TraceID: "trace-y",
}
rec := BuildSessionRecord(req, nil, fail, nil, nil, 8765)
if rec.FinalVerdict != "max_iter_exhausted" {
t.Errorf("final_verdict: %s", rec.FinalVerdict)
}
if rec.Artifact != nil {
t.Errorf("artifact must be omitted on failure path: %v", rec.Artifact)
}
if rec.GroundedInRoster != nil {
t.Errorf("grounded_in_roster should be nil on failure: %v", *rec.GroundedInRoster)
}
if len(rec.Attempts) != 3 || rec.Attempts[2].VerdictKind != "validation_failed" {
t.Errorf("attempts: %+v", rec.Attempts)
}
}
func TestBuildSessionRecord_InfraErrorEmitsRow(t *testing.T) {
// An infrastructure failure (e.g. chat hop crashed) must STILL
// produce a session row — otherwise debugging gets harder
// (no log → operator can't see it failed at all).
req := IterateRequest{TraceID: "trace-z", Kind: "fill"}
rec := BuildSessionRecord(req, nil, nil, errExample{"chat refused"}, nil, 50)
if rec.FinalVerdict != "infra_error" {
t.Errorf("verdict: %s", rec.FinalVerdict)
}
if len(rec.Attempts) != 1 || rec.Attempts[0].VerdictKind != "infra_error" {
t.Errorf("attempts: %+v", rec.Attempts)
}
if rec.Attempts[0].Error == "" {
t.Errorf("error must be carried")
}
}
// ─── Helpers ───────────────────────────────────────────────────
type errExample struct{ msg string }
func (e errExample) Error() string { return e.msg }
func readJSONL(t *testing.T, path string) []map[string]any {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer f.Close()
var out []map[string]any
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 1<<16), 1<<24)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var row map[string]any
if err := json.Unmarshal(line, &row); err != nil {
t.Fatalf("parse line %q: %v", line, err)
}
out = append(out, row)
}
if err := sc.Err(); err != nil {
t.Fatalf("scan: %v", err)
}
return out
}