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:
1 parent
844fa52772
commit
21650455a9
7 files changed
+364
-133
No files matched your search
@@ -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);
|
||||
]),
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user