diff --git a/workbench-ui/src/app/App.tsx b/workbench-ui/src/app/App.tsx index 2fafabf8..10218ba0 100644 --- a/workbench-ui/src/app/App.tsx +++ b/workbench-ui/src/app/App.tsx @@ -26,8 +26,8 @@ export function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/workbench-ui/src/app/EvidenceChainRail.test.tsx b/workbench-ui/src/app/EvidenceChainRail.test.tsx index 0c4c353f..05175cfd 100644 --- a/workbench-ui/src/app/EvidenceChainRail.test.tsx +++ b/workbench-ui/src/app/EvidenceChainRail.test.tsx @@ -92,6 +92,117 @@ describe("EvidenceChainRail honesty contract", () => { expect(statusOf(unrecorded, "replay")).toBe("hollow"); }); + it("MEANINGFULLY FAILS run: removing checkpoint_revision hollows exactly replay", () => { + const full: EvidenceSubject = { + kind: "run", + sessionId: "session-1", + data: { + session_id: "session-1", + source: "engine_state_manifest", + checkpoint_present: true, + checkpoint_revision: "rev-1", + evidence_gap: null, + }, + }; + expect(statusOf(full, "provenance")).toBe("lit"); + expect(statusOf(full, "admissibility")).toBe("lit"); + expect(statusOf(full, "replay")).toBe("lit"); + + const missingRevision: EvidenceSubject = { + ...full, + data: { ...full.data, checkpoint_revision: null }, + }; + expect(statusOf(missingRevision, "replay")).toBe("hollow"); + expect(statusOf(missingRevision, "provenance")).toBe("lit"); + }); + + it("run: evidence_gap dims the replay chain instead of pretending replay is available", () => { + const gapped: EvidenceSubject = { + kind: "run", + sessionId: "session-gap", + data: { + source: "turn_journal", + checkpoint_present: true, + checkpoint_revision: "rev-gap", + evidence_gap: "checkpoint missing from manifest", + }, + }; + expect(statusOf(gapped, "provenance")).toBe("dim"); + expect(statusOf(gapped, "admissibility")).toBe("dim"); + expect(statusOf(gapped, "replay")).toBe("dim"); + }); + + it("run: identity-only subject hollows field-derived stages", () => { + const identityOnly: EvidenceSubject = { kind: "run", sessionId: "session-identity" }; + expect(statusOf(identityOnly, "provenance")).toBe("hollow"); + expect(statusOf(identityOnly, "admissibility")).toBe("hollow"); + expect(statusOf(identityOnly, "replay")).toBe("hollow"); + }); + + it("MEANINGFULLY FAILS pack: removing determinism_class hollows exactly admissibility", () => { + const full: EvidenceSubject = { + kind: "pack", + packId: "en_core", + data: { + pack_id: "en_core", + checksum: "sha256:pack", + manifest_digest: "sha256:manifest", + determinism_class: "deterministic", + }, + }; + expect(statusOf(full, "provenance")).toBe("lit"); + expect(statusOf(full, "admissibility")).toBe("lit"); + + const missingDeterminism: EvidenceSubject = { + ...full, + data: { ...full.data, determinism_class: null }, + }; + expect(statusOf(missingDeterminism, "admissibility")).toBe("hollow"); + expect(statusOf(missingDeterminism, "provenance")).toBe("lit"); + }); + + it("MEANINGFULLY FAILS vault_entry: removing versor_digest hollows exactly provenance", () => { + const full: EvidenceSubject = { + kind: "vault_entry", + entryIndex: 3, + data: { + entry_index: 3, + epistemic_state: "verified", + versor_digest: "sha256:versor", + }, + }; + expect(statusOf(full, "provenance")).toBe("lit"); + expect(statusOf(full, "admissibility")).toBe("lit"); + + const missingVersor: EvidenceSubject = { + ...full, + data: { ...full.data, versor_digest: null }, + }; + expect(statusOf(missingVersor, "provenance")).toBe("hollow"); + expect(statusOf(missingVersor, "admissibility")).toBe("lit"); + }); + + it("MEANINGFULLY FAILS audit_event: removing payload_digest hollows exactly provenance", () => { + const full: EvidenceSubject = { + kind: "audit_event", + eventId: "audit-1", + data: { + event_id: "audit-1", + mutation_boundary: true, + payload_digest: "sha256:audit", + }, + }; + expect(statusOf(full, "provenance")).toBe("lit"); + expect(statusOf(full, "action")).toBe("lit"); + + const missingPayload: EvidenceSubject = { + ...full, + data: { ...full.data, payload_digest: null }, + }; + expect(statusOf(missingPayload, "provenance")).toBe("hollow"); + expect(statusOf(missingPayload, "action")).toBe("lit"); + }); + it("renders no rail for kind=none", () => { const { container } = render(); expect(container.querySelector('[data-testid="evidence-chain-rail"]')).toBeNull(); diff --git a/workbench-ui/src/app/EvidenceChainRail.tsx b/workbench-ui/src/app/EvidenceChainRail.tsx index ad034544..1581ff95 100644 --- a/workbench-ui/src/app/EvidenceChainRail.tsx +++ b/workbench-ui/src/app/EvidenceChainRail.tsx @@ -48,6 +48,10 @@ function evidenceOf(value: unknown): "lit" | "hollow" { return "lit"; } +function evidenceAny(...values: unknown[]): "lit" | "hollow" { + return values.some((value) => evidenceOf(value) === "lit") ? "lit" : "hollow"; +} + /** Pure derivation — exported for the meaningfully-fail tests. */ export function deriveStages(subject: EvidenceSubject): RailStage[] | null { switch (subject.kind) { @@ -77,22 +81,106 @@ export function deriveStages(subject: EvidenceSubject): RailStage[] | null { } case "proposal": { const d = subject.data; + const provenance = d + ? "source_kind" in d + ? d.source_kind + : d.proposed_change_kind + : undefined; + const replayEvidence = d + ? "replay_equivalent" in d + ? d.replay_equivalent + : d.replay_equivalence_hash + : undefined; + const authority = d ? ("state" in d ? d.state : d.handler_name) : undefined; + const action = d + ? "suggested_cli" in d + ? d.suggested_cli + : d.suggested_ratify_cli + : undefined; 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("provenance", evidenceOf(provenance), "source_kind / proposed_change_kind"), stage( "admissibility", - d ? (d.replay_equivalent === null ? "hollow" : "lit") : "hollow", - "replay_equivalent recorded", + replayEvidence === null ? "hollow" : evidenceOf(replayEvidence), + "replay_equivalent / replay_equivalence_hash recorded", ), stage( "replay", - d ? (d.replay_equivalent === null ? "hollow" : "lit") : "hollow", - "replay_equivalent value (true and false are both evidence)", + replayEvidence === null ? "hollow" : evidenceOf(replayEvidence), + "replay evidence value (false is recorded evidence)", ), - stage("authority", d ? evidenceOf(d.state) : "hollow", "review state (ADR-0057 machine)"), - stage("action", d ? evidenceOf(d.suggested_cli) : "hollow", "suggested_cli"), + stage("authority", evidenceOf(authority), "review state / handler_name"), + stage("action", evidenceOf(action), "suggested_cli / suggested_ratify_cli"), + ]; + } + case "run": { + const d = subject.data; + const hasGapField = !!d && Object.prototype.hasOwnProperty.call(d, "evidence_gap"); + const gapped = evidenceOf(d?.evidence_gap) === "lit"; + return [ + stage("intent", "dim", "not applicable to recorded runs"), + stage("subject", "lit", "selected run"), + stage("provenance", gapped ? "dim" : d ? evidenceOf(d.source) : "hollow", "source / evidence_gap"), + stage( + "admissibility", + !d || !hasGapField ? "hollow" : gapped ? "dim" : "lit", + "evidence_gap absent means no gap recorded", + ), + stage( + "replay", + !d + ? "hollow" + : gapped + ? "dim" + : d?.checkpoint_present + ? evidenceOf(d.checkpoint_revision) + : "hollow", + "checkpoint_present + checkpoint_revision", + ), + stage("authority", "dim", "not applicable to read-only run evidence"), + stage("action", "dim", "not applicable to recorded runs"), + ]; + } + case "pack": { + const d = subject.data; + return [ + stage("intent", "dim", "not applicable to semantic packs"), + stage("subject", "lit", "selected pack"), + stage( + "provenance", + d ? evidenceAny(d.checksum, d.manifest_digest) : "hollow", + "checksum / manifest_digest", + ), + stage("admissibility", d ? evidenceOf(d.determinism_class) : "hollow", "determinism_class"), + stage("replay", "dim", "not applicable to pack metadata"), + stage("authority", "dim", "not applicable to pack metadata"), + stage("action", "dim", "not applicable to pack metadata"), + ]; + } + case "vault_entry": { + const d = subject.data; + return [ + stage("intent", "dim", "not applicable to vault entries"), + stage("subject", "lit", "selected vault entry"), + stage("provenance", d ? evidenceOf(d.versor_digest) : "hollow", "versor_digest"), + stage("admissibility", d ? evidenceOf(d.epistemic_state) : "hollow", "epistemic_state"), + stage("replay", "dim", "not applicable to vault entries"), + stage("authority", "dim", "not applicable to vault entries"), + stage("action", "dim", "not applicable to vault entries"), + ]; + } + case "audit_event": { + const d = subject.data; + return [ + stage("intent", "dim", "not applicable to audit events"), + stage("subject", "lit", "selected audit event"), + stage("provenance", d ? evidenceOf(d.payload_digest) : "hollow", "payload_digest"), + stage("admissibility", "dim", "not applicable to audit events"), + stage("replay", "dim", "not applicable to audit events"), + stage("authority", "dim", "not applicable to audit events"), + stage("action", d ? evidenceOf(d.mutation_boundary) : "hollow", "mutation_boundary"), ]; } case "artifact": { diff --git a/workbench-ui/src/app/RightInspector.tsx b/workbench-ui/src/app/RightInspector.tsx index ed55ce38..5699d2cf 100644 --- a/workbench-ui/src/app/RightInspector.tsx +++ b/workbench-ui/src/app/RightInspector.tsx @@ -84,16 +84,126 @@ function ProposalInspector({ subject }: { subject: Extract ); } +function RunInspector({ subject }: { subject: Extract }) { + const { data } = subject; + return ( +
+

Run

+

{subject.sessionId}

+ {data ? ( + + ) : ( + + )} +
+ ); +} + +function PackInspector({ subject }: { subject: Extract }) { + const { data } = subject; + return ( +
+

Pack

+

{subject.packId}

+ {data ? ( + + ) : ( + + )} +
+ ); +} + +function VaultEntryInspector({ subject }: { subject: Extract }) { + const { data } = subject; + return ( +
+

Vault Entry

+

#{subject.entryIndex}

+ {data ? ( + + ) : ( + + )} +
+ ); +} + +function AuditEventInspector({ subject }: { subject: Extract }) { + const { data } = subject; + return ( +
+

Audit Event

+

{subject.eventId}

+ {data ? ( + + ) : ( + + )} +
+ ); +} + function ArtifactInspector({ subject }: { subject: Extract }) { const { data } = subject; if (!data) { @@ -180,6 +290,14 @@ function InspectorProjection({ subject }: { subject: EvidenceSubject }) { return ; case "eval_result": return ; + case "run": + return ; + case "pack": + return ; + case "vault_entry": + return ; + case "audit_event": + return ; case "none": return ; } diff --git a/workbench-ui/src/app/evidenceAddress.test.ts b/workbench-ui/src/app/evidenceAddress.test.ts index 9bcaeb86..04ff84cc 100644 --- a/workbench-ui/src/app/evidenceAddress.test.ts +++ b/workbench-ui/src/app/evidenceAddress.test.ts @@ -22,6 +22,10 @@ function routerParamsFor(url: string): Record { return segment === undefined ? {} : { turnId: segment }; case "proposals": return segment === undefined ? {} : { proposalId: segment }; + case "runs": + return segment === undefined ? {} : { sessionId: segment }; + case "packs": + return segment === undefined ? {} : { packId: segment }; case "evals": return segment === undefined ? {} : { laneId: segment }; case "replay": @@ -54,6 +58,29 @@ const KINDS: Array<{ name: string; subject: AddressableSubject; path: string }> subject: { kind: "artifact", artifactId: "art-trace-1" }, path: "/replay/art-trace-1", }, + { + name: "run", + subject: { kind: "run", sessionId: "session-1" }, + path: "/runs/session-1", + }, + { + name: "pack", + subject: { kind: "pack", packId: "en/core pack" }, + path: "/packs/en%2Fcore%20pack", + }, +]; + +const INSPECT_ONLY_KINDS: Array<{ name: string; subject: AddressableSubject; path: string }> = [ + { + name: "vault_entry", + subject: { kind: "vault_entry", entryIndex: 7 }, + path: "/vault?inspect=vault%3A7", + }, + { + name: "audit_event", + subject: { kind: "audit_event", eventId: "audit:event/1" }, + path: "/audit?inspect=audit%3Aaudit%3Aevent%2F1", + }, ]; describe("subjectToUrl", () => { @@ -61,11 +88,27 @@ describe("subjectToUrl", () => { expect(subjectToUrl(subject)).toBe(path); }); + it.each(INSPECT_ONLY_KINDS)( + "addresses inspect-only $name subject through ?inspect=", + ({ subject, path }) => { + expect(subjectToUrl(subject)).toBe(path); + }, + ); + it("percent-encodes ids containing reserved characters", () => { const url = subjectToUrl({ kind: "proposal", proposalId: "a/b c?d" }); expect(url).toBe("/proposals/a%2Fb%20c%3Fd"); }); + it("preserves math proposal corridor in canonical addresses", () => { + const url = subjectToUrl({ + kind: "proposal", + proposalId: "math-proposal-1", + domain: "math", + }); + expect(url).toBe("/proposals/math-proposal-1?domain=math"); + }); + it("appends ?inspect= when the inspector holds a different subject", () => { const url = subjectToUrl( { kind: "proposal", proposalId: "abc" }, @@ -92,6 +135,28 @@ describe("urlToSubject round-trip", () => { expect(inspect).toBeNull(); }); + it.each(INSPECT_ONLY_KINDS)( + "recovers an inspect-only $name subject from its URL", + ({ subject }) => { + const url = subjectToUrl(subject); + const search = new URL(url, "http://localhost").searchParams; + const { route, inspect } = urlToSubject(routerParamsFor(url), search); + expect(route).toBeNull(); + expect(inspect).not.toBeNull(); + expect(sameIdentity(inspect!, subject)).toBe(true); + }, + ); + + it("recovers math proposal domain from the route query", () => { + const subject: AddressableSubject = { + kind: "proposal", + proposalId: "math-proposal-1", + domain: "math", + }; + const { route } = roundTrip(subject); + expect(route).toEqual(subject); + }); + it("recovers ids containing reserved characters", () => { const subject: AddressableSubject = { kind: "artifact", @@ -120,6 +185,8 @@ describe("urlToSubject malformed input", () => { ["fractional turn id", { turnId: "1.5" }], ["overflowing turn id", { turnId: "99999999999999999999" }], ["empty proposal id", { proposalId: "" }], + ["empty session id", { sessionId: "" }], + ["empty pack id", { packId: "" }], ["empty lane id", { laneId: "" }], ["empty artifact id", { artifactId: "" }], ["no params at all", {}], @@ -137,6 +204,7 @@ describe("urlToSubject malformed input", () => { ["empty id", "proposal:"], ["empty kind", ":abc"], ["non-numeric turn", "turn:abc"], + ["non-numeric vault entry", "vault:abc"], ["empty value", ""], ])("returns inspect null for %s", (_label, value) => { const params = new URLSearchParams(); @@ -162,6 +230,12 @@ describe("inspect value codec", () => { expect(sameIdentity(recovered!, subject)).toBe(true); }); + it.each(INSPECT_ONLY_KINDS)("round-trips a $name inspect value", ({ subject }) => { + const recovered = inspectValueToSubject(subjectToInspectValue(subject)); + expect(recovered).not.toBeNull(); + expect(sameIdentity(recovered!, subject)).toBe(true); + }); + it("preserves ids containing colons (splits on the first only)", () => { expect(inspectValueToSubject("proposal:a:b")).toEqual({ kind: "proposal", @@ -187,6 +261,12 @@ describe("isAddressable / sameIdentity", () => { expect(sameIdentity(a, b)).toBe(true); expect(sameIdentity(a, c)).toBe(false); expect(sameIdentity(a, { kind: "proposal", proposalId: "1" })).toBe(false); + expect( + sameIdentity( + { kind: "proposal", proposalId: "same", domain: "math" }, + { kind: "proposal", proposalId: "same" }, + ), + ).toBe(false); expect(sameIdentity({ kind: "none" }, { kind: "none" })).toBe(true); }); }); diff --git a/workbench-ui/src/app/evidenceAddress.ts b/workbench-ui/src/app/evidenceAddress.ts index 37d640ce..014cd19a 100644 --- a/workbench-ui/src/app/evidenceAddress.ts +++ b/workbench-ui/src/app/evidenceAddress.ts @@ -8,6 +8,10 @@ import type { EvidenceSubject } from "./evidenceContext"; // proposal -> /proposals/ // eval_result -> /evals/ // artifact -> /replay/ +// run -> /runs/ +// pack -> /packs/ +// vault_entry -> /vault?inspect=vault: +// audit_event -> /audit?inspect=audit: // // The `inspect` query param carries the inspector's subject as `:`; // its presence means the inspector is open on that subject. @@ -27,26 +31,70 @@ export function sameIdentity(a: EvidenceSubject, b: EvidenceSubject): boolean { case "turn": return b.kind === "turn" && b.turnId === a.turnId; case "proposal": - return b.kind === "proposal" && b.proposalId === a.proposalId; + return ( + b.kind === "proposal" && + b.proposalId === a.proposalId && + (b.domain ?? "cognition") === (a.domain ?? "cognition") + ); case "eval_result": return b.kind === "eval_result" && b.lane === a.lane; case "artifact": return b.kind === "artifact" && b.artifactId === a.artifactId; + case "run": + return b.kind === "run" && b.sessionId === a.sessionId; + case "pack": + return b.kind === "pack" && b.packId === a.packId; + case "vault_entry": + return b.kind === "vault_entry" && b.entryIndex === a.entryIndex; + case "audit_event": + return b.kind === "audit_event" && b.eventId === a.eventId; case "none": return b.kind === "none"; } } -function subjectPath(subject: AddressableSubject): string { +interface SubjectAddress { + path: string; + params: URLSearchParams; +} + +function emptyParams(): URLSearchParams { + return new URLSearchParams(); +} + +function subjectAddress(subject: AddressableSubject): SubjectAddress { switch (subject.kind) { case "turn": - return `/trace/${subject.turnId}`; + return { path: `/trace/${subject.turnId}`, params: emptyParams() }; case "proposal": - return `/proposals/${encodeURIComponent(subject.proposalId)}`; + { + const params = emptyParams(); + if (subject.domain === "math") params.set("domain", "math"); + return { + path: `/proposals/${encodeURIComponent(subject.proposalId)}`, + params, + }; + } case "eval_result": - return `/evals/${encodeURIComponent(subject.lane)}`; + return { path: `/evals/${encodeURIComponent(subject.lane)}`, params: emptyParams() }; case "artifact": - return `/replay/${encodeURIComponent(subject.artifactId)}`; + return { path: `/replay/${encodeURIComponent(subject.artifactId)}`, params: emptyParams() }; + case "run": + return { path: `/runs/${encodeURIComponent(subject.sessionId)}`, params: emptyParams() }; + case "pack": + return { path: `/packs/${encodeURIComponent(subject.packId)}`, params: emptyParams() }; + case "vault_entry": + { + const params = emptyParams(); + params.set(INSPECT_PARAM, subjectToInspectValue(subject)); + return { path: "/vault", params }; + } + case "audit_event": + { + const params = emptyParams(); + params.set(INSPECT_PARAM, subjectToInspectValue(subject)); + return { path: "/audit", params }; + } } } @@ -60,6 +108,14 @@ export function subjectToInspectValue(subject: AddressableSubject): string { return `eval_result:${subject.lane}`; case "artifact": return `artifact:${subject.artifactId}`; + case "run": + return `run:${subject.sessionId}`; + case "pack": + return `pack:${subject.packId}`; + case "vault_entry": + return `vault:${subject.entryIndex}`; + case "audit_event": + return `audit:${subject.eventId}`; } } @@ -67,13 +123,13 @@ export function subjectToUrl( subject: AddressableSubject, inspect?: EvidenceSubject | null, ): string { - const path = subjectPath(subject); - if (!inspect || !isAddressable(inspect) || sameIdentity(subject, inspect)) { - return path; + const address = subjectAddress(subject); + const params = new URLSearchParams(address.params); + if (inspect && isAddressable(inspect) && !sameIdentity(subject, inspect)) { + params.set(INSPECT_PARAM, subjectToInspectValue(inspect)); } - const params = new URLSearchParams(); - params.set(INSPECT_PARAM, subjectToInspectValue(inspect)); - return `${path}?${params.toString()}`; + const query = params.toString(); + return query ? `${address.path}?${query}` : address.path; } function parseTurnId(raw: string): number | null { @@ -101,6 +157,16 @@ export function inspectValueToSubject( return { kind: "eval_result", lane: id }; case "artifact": return { kind: "artifact", artifactId: id }; + case "run": + return { kind: "run", sessionId: id }; + case "pack": + return { kind: "pack", packId: id }; + case "vault": { + const entryIndex = parseTurnId(id); + return entryIndex === null ? null : { kind: "vault_entry", entryIndex }; + } + case "audit": + return { kind: "audit_event", eventId: id }; default: return null; } @@ -108,6 +174,7 @@ export function inspectValueToSubject( function routeParamsToSubject( params: Readonly>, + searchParams: URLSearchParams, ): AddressableSubject | null { // React Router populates exactly one of these keys per matched route; // precedence below only matters for hand-built (malformed) inputs. @@ -116,10 +183,21 @@ function routeParamsToSubject( return turnId === null ? null : { kind: "turn", turnId }; } if (params.proposalId !== undefined) { - return params.proposalId === "" - ? null + if (params.proposalId === "") return null; + return searchParams.get("domain") === "math" + ? { kind: "proposal", proposalId: params.proposalId, domain: "math" } : { kind: "proposal", proposalId: params.proposalId }; } + if (params.sessionId !== undefined) { + return params.sessionId === "" + ? null + : { kind: "run", sessionId: params.sessionId }; + } + if (params.packId !== undefined) { + return params.packId === "" + ? null + : { kind: "pack", packId: params.packId }; + } if (params.laneId !== undefined) { return params.laneId === "" ? null @@ -145,7 +223,7 @@ export function urlToSubject( searchParams: URLSearchParams, ): UrlSubjects { return { - route: routeParamsToSubject(params), + route: routeParamsToSubject(params, searchParams), inspect: inspectValueToSubject(searchParams.get(INSPECT_PARAM)), }; } diff --git a/workbench-ui/src/app/evidenceContext.tsx b/workbench-ui/src/app/evidenceContext.tsx index 679f2e0c..f18c6b69 100644 --- a/workbench-ui/src/app/evidenceContext.tsx +++ b/workbench-ui/src/app/evidenceContext.tsx @@ -9,18 +9,57 @@ import type { ChatTurnResult, TurnJournalEntry, ProposalDetail, + MathProposalDetail, ArtifactDetail, EvalRunResult, } from "../types/api"; +export type ProposalSubjectDomain = "cognition" | "math"; + +export interface RunSubjectData { + session_id?: string; + source?: string; + checkpoint_present?: boolean; + checkpoint_revision?: string | null; + evidence_gap?: string | null; +} + +export interface PackSubjectData { + pack_id?: string; + checksum?: string | null; + manifest_digest?: string | null; + determinism_class?: string | null; +} + +export interface VaultEntrySubjectData { + entry_index?: number; + epistemic_state?: string; + versor_digest?: string | null; +} + +export interface AuditEventSubjectData { + event_id?: string; + mutation_boundary?: boolean; + payload_digest?: string | null; +} + // `data` is optional: a subject restored from a URL carries identity only // until the owning route's query loads its detail. Inspectors must render // an honest "detail not loaded" state when data is absent. export type EvidenceSubject = | { kind: "turn"; turnId: number; data?: ChatTurnResult | TurnJournalEntry } - | { kind: "proposal"; proposalId: string; data?: ProposalDetail } + | { + kind: "proposal"; + proposalId: string; + domain?: ProposalSubjectDomain; + data?: ProposalDetail | MathProposalDetail; + } | { kind: "artifact"; artifactId: string; data?: ArtifactDetail } | { kind: "eval_result"; lane: string; data?: EvalRunResult } + | { kind: "run"; sessionId: string; data?: RunSubjectData } + | { kind: "pack"; packId: string; data?: PackSubjectData } + | { kind: "vault_entry"; entryIndex: number; data?: VaultEntrySubjectData } + | { kind: "audit_event"; eventId: string; data?: AuditEventSubjectData } | { kind: "none" }; interface EvidenceContextValue { diff --git a/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx b/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx index 94db5ed5..abda2a09 100644 --- a/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx +++ b/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx @@ -12,10 +12,12 @@ import { import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { proposalDetail, proposalSummaries } from "../../api/__fixtures__/proposals"; -import { EvidenceProvider } from "../evidenceContext"; +import { EvidenceProvider, useEvidenceSubject } from "../evidenceContext"; +import { isAddressable, subjectToUrl } from "../evidenceAddress"; import { SuggestedCLIBox } from "./SuggestedCLIBox"; import { ProposalTable } from "./ProposalTable"; import { ProposalsRoute } from "./ProposalsRoute"; +import type { MathProposalDetail, MathProposalSummary } from "../../types/api"; function queryWrapper({ children }: { children: ReactNode }) { const client = createTestQueryClient(); @@ -33,6 +35,21 @@ function LocationProbe() { ); } +function SubjectProbe() { + const { subject } = useEvidenceSubject(); + const path = isAddressable(subject) ? subjectToUrl(subject) : "none"; + const label = + subject.kind === "proposal" + ? `${subject.kind}:${subject.domain ?? "cognition"}:${subject.proposalId}` + : subject.kind; + return ( + <> + {label} + {path} + + ); +} + function renderRoute(initialEntry = "/proposals") { return render( } + element={<>} /> @@ -52,6 +69,72 @@ function renderRoute(initialEntry = "/proposals") { ); } +const mathSummary: MathProposalSummary = { + proposal_id: "math-proposal-1", + domain: "math", + shape_category: "numeric_reasoning", + proposed_change_kind: "handler_route", + structural_commonality: "arithmetic transfer", + evidence_count: 2, + replay_equivalence_hash: "sha256:math-replay", +}; + +const mathDetail: MathProposalDetail = { + ...mathSummary, + wrong_zero_assertion: "wrong=0 for sampled arithmetic corridor", + proposed_change_payload: { route: "math" }, + reasoning_trace_id: "math-trace-1", + reasoning_trace_steps: [ + { + step_index: 1, + step_kind: "compare", + claim: "addition and subtraction share quantity state", + justification: "same parse corridor", + input_pointers: ["case-1"], + output_payload: { ok: true }, + }, + ], + evidence_hashes: ["sha256:evidence-1"], + handler_name: null, + suggested_ratify_cli: "core math proposal ratify math-proposal-1", +}; + +function stubMixedProposalFetch() { + const fetchMock = vi.fn((rawUrl: string) => { + const url = new URL(rawUrl); + if (url.pathname === "/math-proposals") { + return Promise.resolve({ + json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items: [mathSummary] } }), + }); + } + if (url.pathname === `/math-proposals/${mathDetail.proposal_id}`) { + return Promise.resolve({ + json: () => Promise.resolve({ ok: true, generated_at: "now", data: mathDetail }), + }); + } + if (url.pathname === `/proposals/${proposalDetail.proposal_id}`) { + return Promise.resolve({ + json: () => Promise.resolve({ ok: true, generated_at: "now", data: proposalDetail }), + }); + } + if (url.pathname === "/proposals") { + return Promise.resolve({ + json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items: proposalSummaries } }), + }); + } + return Promise.resolve({ + json: () => + Promise.resolve({ + ok: false, + generated_at: "now", + error: { code: "not_found", message: `unexpected path ${url.pathname}` }, + }), + }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + function stubProposalFetch(items = proposalSummaries) { const fetchMock = vi.fn((url: string) => { if (url.endsWith(`/proposals/${proposalDetail.proposal_id}`)) { @@ -161,6 +244,40 @@ describe("ProposalsRoute", () => { expect(await screen.findByText("No proposals match this queue view.")).toBeInTheDocument(); }); + + it("publishes math-domain selections as proposal inspector subjects", async () => { + stubMixedProposalFetch(); + const user = userEvent.setup(); + renderRoute("/proposals?domain=math&state=pending"); + + const row = (await screen.findByTitle("math-proposal-1")).closest('[role="button"]'); + expect(row).not.toBeNull(); + await user.click(row!); + + await waitFor(() => + expect(screen.getByTestId("location")).toHaveTextContent( + "/proposals/math-proposal-1?domain=math&state=pending", + ), + ); + expect(screen.getByTestId("subject")).toHaveTextContent("proposal:math:math-proposal-1"); + expect(await screen.findByText("wrong=0 for sampled arithmetic corridor")).toBeInTheDocument(); + }); + + it("keeps domain=math in the selected proposal evidence address", async () => { + stubMixedProposalFetch(); + const user = userEvent.setup(); + renderRoute("/proposals?domain=math&state=pending"); + + const row = (await screen.findByTitle("math-proposal-1")).closest('[role="button"]'); + expect(row).not.toBeNull(); + await user.click(row!); + + await waitFor(() => + expect(screen.getByTestId("subject-url")).toHaveTextContent( + "/proposals/math-proposal-1?domain=math", + ), + ); + }); }); describe("SuggestedCLIBox", () => { diff --git a/workbench-ui/src/app/proposals/ProposalsRoute.tsx b/workbench-ui/src/app/proposals/ProposalsRoute.tsx index ed8dc2eb..1ceb2760 100644 --- a/workbench-ui/src/app/proposals/ProposalsRoute.tsx +++ b/workbench-ui/src/app/proposals/ProposalsRoute.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { AlertTriangle } from "lucide-react"; import { useEvidenceSubject } from "../evidenceContext"; -import { subjectToUrl } from "../evidenceAddress"; import { WorkbenchApiError, type ProposalStateFilter } from "../../api/client"; import { useProposalDetail, useProposals, useMathProposals, useMathProposalDetail } from "../../api/queries"; import type { ProposalSummary, MathProposalDetail, ProposalState, DownstreamEffect, MathReasoningStep } from "../../types/api"; @@ -144,17 +143,17 @@ export function ProposalsRoute() { }, [domain, filter]); // Publish the selected proposal as the evidence subject: identity - // immediately, detail once the query resolves. Math proposals have a - // distinct detail shape not carried by EvidenceSubject; they stay - // route-local. + // immediately, detail once the query resolves. Math proposal subjects carry + // domain="math" so copied evidence links round-trip to the same corridor. useEffect(() => { - if (!selectedProposalId || domain !== "cognition") return; + if (!selectedProposalId) return; setSubject({ kind: "proposal", proposalId: selectedProposalId, - data: cognitionDetailQuery.data, + domain, + data: domain === "math" ? mathDetailQuery.data : cognitionDetailQuery.data, }); - }, [selectedProposalId, domain, cognitionDetailQuery.data, setSubject]); + }, [selectedProposalId, domain, mathDetailQuery.data, cognitionDetailQuery.data, setSubject]); function updateRoute(next: { proposalId?: string | null; state?: ProposalStateFilter; domain?: "math" | "cognition" }) { const params = new URLSearchParams(searchParams); @@ -171,7 +170,7 @@ export function ProposalsRoute() { const nextProposalId = next.proposalId === null ? null : (next.proposalId ?? selectedProposalId); const path = nextProposalId - ? subjectToUrl({ kind: "proposal", proposalId: nextProposalId }) + ? `/proposals/${encodeURIComponent(nextProposalId)}` : "/proposals"; const search = params.toString(); // Selection churn must not pollute history: replace, never push.