diff --git a/agents/examples/EVIDENCE_TRAIL_PEEK_20260208.json b/agents/examples/EVIDENCE_TRAIL_PEEK_20260208.json new file mode 100644 index 00000000000..755650c811f --- /dev/null +++ b/agents/examples/EVIDENCE_TRAIL_PEEK_20260208.json @@ -0,0 +1,61 @@ +{ + "task_id": "EVIDENCE_TRAIL_PEEK_20260208", + "agent_id": "codex-agent", + "prompt_ref": { + "id": "evidence-trail-peek", + "version": "v1", + "sha256": "2faa11e687cfb8778aea67883185c51b9f420149e9836fb451334ab0c0f157b1", + "path": "prompts/features/evidence-trail-peek@v1.md" + }, + "declared_scope": { + "paths": [ + "apps/web/src/components/evidence/EvidenceTrailPeek.tsx", + "apps/web/src/components/CopilotPanel.tsx", + "apps/web/src/panes/GraphPane.tsx", + "apps/web/src/telemetry/evidenceTrailPeek.ts", + "apps/web/src/config/features.ts", + "server/src/routes/evidence-trail-peek.ts", + "server/src/routes/__tests__/evidence-trail-peek.test.ts", + "server/src/app.ts", + "e2e/tests/evidence-trail-peek.cy.ts", + "docs/standards/evidence-trail-peek.md", + "docs/security/data-handling/evidence-trail-peek.md", + "docs/ops/runbooks/evidence-trail-peek.md", + "docs/roadmap/STATUS.json", + "packages/decision-ledger/decision_ledger.json", + "prompts/features/evidence-trail-peek@v1.md", + "prompts/registry.yaml" + ], + "domains": ["ui", "api", "telemetry", "documentation", "governance"], + "exclusions": [ + ".github/workflows/codeql.yml", + ".github/workflows/*deploy*" + ] + }, + "allowed_operations": ["create", "edit"], + "verification_requirements": { + "tier": "B", + "artifacts": [ + "artifacts/ui/evidence-trail-peek/ui.test.report.json", + "artifacts/api/evidence-trail-peek/api.contract.report.json" + ] + }, + "debt_budget": { + "permitted": 0, + "retirement_target": 0 + }, + "success_criteria": [ + "Read-only endpoints return deterministic responses scoped by tenant.", + "UI overlay renders provenance timeline, top artifacts, and three verifiable claims.", + "Feature flag defaults off and is gated by runtime configuration.", + "Telemetry emits runtime-only metrics without PII." + ], + "stop_conditions": [ + "Missing tenant scoping for evidence queries.", + "Endpoints require new data model or writes." + ], + "dependencies": [ + "prompts/features/evidence-trail-peek@v1.md" + ], + "approvals": [] +} diff --git a/apps/web/src/components/CopilotPanel.tsx b/apps/web/src/components/CopilotPanel.tsx index b94b3fc2aab..af51143857c 100644 --- a/apps/web/src/components/CopilotPanel.tsx +++ b/apps/web/src/components/CopilotPanel.tsx @@ -9,6 +9,9 @@ import { useToast } from '@/components/ui/use-toast'; import { Play, RotateCcw, AlertTriangle, CheckCircle, Code, BookOpen } from 'lucide-react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { EvidenceTrailPeek } from '@/components/evidence/EvidenceTrailPeek'; +import { useFeatureFlag } from '@/hooks/useFeatureFlag'; +import { features } from '@/config/features'; // Define types locally if not available globally interface TranslationResult { @@ -22,6 +25,16 @@ interface TranslationResult { citations?: { id: string; source: string; url?: string; confidence: number }[]; } +const buildAnswerId = (result: TranslationResult | null) => { + if (!result) return undefined; + const astId = (result as TranslationResult & { ast?: { id?: string } }).ast?.id; + if (astId) return String(astId); + if (result.cypher) { + return `cypher-${result.cypher.slice(0, 24).replace(/\s+/g, '-')}`; + } + return undefined; +}; + export function CopilotPanel() { const [prompt, setPrompt] = useState(''); const [result, setResult] = useState(null); @@ -30,6 +43,7 @@ export function CopilotPanel() { const [loading, setLoading] = useState(false); const [activeTab, setActiveTab] = useState('prompt'); const { toast } = useToast(); + const evidenceTrailEnabled = useFeatureFlag('evidenceTrailPeek', features.evidenceTrailPeek); // jQuery ref for the action panel const actionPanelRef = useRef(null); @@ -139,6 +153,14 @@ export function CopilotPanel() { Copilot v0.9 + {evidenceTrailEnabled && result && ( + + )} {result?.isValid === false && ( Invalid Syntax )} diff --git a/apps/web/src/components/evidence/EvidenceTrailPeek.tsx b/apps/web/src/components/evidence/EvidenceTrailPeek.tsx new file mode 100644 index 00000000000..8a5fa84ddfe --- /dev/null +++ b/apps/web/src/components/evidence/EvidenceTrailPeek.tsx @@ -0,0 +1,368 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { recordEvidenceTrailPeekEvent } from '@/telemetry/evidenceTrailPeek'; + +type EvidenceTimelineItem = { + id: string; + type: 'claim' | 'evidence'; + timestamp: string | null; + label: string; + detail?: string | null; +}; + +type EvidenceArtifact = { + id: string; + artifactType: string; + location: string | null; + createdAt: string | null; + preview: string | null; +}; + +type EvidenceBadge = { + kind: 'SBOM' | 'Provenance' | 'Test' | 'Attestation'; + href: string; +}; + +type SupportingEvidence = { + id: string; + artifactType: string; + location: string | null; + badges: EvidenceBadge[]; +}; + +type RankedClaim = { + id: string; + content: string; + confidence: number; + claimType: string; + extractedAt: string | null; + verifiabilityScore: number; + badges: EvidenceBadge[]; + supporting: SupportingEvidence[]; +}; + +type EvidenceTrailPeekProps = { + answerId?: string; + nodeId?: string; + triggerLabel?: string; + triggerVariant?: 'default' | 'secondary' | 'ghost' | 'outline'; + contextLabel?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + showTrigger?: boolean; +}; + +const formatTimestamp = (value: string | null) => { + if (!value) return 'Unknown'; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return 'Unknown'; + return parsed.toLocaleString(); +}; + +const stripQuery = (value: string) => value.split('?')[0]; + +const buildQuery = (answerId?: string, nodeId?: string) => { + const params = new URLSearchParams(); + if (answerId) params.set('answer_id', answerId); + if (nodeId) params.set('node_id', nodeId); + return params.toString(); +}; + +const buildAuthHeaders = () => { + const token = localStorage.getItem('auth_token'); + if (!token) { + return { + 'Content-Type': 'application/json', + }; + } + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; +}; + +const badgeVariant = (kind: EvidenceBadge['kind']) => { + switch (kind) { + case 'SBOM': + return 'secondary'; + case 'Provenance': + return 'default'; + case 'Test': + return 'outline'; + case 'Attestation': + return 'secondary'; + default: + return 'default'; + } +}; + +export function EvidenceTrailPeek({ + answerId, + nodeId, + triggerLabel = 'Evidence trail', + triggerVariant = 'secondary', + contextLabel, + open, + onOpenChange, + showTrigger = true, +}: EvidenceTrailPeekProps) { + const [internalOpen, setInternalOpen] = useState(false); + const [timeline, setTimeline] = useState([]); + const [artifacts, setArtifacts] = useState([]); + const [claims, setClaims] = useState([]); + const [minimized, setMinimized] = useState(true); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const openTimestamp = useRef(null); + const verdictRecorded = useRef(false); + + const resolvedOpen = typeof open === 'boolean' ? open : internalOpen; + const hasScope = Boolean(answerId || nodeId); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (typeof open !== 'boolean') { + setInternalOpen(nextOpen); + } + onOpenChange?.(nextOpen); + }, + [open, onOpenChange], + ); + + const queryString = useMemo(() => buildQuery(answerId, nodeId), [answerId, nodeId]); + + useEffect(() => { + if (!resolvedOpen || !hasScope) return; + setLoading(true); + setErrorMessage(null); + openTimestamp.current = performance.now(); + verdictRecorded.current = false; + + const fetchData = async () => { + try { + const headers = buildAuthHeaders(); + const [indexRes, artifactsRes, rankingRes] = await Promise.all([ + fetch(`/api/evidence-index?${queryString}`, { headers }), + fetch(`/api/evidence-top?${queryString}`, { headers }), + fetch(`/api/claim-ranking?${queryString}`, { headers }), + ]); + + if (!indexRes.ok || !artifactsRes.ok || !rankingRes.ok) { + throw new Error('Evidence trail fetch failed'); + } + + const indexPayload = await indexRes.json(); + const artifactsPayload = await artifactsRes.json(); + const rankingPayload = await rankingRes.json(); + + setTimeline(indexPayload.timeline ?? []); + setArtifacts(artifactsPayload.artifacts ?? []); + setClaims(rankingPayload.claims ?? []); + + if (!verdictRecorded.current && openTimestamp.current) { + const elapsed = performance.now() - openTimestamp.current; + const claimCount = (rankingPayload.claims ?? []).length; + await recordEvidenceTrailPeekEvent('time_to_first_confident_verdict_ms', { + elapsedMs: Math.round(elapsed), + claimCount, + }); + verdictRecorded.current = true; + } + + await recordEvidenceTrailPeekEvent('answer_surface_claim_count', { + claimCount: (rankingPayload.claims ?? []).length, + }); + } catch (error) { + setErrorMessage('Evidence trail data is temporarily unavailable.'); + await recordEvidenceTrailPeekEvent('verification_error_rate', { + errorType: 'fetch_failed', + }); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [resolvedOpen, hasScope, queryString]); + + useEffect(() => { + if (resolvedOpen && hasScope) { + recordEvidenceTrailPeekEvent('evidence_trail_peek_opened', { + answerId: answerId ?? undefined, + nodeId: nodeId ?? undefined, + }); + } + }, [resolvedOpen, hasScope, answerId, nodeId]); + + const minimizedClaims = useMemo(() => claims.slice(0, 3), [claims]); + + return ( + + {showTrigger && ( + + + + )} + + + Evidence-Trail Peek + +
+
+
+

