diff --git a/scripts/dump-schemas.py b/scripts/dump-schemas.py new file mode 100644 index 00000000..62119060 --- /dev/null +++ b/scripts/dump-schemas.py @@ -0,0 +1,49 @@ +"""Dump workbench API dataclass field names for UI schema-drift tests. + +Read-only helper: parses workbench/schemas.py with Python AST and writes +JSON to stdout — {class_name: [own_field, ...]}. Inherited fields belong +to the parent class entry (the TS mirrors use `extends` the same way). +Same pattern as scripts/dump-enums.py (ADR-0162 enum coverage). +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCHEMAS = ROOT / "workbench" / "schemas.py" + + +def is_dataclass_decorated(node: ast.ClassDef) -> bool: + for dec in node.decorator_list: + target = dec.func if isinstance(dec, ast.Call) else dec + if isinstance(target, ast.Name) and target.id == "dataclass": + return True + if isinstance(target, ast.Attribute) and target.attr == "dataclass": + return True + return False + + +def own_fields(node: ast.ClassDef) -> list[str]: + fields: list[str] = [] + for statement in node.body: + if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name): + fields.append(statement.target.id) + return fields + + +def main() -> None: + module = ast.parse(SCHEMAS.read_text(encoding="utf-8")) + out: dict[str, list[str]] = {} + for node in module.body: + if isinstance(node, ast.ClassDef) and is_dataclass_decorated(node): + out[node.name] = own_fields(node) + if not out: + raise SystemExit("no dataclasses found in workbench/schemas.py") + print(json.dumps(out, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/workbench-ui/package.json b/workbench-ui/package.json index acfb96d8..0416eef1 100644 --- a/workbench-ui/package.json +++ b/workbench-ui/package.json @@ -12,7 +12,8 @@ "test:e2e": "playwright test", "test:enum-coverage": "pnpm enum:snapshot && vitest run src/design/components/badges/enumCoverage.test.ts", "generate:tokens": "tsx scripts/generate-tokens.ts", - "enum:snapshot": "cd .. && uv run python scripts/dump-enums.py > workbench-ui/enum-snapshot.json" + "enum:snapshot": "cd .. && uv run python scripts/dump-enums.py > workbench-ui/enum-snapshot.json", + "schema:snapshot": "cd .. && uv run python scripts/dump-schemas.py > workbench-ui/schema-snapshot.json" }, "dependencies": { "@radix-ui/react-dialog": "1.1.15", diff --git a/workbench-ui/schema-snapshot.json b/workbench-ui/schema-snapshot.json new file mode 100644 index 00000000..497d0ac6 --- /dev/null +++ b/workbench-ui/schema-snapshot.json @@ -0,0 +1,223 @@ +{ + "ArtifactDetail": [ + "content_type", + "content" + ], + "ArtifactRef": [ + "artifact_id", + "kind", + "path", + "digest", + "created_at" + ], + "AuditEvent": [ + "event_id", + "source", + "source_path", + "timestamp", + "event_type", + "mutation_boundary", + "summary", + "ref_id", + "payload_digest", + "payload" + ], + "ChatTurnResult": [ + "prompt", + "surface", + "articulation_surface", + "walk_surface", + "grounding_source", + "epistemic_state", + "normative_clearance", + "normative_detail", + "trace_hash", + "refusal_emitted", + "hedge_injected", + "mutation_mode", + "identity_verdict", + "safety_verdict", + "ethics_verdict", + "proposal_candidates", + "turn_cost_ms", + "checkpoint_emitted", + "turn_id" + ], + "EvalLaneSummary": [ + "lane", + "versions", + "read_only", + "description" + ], + "EvalRunResult": [ + "lane", + "version", + "split", + "passed", + "metrics", + "cases", + "source_digest" + ], + "MathProposalDetail": [ + "wrong_zero_assertion", + "proposed_change_payload", + "reasoning_trace_id", + "reasoning_trace_steps", + "evidence_hashes", + "handler_name", + "suggested_ratify_cli" + ], + "MathProposalSummary": [ + "proposal_id", + "domain", + "shape_category", + "proposed_change_kind", + "structural_commonality", + "evidence_count", + "replay_equivalence_hash" + ], + "MathRatifyResult": [ + "proposal_id", + "change_kind", + "handler_name", + "routing_status", + "message", + "suggested_cli", + "applied", + "target_path", + "evidence_hash" + ], + "MathReasoningStep": [ + "step_index", + "step_kind", + "claim", + "justification", + "input_pointers", + "output_payload" + ], + "PackDetail": [ + "manifest_digest", + "manifest" + ], + "PackSummary": [ + "pack_id", + "source", + "manifest_path", + "version", + "language", + "modality", + "determinism_class", + "checksum", + "checksums" + ], + "ProposalDetail": [ + "proposed_chain", + "replay_evidence", + "source", + "evidence", + "artifact_refs", + "suggested_cli" + ], + "ProposalRef": [ + "candidate_id", + "source_kind" + ], + "ProposalSummary": [ + "proposal_id", + "state", + "source_kind", + "replay_equivalent", + "created_at", + "downstream_effect" + ], + "ReplayComparison": [ + "artifact_id", + "original_hash", + "replay_hash", + "equivalent", + "divergences" + ], + "ReplayDivergence": [ + "path", + "original", + "replay", + "severity" + ], + "RunDetail": [ + "turns", + "manifest" + ], + "RunSummary": [ + "session_id", + "source", + "turn_count", + "started_at", + "updated_at", + "checkpoint_present", + "checkpoint_revision", + "artifact_refs", + "evidence_gap" + ], + "RunTurnRef": [ + "turn_id", + "trace_hash", + "timestamp", + "trace_path", + "surface_excerpt" + ], + "RuntimeStatus": [ + "backend", + "git_revision", + "engine_state_present", + "checkpoint_revision", + "revision_warning", + "active_session_id", + "mutation_mode" + ], + "TurnJournalEntrySchema": [ + "turn_id", + "timestamp", + "trace_hash", + "prompt", + "surface", + "articulation_surface", + "walk_surface", + "grounding_source", + "epistemic_state", + "normative_clearance", + "verdicts", + "refusal_emitted", + "hedge_injected", + "proposal_candidates", + "turn_cost_ms", + "checkpoint_emitted", + "journal_digest" + ], + "TurnJournalSummarySchema": [ + "turn_id", + "timestamp", + "prompt_excerpt", + "surface_excerpt", + "trace_hash", + "grounding_source" + ], + "TurnVerdict": [ + "outcome", + "runtime_detail" + ], + "VaultEntry": [ + "entry_index", + "epistemic_status", + "epistemic_state", + "metadata", + "versor_digest" + ], + "VaultSummary": [ + "source_path", + "entry_count", + "store_count", + "reproject_interval", + "max_entries", + "persisted" + ] +} diff --git a/workbench-ui/src/app/EvidenceChainRail.test.tsx b/workbench-ui/src/app/EvidenceChainRail.test.tsx new file mode 100644 index 00000000..0c4c353f --- /dev/null +++ b/workbench-ui/src/app/EvidenceChainRail.test.tsx @@ -0,0 +1,107 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { EvidenceChainRail, deriveStages } from "./EvidenceChainRail"; +import type { EvidenceSubject } from "./evidenceContext"; +import type { ChatTurnResult, ProposalDetail } from "../types/api"; + +const FULL_TURN: ChatTurnResult = { + prompt: "What is alpha?", + surface: "alpha causes beta", + articulation_surface: "alpha causes beta", + walk_surface: "alpha -> beta", + grounding_source: "teaching", + epistemic_state: "decoded", + normative_clearance: "cleared", + normative_detail: "", + trace_hash: "sha256:abc123", + refusal_emitted: false, + hedge_injected: false, + mutation_mode: "runtime_turn", + identity_verdict: null, + safety_verdict: null, + ethics_verdict: null, + proposal_candidates: [], + turn_cost_ms: 17, + checkpoint_emitted: true, +}; + +function turnSubject(data: ChatTurnResult | undefined): EvidenceSubject { + return { kind: "turn", turnId: 1, data } as EvidenceSubject; +} + +function statusOf(subject: EvidenceSubject, stageId: string): string { + const stages = deriveStages(subject); + const found = stages?.find((s) => s.id === stageId); + if (!found) throw new Error(`stage not derived: ${stageId}`); + return found.status; +} + +describe("EvidenceChainRail honesty contract", () => { + it("a fully-evidenced turn lights exactly the applicable stages", () => { + const subject = turnSubject(FULL_TURN); + expect(statusOf(subject, "intent")).toBe("lit"); + expect(statusOf(subject, "subject")).toBe("lit"); + expect(statusOf(subject, "provenance")).toBe("lit"); + expect(statusOf(subject, "admissibility")).toBe("lit"); + expect(statusOf(subject, "replay")).toBe("lit"); + expect(statusOf(subject, "authority")).toBe("lit"); + expect(statusOf(subject, "action")).toBe("dim"); + }); + + it("MEANINGFULLY FAILS: removing a single field hollows exactly its stage", () => { + // This is the test that breaks if anyone makes the rail infer status + // instead of deriving it from the named field. + const noHash = turnSubject({ ...FULL_TURN, trace_hash: "" }); + expect(statusOf(noHash, "replay")).toBe("hollow"); + expect(statusOf(noHash, "provenance")).toBe("lit"); + + const noGrounding = turnSubject({ ...FULL_TURN, grounding_source: "" as ChatTurnResult["grounding_source"] }); + expect(statusOf(noGrounding, "provenance")).toBe("hollow"); + expect(statusOf(noGrounding, "replay")).toBe("lit"); + }); + + it("identity-only subject (detail not loaded): applicable stages are hollow, never lit", () => { + const subject = turnSubject(undefined); + for (const id of ["intent", "provenance", "admissibility", "replay", "authority"]) { + expect(statusOf(subject, id)).toBe("hollow"); + } + expect(statusOf(subject, "subject")).toBe("lit"); + }); + + it("proposal: replay_equivalent=false is still recorded evidence (lit), null is hollow", () => { + const base: ProposalDetail = { + proposal_id: "p-1", + state: "pending", + source_kind: "contemplation", + replay_equivalent: false, + created_at: null, + downstream_effect: "unknown", + proposed_chain: [], + provenance: null, + suggested_cli: null, + } as unknown as ProposalDetail; + + const recorded: EvidenceSubject = { kind: "proposal", proposalId: "p-1", data: base } as EvidenceSubject; + expect(statusOf(recorded, "replay")).toBe("lit"); + + const unrecorded: EvidenceSubject = { + kind: "proposal", + proposalId: "p-1", + data: { ...base, replay_equivalent: null }, + } as EvidenceSubject; + expect(statusOf(unrecorded, "replay")).toBe("hollow"); + }); + + it("renders no rail for kind=none", () => { + const { container } = render(); + expect(container.querySelector('[data-testid="evidence-chain-rail"]')).toBeNull(); + }); + + it("renders all seven stages with status data attributes", () => { + const { container } = render(); + const items = container.querySelectorAll("[data-stage]"); + expect(items.length).toBe(7); + expect(container.querySelectorAll('[data-status="lit"]').length).toBe(6); + expect(container.querySelectorAll('[data-status="dim"]').length).toBe(1); + }); +}); diff --git a/workbench-ui/src/app/EvidenceChainRail.tsx b/workbench-ui/src/app/EvidenceChainRail.tsx new file mode 100644 index 00000000..6a40e8e3 --- /dev/null +++ b/workbench-ui/src/app/EvidenceChainRail.tsx @@ -0,0 +1,186 @@ +import type { CSSProperties } from "react"; +import type { EvidenceSubject } from "./evidenceContext"; + +/** + * EvidenceChainRail — the spine's seven stages rendered for the selected + * subject (Wave R brief R1): + * + * intent -> subject -> provenance -> admissibility -> replay -> authority -> action + * + * HONESTY CONTRACT: a stage's status derives ONLY from fields the subject + * actually carries. `lit` = the named field holds evidence. `hollow` = the + * stage applies to this subject kind but no evidence is recorded/loaded. + * `dim` = the stage does not apply to this kind. Nothing is ever inferred: + * a recorded trace hash lights "replay" as "trace hash recorded" — it does + * NOT claim replay was verified. + */ + +export type StageStatus = "lit" | "hollow" | "dim"; + +export interface RailStage { + id: string; + label: string; + status: StageStatus; + /** What the status derives from — shown in the tooltip, audit-honest. */ + derivation: string; +} + +const STAGE_IDS = [ + "intent", + "subject", + "provenance", + "admissibility", + "replay", + "authority", + "action", +] as const; + +function stage( + id: (typeof STAGE_IDS)[number], + status: StageStatus, + derivation: string, +): RailStage { + return { id, label: id, status, derivation }; +} + +function evidenceOf(value: unknown): "lit" | "hollow" { + if (value === null || value === undefined || value === "") return "hollow"; + return "lit"; +} + +/** Pure derivation — exported for the meaningfully-fail tests. */ +export function deriveStages(subject: EvidenceSubject): RailStage[] | null { + switch (subject.kind) { + case "none": + return null; + case "turn": { + const d = subject.data; + return [ + stage("intent", d ? evidenceOf(d.prompt) : "hollow", "prompt"), + stage("subject", "lit", "selected turn"), + stage("provenance", d ? evidenceOf(d.grounding_source) : "hollow", "grounding_source"), + stage( + "admissibility", + d ? evidenceOf(d.epistemic_state) : "hollow", + "epistemic_state + normative_clearance", + ), + stage("replay", d ? evidenceOf(d.trace_hash) : "hollow", "trace_hash recorded (not a verification claim)"), + stage("authority", d ? evidenceOf(d.mutation_mode) : "hollow", "mutation_mode"), + stage("action", "dim", "not applicable to a completed turn"), + ]; + } + case "proposal": { + const d = subject.data; + return [ + stage("intent", "dim", "not applicable — proposals originate in contemplation"), + stage("subject", "lit", "selected proposal"), + stage("provenance", d ? evidenceOf(d.source_kind) : "hollow", "source_kind"), + stage( + "admissibility", + d ? (d.replay_equivalent === null ? "hollow" : "lit") : "hollow", + "replay_equivalent recorded", + ), + stage( + "replay", + d ? (d.replay_equivalent === null ? "hollow" : "lit") : "hollow", + "replay_equivalent value (true and false are both evidence)", + ), + stage("authority", d ? evidenceOf(d.state) : "hollow", "review state (ADR-0057 machine)"), + stage("action", d ? evidenceOf(d.suggested_cli) : "hollow", "suggested_cli"), + ]; + } + case "artifact": { + const d = subject.data; + return [ + stage("intent", "dim", "not applicable to stored artifacts"), + stage("subject", "lit", "selected artifact"), + stage("provenance", d ? evidenceOf(d.path) : "hollow", "path"), + stage("admissibility", "dim", "not applicable to stored artifacts"), + stage("replay", d ? evidenceOf(d.digest) : "hollow", "digest"), + stage("authority", "dim", "not applicable to stored artifacts"), + stage("action", "dim", "not applicable to stored artifacts"), + ]; + } + case "eval_result": { + const d = subject.data; + return [ + stage("intent", "dim", "not applicable to eval runs"), + stage("subject", "lit", "selected eval result"), + stage("provenance", d ? evidenceOf(d.lane) : "hollow", "lane + version"), + stage("admissibility", d ? evidenceOf(d.split) : "hollow", "split (lane discipline)"), + stage("replay", d ? evidenceOf(d.source_digest) : "hollow", "source_digest"), + stage("authority", "dim", "not applicable to read-only lanes"), + stage("action", d ? (d.passed === null ? "hollow" : "lit") : "hollow", "pass/fail recorded"), + ]; + } + } +} + +const STATUS_STYLE: Record = { + lit: { + dot: { + background: "var(--color-state-evidenced)", + border: "1px solid var(--color-state-evidenced)", + }, + text: "text-[var(--color-text-secondary)]", + }, + hollow: { + dot: { + background: "transparent", + border: "1px solid var(--color-border-strong)", + }, + text: "text-[var(--color-text-muted)]", + }, + dim: { + dot: { + background: "var(--color-border-subtle)", + border: "1px solid var(--color-border-subtle)", + opacity: 0.5, + }, + text: "text-[var(--color-text-muted)] opacity-60", + }, +}; + +const STATUS_WORD: Record = { + lit: "evidence present", + hollow: "not recorded", + dim: "not applicable", +}; + +export function EvidenceChainRail({ subject }: { subject: EvidenceSubject }) { + const stages = deriveStages(subject); + if (!stages) return null; + + return ( +
    + {stages.map((s, i) => ( +
  1. + + + {s.label} + + {i < stages.length - 1 && ( + + → + + )} +
  2. + ))} +
+ ); +} diff --git a/workbench-ui/src/app/RightInspector.tsx b/workbench-ui/src/app/RightInspector.tsx index 2676cdee..ed55ce38 100644 --- a/workbench-ui/src/app/RightInspector.tsx +++ b/workbench-ui/src/app/RightInspector.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { useEvidenceSubject, type EvidenceSubject } from "./evidenceContext"; +import { EvidenceChainRail } from "./EvidenceChainRail"; import { MetadataTable } from "../design/components/MetadataTable/MetadataTable"; import { DigestBadge } from "../design/components/DigestBadge/DigestBadge"; import { Timestamp } from "../design/components/Timestamp/Timestamp"; @@ -161,6 +162,15 @@ function NoneInspector() { function InspectorContent() { const { subject } = useEvidenceSubject(); + return ( +
+ + +
+ ); +} + +function InspectorProjection({ subject }: { subject: EvidenceSubject }) { switch (subject.kind) { case "turn": return ; diff --git a/workbench-ui/src/app/chat/CopyableHash.tsx b/workbench-ui/src/app/chat/CopyableHash.tsx deleted file mode 100644 index 4d345e38..00000000 --- a/workbench-ui/src/app/chat/CopyableHash.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Copy } from "lucide-react"; -import { copyText } from "../../design/lib"; - -export function CopyableHash({ value, label = "trace_hash" }: { value: string; label?: string }) { - const short = value.length > 18 ? `${value.slice(0, 18)}...` : value; - return ( - - ); -} diff --git a/workbench-ui/src/app/chat/EvidenceStrip.tsx b/workbench-ui/src/app/chat/EvidenceStrip.tsx index 5bcb25c7..4b676164 100644 --- a/workbench-ui/src/app/chat/EvidenceStrip.tsx +++ b/workbench-ui/src/app/chat/EvidenceStrip.tsx @@ -10,7 +10,7 @@ import { } from "../../design/components/badges"; import type { KeyboardEvent, ReactNode } from "react"; import type { ChatTurnResult } from "../../types/api"; -import { CopyableHash } from "./CopyableHash"; +import { DigestBadge } from "../../design/components/DigestBadge/DigestBadge"; export type TraceFocus = | "metadata" @@ -98,7 +98,7 @@ export function EvidenceStrip({ ) : null} {result.trace_hash ? ( onOpen("trace")}> - + ) : null} diff --git a/workbench-ui/src/app/chat/TraceDrawer.tsx b/workbench-ui/src/app/chat/TraceDrawer.tsx index 931ddee9..c6daf389 100644 --- a/workbench-ui/src/app/chat/TraceDrawer.tsx +++ b/workbench-ui/src/app/chat/TraceDrawer.tsx @@ -6,7 +6,7 @@ import { Button } from "../../design/components/primitives/Button"; import { StableJsonViewer } from "../../design/components/StableJsonViewer"; import { copyText } from "../../design/lib"; import type { ChatTurnResult, TurnVerdict } from "../../types/api"; -import { CopyableHash } from "./CopyableHash"; +import { DigestBadge } from "../../design/components/DigestBadge/DigestBadge"; import type { TraceFocus } from "./EvidenceStrip"; function Panel({ @@ -159,7 +159,7 @@ export function TraceDrawer({ )} - {result.trace_hash ? :

No trace hash recorded.

} + {result.trace_hash ? :

No trace hash recorded.

} - - + } + > +
{domain === "cognition" && (
{filters.map((state) => ( @@ -288,7 +288,6 @@ export function ProposalsRoute() { ))}
)} -
)} - + +
{!selectedProposalId ? ( diff --git a/workbench-ui/src/app/replay/ArtifactList.tsx b/workbench-ui/src/app/replay/ArtifactList.tsx index 4a844e5d..e482aa1f 100644 --- a/workbench-ui/src/app/replay/ArtifactList.tsx +++ b/workbench-ui/src/app/replay/ArtifactList.tsx @@ -106,7 +106,7 @@ export function ArtifactList({ artifacts, selectedId, onSelect }: ArtifactListPr className={cn( "w-full rounded px-2 py-1.5 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]", isSelected - ? "bg-[var(--color-surface-raised)] text-[var(--color-text-primary)] border-l-2 border-[var(--color-focus-ring)] pl-1.5" + ? "bg-[var(--color-selected-bg)] text-[var(--color-text-primary)] border-l-2 border-[var(--color-selected-border)] pl-1.5" : "text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-primary)]" )} aria-current={isSelected ? "true" : undefined} diff --git a/workbench-ui/src/design/components/DigestBadge/DigestBadge.test.tsx b/workbench-ui/src/design/components/DigestBadge/DigestBadge.test.tsx index cdc9fc2f..0b8c7dff 100644 --- a/workbench-ui/src/design/components/DigestBadge/DigestBadge.test.tsx +++ b/workbench-ui/src/design/components/DigestBadge/DigestBadge.test.tsx @@ -7,7 +7,7 @@ describe("DigestBadge", () => { it("renders truncated digest with algorithm prefix", () => { render(); const badge = screen.getByTestId("digest-badge"); - expect(badge).toHaveTextContent("sha256:4f80f7e12c7e8ca1..."); + expect(badge).toHaveTextContent("sha256:4f80f7e12c7e..."); }); it("uses custom algorithm prefix", () => { diff --git a/workbench-ui/src/design/components/DigestBadge/DigestBadge.tsx b/workbench-ui/src/design/components/DigestBadge/DigestBadge.tsx index 4e8f43b3..5d468531 100644 --- a/workbench-ui/src/design/components/DigestBadge/DigestBadge.tsx +++ b/workbench-ui/src/design/components/DigestBadge/DigestBadge.tsx @@ -37,7 +37,8 @@ export function DigestBadge({ digest, algorithm = "sha256", verified, - truncate = 16, + // Wave R hash display standard: 12 visible chars + copy, everywhere. + truncate = 12, }: DigestBadgeProps) { const [copied, setCopied] = useState(false); const scheduleReset = useManagedTimeout(); diff --git a/workbench-ui/src/design/components/MetadataTable/MetadataTable.tsx b/workbench-ui/src/design/components/MetadataTable/MetadataTable.tsx index 4c329601..b44defd1 100644 --- a/workbench-ui/src/design/components/MetadataTable/MetadataTable.tsx +++ b/workbench-ui/src/design/components/MetadataTable/MetadataTable.tsx @@ -66,7 +66,7 @@ export function MetadataTable({ rows }: MetadataTableProps) { {row.key}

{title} diff --git a/workbench-ui/src/design/components/states/EmptyState.tsx b/workbench-ui/src/design/components/states/EmptyState.tsx index c9906d99..1b8d7f24 100644 --- a/workbench-ui/src/design/components/states/EmptyState.tsx +++ b/workbench-ui/src/design/components/states/EmptyState.tsx @@ -18,7 +18,20 @@ export function EmptyState({ return (
-

{statement}

+ {/* Deterministic monochrome glyph: an empty evidence slot. Static + inline SVG — same bytes every render, no asset fetch. */} + + + + +

{statement}

{typeof nextAction === "string" ? (