fix(reconstruction): share authenticated obligation ledger sources

This commit is contained in:
morluto
2026-10-23 06:15:38 +08:00
parent 47353e494d
commit db2a3174f6
14 changed files with 467 additions and 416 deletions
@@ -12,8 +12,11 @@ import {
createJavaScriptSemanticGraphUnknown,
JavaScriptSemanticEvidenceContextRegistry,
} from "../domain/javascript/javascriptSemanticGraph.js";
import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js";
import { build, request } from "./ReconstructionObligationLedger.fixture.js";
import {
build,
deriveCandidates,
request,
} from "./ReconstructionObligationLedger.fixture.js";
const semanticApplication = (
frontiers: readonly {
@@ -162,10 +165,7 @@ describe("semantic uncertainty at the obligation candidate boundary", () => {
),
].sort();
for (const order of [records, [...records].reverse()]) {
const generated = deriveReconstructionObligationCandidates(
createEvidenceBundle(order),
[],
);
const generated = deriveCandidates(createEvidenceBundle(order), []);
expect(generated.candidates).toHaveLength(1);
expect(generated.candidates[0]).toMatchObject({
definitionCount: 1,
@@ -5,7 +5,10 @@ import {
createJavaScriptSemanticGraphNode,
JavaScriptSemanticEvidenceContextRegistry,
} from "../domain/javascript/javascriptSemanticGraph.js";
import { proofEvidence } from "./ReconstructionObligationLedger.fixture.js";
import {
deriveCandidates,
proofEvidence,
} from "./ReconstructionObligationLedger.fixture.js";
import { describe, expect, it } from "vitest";
import { createEvidence } from "../domain/evidence.js";
@@ -33,7 +36,6 @@ import {
import { jsonValueSchema } from "../domain/jsonValue.js";
import { MANAGED_WORKFLOW_PROVIDER } from "./InvestigationProviders.js";
import { createProcessCaptureEvidence } from "./process/ProcessEvidence.js";
import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js";
const applicationWithUnknowns = (
keys: readonly string[],
@@ -119,7 +121,7 @@ const applicationWithUnknowns = (
return {
graph,
unknownNodes,
generated: deriveReconstructionObligationCandidates(
generated: deriveCandidates(
createEvidenceBundle([source, application]),
[],
),
@@ -192,10 +194,7 @@ describe("reconstruction obligation candidates", () => {
},
});
const evidence = createProcessCaptureEvidence(scenario, capture);
const generated = deriveReconstructionObligationCandidates(
createEvidenceBundle([evidence]),
[],
);
const generated = deriveCandidates(createEvidenceBundle([evidence]), []);
expect(generated.candidates).toEqual([]);
expect(generated.limitations).toContain(
@@ -227,10 +226,7 @@ describe("reconstruction obligation candidates", () => {
},
}),
});
const generated = deriveReconstructionObligationCandidates(
createEvidenceBundle([evidence]),
[],
);
const generated = deriveCandidates(createEvidenceBundle([evidence]), []);
expect(generated.limitations).toEqual([
`Process Evidence ${evidence.evidence_id} could not generate candidates: at manifest.completed_at: Invalid input: expected string, received undefined (1 more issues)`,
@@ -318,7 +314,7 @@ it("keeps each semantic candidate bound to its own file and authority context",
evidenceLinks: sourceRecords.map(({ evidence_id }) => evidence_id),
},
);
const generated = deriveReconstructionObligationCandidates(
const generated = deriveCandidates(
createEvidenceBundle([...sourceRecords, application]),
[],
);
@@ -425,7 +421,7 @@ it("falls back to a kind-based title when a semantic node label is blank", () =>
evidenceLinks: [source.evidence_id],
},
);
const generated = deriveReconstructionObligationCandidates(
const generated = deriveCandidates(
createEvidenceBundle([source, application]),
[],
);
@@ -3,11 +3,11 @@ import { digestCanonicalValue } from "../domain/canonicalDigest.js";
import type { Evidence } from "../domain/evidence.js";
import { describeValidationFailure } from "../domain/validationFailure.js";
import { z } from "zod";
import {
parseEvidenceBundle,
type EvidenceBundle,
} from "../domain/evidenceBundle.js";
import { parseProcessCapture } from "../domain/process/processCaptureParsing.js";
parseProcessCapture,
type ProcessCapture,
} from "../domain/process/processCaptureParsing.js";
import type {
ReconstructionObligation,
ReviewedReconstructionObligation,
@@ -18,6 +18,7 @@ import {
semanticObligationPolicy,
type ReconstructionObligationCandidatePolicy,
} from "./ReconstructionObligationPolicies.js";
import type { ResolvedReconstructionObligationRequest } from "./ReconstructionObligationLedgerRequest.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
type CandidateFields = Omit<
@@ -36,30 +37,21 @@ export interface ReconstructionObligationCandidateSet {
readonly limitations: readonly string[];
}
/** Caller input cites review Evidence that its bundle does not contain. */
export class MissingReviewEvidenceError extends TypeError {
constructor(evidenceId: string) {
super(`Reviewed obligation Evidence is missing: ${evidenceId}`);
this.name = "MissingReviewEvidenceError";
}
}
/** Derive conservative candidates from graph, process, and reviewed inputs. */
export const deriveReconstructionObligationCandidates = (
bundleInput: EvidenceBundle,
reviewed: readonly ReviewedReconstructionObligation[],
request: Pick<
ResolvedReconstructionObligationRequest,
"sources" | "reviewedObligations"
>,
): ReconstructionObligationCandidateSet => {
const bundle = parseEvidenceBundle(bundleInput);
const records = new Map(
bundle.records.map((record) => [record.evidence_id, record]),
);
const { bundle, records } = request.sources;
const candidates = new Map<string, ReconstructionObligationCandidate>();
const limitations = new Set<string>();
for (const evidence of bundle.records) {
deriveApplicationCandidates(evidence, records, candidates, limitations);
deriveProcessCandidate(evidence, candidates, limitations);
}
for (const obligation of reviewed)
for (const obligation of request.reviewedObligations)
addCandidate(candidates, reviewedCandidate(obligation, records));
return {
candidates: [...candidates.values()].sort((left, right) =>
@@ -83,115 +75,109 @@ const deriveApplicationCandidates = (
].includes(evidence.operation)
)
return;
try {
const parsed = parseApplicationGraphEvidence(evidence);
if (!parsed.ok) {
limitations.add(
`Application Evidence ${evidence.evidence_id} could not generate candidates because it failed graph validation.`,
);
return;
const parsed = parseApplicationGraphEvidence(evidence);
if (!parsed.ok) {
limitations.add(
`Application Evidence ${evidence.evidence_id} could not generate candidates because it failed graph validation.`,
);
return;
}
const source = parsed.value;
for (const node of source.graph.nodes) {
const candidatePolicy = applicationObligationPolicy(node.kind);
if (candidatePolicy === undefined) {
if (node.kind === "unknown")
limitations.add(
`Application graph ${source.graph.graph_id} contains unresolved node ${node.node_id}; reconstruction obligations remain unknown.`,
);
continue;
}
const source = parsed.value;
for (const node of source.graph.nodes) {
const candidatePolicy = applicationObligationPolicy(node.kind);
if (candidatePolicy === undefined) {
if (node.kind === "unknown")
limitations.add(
`Application graph ${source.graph.graph_id} contains unresolved node ${node.node_id}; reconstruction obligations remain unknown.`,
);
continue;
const reference = node.observations.map((observation) =>
graphReference(
evidence,
records,
observation.evidence,
`${source.graph.graph_id}/node/${node.node_id}/observation/${observation.observation_id}`,
),
);
addCandidate(
candidates,
generatedCandidate({
key: `application:${node.node_id}:${candidatePolicy.family}`,
title: node.observations[0]?.label?.trim() || `${node.kind} boundary`,
policy: candidatePolicy,
artifactSha256: source.rootArtifactSha256,
applicationNodeId: node.node_id,
semanticNodeId: null,
location: `application-graph:${source.graph.graph_id}/nodes/${node.node_id}`,
authorityReferences: reference,
}),
);
}
if (source.semanticGraph !== null) {
const semanticGraph = source.semanticGraph;
const unknownIdsByNode = new Map<string, string[]>();
for (const unknown of semanticGraph.unknowns) {
// Candidate targets are possibilities, not established node bindings.
if (unknown.node_id !== null) {
const ids = unknownIdsByNode.get(unknown.node_id) ?? [];
ids.push(unknown.unknown_id);
unknownIdsByNode.set(unknown.node_id, ids);
}
const reference = node.observations.map((observation) =>
graphReference(
evidence,
records,
observation.evidence,
`${source.graph.graph_id}/node/${node.node_id}/observation/${observation.observation_id}`,
),
// Keep every frontier visible, including families without a policy.
limitations.add(
`Semantic graph ${semanticGraph.graph_id} contains unresolved ${unknown.family} unknown ${unknown.unknown_id}: ${unknown.detail}`,
);
}
if (semanticGraph.coverage.status !== "complete")
limitations.add(
`Semantic graph ${semanticGraph.graph_id} coverage is ${semanticGraph.coverage.status}; omitted candidates remain unknown.`,
);
for (const limitation of semanticGraph.limitations)
limitations.add(
`Semantic graph ${semanticGraph.graph_id}: ${limitation}`,
);
const evidenceContexts = new Map(
semanticGraph.evidence_contexts.map((context) => [
context.context_id,
context,
]),
);
for (const node of semanticGraph.nodes) {
const candidatePolicy = semanticObligationPolicy(node.kind);
if (candidatePolicy === undefined) continue;
const evidenceContext = evidenceContexts.get(node.evidence.context_id);
if (evidenceContext === undefined)
throw new TypeError(
"Semantic evidence reference names an absent context",
);
addCandidate(
candidates,
generatedCandidate({
key: `application:${node.node_id}:${candidatePolicy.family}`,
title: node.observations[0]?.label?.trim() || `${node.kind} boundary`,
key: `semantic:${node.node_id}:${candidatePolicy.family}`,
title: node.label?.trim() || `${node.kind} semantic boundary`,
policy: candidatePolicy,
artifactSha256: source.rootArtifactSha256,
applicationNodeId: node.node_id,
semanticNodeId: null,
location: `application-graph:${source.graph.graph_id}/nodes/${node.node_id}`,
authorityReferences: reference,
artifactSha256: node.identity.artifact_sha256,
applicationNodeId: node.application_node_ids[0] ?? null,
semanticNodeId: node.node_id,
residualUnknownIds: unknownIdsByNode.get(node.node_id) ?? [],
location: `semantic-graph:${semanticGraph.graph_id}/nodes/${node.node_id}`,
authorityReferences: [
graphReference(
evidence,
records,
evidenceContext,
`${semanticGraph.graph_id}/node/${node.node_id}`,
),
],
}),
);
}
if (source.semanticGraph !== null) {
const semanticGraph = source.semanticGraph;
const unknownIdsByNode = new Map<string, string[]>();
for (const unknown of semanticGraph.unknowns) {
// Candidate targets are possibilities, not established node bindings.
if (unknown.node_id !== null) {
const ids = unknownIdsByNode.get(unknown.node_id) ?? [];
ids.push(unknown.unknown_id);
unknownIdsByNode.set(unknown.node_id, ids);
}
// Keep every frontier visible, including families without a policy.
limitations.add(
`Semantic graph ${semanticGraph.graph_id} contains unresolved ${unknown.family} unknown ${unknown.unknown_id}: ${unknown.detail}`,
);
}
if (semanticGraph.coverage.status !== "complete")
limitations.add(
`Semantic graph ${semanticGraph.graph_id} coverage is ${semanticGraph.coverage.status}; omitted candidates remain unknown.`,
);
for (const limitation of semanticGraph.limitations)
limitations.add(
`Semantic graph ${semanticGraph.graph_id}: ${limitation}`,
);
const evidenceContexts = new Map(
semanticGraph.evidence_contexts.map((context) => [
context.context_id,
context,
]),
);
for (const node of semanticGraph.nodes) {
const candidatePolicy = semanticObligationPolicy(node.kind);
if (candidatePolicy === undefined) continue;
const evidenceContext = evidenceContexts.get(node.evidence.context_id);
if (evidenceContext === undefined)
throw new TypeError(
"Semantic evidence reference names an absent context",
);
addCandidate(
candidates,
generatedCandidate({
key: `semantic:${node.node_id}:${candidatePolicy.family}`,
title: node.label?.trim() || `${node.kind} semantic boundary`,
policy: candidatePolicy,
artifactSha256: node.identity.artifact_sha256,
applicationNodeId: node.application_node_ids[0] ?? null,
semanticNodeId: node.node_id,
residualUnknownIds: unknownIdsByNode.get(node.node_id) ?? [],
location: `semantic-graph:${semanticGraph.graph_id}/nodes/${node.node_id}`,
authorityReferences: [
graphReference(
evidence,
records,
evidenceContext,
`${semanticGraph.graph_id}/node/${node.node_id}`,
),
],
}),
);
}
}
if (source.graph.coverage.status !== "complete")
limitations.add(
`Application graph ${source.graph.graph_id} coverage is ${source.graph.coverage.status}; omitted candidates remain unknown.`,
);
} catch (cause: unknown) {
limitations.add(
`Application Evidence ${evidence.evidence_id} could not generate candidates: ${describeValidationFailure(cause)}`,
);
}
if (source.graph.coverage.status !== "complete")
limitations.add(
`Application graph ${source.graph.graph_id} coverage is ${source.graph.coverage.status}; omitted candidates remain unknown.`,
);
};
const deriveProcessCandidate = (
@@ -204,86 +190,89 @@ const deriveProcessCandidate = (
evidence.predicate_type !== "rea.process-capture"
)
return;
let capture: ProcessCapture;
try {
const capture = parseProcessCapture(evidence.normalized_result);
const observedCases: ReconstructionObligation["observed_cases"] = [
{
kind: capture.exit.code === 0 ? "positive" : "negative",
evidence_id: evidence.evidence_id,
location: "/normalized_result/exit",
},
{
kind: "teardown",
evidence_id: evidence.evidence_id,
location: "/normalized_result/settlement",
},
...(capture.exit.signal !== null || capture.exit.reason !== "exited"
? [
{
kind: "cancellation" as const,
evidence_id: evidence.evidence_id,
location: "/normalized_result/exit",
},
]
: []),
];
if (capture.manifest.executable_sha256 === null) {
limitations.add(
`Process capture ${evidence.evidence_id} has no validated executable artifact identity; packaged-process lifecycle obligation was not bound to an artifact.`,
);
return;
}
addCandidate(candidates, {
obligation_id: obligationId(
`process:${capture.manifest.executable_sha256}:${capture.manifest.full_scenario_sha256}`,
),
obligation_version: 1,
title: "Captured packaged-process lifecycle",
origin: "generated",
application_layer: "process",
family: "packaged-process-lifecycle",
target: {
artifact_sha256: capture.manifest.executable_sha256,
application_node_id: null,
semantic_node_id: null,
location: `/evidence/${evidence.evidence_id}/normalized_result`,
},
authority_references: [
{
evidence_id: evidence.evidence_id,
authority: evidence.authority,
state: "observed",
location: "/normalized_result",
},
],
source_state: "observed",
observed_cases: observedCases,
required: true,
required_case_kinds: ["positive", "negative", "cancellation", "teardown"],
required_original_authority: "process",
required_fixture_authority: "packaged-process",
required_verifier_authority: "packaged-process",
requires_parser_type: false,
dependency_obligation_ids: [],
residual_unknown_ids: [],
unavailable_authority: capture.residual_unknowns.map(
({ scope, reason }) => `${scope}: ${reason}`,
),
required_next_evidence: capture.truncated
? ["Repeat the packaged-process capture without truncation."]
: [],
definitionCount: 1,
disposition: "active",
});
if (capture.truncated)
limitations.add(
`Process Evidence ${evidence.evidence_id} is truncated; absence and upper bounds remain unknown.`,
);
capture = parseProcessCapture(evidence.normalized_result);
} catch (cause: unknown) {
if (!(cause instanceof z.ZodError)) throw cause;
limitations.add(
`Process Evidence ${evidence.evidence_id} could not generate candidates: ${describeValidationFailure(cause)}`,
);
return;
}
const observedCases: ReconstructionObligation["observed_cases"] = [
{
kind: capture.exit.code === 0 ? "positive" : "negative",
evidence_id: evidence.evidence_id,
location: "/normalized_result/exit",
},
{
kind: "teardown",
evidence_id: evidence.evidence_id,
location: "/normalized_result/settlement",
},
...(capture.exit.signal !== null || capture.exit.reason !== "exited"
? [
{
kind: "cancellation" as const,
evidence_id: evidence.evidence_id,
location: "/normalized_result/exit",
},
]
: []),
];
if (capture.manifest.executable_sha256 === null) {
limitations.add(
`Process capture ${evidence.evidence_id} has no validated executable artifact identity; packaged-process lifecycle obligation was not bound to an artifact.`,
);
return;
}
addCandidate(candidates, {
obligation_id: obligationId(
`process:${capture.manifest.executable_sha256}:${capture.manifest.full_scenario_sha256}`,
),
obligation_version: 1,
title: "Captured packaged-process lifecycle",
origin: "generated",
application_layer: "process",
family: "packaged-process-lifecycle",
target: {
artifact_sha256: capture.manifest.executable_sha256,
application_node_id: null,
semantic_node_id: null,
location: `/evidence/${evidence.evidence_id}/normalized_result`,
},
authority_references: [
{
evidence_id: evidence.evidence_id,
authority: evidence.authority,
state: "observed",
location: "/normalized_result",
},
],
source_state: "observed",
observed_cases: observedCases,
required: true,
required_case_kinds: ["positive", "negative", "cancellation", "teardown"],
required_original_authority: "process",
required_fixture_authority: "packaged-process",
required_verifier_authority: "packaged-process",
requires_parser_type: false,
dependency_obligation_ids: [],
residual_unknown_ids: [],
unavailable_authority: capture.residual_unknowns.map(
({ scope, reason }) => `${scope}: ${reason}`,
),
required_next_evidence: capture.truncated
? ["Repeat the packaged-process capture without truncation."]
: [],
definitionCount: 1,
disposition: "active",
});
if (capture.truncated)
limitations.add(
`Process Evidence ${evidence.evidence_id} is truncated; absence and upper bounds remain unknown.`,
);
};
interface GeneratedCandidateInput {
@@ -355,7 +344,9 @@ const reviewedCandidate = (
authority_references: reviewEvidenceIds.map((evidenceId) => {
const evidence = records.get(evidenceId);
if (evidence === undefined)
throw new MissingReviewEvidenceError(evidenceId);
throw new Error(
`Resolved reviewed obligation references absent Evidence: ${evidenceId}`,
);
return {
evidence_id: evidenceId,
authority: evidence.authority,
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import {
build,
deriveCandidates,
resolveRequest,
originalCases,
processEvidence,
proofEvidence,
@@ -9,7 +11,6 @@ import {
request,
reviewedObligation,
} from "./ReconstructionObligationLedger.fixture.js";
import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js";
import { evaluateReconstructionObligationLedger } from "./ReconstructionObligationLedgerEvaluation.js";
describe("reconstruction obligation ledger", () => {
@@ -136,7 +137,7 @@ describe("reconstruction obligation ledger Evidence provenance", () => {
const capture = processEvidence();
const caseEvidence = proofEvidence("distinct-original-case");
const bundle = request([capture, caseEvidence]).evidence_bundle;
const generated = deriveReconstructionObligationCandidates(bundle, []);
const generated = deriveCandidates(bundle, []);
const candidates = generated.candidates.map((candidate) => ({
...candidate,
observed_cases: candidate.observed_cases.map((observedCase) => ({
@@ -147,7 +148,11 @@ describe("reconstruction obligation ledger Evidence provenance", () => {
const result = evaluateReconstructionObligationLedger({
candidates,
bundle,
sources: resolveRequest({
evidence_bundle: bundle,
reviewed_obligations: [],
manifest: { bindings: [], contradictions: [] },
}).sources,
manifest: { bindings: [], contradictions: [] },
generationLimitations: [],
});
@@ -234,13 +239,13 @@ describe("reconstruction obligation ledger case closure", () => {
expect(result.status).toBe("ready");
expect(result.summary.required_open).toBe(0);
const generated = deriveReconstructionObligationCandidates(
const generated = deriveCandidates(
ledgerRequest.evidence_bundle,
ledgerRequest.reviewed_obligations,
);
const limited = evaluateReconstructionObligationLedger({
candidates: generated.candidates,
bundle: ledgerRequest.evidence_bundle,
sources: resolveRequest(ledgerRequest).sources,
manifest: ledgerRequest.manifest,
generationLimitations: ["A supplementary source was unavailable."],
});
@@ -1,11 +1,13 @@
import { PROCESS_PROVIDER } from "../domain/process/processEvidenceProvider.js";
import {
buildReconstructionObligationLedgerEvidenceValidated,
resolveReconstructionObligationLedgerRequest,
} from "./ReconstructionObligationLedgerService.js";
import { buildReconstructionObligationLedgerEvidence } from "./ReconstructionObligationLedgerService.js";
import { resolveReconstructionObligationLedgerRequest } from "./ReconstructionObligationLedgerRequest.js";
import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js";
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../domain/process/processCaptureExample.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import { createEvidenceBundle } from "../domain/evidenceBundle.js";
import {
createEvidenceBundle,
type EvidenceBundle,
} from "../domain/evidenceBundle.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import {
reconstructionObligationLedgerSchema,
@@ -98,11 +100,7 @@ export const request = (
export const build = (
input: ReconstructionObligationLedgerInput,
): ReconstructionObligationLedger => {
const parsed = resolveReconstructionObligationLedgerRequest(input);
if (!parsed.ok) throw parsed.error;
const result = buildReconstructionObligationLedgerEvidenceValidated(
parsed.value,
);
const result = buildReconstructionObligationLedgerEvidence(input);
if (!result.ok) throw result.error;
return reconstructionObligationLedgerSchema.parse(
result.value.normalized_result,
@@ -266,3 +264,23 @@ export const MIXED_AUTHORITY_SUMMARY = {
{ key: "unknown", count: 0 },
],
} as const;
/** Resolve fixture sources through the production request owner. */
export const resolveRequest = (input: ReconstructionObligationLedgerInput) => {
const resolved = resolveReconstructionObligationLedgerRequest(input);
if (!resolved.ok) throw resolved.error;
return resolved.value;
};
/** Derive candidates from fixture requests after authenticating their sources. */
export const deriveCandidates = (
bundle: EvidenceBundle,
reviewed: readonly ReviewedReconstructionObligation[],
) =>
deriveReconstructionObligationCandidates(
resolveRequest({
evidence_bundle: bundle,
reviewed_obligations: [...reviewed],
manifest: { bindings: [], contradictions: [] },
}),
);
@@ -1,8 +1,5 @@
import type { Evidence } from "../domain/evidence.js";
import {
parseEvidenceBundle,
type EvidenceBundle,
} from "../domain/evidenceBundle.js";
import type { ReconstructionObligationSources } from "./ReconstructionObligationLedgerRequest.js";
import {
reconstructionObligationLedgerSchema,
type ReconstructionObligation,
@@ -52,22 +49,21 @@ interface EvaluationContext {
export const evaluateReconstructionObligationLedger = ({
candidates: candidatesInput,
bundle: bundleInput,
sources,
manifest,
generationLimitations,
}: {
readonly candidates: readonly ReconstructionObligationCandidate[];
readonly bundle: EvidenceBundle;
readonly sources: ReconstructionObligationSources;
readonly manifest: ReconstructionObligationManifest;
readonly generationLimitations: readonly string[];
}): ReconstructionObligationLedger => {
const bundle = parseEvidenceBundle(bundleInput);
const allCandidates = [...candidatesInput].sort((left, right) =>
const { bundle, records } = sources;
const candidates = [...candidatesInput].sort((left, right) =>
compareUnicodeCodePoints(left.obligation_id, right.obligation_id),
);
const orphanManifestIds = findOrphanManifestIds(allCandidates, manifest);
const candidates = allCandidates;
const context = createEvaluationContext(candidates, bundle, manifest);
const orphanManifestIds = findOrphanManifestIds(candidates, manifest);
const context = createEvaluationContext(candidates, records, manifest);
const obligations = applyDependencyDiagnostics(
candidates.map((candidate) => evaluateObligation(candidate, context)),
context,
@@ -77,7 +73,7 @@ export const evaluateReconstructionObligationLedger = ({
const evidenceLinks = obligationLedgerEvidenceLinks(obligations, manifest);
const limitations = ledgerLimitations(
generationLimitations,
allCandidates.length,
candidates.length,
orphanManifestIds,
);
const coverage = reconstructionObligationCoverage(limitations);
@@ -115,15 +111,13 @@ export const evaluateReconstructionObligationLedger = ({
const createEvaluationContext = (
candidates: readonly ReconstructionObligationCandidate[],
bundle: EvidenceBundle,
evidence: ReadonlyMap<string, Evidence>,
manifest: ReconstructionObligationManifest,
): EvaluationContext => ({
candidates: new Map(
candidates.map((candidate) => [candidate.obligation_id, candidate]),
),
evidence: new Map(
bundle.records.map((evidence) => [evidence.evidence_id, evidence]),
),
evidence,
bindings: groupManifestBindings(manifest),
contradictions: groupManifestContradictions(manifest),
});
@@ -0,0 +1,75 @@
import { z } from "zod";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { Evidence } from "../domain/evidence.js";
import {
readEvidenceBundle,
type EvidenceBundle,
} from "../domain/evidenceBundle.js";
import {
reconstructionObligationLedgerInputSchema,
type ReviewedReconstructionObligation,
type ReconstructionObligationManifest,
} from "../domain/reconstructionObligationLedgerSchemas.js";
import { err, ok, type Result } from "../domain/result.js";
import { requestInputError } from "./workflowInputError.js";
const OPERATION = "build_reconstruction_obligation_ledger";
const requestSchema = reconstructionObligationLedgerInputSchema.extend({
evidence_bundle: z.unknown(),
});
/** Authenticated bundle and its single request-owned record index. */
export interface ReconstructionObligationSources {
readonly bundle: EvidenceBundle;
readonly records: ReadonlyMap<string, Evidence>;
}
export interface ResolvedReconstructionObligationRequest {
readonly sources: ReconstructionObligationSources;
readonly reviewedObligations: readonly ReviewedReconstructionObligation[];
readonly manifest: ReconstructionObligationManifest;
}
/** Authenticate the source bundle and bind every selected review reference. */
export const resolveReconstructionObligationLedgerRequest = (
input: unknown,
): Result<ResolvedReconstructionObligationRequest, AnalysisError> => {
const frame = requestSchema.safeParse(input);
if (!frame.success)
return err(requestInputError(OPERATION, frame.error, input));
const bundle = readEvidenceBundle(frame.data.evidence_bundle);
if (!bundle.ok) return bundle;
const records = new Map(
bundle.value.records.map((record) => [record.evidence_id, record]),
);
for (const [index, obligation] of frame.data.reviewed_obligations.entries()) {
for (const [
referenceIndex,
id,
] of obligation.review_evidence_ids.entries()) {
if (records.has(id)) continue;
const message = `Reviewed obligation Evidence is missing: ${id}`;
return err(
new AnalysisInputError(OPERATION, { userMessage: message }, [
{
path: [
"reviewed_obligations",
index,
"review_evidence_ids",
referenceIndex,
],
reason: "invalid_value",
message,
},
]),
);
}
}
return ok({
sources: { bundle: bundle.value, records },
reviewedObligations: frame.data.reviewed_obligations,
manifest: frame.data.manifest,
});
};
@@ -1,94 +1,55 @@
import { z } from "zod";
import { AnalysisProtocolError } from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
DuplicateEvidenceRecordError,
InvalidResidualUnknownGraphError,
NonCanonicalEvidenceBundleError,
} from "../domain/evidenceBundle.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js";
import {
reconstructionObligationLedgerInputSchema,
reconstructionObligationLedgerSchema,
type ReconstructionObligationLedgerInput,
type ReconstructionObligationLedger,
} from "../domain/reconstructionObligationLedgerSchemas.js";
import { err, ok, type Result } from "../domain/result.js";
import { requestInputError, workflowInputError } from "./workflowInputError.js";
import type { ReconstructionObligationLedger } from "../domain/reconstructionObligationLedgerSchemas.js";
import { ok, type Result } from "../domain/result.js";
import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "./InvestigationProviders.js";
import {
deriveReconstructionObligationCandidates,
MissingReviewEvidenceError,
} from "./ReconstructionObligationCandidates.js";
import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js";
import { evaluateReconstructionObligationLedger } from "./ReconstructionObligationLedgerEvaluation.js";
import {
resolveReconstructionObligationLedgerRequest,
type ResolvedReconstructionObligationRequest,
} from "./ReconstructionObligationLedgerRequest.js";
const OPERATION = "build_reconstruction_obligation_ledger" as const;
const OPERATION = "build_reconstruction_obligation_ledger";
/** Parse one portable ledger request for both CLI and MCP adapters. */
export const resolveReconstructionObligationLedgerRequest = (
/** Build portable ledger Evidence from a caller-selected request. */
export const buildReconstructionObligationLedgerEvidence = (
input: unknown,
): Result<ReconstructionObligationLedgerInput, AnalysisError> => {
const parsed = reconstructionObligationLedgerInputSchema.safeParse(input);
return parsed.success
? ok(parsed.data)
: err(requestInputError(OPERATION, parsed.error, input));
): Result<Evidence, AnalysisError> => {
const request = resolveReconstructionObligationLedgerRequest(input);
if (!request.ok) return request;
return ok(buildResolvedReconstructionObligationLedgerEvidence(request.value));
};
/** Build the complete deterministic ledger and wrap it in portable Evidence. */
export const buildReconstructionObligationLedgerEvidenceValidated = (
input: ReconstructionObligationLedgerInput,
): Result<Evidence, AnalysisError> => {
try {
const generated = deriveReconstructionObligationCandidates(
input.evidence_bundle,
input.reviewed_obligations,
);
const ledger = evaluateReconstructionObligationLedger({
candidates: generated.candidates,
bundle: input.evidence_bundle,
manifest: input.manifest,
generationLimitations: generated.limitations,
});
return ok(createLedgerEvidence(input, ledger));
} catch (cause: unknown) {
if (
cause instanceof NonCanonicalEvidenceBundleError ||
cause instanceof DuplicateEvidenceRecordError ||
cause instanceof InvalidResidualUnknownGraphError ||
cause instanceof MissingReviewEvidenceError
)
return err(workflowInputError(OPERATION, cause));
if (cause instanceof z.ZodError)
return err(workflowInputError(OPERATION, cause));
return err(
new AnalysisProtocolError(
"Reconstruction obligation ledger generation failed",
{ cause },
),
);
}
/** Generate and evaluate against the same authenticated bundle and record index. */
export const buildResolvedReconstructionObligationLedgerEvidence = (
request: ResolvedReconstructionObligationRequest,
): Evidence => {
const generated = deriveReconstructionObligationCandidates(request);
const ledger = evaluateReconstructionObligationLedger({
candidates: generated.candidates,
sources: request.sources,
manifest: request.manifest,
generationLimitations: generated.limitations,
});
return createLedgerEvidence(ledger);
};
const createLedgerEvidence = (
input: ReconstructionObligationLedgerInput,
ledger: ReconstructionObligationLedger,
): Evidence => {
const completeLedger = reconstructionObligationLedgerSchema.parse(ledger);
return createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
): Evidence =>
createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
predicateType: "rea.reconstruction-obligation-ledger",
operation: OPERATION,
parameters: jsonObjectSchema.parse({
ledger_id: completeLedger.ledger_id,
closure_digest: completeLedger.closure_digest,
}),
result: jsonValueSchema.parse(completeLedger),
parameters: {
ledger_id: ledger.ledger_id,
closure_digest: ledger.closure_digest,
},
result: ledger,
rawResult: null,
confidence: "inferred",
authority: "analyst-inference",
environment: null,
limitations: completeLedger.limitations,
evidenceLinks: completeLedger.evidence_links,
limitations: ledger.limitations,
evidenceLinks: ledger.evidence_links,
});
};
+2 -24
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import type { z } from "zod";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import type { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
/** Project schema issues of the raw request onto their request paths. */
@@ -10,25 +10,3 @@ export const requestInputError = (
input: unknown,
): AnalysisInputError =>
analysisInputErrorFromIssues(operation, cause.issues, input, { cause });
/**
* Keep the failed constraint when a parsed request is rejected later. Nested
* values such as Evidence results are parsed on their own, so their issue
* paths are relative to that value and are reported in the message, not as
* request paths.
*/
export const workflowInputError = (
operation: string,
cause: z.ZodError | TypeError,
): AnalysisInputError =>
new AnalysisInputError(
operation,
{ cause },
cause instanceof z.ZodError
? cause.issues.map((issue) => ({
path: [],
reason: "invalid_value" as const,
message: `A nested value failed validation at ${issue.path.length === 0 ? "its root" : issue.path.map(String).join(".")}: ${issue.message}`,
}))
: [{ path: [], reason: "invalid_value", message: cause.message }],
);
+3 -8
View File
@@ -17,7 +17,6 @@ import { compareApplicationVersionsInputSchema } from "../domain/javascript/java
import { registerSourceToBundleCommand } from "./sourceToBundleCommand.js";
import { compareJavaScriptExportShapesInputSchema } from "../domain/javascript/javascriptExportShapeComparisonSchemas.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { reconstructionObligationLedgerInputSchema } from "../domain/reconstructionObligationLedgerSchemas.js";
type CliInstance = ReturnType<typeof Cli.create>;
@@ -141,15 +140,11 @@ const registerObligationLedgerCommand = (
name: CLI_COMMANDS.buildReconstructionObligationLedger,
description:
"Generate a deterministic Evidence-backed reconstruction obligation ledger page",
inputSchema: reconstructionObligationLedgerInputSchema,
inputSchema: z.unknown(),
workflow: async (input) => {
const { buildReconstructionObligationLedgerEvidenceValidated } =
const { buildReconstructionObligationLedgerEvidence } =
await import("../application/ReconstructionObligationLedgerService.js");
const result =
buildReconstructionObligationLedgerEvidenceValidated(input);
return result.ok
? { ok: true, value: jsonValueSchema.parse(result.value) }
: result;
return buildReconstructionObligationLedgerEvidence(input);
},
});
@@ -2,10 +2,9 @@ import type { EvidenceMcpServer } from "../EvidenceMcpServer.js";
import { runAdmittedToolOperation } from "../admittedToolOperation.js";
import { recordSessionEvidenceSources } from "../sessionEvidence.js";
import {
buildReconstructionObligationLedgerEvidenceValidated,
resolveReconstructionObligationLedgerRequest,
} from "../../application/ReconstructionObligationLedgerService.js";
import { buildResolvedReconstructionObligationLedgerEvidence } from "../../application/ReconstructionObligationLedgerService.js";
import { resolveReconstructionObligationLedgerRequest } from "../../application/ReconstructionObligationLedgerRequest.js";
import { ok } from "../../domain/result.js";
import { applicationToolContract } from "../../contracts/applicationToolContracts.js";
import { logToolExecution } from "../toolLogging.js";
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
@@ -39,8 +38,10 @@ export const registerReconstructionObligationLedgerTool = (
contract.name,
() =>
Promise.resolve(
buildReconstructionObligationLedgerEvidenceValidated(
resolved.value,
ok(
buildResolvedReconstructionObligationLedgerEvidence(
resolved.value,
),
),
),
);
@@ -48,7 +49,7 @@ export const registerReconstructionObligationLedgerTool = (
return server.delivery.toCallToolResult(result, contract);
const recorded = recordSessionEvidenceSources(
options.recordEvidence,
resolved.value.evidence_bundle.records,
resolved.value.sources.bundle.records,
);
if (!recorded.ok)
return server.delivery.toCallToolResult(recorded, contract);
@@ -79,18 +79,13 @@ describe("reconstruction obligation ledger CLI parity", () => {
stdout = cause.stdout;
}
expect(JSON.parse(stdout)).toMatchObject({
code: "invalid_request",
category: "invalid_input",
code: "evidence_integrity_mismatch",
category: "integrity_mismatch",
retryable: false,
details: {
operation: "build_reconstruction_obligation_ledger",
issues: [
expect.objectContaining({
message: expect.stringContaining(
"Evidence bundle manifests are not canonical",
),
}),
],
reason: expect.stringContaining(
"Evidence bundle manifests are not canonical",
),
},
});
}, 20_000);
@@ -55,16 +55,11 @@ const withLedgerClient = async (
}
};
const inputError = (message: string) => ({
const integrityError = (message: string) => ({
error: {
code: "invalid_request",
category: "invalid_input",
details: {
operation: "build_reconstruction_obligation_ledger",
issues: [
expect.objectContaining({ message: expect.stringContaining(message) }),
],
},
code: "evidence_integrity_mismatch",
category: "integrity_mismatch",
details: { reason: expect.stringContaining(message) },
},
});
@@ -97,7 +92,7 @@ describe("reconstruction obligation ledger residual graph input", () => {
const result = await request(createEvidenceBundle([], [unknown]));
expect(result.isError).toBe(true);
expect(parseMcpToolError(result)).toMatchObject(
inputError(
integrityError(
`Residual unknown references missing evidence ${mutation.evidence_id}`,
),
);
@@ -110,7 +105,7 @@ describe("reconstruction obligation ledger residual graph input", () => {
});
it.each(["history", "relationship", "resolution"] as const)(
"reports residual %s constraints as caller input",
"reports residual %s source integrity constraints",
async (constraint) => {
const missingId = `unk_${"a".repeat(64)}`;
const input = recordUnknownInputSchema.parse({
@@ -168,14 +163,14 @@ describe("reconstruction obligation ledger residual graph input", () => {
createEvidenceBundle([mutation], unknowns),
);
expect(result.isError).toBe(true);
expect(parseMcpToolError(result)).toMatchObject(inputError(reason));
expect(parseMcpToolError(result)).toMatchObject(integrityError(reason));
});
},
);
});
describe("reconstruction obligation ledger MCP parity", () => {
it("returns canonical bundle failures as caller input errors", async () => {
it("rejects noncanonical source bundles before ledger generation", async () => {
const records = ["first", "second"].map((label) =>
createEvidence(
undefined,
@@ -218,22 +213,9 @@ describe("reconstruction obligation ledger MCP parity", () => {
records: [...canonical.records].reverse(),
});
expect(result.isError).toBe(true);
expect(parseMcpToolError(result)).toMatchObject({
error: {
code: "invalid_request",
category: "invalid_input",
details: {
operation: "build_reconstruction_obligation_ledger",
issues: [
expect.objectContaining({
message: expect.stringContaining(
"Evidence bundle manifests are not canonical",
),
}),
],
},
},
});
expect(parseMcpToolError(result)).toMatchObject(
integrityError("Evidence bundle manifests are not canonical"),
);
} finally {
await client.close();
await server.close();
@@ -241,7 +223,7 @@ describe("reconstruction obligation ledger MCP parity", () => {
}
});
it("returns duplicate records and missing review Evidence as caller input errors", async () => {
it("distinguishes corrupt source bundles from missing caller-selected review references", async () => {
const record = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
@@ -256,17 +238,25 @@ describe("reconstruction obligation ledger MCP parity", () => {
});
expect(duplicate.isError).toBe(true);
expect(parseMcpToolError(duplicate)).toMatchObject(
inputError("Evidence bundle contains duplicate record IDs"),
integrityError("Evidence bundle contains duplicate record IDs"),
);
const missing = await request(canonical, [
reviewedObligation("obl.reviewed", missingEvidenceId),
]);
expect(missing.isError).toBe(true);
expect(parseMcpToolError(missing)).toMatchObject(
inputError(
`Reviewed obligation Evidence is missing: ${missingEvidenceId}`,
),
);
expect(parseMcpToolError(missing)).toMatchObject({
error: {
code: "invalid_request",
details: {
issues: [
expect.objectContaining({
path: ["reviewed_obligations", 0, "review_evidence_ids", 0],
message: `Reviewed obligation Evidence is missing: ${missingEvidenceId}`,
}),
],
},
},
});
});
});
@@ -30,6 +30,25 @@ import { compareManagedMemberPaths } from "../../src/application/managed/Managed
import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { reconstructionObligationLedgerSchema } from "../../src/domain/reconstructionObligationLedgerSchemas.js";
import {
request as ledgerRequest,
processEvidence,
} from "../../src/application/ReconstructionObligationLedger.fixture.js";
import { processCaptureSchema } from "../../src/domain/process/processCapture.js";
/** Unexpected implementation failures retain the SDK text surface, without a typed caller error. */
const expectUnexpectedToolFailure = (
response: Awaited<ReturnType<Client["callTool"]>>,
failure: Error,
): void => {
expect(response).toMatchObject({
isError: true,
content: [{ type: "text", text: failure.message }],
});
expect(response.structuredContent).toBeUndefined();
};
const unexpectedResultFailures = [
["TypeError", () => new TypeError("injected result parser TypeError")],
["Error", () => new Error("injected result parser Error")],
@@ -226,11 +245,7 @@ describe("derived operation failures through the public SDK", () => {
},
});
expect(response).toMatchObject({
isError: true,
content: [{ type: "text", text: failure.message }],
});
expect(response.structuredContent).toBeUndefined();
expectUnexpectedToolFailure(response, failure);
expect(session.exportEvidenceBundle().records).toEqual([]);
});
},
@@ -251,11 +266,7 @@ describe("derived operation failures through the public SDK", () => {
name: "evaluate_reconstruction_coverage",
arguments: { coverage, boundary_id: "replacement.cli" },
});
expect(response).toMatchObject({
isError: true,
content: [{ type: "text", text: failure.message }],
});
expect(response.structuredContent).toBeUndefined();
expectUnexpectedToolFailure(response, failure);
expect(session.exportEvidenceBundle().records).toEqual([]);
});
},
@@ -366,11 +377,7 @@ describe.each(managedWorkflows)(
name: workflow.name,
arguments: workflow.input,
});
expect(response).toMatchObject({
isError: true,
content: [{ type: "text", text: failure.message }],
});
expect(response.structuredContent).toBeUndefined();
expectUnexpectedToolFailure(response, failure);
expect(session.exportEvidenceBundle().records).toEqual([]);
});
},
@@ -416,3 +423,48 @@ describe("managed comparison output failure with completed source reads", () =>
});
});
});
describe("ledger generation failure ownership through the public SDK", () => {
afterEach(() => vi.restoreAllMocks());
it.each(unexpectedResultFailures)(
"preserves an unexpected %s from generated ledger validation",
async (_label, createFailure) => {
await withSdk(async ({ client, session }) => {
const input = ledgerRequest([processEvidence()]);
const failure = createFailure();
vi.spyOn(
reconstructionObligationLedgerSchema,
"parse",
).mockImplementation(() => {
throw failure;
});
const response = await client.callTool({
name: "build_reconstruction_obligation_ledger",
arguments: input,
});
expectUnexpectedToolFailure(response, failure);
expect(session.exportEvidenceBundle().records).toEqual([]);
});
},
);
it.each(unexpectedResultFailures.filter(([kind]) => kind !== "ZodError"))(
"preserves an unexpected %s while deriving process candidates",
async (_label, createFailure) => {
await withSdk(async ({ client, session }) => {
const input = ledgerRequest([processEvidence()]);
const failure = createFailure();
vi.spyOn(processCaptureSchema, "parse").mockImplementation(() => {
throw failure;
});
const response = await client.callTool({
name: "build_reconstruction_obligation_ledger",
arguments: input,
});
expectUnexpectedToolFailure(response, failure);
expect(session.exportEvidenceBundle().records).toEqual([]);
});
},
);
});