Provenance timeline & artifact summary

+ {contextLabel &&

{contextLabel}

} +
+ +
+ + {loading &&
Loading evidence trail...
} + {errorMessage &&
{errorMessage}
} + +
+
+

Provenance timeline

+ +
    + {timeline.map((item) => ( +
  • +
    + {item.label} + {formatTimestamp(item.timestamp)} +
    + {item.detail &&
    {item.detail}
    } +
  • + ))} + {timeline.length === 0 && !loading && ( +
  • No timeline events found.
  • + )} +
+
+
+ +
+

Top artifacts

+ + + +
+
+ +
+

Answer-Surface Minimizer

+
+

+ {minimized ? 'Showing the 3 most-verifiable claims.' : 'Showing claim details with deterministic evidence badges.'} +

+
+ {minimizedClaims.map((claim) => ( +
+
+ + {claim.content} + + + {Math.round(claim.confidence * 100)}% confidence + +
+ + {!minimized && claim.supporting.length > 0 && ( +
+
Supporting evidence
+ +
+ )} +
+ ))} + {minimizedClaims.length === 0 && !loading && ( +
No verifiable claims available.
+ )} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/config/features.ts b/apps/web/src/config/features.ts new file mode 100644 index 00000000000..c0ac24daf90 --- /dev/null +++ b/apps/web/src/config/features.ts @@ -0,0 +1,18 @@ +type RuntimeFeatureFlags = { + evidenceTrailPeek?: boolean; +}; + +const getRuntimeFlags = () => + (globalThis as typeof globalThis & { __SUMMIT_FEATURE_FLAGS__?: RuntimeFeatureFlags }) + .__SUMMIT_FEATURE_FLAGS__; + +const getEnvFlag = (key: string) => { + if (typeof import.meta !== 'undefined' && import.meta.env) { + return import.meta.env[key] === 'true'; + } + return false; +}; + +export const features = { + evidenceTrailPeek: getRuntimeFlags()?.evidenceTrailPeek ?? getEnvFlag('VITE_FEATURE_EVIDENCE_TRAIL_PEEK'), +} as const; diff --git a/apps/web/src/panes/GraphPane.tsx b/apps/web/src/panes/GraphPane.tsx index 9744476614b..32ce3547ff3 100644 --- a/apps/web/src/panes/GraphPane.tsx +++ b/apps/web/src/panes/GraphPane.tsx @@ -3,6 +3,9 @@ import React, { useRef, useEffect, useCallback, useState, useMemo } from 'react'; import ForceGraph2D, { ForceGraphMethods } from 'react-force-graph-2d'; import { useWorkspaceStore } from '../store/workspaceStore'; +import { EvidenceTrailPeek } from '@/components/evidence/EvidenceTrailPeek'; +import { useFeatureFlag } from '@/hooks/useFeatureFlag'; +import { features } from '@/config/features'; interface GraphWrapperProps { children: (width: number, height: number) => React.ReactNode; @@ -39,6 +42,16 @@ const GraphWrapper = ({ children }: GraphWrapperProps) => { export const GraphPane = () => { const { entities, links, selectedEntityIds, selectEntity, isSyncing, syncError, retrySync } = useWorkspaceStore(); const graphRef = useRef(undefined); + const evidenceTrailEnabled = useFeatureFlag('evidenceTrailPeek', features.evidenceTrailPeek); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; nodeId: string } | null>(null); + const [evidencePeekOpen, setEvidencePeekOpen] = useState(false); + const [evidenceNodeId, setEvidenceNodeId] = useState(null); + + useEffect(() => { + if (!evidencePeekOpen) { + setEvidenceNodeId(null); + } + }, [evidencePeekOpen]); // Syncing indicator logic (only show if lag > 250ms) const [showSyncing, setShowSyncing] = useState(false); @@ -67,6 +80,20 @@ export const GraphPane = () => { selectEntity(node.id); }, [selectEntity]); + const handleNodeRightClick = useCallback( + (node: any, event?: MouseEvent) => { + if (!evidenceTrailEnabled) return; + if (event?.preventDefault) { + event.preventDefault(); + } + const bounds = (event?.currentTarget as HTMLElement | null)?.getBoundingClientRect(); + const x = bounds ? event.clientX - bounds.left : event?.clientX ?? 0; + const y = bounds ? event.clientY - bounds.top : event?.clientY ?? 0; + setContextMenu({ x, y, nodeId: node.id }); + }, + [evidenceTrailEnabled], + ); + // Effect to highlight/zoom on selection useEffect(() => { if (selectedEntityIds.length === 1 && graphRef.current) { @@ -77,7 +104,10 @@ export const GraphPane = () => { }, [selectedEntityIds]); return ( -
+
contextMenu && setContextMenu(null)} + >
NETWORK ANALYSIS
@@ -103,6 +133,35 @@ export const GraphPane = () => {
)} + {evidenceTrailEnabled && selectedEntityIds.length === 1 && ( +
+ +
+ )} + + {contextMenu && ( +
event.stopPropagation()} + > + +
+ )} + {(width, height) => ( { linkColor={() => '#475569'} backgroundColor="#0f172a" onNodeClick={handleNodeClick} + onNodeRightClick={handleNodeRightClick} cooldownTicks={100} linkDirectionalArrowLength={3.5} linkDirectionalArrowRelPos={1} /> )} + + {evidenceTrailEnabled && evidenceNodeId && ( + + )}
); }; diff --git a/apps/web/src/telemetry/evidenceTrailPeek.ts b/apps/web/src/telemetry/evidenceTrailPeek.ts new file mode 100644 index 00000000000..96e27eadafe --- /dev/null +++ b/apps/web/src/telemetry/evidenceTrailPeek.ts @@ -0,0 +1,38 @@ +import { getTelemetryContext } from './metrics'; + +type EvidenceTrailPeekEvent = + | 'evidence_trail_peek_opened' + | 'time_to_first_confident_verdict_ms' + | 'answer_surface_claim_count' + | 'verification_error_rate' + | 'badge_click_through' + | 'artifact_click_through'; + +export const recordEvidenceTrailPeekEvent = async ( + event: EvidenceTrailPeekEvent, + payload: Record, +) => { + try { + const context = getTelemetryContext(); + await fetch('/api/monitoring/telemetry/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-correlation-id': context.sessionId, + }, + body: JSON.stringify({ + event, + labels: { + feature: 'evidence-trail-peek', + }, + payload, + context: { + sessionId: context.sessionId, + deviceId: context.deviceId, + }, + }), + }); + } catch (error) { + console.warn('Evidence trail telemetry dropped', error); + } +}; diff --git a/docs/ops/runbooks/evidence-trail-peek.md b/docs/ops/runbooks/evidence-trail-peek.md new file mode 100644 index 00000000000..65bc07e3df1 --- /dev/null +++ b/docs/ops/runbooks/evidence-trail-peek.md @@ -0,0 +1,36 @@ +# Evidence-Trail Peek Runbook + +## Summary + +Evidence-Trail Peek is a feature-flagged UI overlay that surfaces provenance timelines, top artifacts, and minimized claims. It depends on read-only APIs and does not modify data. + +## Enable in Staging + +1. Set feature flag `evidenceTrailPeek` to **true** in the feature flag service, or set `VITE_FEATURE_EVIDENCE_TRAIL_PEEK=true` for the web build. +2. Reload the web app. +3. Validate the overlay in `/copilot` and graph panes. + +## Health Checks + +- `GET /api/evidence-index?answer_id=` +- `GET /api/evidence-top?answer_id=` +- `GET /api/claim-ranking?answer_id=` + +Expected responses are `200` with empty arrays when no data exists. + +## Rollback + +1. Flip `evidenceTrailPeek` flag to **false** (or unset `VITE_FEATURE_EVIDENCE_TRAIL_PEEK`). +2. Reload UI assets. + +Endpoints can remain deployed (read-only). + +## Observability + +Monitor telemetry events: + +- `time_to_first_confident_verdict_ms` +- `verification_error_rate` +- `answer_surface_claim_count` +- `badge_click_through` +- `artifact_click_through` diff --git a/docs/roadmap/STATUS.json b/docs/roadmap/STATUS.json index 281d973af71..0194e35aaff 100644 --- a/docs/roadmap/STATUS.json +++ b/docs/roadmap/STATUS.json @@ -1,6 +1,6 @@ { - "last_updated": "2026-02-07T00:00:00Z", - "revision_note": "Added Summit PR Stack Sequencer skill scaffolding.", + "last_updated": "2026-02-08T00:00:00Z", + "revision_note": "Added Evidence-Trail Peek overlay, API, telemetry, and docs scope.", "initiatives": [ { "id": "adenhq-hive-subsumption-lane1", @@ -8,6 +8,12 @@ "owner": "codex", "notes": "Scaffold adenhq/hive subsumption bundle, required check mapping, and evidence-first lane-1 posture." }, + { + "id": "evidence-trail-peek", + "status": "in_progress", + "owner": "codex", + "notes": "Read-only evidence trail peek overlay, API endpoints, telemetry, and runbook documentation." + }, { "id": "B", "name": "Federation + Ingestion Mesh", @@ -200,7 +206,7 @@ "partial": 2, "incomplete": 0, "not_started": 5, - "total": 17, + "total": 18, "ga_blockers": [] } } diff --git a/docs/security/data-handling/evidence-trail-peek.md b/docs/security/data-handling/evidence-trail-peek.md new file mode 100644 index 00000000000..e1912165d98 --- /dev/null +++ b/docs/security/data-handling/evidence-trail-peek.md @@ -0,0 +1,27 @@ +# Evidence-Trail Peek Data Handling + +## Classification + +Evidence-Trail Peek is **read-only** and does not create or persist new evidence. It renders metadata already stored under existing `evidence_id` joins. + +## Never Log + +- Raw evidence bodies +- Full URLs containing query strings or tokens +- User-entered prompt text + +Telemetry only includes identifiers (`answer_id`, `node_id`, `artifact_id`) and aggregate counts. Query strings are stripped before telemetry submission. + +## Retention + +Telemetry events follow existing Summit retention policies. Evidence-Trail Peek adds no new deterministic timestamps or storage. + +## Access Control + +Endpoints require existing authentication and tenant scoping. The overlay does not bypass role or policy checks. + +## MAESTRO Alignment + +- **MAESTRO Layers**: Data, Observability, Security +- **Threats Considered**: leakage via telemetry, evidence spoofing +- **Mitigations**: tenant scoping, read-only endpoints, telemetry redaction diff --git a/docs/standards/evidence-trail-peek.md b/docs/standards/evidence-trail-peek.md new file mode 100644 index 00000000000..fef7472ff44 --- /dev/null +++ b/docs/standards/evidence-trail-peek.md @@ -0,0 +1,136 @@ +# Evidence-Trail Peek Standard + +## Summit Readiness Assertion + +This change is aligned with the Summit Readiness Assertion and implements evidence-first UX for auditable answers. Deferred pending no additional readiness gates. + +## Purpose + +Evidence-Trail Peek provides a lightweight overlay that surfaces provenance timeline events, top-N evidence artifacts, and a minimized set of the three most-verifiable claims with deterministic evidence badges. The overlay is read-only and uses existing `evidence_id` relationships. + +## Scope + +- **UI overlay** attached to answer surfaces and graph nodes. +- **Read-only REST endpoints**: + - `GET /api/evidence-index` + - `GET /api/evidence-top` + - `GET /api/claim-ranking` +- **Feature flag**: `features.evidenceTrailPeek` (default OFF). +- **Telemetry**: runtime-only metrics; no new persistent data. + +## Data Contracts + +### Evidence Index (`GET /api/evidence-index`) + +Query parameters: `answer_id` or `node_id`. + +Response: + +```json +{ + "timeline": [ + { + "id": "claim-123", + "type": "claim", + "timestamp": "2026-02-07T10:00:00Z", + "label": "Claim text", + "detail": "factual" + } + ], + "claimCount": 1, + "evidenceCount": 1 +} +``` + +### Evidence Top (`GET /api/evidence-top`) + +Query parameters: `answer_id` or `node_id`, optional `limit`. + +Response: + +```json +{ + "artifacts": [ + { + "id": "evidence-1", + "artifactType": "sbom", + "location": "s3://...", + "createdAt": "2026-02-07T11:00:00Z", + "preview": "preview text" + } + ] +} +``` + +### Claim Ranking (`GET /api/claim-ranking`) + +Query parameters: `answer_id` or `node_id`. + +Response: + +```json +{ + "claims": [ + { + "id": "claim-1", + "content": "Claim text", + "confidence": 0.9, + "claimType": "factual", + "extractedAt": "2026-02-07T10:00:00Z", + "verifiabilityScore": 1.0, + "badges": [ + { "kind": "SBOM", "href": "/api/provenance-beta/evidence/evidence-1" } + ], + "supporting": [] + } + ] +} +``` + +Deterministic badges are limited to `SBOM`, `Provenance`, `Test`, and `Attestation`. Claims without at least one deterministic badge are excluded. +Badge links reuse existing provenance evidence endpoints (`/api/provenance-beta/evidence/:id`). + +## Feature Flag + +`features.evidenceTrailPeek` is enabled via runtime feature flags or `VITE_FEATURE_EVIDENCE_TRAIL_PEEK=true`. Default OFF. + +## Import/Export Matrix + +**Imports** + +- `answer_id`, optional `node_id` +- `evidence_id` relationships via `claim_evidence_links` +- Evidence metadata from `evidence_artifacts` + +**Exports** + +- Overlay UI with provenance timeline, top artifacts, and minimized claims +- Telemetry events (runtime only; no raw evidence bodies) + +## Telemetry (Runtime Only) + +- `time_to_first_confident_verdict_ms` +- `verification_error_rate` (derived from failed fetch attempts) +- `answer_surface_claim_count` +- `badge_click_through` +- `artifact_click_through` + +Payloads do **not** include raw evidence bodies or URL query strings. + +## MAESTRO Alignment + +- **MAESTRO Layers**: Foundation, Data, Agents, Tools, Observability, Security +- **Threats Considered**: + - Evidence spoofing via arbitrary URLs + - UI injection via untrusted titles + - Data leakage through telemetry payloads +- **Mitigations**: + - Server-side scoping by tenant and read-only enforcement + - Client rendering without `dangerouslySetInnerHTML` + - Telemetry strips query strings and excludes evidence bodies + +## Non-Goals + +- No changes to provenance storage or evidence generation. +- No new data model or persistent timestamps. +- No UI redesign outside the overlay. diff --git a/e2e/tests/evidence-trail-peek.cy.ts b/e2e/tests/evidence-trail-peek.cy.ts new file mode 100644 index 00000000000..13bc45f9576 --- /dev/null +++ b/e2e/tests/evidence-trail-peek.cy.ts @@ -0,0 +1,101 @@ +describe('Evidence Trail Peek', () => { + it('shows minimized view with exactly 3 verifiable claims', () => { + cy.intercept('POST', '/api/nl2cypher', { + statusCode: 200, + body: { + ast: { id: 'answer-1' }, + cypher: 'MATCH (n) RETURN n LIMIT 5', + rationale: [], + estimatedCost: 1, + isValid: true, + }, + }).as('nl2cypher'); + + cy.intercept('GET', '/api/evidence-index*', { + statusCode: 200, + body: { + timeline: [ + { + id: 'claim-1', + type: 'claim', + timestamp: '2026-02-07T10:00:00Z', + label: 'Claim 1', + }, + ], + claimCount: 3, + evidenceCount: 3, + }, + }).as('evidenceIndex'); + + cy.intercept('GET', '/api/evidence-top*', { + statusCode: 200, + body: { + artifacts: [ + { + id: 'artifact-1', + artifactType: 'sbom', + location: 'https://example.com/evidence/1', + createdAt: '2026-02-07T10:00:00Z', + preview: 'Preview', + }, + ], + }, + }).as('evidenceTop'); + + cy.intercept('GET', '/api/claim-ranking*', { + statusCode: 200, + body: { + claims: [ + { + id: 'claim-1', + content: 'Claim 1', + confidence: 0.9, + claimType: 'factual', + extractedAt: null, + verifiabilityScore: 1.0, + badges: [{ kind: 'SBOM', href: '/api/provenance-beta/evidence/evidence-1' }], + supporting: [], + }, + { + id: 'claim-2', + content: 'Claim 2', + confidence: 0.85, + claimType: 'factual', + extractedAt: null, + verifiabilityScore: 0.95, + badges: [{ kind: 'Attestation', href: '/api/provenance-beta/evidence/evidence-2' }], + supporting: [], + }, + { + id: 'claim-3', + content: 'Claim 3', + confidence: 0.8, + claimType: 'factual', + extractedAt: null, + verifiabilityScore: 0.9, + badges: [{ kind: 'Test', href: '/api/provenance-beta/evidence/evidence-3' }], + supporting: [], + }, + ], + }, + }).as('claimRanking'); + + cy.intercept('POST', '/api/monitoring/telemetry/events', { statusCode: 200 }).as('telemetry'); + + cy.visit('/copilot', { + onBeforeLoad(win) { + (win as any).__SUMMIT_FEATURE_FLAGS__ = { evidenceTrailPeek: true }; + }, + }); + + cy.get('textarea').first().type('What links A and B?'); + cy.contains('button', 'Generate Cypher').click(); + cy.wait('@nl2cypher'); + + cy.get('[data-testid="evidence-trail-trigger"]').click(); + cy.wait(['@evidenceIndex', '@evidenceTop', '@claimRanking']); + + cy.get('[data-testid="evidence-trail-claim"]').should('have.length', 3); + cy.get('[data-testid="evidence-trail-badge"]').should('have.length', 3); + }); +}); diff --git a/packages/decision-ledger/decision_ledger.json b/packages/decision-ledger/decision_ledger.json index bb6140923c7..8a3caddc7b0 100644 --- a/packages/decision-ledger/decision_ledger.json +++ b/packages/decision-ledger/decision_ledger.json @@ -253,5 +253,22 @@ }, "reasoning": "Pinned entities to 4.5.x to restore parse5 Jest compatibility while maintaining scoped dependency behavior. Rollback: revert the commit that adds the override and lockfile update.", "reverted": false + }, + { + "id": "evidence-trail-peek-20260208", + "timestamp": "2026-02-08T00:00:00Z", + "agentId": "codex-agent", + "policyVersion": "1.0.0", + "inputHash": "eyJjaGFuZ2UiOiJldmlkZW5jZS10cmFpbC1wZWVrIG92ZXJsYXksIHJlYWQtb25seSBBUElzLCB0ZWxlbWV0cnksIGUyZSwgYW5kIGRvY3MiLCJmaWxlcyI6WyJzZXJ2ZXIvc3JjL3JvdXRlcy9ldmlkZW5jZS10cmFpbC1wZWVrLnRzIiwic2VydmVyL3NyYy9yb3V0ZXMvX190ZXN0c19fL2V2aWRlbmNlLXRyYWlsLXBlZWsudGVzdC50cyIsInNlcnZlci9zcmMvYXBwLnRzIiwiYXBwcy93ZWIvc3JjL2NvbXBvbmVudHMvZXZpZGVuY2UvRXZpZGVuY2VUcmFpbFBlZWsudHN4IiwiYXBwcy93ZWIvc3JjL3BhbmVzL0dyYXBoUGFuZS50c3giLCJhcHBzL3dlYi9zcmMvY29tcG9uZW50cy9Db3BpbG90UGFuZWwudHN4IiwiYXBwcy93ZWIvc3JjL3RlbGVtZXRyeS9ldmlkZW5jZVRyYWlsUGVlay50cyIsImFwcHMvd2ViL3NyYy9jb25maWcvZmVhdHVyZXMudHMiLCJlMmUvdGVzdHMvZXZpZGVuY2UtdHJhaWwtcGVlay5jeS50cyIsImRvY3Mvc3RhbmRhcmRzL2V2aWRlbmNlLXRyYWlsLXBlZWsubWQiLCJkb2NzL3NlY3VyaXR5L2RhdGEtaGFuZGxpbmcvZXZpZGVuY2UtdHJhaWwtcGVlay5tZCIsImRvY3Mvb3BzL3J1bmJvb2tzL2V2aWRlbmNlLXRyYWlsLXBlZWsubWQiLCJkb2NzL3JvYWRtYXAvU1RBVFVTLmpzb24iLCJwYWNrYWdlcy9kZWNpc2lvbi1sZWRnZXIvZGVjaXNpb25fbGVkZ2VyLmpzb24iLCJwcm9tcHRzL2ZlYXR1cmVzL2V2aWRlbmNlLXRyYWlsLXBlZWtAdjEubWQiLCJwcm9tcHRzL3JlZ2lzdHJ5LnlhbWwiLCJhZ2VudHMvZXhhbXBsZXMvRVZJREVOQ0VfVFJBSUxfUEVFS18yMDI2MDIwOC5qc29uIl19", + "decision": { + "action": "implement_evidence_trail_peek", + "rule": { + "condition": "auditable overlay and read-only evidence trail endpoints required", + "action": "ship_evidence_trail_peek", + "max_cost": 0 + } + }, + "reasoning": "Implemented Evidence-Trail Peek overlay, read-only endpoints, telemetry, and runbook/standards docs with default-off feature flag. Rollback: revert the evidence-trail-peek commit set and disable the feature flag.", + "reverted": false } ] diff --git a/prompts/features/evidence-trail-peek@v1.md b/prompts/features/evidence-trail-peek@v1.md new file mode 100644 index 00000000000..ca6f924d746 --- /dev/null +++ b/prompts/features/evidence-trail-peek@v1.md @@ -0,0 +1,38 @@ +# Evidence-Trail Peek Overlay + API Prompt + +## Objective + +Deliver the Evidence-Trail Peek overlay and associated read-only APIs for Summit answers and graph nodes. + +## Requirements + +1. Add read-only endpoints: + - `GET /api/evidence-index` + - `GET /api/evidence-top` + - `GET /api/claim-ranking` +2. Enforce server-side ranking: + - Cap results to three claims. + - Require at least one deterministic badge (`SBOM`, `Provenance`, `Test`, `Attestation`). +3. Implement UI overlay with: + - Provenance timeline + - Top-N artifacts + - Answer-Surface Minimizer (3 claims with deterministic badges) +4. Gate with `features.evidenceTrailPeek` (default OFF). +5. Add telemetry events (runtime only, no PII, no raw evidence bodies). +6. Add Cypress E2E validation for minimized view. +7. Update standards, data-handling, and runbook docs. +8. Update `docs/roadmap/STATUS.json` and DecisionLedger entry. + +## Guardrails + +- No new data model or persistent timestamps. +- No write operations; endpoints are read-only. +- Reuse existing `evidence_id` joins and existing evidence endpoints for badge links. + +## Deliverables + +- UI component + mount points +- API routes + contract tests +- Telemetry helper +- E2E test +- Documentation updates diff --git a/prompts/registry.yaml b/prompts/registry.yaml index 4993c520476..4d3fa02478f 100644 --- a/prompts/registry.yaml +++ b/prompts/registry.yaml @@ -50,6 +50,45 @@ prompts: allowed_operations: - create - edit + - id: evidence-trail-peek + version: v1 + path: prompts/features/evidence-trail-peek@v1.md + sha256: 2faa11e687cfb8778aea67883185c51b9f420149e9836fb451334ab0c0f157b1 + description: Evidence-Trail Peek overlay with read-only APIs, telemetry, E2E coverage, and governance docs. + scope: + paths: + - apps/web/src/components/evidence/EvidenceTrailPeek.tsx + - apps/web/src/components/CopilotPanel.tsx + - apps/web/src/panes/GraphPane.tsx + - apps/web/src/telemetry/evidenceTrailPeek.ts + - apps/web/src/config/features.ts + - server/src/routes/evidence-trail-peek.ts + - server/src/routes/__tests__/evidence-trail-peek.test.ts + - server/src/app.ts + - e2e/tests/evidence-trail-peek.cy.ts + - docs/standards/evidence-trail-peek.md + - docs/security/data-handling/evidence-trail-peek.md + - docs/ops/runbooks/evidence-trail-peek.md + - docs/roadmap/STATUS.json + - packages/decision-ledger/decision_ledger.json + - prompts/features/evidence-trail-peek@v1.md + - prompts/registry.yaml + - agents/examples/EVIDENCE_TRAIL_PEEK_20260208.json + domains: + - ui + - api + - governance + - telemetry + - documentation + verification: + tiers_required: + - B + debt_budget: + permitted: 0 + retirement_target: 0 + allowed_operations: + - create + - edit - id: semhl-required-checks version: v1 path: prompts/ci/semhl-required-checks@v1.md diff --git a/server/src/app.ts b/server/src/app.ts index d3796ef74de..1a103ba9eae 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -123,6 +123,7 @@ import masteryRouter from './routes/mastery.js'; import cryptoIntelligenceRouter from './routes/crypto-intelligence.js'; import demoRouter from './routes/demo.js'; import claimsRouter from './routes/claims.js'; +import evidenceTrailPeekRouter from './routes/evidence-trail-peek.js'; import opsRouter from './routes/ops.js'; import featureFlagsRouter from './routes/feature-flags.js'; import mlReviewRouter from './routes/ml_review.js'; @@ -477,6 +478,7 @@ export const createApp = async () => { app.use('/api/crypto-intelligence', cryptoIntelligenceRouter); app.use('/api/demo', demoRouter); app.use('/api/claims', claimsRouter); + app.use('/api', evidenceTrailPeekRouter); app.use('/api/feature-flags', featureFlagsRouter); app.use('/api/ml-reviews', mlReviewRouter); app.use('/api/admin/flags', adminFlagsRouter); diff --git a/server/src/routes/__tests__/evidence-trail-peek.test.ts b/server/src/routes/__tests__/evidence-trail-peek.test.ts new file mode 100644 index 00000000000..6aaf24a40cd --- /dev/null +++ b/server/src/routes/__tests__/evidence-trail-peek.test.ts @@ -0,0 +1,125 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import express from 'express'; +import request from 'supertest'; +import evidenceTrailPeekRouter from '../evidence-trail-peek.js'; +import { pg } from '../../db/pg.js'; + +jest.mock('../../middleware/auth.js', () => ({ + ensureAuthenticated: (req: any, _res: any, next: any) => { + req.user = { id: 'test-user', tenantId: 'tenant-1', role: 'ANALYST' }; + next(); + }, +})); + +jest.mock('../../db/pg.js', () => ({ + pg: { + readMany: jest.fn(), + }, +})); + +const readManyMock = pg.readMany as unknown as jest.Mock; + +describe('Evidence Trail Peek API', () => { + let app: express.Application; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/api', evidenceTrailPeekRouter); + readManyMock.mockReset(); + }); + + it('rejects requests without scope parameters', async () => { + const response = await request(app).get('/api/evidence-index'); + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'answer_id or node_id required' }); + }); + + it('returns evidence index timeline and counts', async () => { + readManyMock.mockImplementation((query: string) => { + if (query.includes('FROM claims_registry')) { + return [ + { + id: 'claim-1', + content: 'Claim one', + confidence: 0.9, + claim_type: 'factual', + extracted_at: '2026-02-07T10:00:00Z', + created_at: '2026-02-07T10:00:00Z', + }, + ]; + } + if (query.includes('FROM evidence_artifacts')) { + return [ + { + id: 'evidence-1', + artifact_type: 'sbom', + storage_uri: 's3://evidence/1', + content_preview: 'preview', + created_at: '2026-02-07T11:00:00Z', + }, + ]; + } + return []; + }); + + const response = await request(app).get('/api/evidence-index?answer_id=answer-1'); + expect(response.status).toBe(200); + expect(response.body.timeline).toHaveLength(2); + expect(response.body.claimCount).toBe(1); + expect(response.body.evidenceCount).toBe(1); + }); + + it('returns top artifacts with configured limit', async () => { + readManyMock.mockImplementation((query: string) => { + if (query.includes('FROM claims_registry')) { + return [{ id: 'claim-1' }, { id: 'claim-2' }]; + } + if (query.includes('FROM evidence_artifacts')) { + return [ + { + id: 'evidence-1', + artifact_type: 'attestation', + storage_uri: 's3://evidence/1', + content_preview: null, + created_at: '2026-02-07T11:00:00Z', + }, + ]; + } + return []; + }); + + const response = await request(app).get('/api/evidence-top?node_id=node-1&limit=1'); + expect(response.status).toBe(200); + expect(response.body.artifacts).toHaveLength(1); + expect(response.body.artifacts[0].artifactType).toBe('attestation'); + }); + + it('returns only ranked claims with deterministic badges', async () => { + readManyMock.mockImplementation((query: string) => { + if (query.includes('FROM claims_registry')) { + return [ + { id: 'claim-1', content: 'Claim 1', confidence: 0.9, claim_type: 'factual', extracted_at: null }, + { id: 'claim-2', content: 'Claim 2', confidence: 0.8, claim_type: 'factual', extracted_at: null }, + { id: 'claim-3', content: 'Claim 3', confidence: 0.7, claim_type: 'factual', extracted_at: null }, + { id: 'claim-4', content: 'Claim 4', confidence: 0.95, claim_type: 'factual', extracted_at: null }, + ]; + } + if (query.includes('FROM claim_evidence_links')) { + return [ + { claim_id: 'claim-1', id: 'evidence-1', artifact_type: 'sbom', storage_uri: 's3://evidence/1' }, + { claim_id: 'claim-2', id: 'evidence-2', artifact_type: 'attestation', storage_uri: 's3://evidence/2' }, + { claim_id: 'claim-2', id: 'evidence-3', artifact_type: 'test', storage_uri: 's3://evidence/3' }, + { claim_id: 'claim-3', id: 'evidence-4', artifact_type: 'provenance', storage_uri: 's3://evidence/4' }, + { claim_id: 'claim-4', id: 'evidence-5', artifact_type: 'log', storage_uri: 's3://evidence/5' }, + ]; + } + return []; + }); + + const response = await request(app).get('/api/claim-ranking?answer_id=answer-1'); + expect(response.status).toBe(200); + expect(response.body.claims).toHaveLength(3); + expect(response.body.claims.every((claim: any) => claim.badges.length > 0)).toBe(true); + }); +}); diff --git a/server/src/routes/evidence-trail-peek.ts b/server/src/routes/evidence-trail-peek.ts new file mode 100644 index 00000000000..16b18a31767 --- /dev/null +++ b/server/src/routes/evidence-trail-peek.ts @@ -0,0 +1,304 @@ +import { Router } from 'express'; +import { ensureAuthenticated } from '../middleware/auth.js'; +import { pg } from '../db/pg.js'; + +type EvidenceTimelineItem = { + id: string; + type: 'claim' | 'evidence'; + timestamp: string | null; + label: string; + detail?: string | null; +}; + +type EvidenceArtifact = { + id: string; + artifactType: string; + location: string | null; + createdAt: string | null; + preview: string | null; +}; + +type RankedClaim = { + id: string; + content: string; + confidence: number; + claimType: string; + extractedAt: string | null; + verifiabilityScore: number; + badges: EvidenceBadge[]; + supporting: SupportingEvidence[]; +}; + +type EvidenceBadge = { + kind: 'SBOM' | 'Provenance' | 'Test' | 'Attestation'; + href: string; +}; + +type SupportingEvidence = { + id: string; + artifactType: string; + location: string | null; + badges: EvidenceBadge[]; +}; + +const router = Router(); + +const DEFAULT_TIMELINE_LIMIT = 50; +const DEFAULT_TOP_ARTIFACTS = 5; +const MAX_TOP_ARTIFACTS = 20; + +const DETERMINISTIC_BADGE_KINDS = ['SBOM', 'Provenance', 'Test', 'Attestation'] as const; +const DETERMINISTIC_BADGES = new Set(DETERMINISTIC_BADGE_KINDS); + +const badgeForArtifactType = (artifactType: string | null): EvidenceBadge['kind'] | null => { + if (!artifactType) return null; + const normalized = artifactType.toLowerCase(); + if (normalized.includes('sbom')) return 'SBOM'; + if (normalized.includes('provenance')) return 'Provenance'; + if (normalized.includes('test')) return 'Test'; + if (normalized.includes('attestation')) return 'Attestation'; + return null; +}; + +const clampLimit = (value: number, fallback: number, max: number) => { + if (!Number.isFinite(value) || value <= 0) { + return fallback; + } + return Math.min(value, max); +}; + +const getTenantId = (req: any) => req.user?.tenantId || req.user?.tenant_id || 'unknown'; + +const ensureScope = (answerId?: string | null, nodeId?: string | null) => { + if (!answerId && !nodeId) { + return { + ok: false, + error: 'answer_id or node_id required', + } as const; + } + return { ok: true } as const; +}; + +const fetchScopedClaims = async ( + tenantId: string, + answerId: string | null, + nodeId: string | null, + limit = DEFAULT_TIMELINE_LIMIT, +) => { + const query = ` + SELECT id, content, confidence, claim_type, extracted_at, created_at + FROM claims_registry + WHERE tenant_id = $1 + AND ( + ($2::text IS NOT NULL AND (investigation_id = $2 OR id = $2)) + OR ($3::text IS NOT NULL AND (source_id = $3 OR id = $3)) + ) + ORDER BY extracted_at DESC NULLS LAST, created_at DESC, id ASC + LIMIT $4 + `; + return pg.readMany(query, [tenantId, answerId, nodeId, limit], { tenantId }); +}; + +const fetchEvidenceForClaims = async (tenantId: string, claimIds: string[], limit: number) => { + if (claimIds.length === 0) return []; + const query = ` + SELECT ea.id, + ea.artifact_type, + ea.storage_uri, + ea.content_preview, + ea.created_at + FROM evidence_artifacts ea + JOIN claim_evidence_links cel ON cel.evidence_id = ea.id + WHERE cel.claim_id = ANY($1) + AND ea.tenant_id = $2 + AND cel.tenant_id = $2 + ORDER BY ea.created_at DESC NULLS LAST, ea.id ASC + LIMIT $3 + `; + return pg.readMany(query, [claimIds, tenantId, limit], { tenantId }); +}; + +const fetchEvidenceLinks = async (tenantId: string, claimIds: string[]) => { + if (claimIds.length === 0) return []; + const query = ` + SELECT cel.claim_id, + ea.id, + ea.artifact_type, + ea.storage_uri, + ea.content_preview, + ea.created_at, + cel.created_at AS linked_at + FROM claim_evidence_links cel + JOIN evidence_artifacts ea ON ea.id = cel.evidence_id + WHERE cel.claim_id = ANY($1) + AND cel.tenant_id = $2 + AND ea.tenant_id = $2 + ORDER BY cel.created_at DESC NULLS LAST, ea.created_at DESC NULLS LAST, ea.id ASC + `; + return pg.readMany(query, [claimIds, tenantId], { tenantId }); +}; + +const buildBadgeLinks = (tenantId: string, evidenceId: string, artifactType: string | null) => { + const badgeKind = badgeForArtifactType(artifactType); + if (!badgeKind || !DETERMINISTIC_BADGES.has(badgeKind)) { + return [] as EvidenceBadge[]; + } + + const href = `/api/provenance-beta/evidence/${evidenceId}?tenant=${encodeURIComponent(tenantId)}`; + return [{ kind: badgeKind, href }]; +}; + +const buildTimeline = (claims: any[], evidence: any[]): EvidenceTimelineItem[] => { + const claimItems = claims.map((claim: any) => ({ + id: claim.id, + type: 'claim' as const, + timestamp: claim.extracted_at ? new Date(claim.extracted_at).toISOString() : claim.created_at ? new Date(claim.created_at).toISOString() : null, + label: claim.content?.slice(0, 120) || 'Claim recorded', + detail: claim.claim_type || null, + })); + + const evidenceItems = evidence.map((artifact: any) => ({ + id: artifact.id, + type: 'evidence' as const, + timestamp: artifact.created_at ? new Date(artifact.created_at).toISOString() : null, + label: artifact.artifact_type || 'Evidence artifact', + detail: artifact.content_preview || null, + })); + + return [...claimItems, ...evidenceItems].sort((a, b) => { + const aTime = a.timestamp ? Date.parse(a.timestamp) : 0; + const bTime = b.timestamp ? Date.parse(b.timestamp) : 0; + if (aTime !== bTime) return bTime - aTime; + return a.id.localeCompare(b.id); + }); +}; + +const rankClaims = (claims: any[], evidenceLinks: any[], tenantId: string): RankedClaim[] => { + const evidenceByClaim = new Map(); + + evidenceLinks.forEach((link: any) => { + const badges = buildBadgeLinks(tenantId, link.id, link.artifact_type); + const entry: SupportingEvidence = { + id: link.id, + artifactType: link.artifact_type || 'evidence', + location: link.storage_uri || null, + badges, + }; + const list = evidenceByClaim.get(link.claim_id) ?? []; + list.push(entry); + evidenceByClaim.set(link.claim_id, list); + }); + + const ranked = claims.map((claim: any) => { + const supporting = evidenceByClaim.get(claim.id) ?? []; + const badgeSet = new Map(); + supporting.forEach((item) => { + item.badges.forEach((badge) => { + badgeSet.set(badge.kind, badge); + }); + }); + + const badges = Array.from(badgeSet.values()); + const verifiabilityScore = Number(claim.confidence || 0) + badges.length * 0.1; + + return { + id: claim.id, + content: claim.content, + confidence: Number(claim.confidence || 0), + claimType: claim.claim_type || 'factual', + extractedAt: claim.extracted_at ? new Date(claim.extracted_at).toISOString() : null, + verifiabilityScore, + badges, + supporting, + }; + }); + + return ranked + .filter((claim) => claim.badges.some((badge) => DETERMINISTIC_BADGES.has(badge.kind))) + .sort((a, b) => { + if (b.verifiabilityScore !== a.verifiabilityScore) { + return b.verifiabilityScore - a.verifiabilityScore; + } + return a.id.localeCompare(b.id); + }) + .slice(0, 3); +}; + +router.get('/evidence-index', ensureAuthenticated, async (req, res) => { + const answerId = (req.query.answer_id as string) || null; + const nodeId = (req.query.node_id as string) || null; + const scopeCheck = ensureScope(answerId, nodeId); + if (!scopeCheck.ok) { + return res.status(400).json({ error: scopeCheck.error }); + } + + const tenantId = getTenantId(req); + + try { + const claims = await fetchScopedClaims(tenantId, answerId, nodeId, DEFAULT_TIMELINE_LIMIT); + const claimIds = claims.map((claim: any) => claim.id); + const evidence = await fetchEvidenceForClaims(tenantId, claimIds, DEFAULT_TIMELINE_LIMIT); + + return res.json({ + timeline: buildTimeline(claims, evidence), + claimCount: claims.length, + evidenceCount: evidence.length, + }); + } catch (error: any) { + return res.status(500).json({ error: 'Failed to load evidence index' }); + } +}); + +router.get('/evidence-top', ensureAuthenticated, async (req, res) => { + const answerId = (req.query.answer_id as string) || null; + const nodeId = (req.query.node_id as string) || null; + const scopeCheck = ensureScope(answerId, nodeId); + if (!scopeCheck.ok) { + return res.status(400).json({ error: scopeCheck.error }); + } + + const tenantId = getTenantId(req); + const limit = clampLimit(Number(req.query.limit ?? DEFAULT_TOP_ARTIFACTS), DEFAULT_TOP_ARTIFACTS, MAX_TOP_ARTIFACTS); + + try { + const claims = await fetchScopedClaims(tenantId, answerId, nodeId, DEFAULT_TIMELINE_LIMIT); + const claimIds = claims.map((claim: any) => claim.id); + const evidence = await fetchEvidenceForClaims(tenantId, claimIds, limit); + + const artifacts: EvidenceArtifact[] = evidence.map((artifact: any) => ({ + id: artifact.id, + artifactType: artifact.artifact_type || 'evidence', + location: artifact.storage_uri || null, + createdAt: artifact.created_at ? new Date(artifact.created_at).toISOString() : null, + preview: artifact.content_preview || null, + })); + + return res.json({ artifacts }); + } catch (error: any) { + return res.status(500).json({ error: 'Failed to load top evidence' }); + } +}); + +router.get('/claim-ranking', ensureAuthenticated, async (req, res) => { + const answerId = (req.query.answer_id as string) || null; + const nodeId = (req.query.node_id as string) || null; + const scopeCheck = ensureScope(answerId, nodeId); + if (!scopeCheck.ok) { + return res.status(400).json({ error: scopeCheck.error }); + } + + const tenantId = getTenantId(req); + + try { + const claims = await fetchScopedClaims(tenantId, answerId, nodeId, DEFAULT_TIMELINE_LIMIT); + const claimIds = claims.map((claim: any) => claim.id); + const evidenceLinks = await fetchEvidenceLinks(tenantId, claimIds); + const rankedClaims = rankClaims(claims, evidenceLinks, tenantId); + + return res.json({ claims: rankedClaims }); + } catch (error: any) { + return res.status(500).json({ error: 'Failed to load claim ranking' }); + } +}); + +export default router;