Repository navigation
Fix LLM audit prompt fingerprints - #1843
Conversation
laboef1900
left a comment
There was a problem hiding this comment.
Independent Astra-high review of published 9317f54: correct, no actionable findings. Traced seven inference producers, provider serialization, SSE forwarding, hook dispatch and CE persistence: actual content fingerprints preserve ordered role/content framing and structured text/image distinctions, while hooks/persistence carry only digest and summaries. Inspected sanitized actual HTTP Generate/provider/audit-row evidence: different equal-length content differs, identical framed content is stable, no prompt plaintext in audit rows. Focused12/routes146; corrected backend7168/frontend5799/root unit30; final lint/typecheck and unchanged build/contracts/audit reuse. Initial closed-mock export failures retained and corrected. Published-source attestation matches. Read-only reviewer did not execute checks; no approval claimed. CI completion remains separately tracked.
laboef1900
left a comment
There was a problem hiding this comment.
Independent review: PR #1843
Issue: #1790 (bug; no NO AI label). Target: dev.
Head: 9317f544b22874712c2cfcc9f873a916892b3c6d
Base: 5edff9e08dad25e9db1c5668ff211c794fc9c2fd
Verdict
Correct; no confirmed patch-introduced actionable findings. Confidence: 0.97. Review disposition: COMMENT (read-only source/evidence assessment, not newly executed verification or GitHub approval).
Scope and boundary review
Full seventeen-file diff and surrounding code/tests. Traced Generate, Improve, Ask, Diagram, Quality, Summarize and inline completion through provider serialization, shared SSE audit forwarding, inference/admin dispatcher and CE SQL writer. Cache-hit exits precede audit inference paths. No other inference emitters found in CE production source.
Acceptance criteria
-
Deterministic content fingerprint with unambiguous ordered framing — FULLY ADDRESSED. llm-audit-hook.ts:12-18 hashes ordered role/content JSON tuples; structured text/image tags and part order retained. All seven inference producers pass actual provider messages (inline FIM wraps actual prompt as one user tuple).
-
Different equal-length contents differ, identical contents stable — FULLY ADDRESSED. Hook framing tests and listening Generate integration compare independently computed expected hashes with captured provider messages and real audit rows.
-
No prompt plaintext in summary-only hooks or CE persistence — FULLY ADDRESSED. Hook payload replaces inputText with required promptHash; shared SSE helper spreads audit metadata unchanged; emitLlmAudit routes inference to _hook; default writer binds entry.promptHash to prompt_hash. Image regression checks content and data URL absent from emitted entry.
Findings
None.
Evidence inspected (reused, not executed here)
- pr-1843-metadata.json: applicable final-head CI SUCCESS
- epic-1827-evidence/1790/http-provider-rows.log: three actual Generate requests; alpha/bravo equal lengths have different hashes, repeated alpha same hash; rows contain no prompt plaintext
- epic-1827-evidence/1790/published-source-attestation.json: final-head tested source linkage
Evidence limits
No checks run here; private EE implementation not included in CE review. Hashes are audit identities, not encryption, egress attestation or proof of model execution. Old length-summary hashes remain historically incomparable. Initial omitted-export mock failures and correction retained in PR evidence.
Suggested targeted verification
Parent only in isolated PostgreSQL/Redis: npx vitest run src/domains/llm/services/llm-audit-hook.test.ts src/domains/llm/services/llm-audit-default-writer.test.ts src/routes/llm/generate-audit.integration.test.ts src/routes/llm/generate-with-image.test.ts src/routes/llm/sse-stream-limit-gate.test.ts. Include EE writer contract suite if reviewing EE separately.
Cross-PR interaction
Compatible by source reasoning with 1842: changed source-aware context feeds the messages fingerprinted by this PR. No merge or combined execution performed.
No source edits, branch changes, services, GitHub reviews, pushes or merges performed.
Parent validation and publication
No additional actionable findings from parent triage.
Current final-head GitHub CI is passing. This is an independent source/evidence review, not a newly rerun full Docker/browser matrix or a GitHub approval. Source-only findings are identified as such. No code modifications or merges performed.
Reviewed by PR Reviewer Agent (eight independent review contexts; parent-validated publication).
laboef1900
left a comment
There was a problem hiding this comment.
Automated PR Review
PR: #1843 -- Fix LLM audit prompt fingerprints
Branch: feature/1790-prompt-content-fingerprints -> dev (reviewed at 9317f544)
Diff size: 569 lines (+196/-45)
Reviewed at: 2026-11-01 10:43 UTC
Static Checks
| Check | Status | Details |
|---|---|---|
| TypeScript | PASS | npm run typecheck |
| Lint | PASS | npm run lint |
| Backend Tests | PASS* | 1 failed |
| Frontend Tests | PASS | 5799 passed (5799) |
| Production Build | PASS | npm run build |
| npm audit | FAIL | pre-existing: 11 advisories (10 moderate, 1 high systeminformation) in the shared lockfile; package-lock.json not changed by this PR |
| Branch Target | PASS | feature/* → dev |
Docker App Test
Built from source (docker-compose.yml + docker-compose.build.yml, native arch) on an isolated project pr-review-1843; API reached through the nginx proxy (the backend publishes no host port by design).
| Check | Status |
|---|---|
Containers Healthy (postgres, redis, backend, frontend) |
PASS |
Migrations (fresh DB → Migrations complete, through 129) |
PASS |
Backend /api/health + /api/health/ready |
PASS |
Fresh-instance setup (POST /api/setup/admin → 201) + Login |
PASS |
API /api/pages, /api/search?q=…, /api/analytics/trending, /api/settings |
PASS (200) |
API /api/admin/settings, /api/admin/llm-providers, /api/notifications/count |
PASS (200) |
Frontend loads (GET / → 200) |
PASS |
| No error/fatal logs (level ≥ 50) | PASS |
E2E Browser Tests
CI recipe (E2E_CI=1 npx playwright test: built backend + vite preview, fresh kb_e2e database, provisioning via e2e/helpers/ci-setup.ts).
| Project | Status |
|---|---|
e2e helper unit tests (vitest --config e2e/vitest.config.ts) |
PASS |
| chromium + collab | PASS (68 passed (3.4m)) |
Code Review Findings
| # | Severity | File:Line | Finding |
|---|---|---|---|
| 1 | INFO | backend/src/domains/llm/services/llm-audit-hook.ts:13-18 | fingerprintPrompt builds one JSON.stringify string of the whole framed prompt before hashing. For image-bearing Generate/Improve/Ask requests the image_url parts are full base64 data URLs (up to base64Length(MAX_IMAGE_BYTES) ≈ 6.8 MB each, plus the /llm/ask retrieved-image budget), so each audit emit makes an extra multi-MB string copy synchronously on the event loop. Correct, but avoidable: feed the hasher incrementally per framed element (hash.update(...) with explicit separators), or reuse the byte digests the cache key already has (imageHash, retrieved images' sha256) as CLAUDE.md:108-109 does for the cache key. |
| 2 | INFO | backend/src/domains/llm/services/llm-audit-hook.ts:21-29 | LlmAuditEntry — named as the EE audit-hook contract in CLAUDE.md:736 and documented (llm-audit-hook.ts:57-59 area) as "adding optional fields is compatible; renaming or requiring one is not" — gains the required promptHash and loses inputText?. Adding a required field is safe for a consumer-only EE writer, and git grep of origin/dev shows no CE producer ever populated inputText (only the deleted writer helper read it), so the CE cutover is clean. An EE writer that references entry.inputText would stop compiling against the new type; verify in compendiq-ee before merge. |
| 3 | INFO | docs/ADMIN-GUIDE.md:1313-1325 | The auditor-facing compliance notes explain pivoting on prompt_hash and the pre-073 NULL-hash caveat, but not the new discontinuity introduced here (rows written before #1790 hold role/length-summary digests that are not comparable to content fingerprints). CHANGELOG.md:10-14, CLAUDE.md:114-119 and docs/architecture/10-flow-enterprise-license.md:195-198 carry the note; one sentence in the ADMIN-GUIDE caveat would keep the operator view consistent. |
Acceptance Criteria (Issue #1790)
| AC | Criterion | Status | Evidence |
|---|---|---|---|
| AC-1 | Compute a deterministic content fingerprint with unambiguous message framing at a boundary that has the prompt | PASS | fingerprintPrompt (backend/src/domains/llm/services/llm-audit-hook.ts:12-19) SHA-256s JSON.stringify of ordered [role, content] tuples, with structured parts framed as ['text', text] / ['image_url', url]; no salt, no per-process state, so it is stable across restarts. Called by every producer that holds the exact array passed to streamChat: llm-ask.ts:1142,1176 (messages), llm-generate.ts:197,213 (generateMessages), llm-improve.ts:289,305 (improveMessages), llm-diagram.ts:107, llm-quality.ts:87, llm-summarize.ts:130 (via streamSSE audit → _helpers.ts:363-372), inline-completion-client.ts:227 (inputMessages). openai-compatible-client.ts:507-511,565 sends messages unchanged, so the hashed array is the provider prompt. Framing tests: llm-audit-hook.test.ts:49-62 (boundary shift 'a'/'bcd' vs 'ab'/'cd', order swap, role swap, 'x' vs [{type:'text',text:'x'}], distinct image URLs). |
| AC-2 | Keep plaintext out of persisted audit data and out of hooks that currently receive summaries only | PASS | LlmAuditEntry carries only inputMessages[].contentLength + promptHash; inputText? removed (llm-audit-hook.ts:21-34). CE writer stores entry.promptHash verbatim and no longer hashes anything itself (llm-audit-default-writer.ts:34-62; node:crypto import removed). Tests: llm-audit-default-writer.test.ts:64-71 (to_jsonb(row) does not contain the secret, real Postgres), generate-audit.integration.test.ts:109 (rows do not contain 'private prompt'), generate-with-image.test.ts:302-308 (serialized entry contains neither the user text nor the image data URL), llm-audit-hook.test.ts:61. |
| AC-3 | Different equal-length prompt contents must produce distinct fingerprints | PASS | Unit: llm-audit-hook.test.ts:53 (system:'ab'→'ac', same lengths, different hash). Integration against a real loopback OpenAI-compatible provider + real Postgres + Redis: generate-audit.integration.test.ts:87-110 sends 'alpha private prompt' / 'bravo private prompt' (both 20 chars) through POST /api/llm/generate, asserts the provider-received [role, length] sequences are equal (line 99) while contents differ (100), and that the three persisted prompt_hash values equal an independently computed SHA-256 of the received messages with hash[0] !== hash[1] and hash[0] === hash[2] (103-108). Cache replay is excluded by deleting the prior key before each call (84-86; buildLlmCacheKey without opts equals the route's key since thinking/imageHash are undefined, llm-cache.ts:39-55). |
Story Validation (Issue #1790)
Issue intent: Audit prompt_hash for message-based LLM calls was a digest of role:length summaries, so distinct prompts of equal lengths collided; the issue asks for a deterministic content fingerprint computed where the prompt exists, without letting plaintext reach the hook or the persisted row.
Story completion: FULLY ADDRESSED
User journey: Before: two uncached Generate calls with different prompts of equal role/length shape wrote identical prompt_hash rows, so exported audit records could not distinguish them -> After: each producer hashes the exact ordered role/content tuples it hands to the provider (text and image parts framed), passes only the digest through LlmAuditEntry.promptHash, and the CE writer persists it unchanged; equal-length distinct prompts now yield distinct hashes and identical prompts repeat the same hash, verified end-to-end against a real provider and Postgres.
Gaps: None blocking. Non-blocking: fingerprint serializes full base64 image payloads before hashing (perf, finding 1); inputText? removed from the EE-facing contract — confirm the EE writer does not reference it (finding 2); ADMIN-GUIDE auditor caveat does not mention the hash discontinuity (finding 3).
Evidence: backend/src/domains/llm/services/llm-audit-hook.ts:8-34; backend/src/domains/llm/services/llm-audit-default-writer.ts:21-62; backend/src/routes/llm/llm-ask.ts:1131-1147,1165-1181; llm-generate.ts:189-220; llm-improve.ts:281-312; llm-diagram.ts:100-110; llm-quality.ts:80-90; llm-summarize.ts:123-133; backend/src/domains/llm/services/inline-completion-client.ts:221-233; backend/src/routes/llm/_helpers.ts:271,363-372; tests llm-audit-hook.test.ts:49-62, llm-audit-default-writer.test.ts:31-71, generate-audit.integration.test.ts:1-111, generate-with-image.test.ts:299-309, sse-stream-limit-gate.test.ts:145-148 (mock now spreads actual exports so fingerprintPrompt resolves); docs CHANGELOG.md:10-14, CLAUDE.md:114-119, docs/architecture/10-flow-enterprise-license.md:184,195-198. Grep of the worktree finds no inputText references and no inference emitLlmAudit/audit: producer lacking promptHash; the remaining closed vi.mock stubs of the hook module (llm-providers-shadow-guard, llm-providers-test-connection, llm-usecases-shadow-guard) exercise only admin-event routes that import just emitLlmAudit.
Suggested Actions
- [CONSIDER] backend/src/domains/llm/services/llm-audit-hook.ts:13-18 — hash incrementally per framed element (or reuse existing image byte digests) instead of
JSON.stringify-ing multi-MB base64 data URLs into a temporary string on every audit emit. - [CONSIDER] backend/src/domains/llm/services/llm-audit-hook.ts:21-29 — confirm the compendiq-ee audit writer does not reference the removed
inputTextfield before merging the contract change. - [OPTIONAL] docs/ADMIN-GUIDE.md:1313-1325 — add the "pre-#1790 hashes are length-summary digests, not comparable" caveat next to the existing pre-073 NULL-hash note.
Verdict: APPROVE
Reason: All static checks, the Docker smoke run and the full Playwright suite pass; no CRITICAL or WARNING findings; issue #1790 is fully addressed — content fingerprints are SHA-256 over framed role/content tuples, plaintext stays out of LlmAuditEntry and the persisted row, every producer is covered, and an end-to-end test proves equal-length distinct prompts hash differently. INFO items (incremental hashing of image payloads, EE inputText consumer check, ADMIN-GUIDE caveat) are optional.
Posted as a comment: GitHub rejects APPROVE/REQUEST_CHANGES from the PR author's own account, so the verdict above is advisory.
Reviewed by PR Reviewer Agent | 2026-11-01
CLAUDE.md was split into docs/claude/*.md on dev (#1877); this branch's guidance paragraph moves to the matching split file.
Fixes #1790
Part of #1827
Change
Evidence and checks
epic-1827-evidence/1790/http-provider-rows.log.npm test -w backend,npm test -w frontend,npx vitest run --config e2e/vitest.config.tspassed on the final precommit source (global-r2 manifest SHA-2564d92a43c2288a5d622941b52aa0b2ca110eb809cadfeadc5e58db52f389e9c29).npm run lintandnpm run typecheckpassed on the same manifest (global-r3). Node v24.22.1.npm run build, contracts tests within the initial rootnpm test, andnpm audit --omit=dev --audit-level=highpassed on global-r1; reused because subsequent change was test-only. The initial root test run failed three SSE success cases because its closed audit-hook mock omitted the new export. The mock now keeps actual exports; the focused SSE suite passed 30/30 before removal of an additional assertion, and the final backend suite passed after that removal. The initial failure is retained in global-r1/all-tests.log.epic-1827-evidence/1790/.Limits and rollback