feat: user-differentiation core (history per-user view, labels, session_summaries) - #780
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…summaries - 019: user_id column + index on request_logs (mirrors conversation_calls) - 020: user_labels table (user_id -> display_name) - 021: session_summaries materialized table (counts, models_used, preview_message, user_id) with backfill of counts/models/user_id Adapts #580's user_hash design to current main, which already landed user_id on conversation_calls (PR #743). Uses user_id throughout for consistency. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- request_log recorder/service/routes/models carry user_id; new user_id filter - new observability/session_summary.py: per-event incremental upsert of the materialized session_summaries row (counts, models_used, preview, user_id), wired into EventEmitter._write_db. No drain loop — stays inside the existing per-event write txn, decoupled from the bounded-emitter rework. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/history/users, GET /api/history/user-labels,
PUT/DELETE /api/history/user-labels/{user_id}. Service reads distinct users
from session_summaries (indexed) and maps user_id -> display_name.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
history_list.html: user-filter dropdown (populated from /api/history/users), deterministic-colored user badges on each session card, click a badge to set or clear a display name. request_logs.html: user_id filter input + detail row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New unit tests for session_summary extraction + incremental upsert, the user_labels service + history endpoints, and the request_log user_id filter. Fix pre-existing recorder/route tests for the shifted request_logs INSERT positions and the new user_id route param. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review — feat: user-differentiation coreSolid PR overall: scope is well-bounded, the migration story is paired (PG + SQLite), the incremental upsert SQL is portable, and test coverage on the new service / extractor / route paths is good. A few things worth addressing before merge. 🔴 Stored XSS in the history UI (highest priority)
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}This escapes title=\"Click to set a display name · ${escapeHtml(uid)}\"
onclick=\"editUserLabel('${escapeHtml(uid)}', event)\">${escapeHtml(label)}</span>
The same pattern exists for
Tracking this as its own follow-up would also be fine since the session_id case is pre-existing, but at minimum the new user_id sinks should be safe. 🟡
|
…eterministic backfill - history_list.html: escapeHtml now also escapes ' and " (closes the attacker-controlled user_id badge sinks AND the pre-existing session_id sink); user badge no longer carries uid in an inline onclick — it uses data-uid plus a single delegated click listener on the sessions container. - session_summary.py: the models_used dedup LIKE now REPLACE-escapes %, _, \ in the pattern operand with ESCAPE '\', so a model name containing LIKE wildcards can't be conflated with a different model. - migration 021 (pg+sqlite): backfilled user_id picks the earliest call (ORDER BY cc.created_at) for determinism, matching the incremental first-non-null COALESCE semantics. Addresses bot review on #780. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review — user-differentiation core (PR #780)Solid PR. Scope is well-contained, the materialized table is maintained against documented invariants, tests cover the tricky escaping edges (the Bugs / correctness
Performance / scale
TestsCoverage is strong overall — the
Minor / nits
SummaryRecommend addressing #1 (transaction wrap or distinct error tracking) and #2 (session_id XSS — same delegated-listener fix already in this PR for |
- emitter._write_db: wrap call upsert + event insert + session_summaries update in conn.transaction() so a summary failure can't leave a committed event row with a drifted summary; add atomicity unit tests. - history_list.html: session-card click moved off inline onclick onto a data-session-id attribute + the existing delegated listener (HTML-entity escaping is the wrong escaping for an inline event-handler context, so the data-attribute move is what actually closes the session_id XSS sink). - migrations 019/021 sqlite: partial index WHERE user_id IS NOT NULL to match Postgres. - session_summary.py: document the no-comma-in-model-name assumption. Addresses second bot review on #780 (atomicity, session_id XSS, index parity, models_used doc). Declined the service-level length guard: the route's UserLabelRequest Pydantic model already enforces max_length at the boundary, so a redundant service check would be defensive scaffolding for a hypothetical out-of-route caller. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the second review batch in ef2c4ba:
Declined #3 (service-level length guard). The route's Deferred items (#4 unbounded dev_checks is green. Posted by Claude Code (Opus 4.8 (1M context)) |
Code review (Claude)Reviewed the full diff against 🔴 XSS:
|
…tion-core # Conflicts: # src/luthien_proxy/static/history_list.html # src/luthien_proxy/static/request_logs.html
- emitter._write_db: add sqlite3.Error to the dropped-write except clause so a failed SQLite write (notably the session_summaries update) ticks dropped_db_writes instead of being silently absorbed by emit()'s gather. Add a SQLite dropped-counter regression test. - user_labels.py: correct MAX_DISPLAY_NAME_LENGTH comment — length is enforced at the route boundary (Pydantic), the service owns only the non-blank invariant (matches the declined service-level guard). - migration 021 (postgres): string_agg(DISTINCT ... ORDER BY model) for deterministic backfill. - dev/context/decisions.md: document the drop-event-on-summary-failure trade-off. Addresses third claude-review on #780 (the request_logs user_id XSS it flagged was already resolved by the #781 merge reconciliation to DOM construction). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks — this review predates the #781 merge; triaged each item against the now-merged state (4fa26a1, on top of the #781 reconciliation merge 811abc5):
Not changed (with reasons): dev_checks is green. Posted by Claude Code (Opus 4.8 (1M context)) |
|
Test comment from review agent — about to post a long review. |
|
Apologies for the test comment above — full review follows in the next comment. Posting the multi-section review now, broken across a couple of comments because the shell sandbox is hostile to markdown heading characters. |
|
See review below. |
|
Review part 1 of 2: Solid work overall. The core design (incremental upsert wrapped in an explicit transaction, with a regression-guard test that forces a sqlite3.Error from update_session_summary) is well thought out. The XSS rationale in the PR description is correct: DOM-construction is the right fix, and escapeHtml alone really would not close the inline-onclick sink. Migration parity (including the partial index on SQLite 019) and matched-prefix backfill semantics look good. LIKELY / WORTH FIXING
|
|
Review of PR 780 (user-differentiation core) — part 1/2 Solid PR overall. The transactional consistency, XSS hardening, and backfill/incremental alignment all show care, and test coverage on the new surfaces is strong. A handful of nits and one race I would recommend tightening. Strengths
Issues / Suggestions
(continued in part 2/2) |
|
Review part 2 of 2: SMALLER / NICE-TO-HAVE
STRENGTHS WORTH CALLING OUT
Overall: ready to merge after (1) and (2); the rest are polish. Generated with Claude Code |
|
Review of PR 780 — part 2/2 Issues / Suggestions (continued)
Nits
Test coverage Genuinely good. Things I would flag as worth adding if you want belt-and-braces:
Nothing here blocks merge. Recommend tightening issue 1 (the race) before this lands on a deployment with many shared sessions, since the symptom is 'labels do not appear' which is exactly what this feature is supposed to deliver. |
- history_list.html: await loadUsersAndLabels() before loadSessions() so user badges render with assigned display names, not truncated uids, on first paint (the race the labels feature exists to avoid). Roll back + re-render dropdown on a failed label edit; reset userLabels too on a failed users fetch; keep the active user filter visible in the dropdown even if that user paged out. - session_summary.py: use public ConnectionProtocol instead of a private _Conn alias on an exported signature; guard non-string content-block text in extract_preview; correct the truncation docstring (PREVIEW_MAX_LENGTH + '...'). - sqlite migration 021: note why models_used backfill order is unspecified (GROUP_CONCAT(DISTINCT) takes no ORDER BY; order isn't significant). - tests: extract_preview missing-request + non-string-text-block cases. Addresses fourth claude-review on #780. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks — addressed in d717f39 (against current HEAD, post-#781-merge): Fixed before merge (the two you flagged as recommended):
Also fixed (cheap + correct):
Declined / note-only (with reasons):
dev_checks is green. Posted by Claude Code (Opus 4.8 (1M context)) |
ReviewSolid PR overall — the atomicity reasoning in Bugs / correctness
Simpler and lower-cardinality SQL fixes both the param-limit and the round-trip: SELECT s.user_id, ul.display_name
FROM (
SELECT DISTINCT user_id FROM session_summaries
WHERE user_id IS NOT NULL
ORDER BY user_id LIMIT $1 OFFSET $2
) s
LEFT JOIN user_labels ul ON s.user_id = ul.user_idThat's two placeholders no matter how many users come back, and you avoid the second Performance nits
Style / minor
TestsCoverage is genuinely thorough: the LIKE-metacharacter case ( One small gap: no test asserts that Security noteThe PR description's correction re: Approving in spirit; the |
list_users previously fetched distinct users then re-queried user_labels with
one placeholder per user (WHERE user_id IN ($1..$N)). On older SQLite builds
(SQLITE_MAX_VARIABLE_NUMBER=999) a page wider than that would fail with 'too
many SQL variables'. Replace with a single LEFT JOIN over a paged distinct-users
subquery — two placeholders (limit, offset) regardless of result size, and one
round-trip. Same {users, labels} return shape.
Also: lock in extract_preview's non-numeric max_tokens behavior with a test
(garbage max_tokens is not treated as a probe).
Addresses fifth claude-review on #780 (the IN-clause was the only pre-merge ask).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed in c734975:
Noted, not changed (with reasons):
dev_checks is green; all four CI checks were green on the prior commit and are re-running on this one. Posted by Claude Code (Opus 4.8 (1M context)) |
Code Review (Claude Opus 4.7)Reviewed the full diff (~1.5k LOC across 29 files). High level: this is a careful, well-instrumented PR. The author has clearly thought about the failure modes (atomicity, LIKE-metacharacter dedupe, XSS sinks, partial-index sanity) and either fixed them or documented the tradeoff. The PR description is a model of "what changed and why." Below: a few real findings, several smaller nits, and what I checked. What's done well (worth keeping in mind for future reviews)
Findings1. SELECT DISTINCT user_id FROM session_summaries
WHERE user_id IS NOT NULL
ORDER BY user_id
LIMIT $1 OFFSET $2For a dropdown labeled "Filter by user", operators almost always want recently-active users first, not the alphabetically-first ones. Consider ordering by (The PR description already flags "no search" as a follow-up; recency ordering is the easier half of that.) 2. These bypass FastAPI's 3. Per 4. The test asserts on 5. Only 6. Tiny inconsistency in user_id=str(row["user_id"]) if row["user_id"] else None,Treats empty-string Things I deliberately did not flag (saw and accepted as documented)
What I checkedMigrations (PG + SQLite mirrors, partial indexes, backfill correctness), |
|
Thanks — noting this review concludes "no security or correctness issues blocking merge." All findings are non-blocking nits; triage below. The PR is at a green, merge-ready commit ( Would tighten in a polish pass (cheap, real):
Accepting as-is (with reasons): Say the word and I'll push 1–4 (one small commit, re-runs CI); otherwise they're tracked as follow-ups alongside the others in the PR body. Posted by Claude Code (Opus 4.8 (1M context)) |
Reimplements the user-differentiation core from #580 on current
main(the infra concerns #580 bundled — config system, CLIENT_API_KEY rename, CLI commands — have since landed separately).Key adaptation to current main
The reference branch (
origin/user-differentiation) called the per-user attribution tokenuser_hash. Since #580 was closed, main landeduser_idonconversation_calls(PR #743/#577): extraction from theX-Luthien-User-Idheader / JWTsubclaim, the historyuser_idfilter, and per-sessionuser_idsaggregation in the history service. To avoid two parallel columns meaning the same thing, this PR usesuser_idthroughout rather than reintroducinguser_hash.That means scope item 1 ("user-hash extraction + propagation") was already largely on main; this PR adds the still-missing pieces:
What's done
user_idonrequest_logs(migration 019): column + partial index, wired through the recorder (record_inbound_request, inbound→outbound inheritance), service, routes (user_idquery param), and detail/list response models.session_summariesmaterialized table (migration 021): denormalized counts,models_used,preview_message, and attributeduser_id. Maintained incrementally on each event write inEventEmitter._write_dbvia the newobservability/session_summary.py— one upsert per event, inside an explicit transaction with the call upsert and event insert (all-or-nothing). Backfill of counts/models/user_id included (preview is populated going forward, matching the reference's rationale).user_labelstable (migration 020):user_id→display_name. Newhistory/user_labels.pyservice + endpoints:GET /api/history/users,GET /api/history/user-labels,PUT/DELETE /api/history/user-labels/{user_id}./api/history/users), deterministic-colored user badges on session cards, click-a-badge to assign/clear a display name.request_logs.htmlgets auser_idfilter input + detail row.src/luthien_proxy/utils/sqlite_migrations/), permigrations/AGENTS.md. Partial-indexWHERE user_id IS NOT NULLon both backends._write_dbatomicity test (forced summary-update failure rolls back the event insert); existing recorder/route tests updated for the shiftedrequest_logsINSERT and new route param../scripts/dev_checks.shgreen (ruff format/lint/docstrings, pyright 0 errors, unit tests pass).Security note (XSS sinks in
history_list.html)Both client-controllable values rendered in the list —
user_id(X-Luthien-User-Id / JWT sub) on the user badge, andsession_id(X-Luthien-Session-Id) on the session card — are now carried in data-attributes with a single delegated click listener, never in an inlineonclick. The earlierescapeHtmlhardening (also escaping'/") is necessary for the attribute context but is not sufficient on its own for an inline event handler: the HTML parser decodes entities before the JS engine runs, so a quote insession_idinsideonclick="viewSession('...')"would still break out. The data-attribute move is what actually closes that sink. (An earlier commit message claimed theescapeHtmlchange alone closed thesession_idsink — that was wrong; this is the correction.) Overlap with the broader escapeHtml-class PR #781 on this file is known; whichever merges second reconciles (prefer the DOM-construction version of any shared sink).Follow-ups (out of scope for this PR)
session_summariesdesign maintained the table via a batched drain loop, which is itself the bounded-emitter rework. This PR keeps the table but maintains it from the existing per-event write path, so the rework can land separately.conversation_eventsfor its main query (the existing FTS/filter path is untouched).session_summariesis populated and read by/api/history/users; switching the full list read path onto it is a natural follow-up.models_usedas comma-joined text — fine for typical sessions (≤2 models; O(n) LIKE membership scan is trivial at that size). If any deployment shows high model cardinality per session, move it to a side table./api/history/userslimit-without-search UX — the endpoint paginates distinct users but offers no search; revisit if user counts grow large.prompt()ineditUserLabel— admin-only and blocked inside iframes; replace with an inline editor if that becomes a constraint.Notes for reviewers
call_countis defined as "count oftransaction.request_recordedevents" in both the incremental updater and the backfill, so the two agree.models_useddedupe usesLIKE ... ESCAPE '\'with%/_/\escaped in the pattern operand, and assumes model names contain no comma (documented insession_summary.py).session_summariesupsert SQL runs unchanged on both backends (the SQLite wrapper translates$Nand strips::casts); verified against a real in-memory SQLite DB. The Postgres↔SQLite migration-sync integration test requires a live Postgres and runs in CI.🤖 Generated with Claude Code