fix: require exact capability evidence match
This commit is contained in:
@@ -65,13 +65,19 @@ const capabilityEvidence = (
|
||||
qualifiedName: string,
|
||||
durationMs: number,
|
||||
evidenceTarget = target,
|
||||
payload: Record<string, unknown> = {},
|
||||
deploymentId: string | null = null,
|
||||
): EvidenceRecord => ({
|
||||
id,
|
||||
target: evidenceTarget,
|
||||
operation: "workflow.capabilities.call",
|
||||
label: "Call capability",
|
||||
equivalentCli: "wf capability call",
|
||||
request: { qualified_name: qualifiedName, payload: {} },
|
||||
request: {
|
||||
qualified_name: qualifiedName,
|
||||
payload,
|
||||
...(deploymentId === null ? {} : { deployment_id: deploymentId }),
|
||||
},
|
||||
response: {},
|
||||
durationMs,
|
||||
});
|
||||
@@ -303,7 +309,14 @@ describe("CapabilityPlayground", () => {
|
||||
mockedUseCapabilityPlayground.mockReturnValue(activeController);
|
||||
mockedUseConsoleWorkspace.mockReturnValue(
|
||||
workspaceWithEvidence([
|
||||
capabilityEvidence("call-submitted", wrapperCapability.name, 18),
|
||||
capabilityEvidence(
|
||||
"call-submitted",
|
||||
wrapperCapability.name,
|
||||
18,
|
||||
target,
|
||||
{ query: "README.md" },
|
||||
"docs.default",
|
||||
),
|
||||
]),
|
||||
);
|
||||
view.rerender(
|
||||
@@ -354,7 +367,11 @@ describe("CapabilityPlayground", () => {
|
||||
});
|
||||
mockedUseCapabilityPlayground.mockReturnValue(activeController);
|
||||
mockedUseConsoleWorkspace.mockReturnValue(
|
||||
workspaceWithEvidence([capabilityEvidence("call-output", nodeCapability.name, 18)]),
|
||||
workspaceWithEvidence([
|
||||
capabilityEvidence("call-output", nodeCapability.name, 18, target, {
|
||||
query: "README.md",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
view.rerender(
|
||||
<CapabilityPlayground
|
||||
@@ -399,7 +416,9 @@ describe("CapabilityPlayground", () => {
|
||||
mockedUseConsoleWorkspace.mockReturnValue(
|
||||
workspaceWithEvidence([
|
||||
capabilityEvidence("call-1", "local.documents.other", 18),
|
||||
capabilityEvidence("call-2", nodeCapability.name, 24),
|
||||
capabilityEvidence("call-2", nodeCapability.name, 24, target, {
|
||||
query: "README.md",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
view.rerender(
|
||||
@@ -432,7 +451,9 @@ describe("CapabilityPlayground", () => {
|
||||
mockedUseConsoleWorkspace.mockReturnValue(
|
||||
workspaceWithEvidence([
|
||||
capabilityEvidence("call-1", "local.documents.other", 18),
|
||||
capabilityEvidence("call-runtime", nodeCapability.name, 31),
|
||||
capabilityEvidence("call-runtime", nodeCapability.name, 31, target, {
|
||||
query: "README.md",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
view.rerender(
|
||||
@@ -493,7 +514,9 @@ describe("CapabilityPlayground", () => {
|
||||
mockedUseConsoleWorkspace.mockReturnValue(
|
||||
workspaceWithEvidence([
|
||||
capabilityEvidence("call-1", "local.documents.other", 18),
|
||||
capabilityEvidence("call-clear", nodeCapability.name, 22),
|
||||
capabilityEvidence("call-clear", nodeCapability.name, 22, target, {
|
||||
query: "README.md",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
rerender(
|
||||
@@ -531,7 +554,9 @@ describe("CapabilityPlayground", () => {
|
||||
mockedUseCapabilityPlayground.mockReturnValue(activeController);
|
||||
mockedUseConsoleWorkspace.mockReturnValue(
|
||||
workspaceWithEvidence([
|
||||
capabilityEvidence("call-submitted", nodeCapability.name, 24),
|
||||
capabilityEvidence("call-submitted", nodeCapability.name, 24, target, {
|
||||
query: "README.md",
|
||||
}),
|
||||
capabilityEvidence("call-interleaved", "local.documents.other", 99),
|
||||
]),
|
||||
);
|
||||
@@ -547,11 +572,140 @@ describe("CapabilityPlayground", () => {
|
||||
expect(screen.queryByText("99 ms")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches the exact submitted payload among same-target calls", async () => {
|
||||
let activeController = controller({ acknowledged: true });
|
||||
mockedUseCapabilityPlayground.mockImplementation(() => activeController);
|
||||
const view = renderPlayground();
|
||||
await submitNodeCall();
|
||||
|
||||
activeController = controller({
|
||||
phase: "result",
|
||||
acknowledged: true,
|
||||
result: callResult(),
|
||||
});
|
||||
mockedUseCapabilityPlayground.mockReturnValue(activeController);
|
||||
mockedUseConsoleWorkspace.mockReturnValue(
|
||||
workspaceWithEvidence([
|
||||
capabilityEvidence("call-payload-match", nodeCapability.name, 24, target, {
|
||||
query: "README.md",
|
||||
}),
|
||||
capabilityEvidence("call-payload-other", nodeCapability.name, 99, target, {
|
||||
query: "CHANGELOG.md",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
view.rerender(
|
||||
<CapabilityPlayground
|
||||
capability={nodeCapability}
|
||||
executor={writeExecutor}
|
||||
target={target}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("24 ms")).toBeInTheDocument();
|
||||
expect(screen.queryByText("99 ms")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches the normalized submitted deployment", async () => {
|
||||
let activeController = controller({
|
||||
acknowledged: true,
|
||||
deploymentId: " docs.default ",
|
||||
});
|
||||
mockedUseCapabilityPlayground.mockImplementation(() => activeController);
|
||||
const view = renderPlayground(wrapperCapability);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("tab", { name: "Try capability" }));
|
||||
await user.type(screen.getByRole("textbox", { name: "Query" }), "README.md");
|
||||
await user.click(screen.getByRole("button", { name: "Call capability" }));
|
||||
|
||||
activeController = controller({
|
||||
phase: "result",
|
||||
acknowledged: true,
|
||||
deploymentId: " docs.default ",
|
||||
result: callResult({
|
||||
qualifiedName: wrapperCapability.name,
|
||||
sourceId: wrapperCapability.sourceId,
|
||||
kind: wrapperCapability.kind,
|
||||
deploymentId: "docs.default",
|
||||
}),
|
||||
});
|
||||
mockedUseCapabilityPlayground.mockReturnValue(activeController);
|
||||
mockedUseConsoleWorkspace.mockReturnValue(
|
||||
workspaceWithEvidence([
|
||||
capabilityEvidence(
|
||||
"call-deployment-match",
|
||||
wrapperCapability.name,
|
||||
27,
|
||||
target,
|
||||
{ query: "README.md" },
|
||||
"docs.default",
|
||||
),
|
||||
capabilityEvidence(
|
||||
"call-deployment-other",
|
||||
wrapperCapability.name,
|
||||
99,
|
||||
target,
|
||||
{ query: "README.md" },
|
||||
"docs.other",
|
||||
),
|
||||
]),
|
||||
);
|
||||
view.rerender(
|
||||
<CapabilityPlayground
|
||||
capability={wrapperCapability}
|
||||
executor={writeExecutor}
|
||||
target={target}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("27 ms")).toBeInTheDocument();
|
||||
expect(screen.queryByText("99 ms")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits provenance when identical matching calls are ambiguous", async () => {
|
||||
let activeController = controller({ acknowledged: true });
|
||||
mockedUseCapabilityPlayground.mockImplementation(() => activeController);
|
||||
const view = renderPlayground();
|
||||
await submitNodeCall();
|
||||
|
||||
activeController = controller({
|
||||
phase: "result",
|
||||
acknowledged: true,
|
||||
result: callResult(),
|
||||
});
|
||||
mockedUseCapabilityPlayground.mockReturnValue(activeController);
|
||||
mockedUseConsoleWorkspace.mockReturnValue(
|
||||
workspaceWithEvidence([
|
||||
capabilityEvidence("call-identical-a", nodeCapability.name, 24, target, {
|
||||
query: "README.md",
|
||||
}),
|
||||
capabilityEvidence("call-identical-b", nodeCapability.name, 31, target, {
|
||||
query: "README.md",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
view.rerender(
|
||||
<CapabilityPlayground
|
||||
capability={nodeCapability}
|
||||
executor={writeExecutor}
|
||||
target={target}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("24 ms")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("31 ms")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Call evidence was not retained for this connection.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches only evidence added after the current repeated call", async () => {
|
||||
let activeController = controller({ acknowledged: true });
|
||||
mockedUseCapabilityPlayground.mockImplementation(() => activeController);
|
||||
mockedUseConsoleWorkspace.mockReturnValue(
|
||||
workspaceWithEvidence([capabilityEvidence("call-first", nodeCapability.name, 11)]),
|
||||
workspaceWithEvidence([
|
||||
capabilityEvidence("call-first", nodeCapability.name, 11, target, {
|
||||
query: "first.md",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const view = renderPlayground();
|
||||
await submitNodeCall("first.md");
|
||||
@@ -564,7 +718,11 @@ describe("CapabilityPlayground", () => {
|
||||
}),
|
||||
);
|
||||
mockedUseConsoleWorkspace.mockReturnValue(
|
||||
workspaceWithEvidence([capabilityEvidence("call-first", nodeCapability.name, 11)]),
|
||||
workspaceWithEvidence([
|
||||
capabilityEvidence("call-first", nodeCapability.name, 11, target, {
|
||||
query: "first.md",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
view.rerender(
|
||||
<CapabilityPlayground
|
||||
@@ -590,8 +748,12 @@ describe("CapabilityPlayground", () => {
|
||||
mockedUseCapabilityPlayground.mockReturnValue(activeController);
|
||||
mockedUseConsoleWorkspace.mockReturnValue(
|
||||
workspaceWithEvidence([
|
||||
capabilityEvidence("call-first", nodeCapability.name, 11),
|
||||
capabilityEvidence("call-second", nodeCapability.name, 22),
|
||||
capabilityEvidence("call-first", nodeCapability.name, 11, target, {
|
||||
query: "first.md",
|
||||
}),
|
||||
capabilityEvidence("call-second", nodeCapability.name, 22, target, {
|
||||
query: "second.md",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
view.rerender(
|
||||
@@ -642,7 +804,11 @@ describe("CapabilityPlayground", () => {
|
||||
});
|
||||
mockedUseCapabilityPlayground.mockReturnValue(activeController);
|
||||
mockedUseConsoleWorkspace.mockReturnValue(
|
||||
workspaceWithEvidence([capabilityEvidence("call-connected", nodeCapability.name, 24)]),
|
||||
workspaceWithEvidence([
|
||||
capabilityEvidence("call-connected", nodeCapability.name, 24, target, {
|
||||
query: "README.md",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
view.rerender(
|
||||
<CapabilityPlayground
|
||||
|
||||
@@ -23,8 +23,9 @@ type PlaygroundTab = "contract" | "try";
|
||||
|
||||
type SubmittedCall = {
|
||||
readonly baselineEvidenceIds: ReadonlySet<string>;
|
||||
readonly deploymentId: string;
|
||||
readonly deploymentId: string | null;
|
||||
readonly executor: ConsoleWriteExecutor | null;
|
||||
readonly payload: Record<string, unknown>;
|
||||
readonly payloadText: string;
|
||||
readonly qualifiedName: string;
|
||||
readonly target: string | null;
|
||||
@@ -46,33 +47,82 @@ const formatKind = (kind: CapabilityDetail["kind"]): string =>
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const requestQualifiedName = (request: unknown): string | null => {
|
||||
type CapabilityRequestProjection = {
|
||||
readonly deploymentId: string | null;
|
||||
readonly payload: Record<string, unknown>;
|
||||
readonly qualifiedName: string;
|
||||
};
|
||||
|
||||
const normalizeDeploymentId = (value: unknown): string | null => {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim();
|
||||
return normalized === "" ? null : normalized;
|
||||
};
|
||||
|
||||
const capabilityRequestProjection = (
|
||||
request: unknown,
|
||||
): CapabilityRequestProjection | null => {
|
||||
if (!isRecord(request) || typeof request.qualified_name !== "string") {
|
||||
return null;
|
||||
}
|
||||
return request.qualified_name;
|
||||
if (!isRecord(request.payload)) return null;
|
||||
if (
|
||||
request.deployment_id !== undefined &&
|
||||
request.deployment_id !== null &&
|
||||
typeof request.deployment_id !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
deploymentId: normalizeDeploymentId(request.deployment_id),
|
||||
payload: request.payload,
|
||||
qualifiedName: request.qualified_name,
|
||||
};
|
||||
};
|
||||
|
||||
const jsonDeepEqual = (left: unknown, right: unknown): boolean => {
|
||||
if (Object.is(left, right)) return true;
|
||||
if (left === null || right === null) return false;
|
||||
if (typeof left !== typeof right) return false;
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
return left.every((value, index) => jsonDeepEqual(value, right[index]));
|
||||
}
|
||||
if (!isRecord(left) || !isRecord(right)) return false;
|
||||
const leftKeys = Object.keys(left).sort();
|
||||
const rightKeys = Object.keys(right).sort();
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
return leftKeys.every(
|
||||
(key, index) => key === rightKeys[index] && jsonDeepEqual(left[key], right[key]),
|
||||
);
|
||||
};
|
||||
|
||||
const submittedCallEvidence = (
|
||||
evidence: ReadonlyArray<EvidenceRecord>,
|
||||
submittedCall: SubmittedCall,
|
||||
): EvidenceRecord | null => {
|
||||
// Evidence is an append-only stream, so the baseline prevents an older
|
||||
// matching call from being mistaken for the call that produced this receipt.
|
||||
if (submittedCall.target === null) return null;
|
||||
for (let index = evidence.length - 1; index >= 0; index -= 1) {
|
||||
const record = evidence[index];
|
||||
if (record === undefined) continue;
|
||||
const matches = evidence.filter((record) => {
|
||||
if (
|
||||
!submittedCall.baselineEvidenceIds.has(record.id) &&
|
||||
record.operation === "workflow.capabilities.call" &&
|
||||
record.target === submittedCall.target &&
|
||||
requestQualifiedName(record.request) === submittedCall.qualifiedName
|
||||
submittedCall.baselineEvidenceIds.has(record.id) ||
|
||||
record.operation !== "workflow.capabilities.call" ||
|
||||
record.target !== submittedCall.target
|
||||
) {
|
||||
return record;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
const request = capabilityRequestProjection(record.request);
|
||||
return (
|
||||
request !== null &&
|
||||
request.qualifiedName === submittedCall.qualifiedName &&
|
||||
request.deploymentId === submittedCall.deploymentId &&
|
||||
jsonDeepEqual(request.payload, submittedCall.payload)
|
||||
);
|
||||
});
|
||||
// Concurrent identical calls are indistinguishable here; fail closed rather
|
||||
// than presenting one call's duration as proof for another call.
|
||||
return matches.length === 1 ? matches[0] ?? null : null;
|
||||
};
|
||||
|
||||
const schemaText = (value: Record<string, unknown>): string =>
|
||||
@@ -196,7 +246,7 @@ const ResultReceipt = ({
|
||||
<div>
|
||||
<dt>Deployment</dt>
|
||||
<dd aria-label="Submitted deployment">
|
||||
{submittedCall.deploymentId || "default"}
|
||||
{submittedCall.deploymentId ?? "default"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -312,8 +362,9 @@ const TryView = ({
|
||||
}
|
||||
setSubmittedCall({
|
||||
baselineEvidenceIds: new Set(connection.evidence.map((record) => record.id)),
|
||||
deploymentId: controller.deploymentId.trim(),
|
||||
deploymentId: normalizeDeploymentId(controller.deploymentId),
|
||||
executor,
|
||||
payload: result.value,
|
||||
payloadText: formatBoundedJson(result.value),
|
||||
qualifiedName: capability.name,
|
||||
target,
|
||||
|
||||
Reference in New Issue
Block a user