feat(workbench): evidence subject extension and proposals wrinkles (Wave R2-S)

This commit is contained in:
Shay 2026-06-12 17:13:59 -07:00
parent d9d5c60d6e
commit f6a03a8c14
9 changed files with 669 additions and 39 deletions

View file

@ -26,8 +26,8 @@ export function App() {
<Route path="replay/:artifactId?" element={<ReplayRoute />} />
<Route path="proposals/:proposalId?" element={<ProposalsRoute />} />
<Route path="evals/:laneId?" element={<EvalsRoute />} />
<Route path="runs" element={<RunsRoutePlaceholder />} />
<Route path="packs" element={<PacksRoutePlaceholder />} />
<Route path="runs/:sessionId?" element={<RunsRoutePlaceholder />} />
<Route path="packs/:packId?" element={<PacksRoutePlaceholder />} />
<Route path="vault" element={<VaultRoutePlaceholder />} />
<Route path="audit" element={<AuditRoute />} />
<Route path="settings" element={<SettingsRoutePlaceholder />} />

View file

@ -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(<EvidenceChainRail subject={{ kind: "none" }} />);
expect(container.querySelector('[data-testid="evidence-chain-rail"]')).toBeNull();

View file

@ -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": {

View file

@ -84,16 +84,126 @@ function ProposalInspector({ subject }: { subject: Extract<EvidenceSubject, { ki
<MetadataTable
rows={[
{ key: "id", value: data.proposal_id, copyable: true, mono: true },
{ key: "state", value: data.state },
{ key: "source", value: data.source_kind },
{ key: "replay_eq", value: data.replay_equivalent === null ? "unknown" : data.replay_equivalent ? "yes" : "no" },
...(data.suggested_cli ? [{ key: "CLI", value: data.suggested_cli, copyable: true, mono: true }] : []),
...("state" in data ? [{ key: "state", value: data.state }] : []),
...("source_kind" in data
? [{ key: "source", value: data.source_kind }]
: [{ key: "source", value: `math / ${data.proposed_change_kind}` }]),
...("replay_equivalent" in data
? [
{
key: "replay_eq",
value:
data.replay_equivalent === null
? "unknown"
: data.replay_equivalent
? "yes"
: "no",
},
]
: [{ key: "replay_hash", value: data.replay_equivalence_hash, mono: true }]),
...("suggested_cli" in data && data.suggested_cli
? [{ key: "CLI", value: data.suggested_cli, copyable: true, mono: true }]
: []),
...("suggested_ratify_cli" in data && data.suggested_ratify_cli
? [{ key: "CLI", value: data.suggested_ratify_cli, copyable: true, mono: true }]
: []),
]}
/>
</div>
);
}
function RunInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "run" }> }) {
const { data } = subject;
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Run</h3>
<p className="m-0 font-mono text-xs text-[var(--color-text-primary)]">{subject.sessionId}</p>
{data ? (
<MetadataTable
rows={[
...(data.source ? [{ key: "source", value: data.source }] : []),
{ key: "checkpoint", value: data.checkpoint_present ? "present" : "not recorded" },
...(data.checkpoint_revision
? [{ key: "revision", value: data.checkpoint_revision, mono: true }]
: []),
...(data.evidence_gap ? [{ key: "gap", value: data.evidence_gap }] : []),
]}
/>
) : (
<DetailNotLoaded />
)}
</div>
);
}
function PackInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "pack" }> }) {
const { data } = subject;
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Pack</h3>
<p className="m-0 font-mono text-xs text-[var(--color-text-primary)]">{subject.packId}</p>
{data ? (
<MetadataTable
rows={[
...(data.checksum ? [{ key: "checksum", value: data.checksum, mono: true, copyable: true }] : []),
...(data.manifest_digest
? [{ key: "manifest", value: data.manifest_digest, mono: true, copyable: true }]
: []),
...(data.determinism_class ? [{ key: "determinism", value: data.determinism_class }] : []),
]}
/>
) : (
<DetailNotLoaded />
)}
</div>
);
}
function VaultEntryInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "vault_entry" }> }) {
const { data } = subject;
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Vault Entry</h3>
<p className="m-0 font-mono text-xs text-[var(--color-text-primary)]">#{subject.entryIndex}</p>
{data ? (
<MetadataTable
rows={[
...(data.epistemic_state ? [{ key: "epistemic", value: data.epistemic_state }] : []),
...(data.versor_digest
? [{ key: "versor", value: data.versor_digest, mono: true, copyable: true }]
: []),
]}
/>
) : (
<DetailNotLoaded />
)}
</div>
);
}
function AuditEventInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "audit_event" }> }) {
const { data } = subject;
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Audit Event</h3>
<p className="m-0 font-mono text-xs text-[var(--color-text-primary)]">{subject.eventId}</p>
{data ? (
<MetadataTable
rows={[
{ key: "mutation_boundary", value: data.mutation_boundary ? "yes" : "no" },
...(data.payload_digest
? [{ key: "payload", value: data.payload_digest, mono: true, copyable: true }]
: []),
]}
/>
) : (
<DetailNotLoaded />
)}
</div>
);
}
function ArtifactInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "artifact" }> }) {
const { data } = subject;
if (!data) {
@ -180,6 +290,14 @@ function InspectorProjection({ subject }: { subject: EvidenceSubject }) {
return <ArtifactInspector subject={subject} />;
case "eval_result":
return <EvalInspector subject={subject} />;
case "run":
return <RunInspector subject={subject} />;
case "pack":
return <PackInspector subject={subject} />;
case "vault_entry":
return <VaultEntryInspector subject={subject} />;
case "audit_event":
return <AuditEventInspector subject={subject} />;
case "none":
return <NoneInspector />;
}

View file

@ -22,6 +22,10 @@ function routerParamsFor(url: string): Record<string, string | undefined> {
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);
});
});

View file

@ -8,6 +8,10 @@ import type { EvidenceSubject } from "./evidenceContext";
// proposal -> /proposals/<proposalId>
// eval_result -> /evals/<laneId>
// artifact -> /replay/<artifactId>
// run -> /runs/<sessionId>
// pack -> /packs/<packId>
// vault_entry -> /vault?inspect=vault:<entryIndex>
// audit_event -> /audit?inspect=audit:<eventId>
//
// The `inspect` query param carries the inspector's subject as `<kind>:<id>`;
// 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<Record<string, string | undefined>>,
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)),
};
}

View file

@ -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 {

View file

@ -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 (
<>
<span data-testid="subject">{label}</span>
<span data-testid="subject-url">{path}</span>
</>
);
}
function renderRoute(initialEntry = "/proposals") {
return render(
<QueryClientProvider
@ -43,7 +60,7 @@ function renderRoute(initialEntry = "/proposals") {
<Routes>
<Route
path="/proposals/:proposalId?"
element={<><ProposalsRoute /><LocationProbe /></>}
element={<><ProposalsRoute /><LocationProbe /><SubjectProbe /></>}
/>
</Routes>
</EvidenceProvider>
@ -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", () => {

View file

@ -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.