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.