fix(evidence): normalize unknown revisions and preserve mutation intent (#1733)

Normalize generated revision fields before hashing and retain complete typed
creation/update requests in a shared mutation Evidence producer. Keep imported
revision authentication and canonical persisted identity/digests unchanged.

Fixes #1723
Fixes #1724

Assisted-by: Suika (gensokyo-skills:suika-scatter-gather-planning)
This commit is contained in:
Ysopwnable authored and GitHub committed 2026-10-11 14:56:46 +08:00
1 parent 844fa52772
commit 21650455a9
7 files changed
+364 -133

No files matched your search

+48
View File
@@ -7,6 +7,10 @@ import {
import { InvestigationRecords } from "./investigation/InvestigationRecords.js";
import { UnknownRegistryError } from "../domain/unknownRegistryError.js";
import { err } from "../domain/result.js";
import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js";
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
import { functionDossierSchema } from "../domain/hopperValues.js";
import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js";
describe("workflow residual question retention", () => {
it("keeps questions linked to workflow Evidence and records repeats idempotently", () => {
@@ -54,3 +58,47 @@ describe("workflow residual question retention", () => {
).toEqual(err(error));
});
});
it.each([
" Which boundary? ",
"\nWhich boundary?\t",
"\uFEFFWhich boundary?\uFEFF",
])(
"retains contract-accepted question %j without rewriting workflow Evidence",
(question) => {
const boundary = nativeApiBoundarySchema.parse({
available: false,
reason: "Synthetic provider unavailable boundary",
residual_unknowns: [question],
});
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const result = projectNativeApiInspection({
...dossier,
native_api: boundary,
});
const evidence = createWorkflowEvidence({
target: undefined,
operation: "inspect_native_api",
parameters: { procedure: "0x1000" },
result,
upstreamProfile: undefined,
});
const records = new InvestigationRecords();
expect(records.recordEvidence(evidence).ok).toBe(true);
expect(
recordWorkflowUnknowns({
name: "inspect_native_api",
result,
evidenceId: evidence.evidence_id,
recordUnknown: (input) => records.recordUnknown(input),
}).ok,
).toBe(true);
expect(records.listUnknowns()).toMatchObject([
{ question: "Which boundary?" },
]);
expect(records.evidenceById(evidence.evidence_id)).toEqual(evidence);
expect(evidence.normalized_result).toMatchObject({
residual_unknowns: [question],
});
},
);
@@ -61,7 +61,10 @@ describe("investigation record ownership", () => {
value: {
scope_digest: target.sha256,
mutation_evidence_ids: [
unknownMutationEvidence(target, input).evidence_id,
unknownMutationEvidence(target, {
operation: "record_unknown",
input,
}).evidence_id,
],
},
});
@@ -71,14 +74,19 @@ describe("investigation record ownership", () => {
value: {
scope_digest: null,
mutation_evidence_ids: [
unknownMutationEvidence(undefined, input).evidence_id,
unknownMutationEvidence(undefined, {
operation: "record_unknown",
input,
}).evidence_id,
],
},
});
const mutation = records.evidenceById(
unknownMutationEvidence(undefined, input).evidence_id,
unknownMutationEvidence(undefined, { operation: "record_unknown", input })
.evidence_id,
);
expect(mutation?.subject).toBeNull();
expect(mutation?.parameters).toEqual(input);
expect(records.listUnknowns()).toHaveLength(2);
});
@@ -124,6 +132,10 @@ describe("investigation record ownership", () => {
bundle.records.find(({ operation }) => operation === "update_unknown")
?.subject?.digest.sha256,
).toBe(target.sha256);
expect(
bundle.records.find(({ operation }) => operation === "update_unknown")
?.parameters,
).toEqual(input);
expect(records.updateUnknown(input, target).ok).toBe(false);
expect(records.exportEvidenceBundle()).toEqual(bundle);
});
@@ -162,3 +174,59 @@ describe("investigation record ownership", () => {
expect(records.exportEvidenceBundle()).toEqual(createEvidenceBundle([]));
});
});
it("retains complete mutation intent and distinguishes independent update requests", () => {
const source = new InvestigationRecords();
expect(source.recordEvidence(evidence).ok).toBe(true);
const input = {
...unknownInput("Which replay establishes the answer?"),
supporting_evidence_ids: [evidence.evidence_id],
required_environment: {
id: "fixture",
platform: "linux",
architecture: "x86_64",
isolation: "process" as const,
},
recommended_probes: [
{ operation: "inspect_native_api", rationale: "Replay" },
],
};
const created = source.recordUnknown(input);
if (!created.ok) throw created.error;
const base = source.exportEvidenceBundle();
const creation = base.records.find(
({ operation }) => operation === "record_unknown",
);
expect(creation?.parameters).toEqual(input);
expect(creation?.evidence_links).toEqual([evidence.evidence_id]);
const { question: _question, domain: _domain, ...details } = input;
const mutations = (["high", "low"] as const).map((severity) => {
const records = new InvestigationRecords();
expect(records.mergeEvidenceBundle(base).ok).toBe(true);
const command = updateUnknownInputSchema.parse({
...details,
unknown_id: created.value.unknown_id,
expected_revision: 1,
status: "resolved",
severity,
resolution: {
disposition: "withdrawn",
rationale: "Caller withdrew this question",
evidence_ids: [evidence.evidence_id],
},
});
const updated = records.updateUnknown(command);
if (!updated.ok) throw updated.error;
const mutation = records
.exportEvidenceBundle()
.records.find(({ operation }) => operation === "update_unknown");
expect(mutation?.parameters).toEqual(command);
expect(mutation?.evidence_links).toEqual([evidence.evidence_id]);
expect(updated.value.mutation_evidence_ids).toContain(
mutation?.evidence_id,
);
return mutation;
});
expect(mutations[0]?.evidence_id).not.toBe(mutations[1]?.evidence_id);
});
@@ -1,5 +1,5 @@
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import type { Evidence } from "../../domain/evidence.js";
import type { EvidenceBundle } from "../../domain/evidenceBundle.js";
import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -12,11 +12,7 @@ import type {
} from "../../domain/residualUnknown.js";
import type { Result } from "../../domain/result.js";
import { EvidenceLedger, type EvidenceImportDelta } from "./EvidenceLedger.js";
import {
UNKNOWN_REGISTRY_PROVIDER,
unknownEvidenceLinks,
unknownMutationEvidence,
} from "./UnknownEvidence.js";
import { unknownMutationEvidence } from "./UnknownEvidence.js";
import type {
EvidenceReader,
EvidenceWriter,
@@ -83,11 +79,10 @@ export class InvestigationRecords
input: RecordUnknownInput,
target?: BinaryTarget,
): Result<ResidualUnknown, AnalysisError> {
const recorded = this.#evidence.recordUnknown(
return this.#evidence.recordUnknown(
input,
unknownMutationEvidence(target, input),
unknownMutationEvidence(target, { operation: "record_unknown", input }),
);
return recorded;
}
/** Commit derived Evidence and its Unknown atomically without an implicit subject. */
@@ -95,12 +90,14 @@ export class InvestigationRecords
evidence: Evidence,
input: RecordUnknownInput,
): Result<ResidualUnknown | null, AnalysisError> {
const recorded = this.#evidence.recordWithUnknown(
return this.#evidence.recordWithUnknown(
evidence,
input,
unknownMutationEvidence(undefined, input),
unknownMutationEvidence(undefined, {
operation: "record_unknown",
input,
}),
);
return recorded;
}
/** Update an optimistic revision with an explicitly supplied Evidence subject. */
@@ -108,23 +105,11 @@ export class InvestigationRecords
input: UpdateUnknownInput,
target?: BinaryTarget,
): Result<ResidualUnknown, AnalysisError> {
const evidence = createEvidence(target, UNKNOWN_REGISTRY_PROVIDER, {
predicateType: "rea.residual-unknown-mutation",
const evidence = unknownMutationEvidence(target, {
operation: "update_unknown",
parameters: {
unknown_id: input.unknown_id,
expected_revision: input.expected_revision,
},
result: { action: "update", status: input.status },
confidence: "derived",
authority: "analyst-inference",
evidenceLinks: unknownEvidenceLinks(input),
limitations: [
"Registry mutation evidence records analyst intent, not proof of the answer.",
],
input,
});
const updated = this.#evidence.updateUnknown(input, evidence);
return updated;
return this.#evidence.updateUnknown(input, evidence);
}
/** Return detached current Unknown revisions matching the selected filters. */
@@ -7,43 +7,54 @@ import type {
import type { ProviderIdentity } from "../AnalysisProvider.js";
/** Provider identity for analyst-authored residual-unknown mutations. */
export const UNKNOWN_REGISTRY_PROVIDER: ProviderIdentity = {
const UNKNOWN_REGISTRY_PROVIDER: ProviderIdentity = {
id: "rea-unknown-registry",
name: "REA residual unknown registry",
version: "1",
};
type UnknownMutation =
| { readonly operation: "record_unknown"; readonly input: RecordUnknownInput }
| {
readonly operation: "update_unknown";
readonly input: UpdateUnknownInput;
};
/** Create immutable evidence for one approved residual-unknown mutation. */
export const unknownMutationEvidence = (
target: BinaryTarget | undefined,
input: RecordUnknownInput,
mutation: UnknownMutation,
): Evidence =>
createEvidence(target, UNKNOWN_REGISTRY_PROVIDER, {
predicateType: "rea.residual-unknown-mutation",
operation: "record_unknown",
parameters: { domain: input.domain, severity: input.severity },
result: {
action: "record",
question: input.question,
required_authority: input.required_authority,
required_confidence: input.required_confidence,
},
operation: mutation.operation,
parameters: { ...mutation.input },
result:
mutation.operation === "record_unknown"
? {
action: "record",
question: mutation.input.question,
required_authority: mutation.input.required_authority,
required_confidence: mutation.input.required_confidence,
}
: { action: "update", status: mutation.input.status },
confidence: "derived",
authority: "analyst-inference",
evidenceLinks: unknownEvidenceLinks(input),
evidenceLinks: unknownEvidenceLinks(mutation.input),
limitations: [
"Registry mutation evidence records analyst intent, not proof of the answer.",
],
});
/** Deduplicate evidence links carried by an unknown mutation. */
export const unknownEvidenceLinks = (
const unknownEvidenceLinks = (
input: RecordUnknownInput | UpdateUnknownInput,
): string[] =>
[
): string[] => [
...new Set([
...input.supporting_evidence_ids,
...input.contradicting_evidence_ids,
...("resolution" in input && input.resolution !== null
? input.resolution.evidence_ids
: []),
].filter((id, index, values) => values.indexOf(id) === index);
]),
];
+106 -1
View File
@@ -1,6 +1,11 @@
import { expect, it } from "vitest";
import { recordUnknownInputSchema } from "./residualUnknown.js";
import {
createResidualUnknown,
recordUnknownInputSchema,
residualUnknownSchema,
updateResidualUnknown,
} from "./residualUnknown.js";
it("accepts complete unknown questions, environment details and probe guidance", () => {
const longText = "x".repeat(4_097);
@@ -23,3 +28,103 @@ it("accepts complete unknown questions, environment details and probe guidance",
expect(parsed.question).toHaveLength(4_097);
expect(parsed.recommended_probes[0]?.operation).toHaveLength(4_097);
});
const input = recordUnknownInputSchema.parse({
question: "Fixture 0",
domain: "canonical-constructor",
severity: "medium",
required_authority: null,
required_confidence: "derived",
required_environment: null,
recommended_probes: [],
relationships: [],
});
const mutationId = `ev_${"a".repeat(64)}`;
const scopeDigest = "b".repeat(64);
it("preserves persisted canonical identity and digests while authenticating imported changes", () => {
const created = createResidualUnknown(input, mutationId, scopeDigest);
expect(created.unknown_id).toBe(
"unk_139dc8e6c9feb0ba370584d190003eaa5064ec693cf1dfd5ec4be7496d59559c",
);
expect(created.revision_digest).toBe(
"7f41f8ddcfdb364f7b05bda5d9ff75ea3a83fe8f3a29f07dfadfe00141b72c25",
);
const updated = updateResidualUnknown(
created,
{
...input,
unknown_id: created.unknown_id,
expected_revision: 1,
status: "investigating",
severity: "high",
required_confidence: "observed",
recommended_probes: [
{ operation: "inspect_native_api", rationale: "Fixture probe" },
],
resolution: null,
},
mutationId,
);
expect(updated.revision_digest).toBe(
"8f2d8c1985f143724b087524ed997d13602d343db6f444b4c37bb8bc9e91dcbb",
);
expect(residualUnknownSchema.parse(updated)).toEqual(updated);
expect(
residualUnknownSchema.safeParse({ ...updated, severity: "low" }).success,
).toBe(false);
});
it("normalizes producer text before identity and revision authentication", () => {
const raw = {
...input,
question: "\uFEFFFixture\t0\n",
domain: " canonical-constructor ",
recommended_probes: [
{ operation: "inspect_native_api", rationale: " Replay " },
],
};
const created = createResidualUnknown(raw, mutationId, scopeDigest);
const canonical = createResidualUnknown(
recordUnknownInputSchema.parse(raw),
mutationId,
scopeDigest,
);
expect(created).toEqual(canonical);
expect(created.unknown_id).toBe(
createResidualUnknown(input, mutationId, scopeDigest).unknown_id,
);
expect(residualUnknownSchema.parse(created)).toEqual(created);
});
it("keeps generated revisions subject to state and relationship validation", () => {
const created = createResidualUnknown(input, mutationId, scopeDigest);
const command = {
...input,
unknown_id: created.unknown_id,
expected_revision: 1,
status: "resolved" as const,
resolution: null,
};
expect(() => updateResidualUnknown(created, command, mutationId)).toThrow(
/Only resolved unknowns carry a resolution/u,
);
expect(() =>
updateResidualUnknown(
created,
{ ...command, status: "contradicted" },
mutationId,
),
).toThrow(/Contradicted status requires contradicting evidence/u);
expect(() =>
updateResidualUnknown(
created,
{
...command,
status: "open",
relationships: [{ type: "related-to", unknown_id: created.unknown_id }],
},
mutationId,
),
).toThrow(/An unknown cannot relate to itself/u);
});
+81 -69
View File
@@ -82,6 +82,70 @@ const residualUnknownObjectSchema = z.object({
mutation_evidence_ids: z.array(evidenceIdSchema).min(1),
});
type UnknownRevision = Omit<
z.infer<typeof residualUnknownObjectSchema>,
"revision_digest"
>;
const refineRevision = (value: UnknownRevision, context: z.RefinementCtx) => {
if ((value.revision === 1) !== (value.previous_revision_digest === null))
context.addIssue({
code: "custom",
message: "Only the first revision has no predecessor",
path: ["previous_revision_digest"],
});
if ((value.status === "resolved") !== (value.resolution !== null))
context.addIssue({
code: "custom",
message: "Only resolved unknowns carry a resolution",
path: ["resolution"],
});
if (
value.status === "contradicted" &&
value.contradicting_evidence_ids.length === 0
)
context.addIssue({
code: "custom",
message: "Contradicted status requires contradicting evidence",
path: ["contradicting_evidence_ids"],
});
if (
value.relationships.some(
(relationship) => relationship.unknown_id === value.unknown_id,
)
)
context.addIssue({
code: "custom",
message: "An unknown cannot relate to itself",
path: ["relationships"],
});
for (const ids of [
value.supporting_evidence_ids,
value.contradicting_evidence_ids,
value.mutation_evidence_ids,
])
if (new Set(ids).size !== ids.length)
context.addIssue({
code: "custom",
message: "Evidence IDs must be unique",
});
const contradictingEvidence = new Set(value.contradicting_evidence_ids);
if (value.supporting_evidence_ids.some((id) => contradictingEvidence.has(id)))
context.addIssue({
code: "custom",
message: "Evidence cannot simultaneously support and contradict",
});
const relationships = value.relationships.map(
(relationship) => `${relationship.type}:${relationship.unknown_id}`,
);
if (new Set(relationships).size !== relationships.length)
context.addIssue({
code: "custom",
message: "Relationships must be unique",
path: ["relationships"],
});
};
export const residualUnknownSchema = residualUnknownObjectSchema.superRefine(
(value, context) => {
if (computeRevisionDigest(value) !== value.revision_digest)
@@ -90,67 +154,14 @@ export const residualUnknownSchema = residualUnknownObjectSchema.superRefine(
message: "Residual unknown revision digest does not match",
path: ["revision_digest"],
});
if ((value.revision === 1) !== (value.previous_revision_digest === null))
context.addIssue({
code: "custom",
message: "Only the first revision has no predecessor",
path: ["previous_revision_digest"],
});
if ((value.status === "resolved") !== (value.resolution !== null))
context.addIssue({
code: "custom",
message: "Only resolved unknowns carry a resolution",
path: ["resolution"],
});
if (
value.status === "contradicted" &&
value.contradicting_evidence_ids.length === 0
)
context.addIssue({
code: "custom",
message: "Contradicted status requires contradicting evidence",
path: ["contradicting_evidence_ids"],
});
if (
value.relationships.some(
(relationship) => relationship.unknown_id === value.unknown_id,
)
)
context.addIssue({
code: "custom",
message: "An unknown cannot relate to itself",
path: ["relationships"],
});
for (const ids of [
value.supporting_evidence_ids,
value.contradicting_evidence_ids,
value.mutation_evidence_ids,
])
if (new Set(ids).size !== ids.length)
context.addIssue({
code: "custom",
message: "Evidence IDs must be unique",
});
const contradictingEvidence = new Set(value.contradicting_evidence_ids);
if (
value.supporting_evidence_ids.some((id) => contradictingEvidence.has(id))
)
context.addIssue({
code: "custom",
message: "Evidence cannot simultaneously support and contradict",
});
const relationships = value.relationships.map(
(relationship) => `${relationship.type}:${relationship.unknown_id}`,
);
if (new Set(relationships).size !== relationships.length)
context.addIssue({
code: "custom",
message: "Relationships must be unique",
path: ["relationships"],
});
refineRevision(value, context);
},
);
const generatedRevisionSchema = residualUnknownObjectSchema
.omit({ revision_digest: true })
.superRefine(refineRevision);
export type ResidualUnknown = z.infer<typeof residualUnknownSchema>;
export type UnknownStatus = ResidualUnknown["status"];
@@ -200,9 +211,10 @@ export const createResidualUnknown = (
mutationEvidenceId: string,
scopeDigest: string | null,
): ResidualUnknown => {
const question = input.question.replace(/\s+/gu, " ");
const question = input.question.replace(/\s+/gu, " ").trim();
const domain = input.domain.trim();
const identity = canonicalJson({
domain: input.domain,
domain,
question,
scope_digest: scopeDigest,
});
@@ -214,15 +226,12 @@ export const createResidualUnknown = (
question,
status:
input.contradicting_evidence_ids.length > 0 ? "contradicted" : "open",
domain: input.domain,
domain,
...projectRevisionDetails(input),
resolution: null,
mutation_evidence_ids: [mutationEvidenceId],
} satisfies Omit<ResidualUnknown, "revision_digest">;
return residualUnknownSchema.parse({
...revision,
revision_digest: computeRevisionDigest(revision),
});
return createRevision(revision);
};
/** Apply a complete, revision-guarded replacement while preserving identity. */
@@ -246,10 +255,13 @@ export const updateResidualUnknown = (
mutationEvidenceId,
]),
} satisfies Omit<ResidualUnknown, "revision_digest">;
return residualUnknownSchema.parse({
...revision,
revision_digest: computeRevisionDigest(revision),
});
return createRevision(revision);
};
/** Validate and normalize owned revision fields before computing their digest. */
const createRevision = (value: UnknownRevision): ResidualUnknown => {
const revision = generatedRevisionSchema.parse(value);
return { ...revision, revision_digest: computeRevisionDigest(revision) };
};
type RevisionDetailsInput = Pick<
@@ -282,26 +282,27 @@ describe("MCP investigation history snapshot replay", () => {
const foreignEvidence = toolContract("list_strings").outputSchema.parse(
unrelated.structuredContent,
);
const updateInput = {
unknown_id: unknown.unknown_id,
expected_revision: unknown.revision,
status: "resolved",
severity: input.severity,
supporting_evidence_ids: resolutionEvidenceIds,
contradicting_evidence_ids: input.contradicting_evidence_ids,
required_authority: input.required_authority,
required_confidence: input.required_confidence,
required_environment: input.required_environment,
recommended_probes: input.recommended_probes,
relationships: input.relationships,
resolution: {
disposition,
rationale: "Disposition recorded after further investigation.",
evidence_ids: resolutionEvidenceIds,
},
};
const updated = await mcp.callTool({
name: "update_unknown",
arguments: {
unknown_id: unknown.unknown_id,
expected_revision: unknown.revision,
status: "resolved",
severity: input.severity,
supporting_evidence_ids: resolutionEvidenceIds,
contradicting_evidence_ids: input.contradicting_evidence_ids,
required_authority: input.required_authority,
required_confidence: input.required_confidence,
required_environment: input.required_environment,
recommended_probes: input.recommended_probes,
relationships: input.relationships,
resolution: {
disposition,
rationale: "Disposition recorded after further investigation.",
evidence_ids: resolutionEvidenceIds,
},
},
arguments: updateInput,
});
expect(updated.isError, JSON.stringify(updated.content)).not.toBe(true);
const resolved = toolContract("update_unknown").outputSchema.parse(
@@ -327,6 +328,7 @@ describe("MCP investigation history snapshot replay", () => {
expect(snapshot.value.evidence_bundle.records).toContainEqual(
expect.objectContaining({
operation: "update_unknown",
parameters: updateInput,
subject: expect.objectContaining({ local_path: secondPath }),
}),
);