feat(workbench): evidence subject extension and proposals wrinkles (Wave R2-S)
This commit is contained in:
parent
d9d5c60d6e
commit
f6a03a8c14
9 changed files with 669 additions and 39 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue