diff --git a/src/application/ReconstructionObligationAuthority.ts b/src/application/ReconstructionObligationAuthority.ts new file mode 100644 index 000000000..f8c7b0bda --- /dev/null +++ b/src/application/ReconstructionObligationAuthority.ts @@ -0,0 +1,70 @@ +import type { Evidence } from "../domain/evidence.js"; +import type { ReconstructionObligation } from "../domain/reconstructionObligationLedgerSchemas.js"; + +type ProofAuthority = ReconstructionObligation["required_fixture_authority"]; +type OriginalAuthority = + ReconstructionObligation["required_original_authority"]; + +/** Return whether an actual reconstruction proof is comparable to a requirement. */ +export const proofAuthoritySatisfies = ( + actual: ProofAuthority, + required: ProofAuthority, +): boolean => { + const accepted: Readonly> = + { + unit: [ + "unit", + "integration", + "protocol", + "renderer", + "packaged-process", + "native-abi", + "live-observation", + "external", + ], + integration: [ + "integration", + "protocol", + "renderer", + "packaged-process", + "native-abi", + "live-observation", + ], + protocol: ["protocol", "packaged-process", "live-observation"], + renderer: ["renderer", "packaged-process", "live-observation"], + "packaged-process": ["packaged-process", "live-observation"], + "native-abi": ["native-abi"], + "live-observation": ["live-observation"], + external: ["external"], + }; + return accepted[required].includes(actual); +}; + +/** Check that Evidence can authenticate the declared reconstruction authority. */ +export const evidenceAuthoritySupportsProof = ( + evidence: Evidence, + authority: ProofAuthority, +): boolean => + evidence.confidence === "observed" && + (authority === "external" + ? evidence.authority === "external-service" + : ["shipped-artifact", "controlled-replay"].includes(evidence.authority)); + +/** Check that Evidence observes the original at the required boundary. */ +export const evidenceSupportsOriginalAuthority = ( + evidence: Evidence, + authority: OriginalAuthority, +): boolean => { + if (evidence.confidence !== "observed") return false; + if (authority === "static") return evidence.authority === "shipped-artifact"; + if (authority === "runtime") + return ["controlled-replay", "external-service"].includes( + evidence.authority, + ); + if (authority === "process") + return ( + evidence.operation === "capture_process_scenario" && + evidence.predicate_type === "rea.process-capture/v4" + ); + return evidence.authority === "external-service"; +}; diff --git a/src/application/ReconstructionObligationCandidates.ts b/src/application/ReconstructionObligationCandidates.ts new file mode 100644 index 000000000..b83100d65 --- /dev/null +++ b/src/application/ReconstructionObligationCandidates.ts @@ -0,0 +1,416 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; + +import type { Evidence } from "../domain/evidence.js"; +import { + parseEvidenceBundle, + type EvidenceBundle, +} from "../domain/evidenceBundle.js"; +import { parseProcessCapture } from "../domain/processCapture.js"; +import type { + ReconstructionObligation, + ReviewedReconstructionObligation, +} from "../domain/reconstructionObligationLedgerSchemas.js"; +import { parseApplicationGraphEvidence } from "./JavaScriptApplicationEvidenceGraph.js"; +import { + applicationObligationPolicy, + semanticObligationPolicy, + type ReconstructionObligationCandidatePolicy, +} from "./ReconstructionObligationPolicies.js"; + +type CandidateFields = Omit< + ReconstructionObligation, + "binding" | "status" | "diagnostics" +>; + +/** Internal generated or reviewed obligation before manifest closure evaluation. */ +export interface ReconstructionObligationCandidate extends CandidateFields { + readonly definitionCount: number; + readonly disposition: "active" | "blocked" | "out-of-scope"; +} + +export interface ReconstructionObligationCandidateSet { + readonly candidates: readonly ReconstructionObligationCandidate[]; + readonly limitations: readonly string[]; +} + +/** Derive conservative candidates from graph, process, and reviewed inputs. */ +export const deriveReconstructionObligationCandidates = ( + bundleInput: EvidenceBundle, + reviewed: readonly ReviewedReconstructionObligation[], +): ReconstructionObligationCandidateSet => { + const bundle = parseEvidenceBundle(bundleInput); + const records = new Map( + bundle.records.map((record) => [record.evidence_id, record]), + ); + const candidates = new Map(); + const limitations = new Set(); + for (const evidence of bundle.records) { + deriveApplicationCandidates(evidence, records, candidates, limitations); + deriveProcessCandidate(evidence, candidates, limitations); + } + for (const obligation of reviewed) + addCandidate(candidates, reviewedCandidate(obligation, records)); + return { + candidates: [...candidates.values()].sort((left, right) => + left.obligation_id.localeCompare(right.obligation_id), + ), + limitations: [...limitations].sort(), + }; +}; + +const deriveApplicationCandidates = ( + evidence: Evidence, + records: ReadonlyMap, + candidates: Map, + limitations: Set, +): void => { + if ( + ![ + "analyze_javascript_application", + "reconcile_javascript_runtime", + "project_managed_application_graph", + ].includes(evidence.operation) + ) + return; + try { + const source = parseApplicationGraphEvidence(evidence); + for (const node of source.graph.nodes) { + const candidatePolicy = applicationObligationPolicy(node.kind); + if (candidatePolicy === undefined) 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 ?? `${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) + for (const node of source.semanticGraph.nodes) { + const candidatePolicy = semanticObligationPolicy(node.kind); + if (candidatePolicy === undefined) continue; + addCandidate( + candidates, + generatedCandidate({ + key: `semantic:${node.node_id}:${candidatePolicy.family}`, + title: node.label ?? `${node.kind} semantic boundary`, + policy: candidatePolicy, + artifactSha256: node.identity.artifact_sha256, + applicationNodeId: node.application_node_ids[0] ?? null, + semanticNodeId: node.node_id, + location: `semantic-graph:${source.semanticGraph.graph_id}/nodes/${node.node_id}`, + authorityReferences: [ + graphReference( + evidence, + records, + node.evidence, + `${source.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: ${errorMessage(cause)}`, + ); + } +}; + +const deriveProcessCandidate = ( + evidence: Evidence, + candidates: Map, + limitations: Set, +): void => { + if ( + evidence.operation !== "capture_process_scenario" || + evidence.predicate_type !== "rea.process-capture/v4" + ) + return; + 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", + }, + ] + : []), + ]; + 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.`, + ); + } catch (cause: unknown) { + limitations.add( + `Process Evidence ${evidence.evidence_id} could not generate candidates: ${errorMessage(cause)}`, + ); + } +}; + +interface GeneratedCandidateInput { + readonly key: string; + readonly title: string; + readonly policy: ReconstructionObligationCandidatePolicy; + readonly artifactSha256: string; + readonly applicationNodeId: string | null; + readonly semanticNodeId: string | null; + readonly location: string; + readonly authorityReferences: ReconstructionObligation["authority_references"]; +} + +const generatedCandidate = ({ + key, + title, + policy: candidatePolicy, + artifactSha256, + applicationNodeId, + semanticNodeId, + location, + authorityReferences, +}: GeneratedCandidateInput): ReconstructionObligationCandidate => ({ + obligation_id: obligationId(key), + obligation_version: 1, + title: title.slice(0, 500), + origin: "generated", + application_layer: candidatePolicy.applicationLayer, + family: candidatePolicy.family, + target: { + artifact_sha256: artifactSha256, + application_node_id: applicationNodeId, + semantic_node_id: semanticNodeId, + location, + }, + authority_references: authorityReferences, + source_state: sourceState(authorityReferences), + observed_cases: [], + required: true, + required_case_kinds: candidatePolicy.cases, + required_original_authority: candidatePolicy.originalAuthority, + required_fixture_authority: candidatePolicy.fixtureAuthority, + required_verifier_authority: candidatePolicy.verifierAuthority, + requires_parser_type: candidatePolicy.requiresParserType, + dependency_obligation_ids: [], + residual_unknown_ids: [], + unavailable_authority: [], + required_next_evidence: [ + `Obtain ${candidatePolicy.originalAuthority} authority for this finite ${candidatePolicy.family} claim.`, + ], + definitionCount: 1, + disposition: "active", +}); + +const reviewedCandidate = ( + obligation: ReviewedReconstructionObligation, + records: ReadonlyMap, +): ReconstructionObligationCandidate => { + const { + review_evidence_ids: reviewEvidenceIds, + disposition, + ...definition + } = obligation; + return { + ...definition, + origin: "reviewed", + authority_references: reviewEvidenceIds.map((evidenceId) => { + const evidence = records.get(evidenceId); + if (evidence === undefined) + throw new TypeError( + `Reviewed obligation Evidence is missing: ${evidenceId}`, + ); + return { + evidence_id: evidenceId, + authority: evidence.authority, + state: reviewedEvidenceState(evidence), + location: `/evidence/${evidenceId}`, + }; + }), + source_state: "reviewed", + observed_cases: [], + definitionCount: 1, + disposition, + }; +}; + +const graphReference = ( + envelope: Evidence, + records: ReadonlyMap, + graphEvidence: { + readonly authority: string; + readonly state: string; + readonly evidence_ids: readonly string[]; + }, + location: string, +): ReconstructionObligation["authority_references"][number] => { + const linkedId = + graphEvidence.evidence_ids.find((evidenceId) => records.has(evidenceId)) ?? + envelope.evidence_id; + const linked = records.get(linkedId) ?? envelope; + const runtimeAuthority = [ + "passive-cdp-runtime", + "controlled-replay", + ].includes(graphEvidence.authority); + return { + evidence_id: linked.evidence_id, + authority: linked.authority, + state: + runtimeAuthority && graphEvidence.state === "observed" + ? "observed" + : graphEvidence.state === "unknown" || + graphEvidence.state === "unavailable" + ? "unknown" + : "candidate", + location, + }; +}; + +const reviewedEvidenceState = ( + evidence: Evidence, +): ReconstructionObligation["authority_references"][number]["state"] => { + if ( + evidence.operation === "capture_process_scenario" && + evidence.predicate_type === "rea.process-capture/v4" && + evidence.confidence === "observed" + ) + return "observed"; + if ( + evidence.authority === "external-service" && + evidence.confidence === "observed" + ) + return "observed"; + if (evidence.authority === "shipped-artifact") return "candidate"; + return "reviewed"; +}; + +const addCandidate = ( + candidates: Map, + candidate: ReconstructionObligationCandidate, +): void => { + const current = candidates.get(candidate.obligation_id); + if (current === undefined) { + candidates.set(candidate.obligation_id, candidate); + return; + } + candidates.set(candidate.obligation_id, { + ...current, + authority_references: uniqueBy( + [...current.authority_references, ...candidate.authority_references], + (reference) => + `${reference.evidence_id}:${reference.state}:${reference.location}`, + ), + observed_cases: uniqueBy( + [...current.observed_cases, ...candidate.observed_cases], + (item) => `${item.kind}:${item.evidence_id}:${item.location}`, + ), + source_state: sourceState([ + ...current.authority_references, + ...candidate.authority_references, + ]), + definitionCount: current.definitionCount + candidate.definitionCount, + }); +}; + +const sourceState = ( + references: ReconstructionObligation["authority_references"], +): ReconstructionObligation["source_state"] => { + if (references.some(({ state }) => state === "observed")) return "observed"; + if (references.some(({ state }) => state === "candidate")) return "candidate"; + if (references.some(({ state }) => state === "reviewed")) return "reviewed"; + return "unknown"; +}; + +const uniqueBy = ( + values: readonly Value[], + key: (value: Value) => string, +): Value[] => + [...new Map(values.map((value) => [key(value), value])).values()].sort( + (left, right) => key(left).localeCompare(key(right)), + ); + +const obligationId = (key: string): string => + `obl_${digest({ schema: "rea.reconstruction-obligation/v1", key })}`; + +const digest = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Reconstruction obligation key is not canonical JSON"); + return createHash("sha256").update(encoded).digest("hex"); +}; + +const errorMessage = (cause: unknown): string => + cause instanceof Error ? cause.message : "unknown validation failure"; diff --git a/src/application/ReconstructionObligationLedgerDiagnostics.ts b/src/application/ReconstructionObligationLedgerDiagnostics.ts new file mode 100644 index 000000000..d92fd21ef --- /dev/null +++ b/src/application/ReconstructionObligationLedgerDiagnostics.ts @@ -0,0 +1,87 @@ +import type { + ReconstructionObligation, + ReconstructionObligationManifest, +} from "../domain/reconstructionObligationLedgerSchemas.js"; +import type { Evidence } from "../domain/evidence.js"; + +type Diagnostic = ReconstructionObligation["diagnostics"][number]; +type Binding = NonNullable; + +export const groupManifestBindings = ( + manifest: ReconstructionObligationManifest, +): ReadonlyMap => { + const grouped = new Map(); + for (const binding of manifest.bindings) + grouped.set(binding.obligation_id, [ + ...(grouped.get(binding.obligation_id) ?? []), + binding, + ]); + return grouped; +}; + +export const groupManifestContradictions = ( + manifest: ReconstructionObligationManifest, +): ReadonlyMap => { + const grouped = new Map(); + for (const contradiction of manifest.contradictions) + grouped.set(contradiction.obligation_id, [ + ...(grouped.get(contradiction.obligation_id) ?? []), + ...contradiction.evidence_ids, + ]); + return grouped; +}; + +export const addObligationDiagnostic = ( + diagnostics: Diagnostic[], + code: Diagnostic["code"], + detail: string, +): void => { + diagnostics.push({ code, detail }); +}; + +export const uniqueObligationDiagnostics = ( + diagnostics: readonly Diagnostic[], +): Diagnostic[] => + [ + ...new Map( + diagnostics.map((diagnostic) => [ + `${diagnostic.code}:${diagnostic.detail}`, + diagnostic, + ]), + ).values(), + ].sort((left, right) => + `${left.code}:${left.detail}`.localeCompare( + `${right.code}:${right.detail}`, + ), + ); + +export const hasObligationDiagnostic = ( + diagnostics: readonly Diagnostic[], + code: Diagnostic["code"], +): boolean => diagnostics.some((diagnostic) => diagnostic.code === code); + +export const contradictionDiagnostics = ( + obligationId: string, + contradictions: ReadonlyMap, + evidenceById: ReadonlyMap, +): Diagnostic[] => { + const evidenceIds = contradictions.get(obligationId) ?? []; + if (evidenceIds.length === 0) return []; + const missing = evidenceIds.filter( + (evidenceId) => !evidenceById.has(evidenceId), + ); + return missing.length > 0 + ? [ + { + code: "contradiction-evidence-missing", + detail: `Contradiction references missing Evidence: ${missing.join(", ")}.`, + }, + ] + : [ + { + code: "contradiction", + detail: + "Authenticated contradictory Evidence reopens this obligation.", + }, + ]; +}; diff --git a/src/application/ReconstructionObligationLedgerEvaluation.ts b/src/application/ReconstructionObligationLedgerEvaluation.ts new file mode 100644 index 000000000..82035c4d4 --- /dev/null +++ b/src/application/ReconstructionObligationLedgerEvaluation.ts @@ -0,0 +1,515 @@ +import type { Evidence } from "../domain/evidence.js"; +import { + parseEvidenceBundle, + type EvidenceBundle, +} from "../domain/evidenceBundle.js"; +import { + reconstructionObligationLedgerSchema, + type ReconstructionObligation, + type ReconstructionObligationLedger, + type ReconstructionObligationManifest, +} from "../domain/reconstructionObligationLedgerSchemas.js"; +import type { ReconstructionObligationCandidate } from "./ReconstructionObligationCandidates.js"; +import { + evidenceAuthoritySupportsProof, + evidenceSupportsOriginalAuthority, + proofAuthoritySatisfies, +} from "./ReconstructionObligationAuthority.js"; +import { + addObligationDiagnostic as addDiagnostic, + contradictionDiagnostics, + groupManifestBindings, + groupManifestContradictions, + hasObligationDiagnostic as hasDiagnostic, + uniqueObligationDiagnostics as uniqueDiagnostics, +} from "./ReconstructionObligationLedgerDiagnostics.js"; +import { + originalCaseDiagnostics, + originalCasesFor, +} from "./ReconstructionObligationOriginalCases.js"; +import { + digestObligationLedgerValue, + obligationEdgeOrder, + obligationLedgerEvidenceLinks, + obligationLedgerStatus, + obligationReports, + summarizeObligations, +} from "./ReconstructionObligationLedgerProjection.js"; + +type Diagnostic = ReconstructionObligation["diagnostics"][number]; +type Binding = NonNullable; + +interface EvaluationContext { + readonly candidates: ReadonlyMap; + readonly evidence: ReadonlyMap; + readonly bindings: ReadonlyMap; + readonly contradictions: ReadonlyMap; +} + +/** Evaluate candidates and manifest bindings into one canonical fail-closed ledger. */ +export const evaluateReconstructionObligationLedger = ({ + candidates: candidatesInput, + bundle: bundleInput, + manifest, + maxObligations, + generationLimitations, +}: { + readonly candidates: readonly ReconstructionObligationCandidate[]; + readonly bundle: EvidenceBundle; + readonly manifest: ReconstructionObligationManifest; + readonly maxObligations: number; + readonly generationLimitations: readonly string[]; +}): ReconstructionObligationLedger => { + const bundle = parseEvidenceBundle(bundleInput); + const allCandidates = [...candidatesInput].sort((left, right) => + left.obligation_id.localeCompare(right.obligation_id), + ); + const orphanManifestIds = findOrphanManifestIds(allCandidates, manifest); + const candidates = allCandidates.slice(0, maxObligations); + const omittedCount = allCandidates.length - candidates.length; + const context = createEvaluationContext(candidates, bundle, manifest); + const preliminary = candidates.map((candidate) => + evaluateObligation(candidate, context), + ); + const obligations = applyDependencyDiagnostics(preliminary, context); + const summary = summarizeObligations(obligations); + const reports = obligationReports(obligations); + const ownershipGraph = obligations + .flatMap((obligation) => + obligation.binding === null + ? [] + : [ + { + obligation_id: obligation.obligation_id, + module_path: obligation.binding.owner.module_path, + symbol: obligation.binding.owner.symbol, + }, + ], + ) + .sort(obligationEdgeOrder); + const dependencyGraph = obligations + .flatMap((obligation) => + obligation.dependency_obligation_ids.map((dependencyId) => ({ + obligation_id: obligation.obligation_id, + depends_on_obligation_id: dependencyId, + })), + ) + .sort(obligationEdgeOrder); + const evidenceLinks = obligationLedgerEvidenceLinks(obligations, manifest); + const limitations = ledgerLimitations( + generationLimitations, + allCandidates.length, + omittedCount, + orphanManifestIds, + ); + const coverage = coverageFor( + omittedCount, + maxObligations, + limitations.length > 0, + ); + const semantic = { + coverage, + summary, + reports, + ownership_graph: ownershipGraph, + dependency_graph: dependencyGraph, + obligations, + evidence_links: evidenceLinks, + limitations, + }; + const evaluatedStatus = obligationLedgerStatus(obligations, omittedCount); + const status = + evaluatedStatus === "failed" + ? evaluatedStatus + : limitations.length > 0 + ? "unknown" + : evaluatedStatus; + const closureDigest = digestObligationLedgerValue({ + schema: "rea.reconstruction-obligation-closure/v1", + status, + ...semantic, + }); + return reconstructionObligationLedgerSchema.parse({ + schema: "ReconstructionObligationLedger", + schema_version: 1, + ledger_id: `rol_${digestObligationLedgerValue({ + schema: "rea.reconstruction-obligation-ledger/v1", + evidence_ids: bundle.records.map(({ evidence_id: id }) => id), + obligation_ids: obligations.map(({ obligation_id: id }) => id), + closure_digest: closureDigest, + })}`, + closure_digest: closureDigest, + status, + ...semantic, + }); +}; + +const createEvaluationContext = ( + candidates: readonly ReconstructionObligationCandidate[], + bundle: EvidenceBundle, + manifest: ReconstructionObligationManifest, +): EvaluationContext => ({ + candidates: new Map( + candidates.map((candidate) => [candidate.obligation_id, candidate]), + ), + evidence: new Map( + bundle.records.map((evidence) => [evidence.evidence_id, evidence]), + ), + bindings: groupManifestBindings(manifest), + contradictions: groupManifestContradictions(manifest), +}); + +const findOrphanManifestIds = ( + candidates: readonly ReconstructionObligationCandidate[], + manifest: ReconstructionObligationManifest, +): string[] => { + const candidateIds = new Set( + candidates.map(({ obligation_id: obligationId }) => obligationId), + ); + return [ + ...new Set( + [ + ...manifest.bindings.map( + ({ obligation_id: obligationId }) => obligationId, + ), + ...manifest.contradictions.map( + ({ obligation_id: obligationId }) => obligationId, + ), + ].filter((obligationId) => !candidateIds.has(obligationId)), + ), + ].sort(); +}; + +const ledgerLimitations = ( + generationLimitations: readonly string[], + candidateCount: number, + omittedCount: number, + orphanManifestIds: readonly string[], +): string[] => + [ + ...new Set([ + ...generationLimitations, + ...(candidateCount === 0 + ? [ + "No reconstruction obligations were derived; closure cannot be claimed.", + ] + : []), + ...(omittedCount > 0 + ? [ + `${String(omittedCount)} obligation candidates were omitted by max_obligations; closure is unknown.`, + ] + : []), + ...(orphanManifestIds.length > 0 + ? [ + `Manifest entries reference unknown obligations: ${orphanManifestIds.join(", ")}.`, + ] + : []), + ]), + ].sort(); + +const coverageFor = ( + omittedCount: number, + maxObligations: number, + incomplete: boolean, +): ReconstructionObligationLedger["coverage"] => ({ + status: incomplete ? "partial" : "complete", + truncated: omittedCount > 0, + omitted_count: omittedCount, + max_obligations: maxObligations, +}); + +const evaluateObligation = ( + candidate: ReconstructionObligationCandidate, + context: EvaluationContext, +): ReconstructionObligation => { + const bindings = context.bindings.get(candidate.obligation_id) ?? []; + const binding = bindings.length === 1 ? (bindings[0] ?? null) : null; + const diagnostics: Diagnostic[] = []; + if (candidate.definitionCount > 1) + addDiagnostic( + diagnostics, + "duplicate-definition", + "Multiple candidate definitions share this obligation ID.", + ); + if (bindings.length === 0) + addDiagnostic( + diagnostics, + "missing-owner", + "No reconstruction manifest binding owns this obligation.", + ); + if (bindings.length > 1) + addDiagnostic( + diagnostics, + "ambiguous-owner", + "Multiple reconstruction manifest bindings claim this obligation.", + ); + if (binding !== null) + evaluateBinding(candidate, binding, context, diagnostics); + diagnostics.push( + ...originalCaseDiagnostics(candidate, binding, context.evidence), + ); + evaluateAuthority(candidate, binding, context, diagnostics); + evaluateDisposition(candidate, diagnostics); + diagnostics.push( + ...contradictionDiagnostics( + candidate.obligation_id, + context.contradictions, + context.evidence, + ), + ); + for (const unknownId of candidate.residual_unknown_ids) + addDiagnostic( + diagnostics, + "residual-unknown", + `Residual unknown remains visible: ${unknownId}.`, + ); + for (const unavailable of candidate.unavailable_authority) + addDiagnostic(diagnostics, "unavailable-authority", unavailable); + return { + obligation_id: candidate.obligation_id, + obligation_version: candidate.obligation_version, + title: candidate.title, + origin: candidate.origin, + application_layer: candidate.application_layer, + family: candidate.family, + target: candidate.target, + authority_references: candidate.authority_references, + source_state: candidate.source_state, + observed_cases: originalCasesFor(candidate, binding), + required: candidate.required, + required_case_kinds: candidate.required_case_kinds, + required_original_authority: candidate.required_original_authority, + required_fixture_authority: candidate.required_fixture_authority, + required_verifier_authority: candidate.required_verifier_authority, + requires_parser_type: candidate.requires_parser_type, + dependency_obligation_ids: candidate.dependency_obligation_ids, + residual_unknown_ids: candidate.residual_unknown_ids, + unavailable_authority: candidate.unavailable_authority, + required_next_evidence: candidate.required_next_evidence, + binding, + status: obligationStatus(candidate, binding, diagnostics), + diagnostics: uniqueDiagnostics(diagnostics), + }; +}; + +const evaluateBinding = ( + candidate: ReconstructionObligationCandidate, + binding: Binding, + context: EvaluationContext, + diagnostics: Diagnostic[], +): void => { + if (candidate.requires_parser_type && binding.parser_type === null) + addDiagnostic( + diagnostics, + "missing-parser-type", + "This boundary requires an explicit parser, schema, and domain type.", + ); + for (const caseKind of candidate.required_case_kinds) { + const fixtures = binding.fixtures.filter( + ({ case_kind: fixtureCase }) => fixtureCase === caseKind, + ); + if (fixtures.length === 0) { + addDiagnostic( + diagnostics, + "missing-case", + `Required ${caseKind} fixture is missing.`, + ); + continue; + } + if ( + !fixtures.some( + (fixture) => + proofAuthoritySatisfies( + fixture.authority, + candidate.required_fixture_authority, + ) && + fixture.evidence_ids.every((id) => { + const evidence = context.evidence.get(id); + return ( + evidence !== undefined && + evidenceAuthoritySupportsProof(evidence, fixture.authority) + ); + }), + ) + ) + addDiagnostic( + diagnostics, + "weak-fixture-authority", + `${caseKind} fixture lacks ${candidate.required_fixture_authority} authority or authenticated Evidence.`, + ); + } + evaluateVerifier(candidate, binding, context, diagnostics); +}; + +const evaluateVerifier = ( + candidate: ReconstructionObligationCandidate, + binding: Binding, + context: EvaluationContext, + diagnostics: Diagnostic[], +): void => { + const verifier = binding.verifier; + if (verifier === null) { + addDiagnostic( + diagnostics, + "missing-verifier", + "No verifier claim is bound to this obligation.", + ); + return; + } + if (!verifier.enumerated_obligation_ids.includes(candidate.obligation_id)) + addDiagnostic( + diagnostics, + "verifier-does-not-enumerate", + "The verifier claim does not enumerate this obligation ID.", + ); + const resultEvidence = context.evidence.get(verifier.result_evidence_id); + if (resultEvidence === undefined) + addDiagnostic( + diagnostics, + "missing-verifier-result", + "The latest verifier result Evidence is absent from the bundle.", + ); + if ( + !proofAuthoritySatisfies( + verifier.authority, + candidate.required_verifier_authority, + ) || + (resultEvidence !== undefined && + !evidenceAuthoritySupportsProof(resultEvidence, verifier.authority)) + ) + addDiagnostic( + diagnostics, + "weak-verifier-authority", + `Verifier authority does not satisfy ${candidate.required_verifier_authority}.`, + ); + if (verifier.status !== "pass") + addDiagnostic( + diagnostics, + "verifier-failed", + `Latest verifier status is ${verifier.status}.`, + ); +}; + +const evaluateAuthority = ( + candidate: ReconstructionObligationCandidate, + binding: Binding | null, + context: EvaluationContext, + diagnostics: Diagnostic[], +): void => { + const referenceSatisfied = candidate.authority_references.some( + (reference) => { + const evidence = context.evidence.get(reference.evidence_id); + return ( + evidence !== undefined && + (candidate.required_original_authority === "static" + ? ["candidate", "observed"].includes(reference.state) + : reference.state === "observed") && + evidenceSupportsOriginalAuthority( + evidence, + candidate.required_original_authority, + ) + ); + }, + ); + const caseSatisfied = originalCasesFor(candidate, binding).some( + ({ evidence_id: evidenceId }) => { + const evidence = context.evidence.get(evidenceId); + return ( + evidence !== undefined && + evidenceSupportsOriginalAuthority( + evidence, + candidate.required_original_authority, + ) + ); + }, + ); + const satisfied = referenceSatisfied || caseSatisfied; + if (!satisfied) + addDiagnostic( + diagnostics, + "static-only", + `No ${candidate.required_original_authority} authority characterizes the original finite behavior.`, + ); +}; + +const evaluateDisposition = ( + candidate: ReconstructionObligationCandidate, + diagnostics: Diagnostic[], +): void => { + if (candidate.disposition === "blocked") + addDiagnostic( + diagnostics, + "blocked", + "Reviewed disposition marks this obligation blocked.", + ); + if (candidate.disposition === "out-of-scope") + addDiagnostic( + diagnostics, + "out-of-scope", + "Reviewed disposition marks this obligation out of scope; it remains visible.", + ); +}; + +const applyDependencyDiagnostics = ( + obligations: readonly ReconstructionObligation[], + context: EvaluationContext, +): ReconstructionObligation[] => { + const statuses = new Map( + obligations.map((obligation) => [ + obligation.obligation_id, + obligation.status, + ]), + ); + return obligations.map((obligation) => { + const diagnostics = [...obligation.diagnostics]; + for (const dependencyId of obligation.dependency_obligation_ids) { + if (!context.candidates.has(dependencyId)) + addDiagnostic( + diagnostics, + "dependency-missing", + `Dependency obligation is missing: ${dependencyId}.`, + ); + else if (statuses.get(dependencyId) !== "verified") + addDiagnostic( + diagnostics, + "dependency-open", + `Dependency obligation is not verified: ${dependencyId}.`, + ); + } + const normalized = uniqueDiagnostics(diagnostics); + return { + ...obligation, + status: dependencyStatus(obligation.status, normalized), + diagnostics: normalized, + }; + }); +}; + +const obligationStatus = ( + candidate: ReconstructionObligationCandidate, + binding: Binding | null, + diagnostics: readonly Diagnostic[], +): ReconstructionObligation["status"] => { + if (hasDiagnostic(diagnostics, "out-of-scope")) return "out-of-scope"; + if (hasDiagnostic(diagnostics, "blocked")) return "blocked"; + if (hasDiagnostic(diagnostics, "contradiction")) return "contradicted"; + if ( + hasDiagnostic(diagnostics, "duplicate-definition") || + hasDiagnostic(diagnostics, "residual-unknown") || + hasDiagnostic(diagnostics, "unavailable-authority") + ) + return "unknown"; + if (binding === null) return "unowned"; + if (diagnostics.length === 0) return "verified"; + return "implemented"; +}; + +const dependencyStatus = ( + status: ReconstructionObligation["status"], + diagnostics: readonly Diagnostic[], +): ReconstructionObligation["status"] => + (hasDiagnostic(diagnostics, "dependency-missing") || + hasDiagnostic(diagnostics, "dependency-open")) && + !["contradicted", "out-of-scope"].includes(status) + ? "blocked" + : status; diff --git a/src/application/ReconstructionObligationLedgerProjection.ts b/src/application/ReconstructionObligationLedgerProjection.ts new file mode 100644 index 000000000..74f3ddc4c --- /dev/null +++ b/src/application/ReconstructionObligationLedgerProjection.ts @@ -0,0 +1,153 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; + +import type { + ReconstructionObligation, + ReconstructionObligationLedger, + ReconstructionObligationManifest, +} from "../domain/reconstructionObligationLedgerSchemas.js"; + +type DiagnosticCode = ReconstructionObligation["diagnostics"][number]["code"]; + +export const summarizeObligations = ( + obligations: readonly ReconstructionObligation[], +): ReconstructionObligationLedger["summary"] => ({ + total: obligations.length, + required: obligations.filter(({ required }) => required).length, + verified: obligations.filter(({ status }) => status === "verified").length, + required_open: obligations.filter( + ({ required, status }) => required && status !== "verified", + ).length, + by_status: countBy( + obligations, + [ + "unowned", + "characterized", + "implemented", + "verified", + "contradicted", + "blocked", + "out-of-scope", + "unknown", + ], + ({ status }) => status, + ), + by_application_layer: countBy( + obligations, + [ + "cli", + "protocol", + "electron", + "persistence", + "process", + "runtime", + "packaging", + "native-abi", + "application", + "other", + ], + ({ application_layer: layer }) => layer, + ), + by_evidence_authority: countBy( + obligations, + ["candidate", "observed", "reviewed", "unknown"], + ({ source_state: state }) => state, + ), +}); + +export const obligationReports = ( + obligations: readonly ReconstructionObligation[], +): ReconstructionObligationLedger["reports"] => ({ + missing_owner_obligation_ids: obligationIdsWith(obligations, "missing-owner"), + missing_verifier_obligation_ids: obligationIdsWith( + obligations, + "missing-verifier", + ), + contradicted_obligation_ids: obligationIdsWith(obligations, "contradiction"), + residual_unknown_ids: [ + ...new Set(obligations.flatMap(({ residual_unknown_ids: ids }) => ids)), + ].sort(), +}); + +export const obligationLedgerEvidenceLinks = ( + obligations: readonly ReconstructionObligation[], + manifest: ReconstructionObligationManifest, +): string[] => + [ + ...new Set([ + ...obligations.flatMap(({ authority_references: references }) => + references.map(({ evidence_id: id }) => id), + ), + ...manifest.bindings.flatMap((binding) => [ + ...binding.original_cases.map(({ evidence_id: id }) => id), + ...binding.fixtures.flatMap(({ evidence_ids: ids }) => ids), + ...(binding.verifier === null + ? [] + : [binding.verifier.result_evidence_id]), + ]), + ...manifest.contradictions.flatMap(({ evidence_ids: ids }) => ids), + ]), + ].sort(); + +export const obligationLedgerStatus = ( + obligations: readonly ReconstructionObligation[], + omittedCount: number, +): ReconstructionObligationLedger["status"] => { + if (obligations.length === 0) return "unknown"; + const requiredOpen = obligations.filter( + ({ required, status }) => required && status !== "verified", + ); + if ( + requiredOpen.some( + ({ status, diagnostics }) => + status === "contradicted" || + hasDiagnostic(diagnostics, "verifier-failed"), + ) + ) + return "failed"; + if ( + omittedCount > 0 || + requiredOpen.some(({ status }) => status === "unknown") + ) + return "unknown"; + return requiredOpen.length === 0 ? "ready" : "open"; +}; + +export const obligationEdgeOrder = ( + left: { readonly obligation_id: string }, + right: { readonly obligation_id: string }, +): number => JSON.stringify(left).localeCompare(JSON.stringify(right)); + +export const digestObligationLedgerValue = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError( + "Reconstruction obligation ledger is not canonical JSON", + ); + return createHash("sha256").update(encoded).digest("hex"); +}; + +const countBy = ( + items: readonly Item[], + values: readonly Value[], + valueFor: (item: Item) => Value, +): { key: Value; count: number }[] => + values.map((value) => ({ + key: value, + count: items.filter((item) => valueFor(item) === value).length, + })); + +const obligationIdsWith = ( + obligations: readonly ReconstructionObligation[], + code: DiagnosticCode, +): string[] => + obligations + .filter(({ diagnostics }) => hasDiagnostic(diagnostics, code)) + .map(({ obligation_id: id }) => id) + .sort(); + +const hasDiagnostic = ( + diagnostics: ReconstructionObligation["diagnostics"], + code: DiagnosticCode, +): boolean => diagnostics.some((diagnostic) => diagnostic.code === code); diff --git a/src/application/ReconstructionObligationLedgerService.ts b/src/application/ReconstructionObligationLedgerService.ts new file mode 100644 index 000000000..b686e46f3 --- /dev/null +++ b/src/application/ReconstructionObligationLedgerService.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +import { + AnalysisInputError, + AnalysisProtocolError, + type AnalysisError, +} from "../domain/errors.js"; +import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js"; +import { + reconstructionObligationLedgerInputSchema, + reconstructionObligationLedgerPageSchema, + type ReconstructionObligationLedgerInput, + type ReconstructionObligationLedgerPage, +} from "../domain/reconstructionObligationLedgerSchemas.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "./InvestigationProviders.js"; +import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js"; +import { evaluateReconstructionObligationLedger } from "./ReconstructionObligationLedgerEvaluation.js"; + +const OPERATION = "build_reconstruction_obligation_ledger" as const; + +/** Parse one portable ledger request for both CLI and MCP adapters. */ +export const resolveReconstructionObligationLedgerRequest = ( + input: unknown, +): Result => { + const parsed = reconstructionObligationLedgerInputSchema.safeParse(input); + return parsed.success + ? ok(parsed.data) + : err(new AnalysisInputError(OPERATION, { cause: parsed.error })); +}; + +/** Build one deterministic ledger page and wrap it in portable Evidence v2. */ +export const buildReconstructionObligationLedgerEvidenceValidated = ( + input: ReconstructionObligationLedgerInput, +): Result => { + try { + const generated = deriveReconstructionObligationCandidates( + input.evidence_bundle, + input.reviewed_obligations, + ); + const ledger = evaluateReconstructionObligationLedger({ + candidates: generated.candidates, + bundle: input.evidence_bundle, + manifest: input.manifest, + maxObligations: input.limits.max_obligations, + generationLimitations: generated.limitations, + }); + const page = pageLedger(ledger, input.page.offset, input.page.limit); + return ok(createLedgerEvidence(input, page)); + } catch (cause: unknown) { + if (cause instanceof z.ZodError) + return err(new AnalysisInputError(OPERATION, { cause })); + return err( + new AnalysisProtocolError( + "Reconstruction obligation ledger generation failed", + { cause }, + ), + ); + } +}; + +const pageLedger = ( + ledger: ReturnType, + offset: number, + limit: number, +): ReconstructionObligationLedgerPage => { + const obligations = ledger.obligations.slice(offset, offset + limit); + const nextOffset = + offset + obligations.length < ledger.obligations.length + ? offset + obligations.length + : null; + const { obligations: _allObligations, ...summary } = ledger; + return reconstructionObligationLedgerPageSchema.parse({ + ...summary, + page: { + offset, + limit, + total: ledger.obligations.length, + returned: obligations.length, + next_offset: nextOffset, + }, + obligations, + }); +}; + +const createLedgerEvidence = ( + input: ReconstructionObligationLedgerInput, + page: ReconstructionObligationLedgerPage, +): Evidence => + createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, { + predicateType: "rea.reconstruction-obligation-ledger/v1", + operation: OPERATION, + parameters: jsonObjectSchema.parse({ + ledger_id: page.ledger_id, + closure_digest: page.closure_digest, + limits: input.limits, + page: input.page, + }), + result: jsonValueSchema.parse(page), + rawResult: null, + confidence: "inferred", + authority: "analyst-inference", + environment: null, + limitations: page.limitations, + evidenceLinks: page.evidence_links, + }); diff --git a/src/application/ReconstructionObligationOriginalCases.ts b/src/application/ReconstructionObligationOriginalCases.ts new file mode 100644 index 000000000..4d6cf3d38 --- /dev/null +++ b/src/application/ReconstructionObligationOriginalCases.ts @@ -0,0 +1,64 @@ +import type { Evidence } from "../domain/evidence.js"; +import type { ReconstructionObligation } from "../domain/reconstructionObligationLedgerSchemas.js"; +import { evidenceSupportsOriginalAuthority } from "./ReconstructionObligationAuthority.js"; +import type { ReconstructionObligationCandidate } from "./ReconstructionObligationCandidates.js"; + +type Binding = NonNullable; +type Diagnostic = ReconstructionObligation["diagnostics"][number]; + +/** Merge generated and manifest-supplied original observations canonically. */ +export const originalCasesFor = ( + candidate: ReconstructionObligationCandidate, + binding: Binding | null, +): ReconstructionObligation["observed_cases"] => + [ + ...new Map( + [...candidate.observed_cases, ...(binding?.original_cases ?? [])].map( + (observed) => [ + `${observed.kind}:${observed.evidence_id}:${observed.location}`, + observed, + ], + ), + ).values(), + ].sort((left, right) => + `${left.kind}:${left.evidence_id}:${left.location}`.localeCompare( + `${right.kind}:${right.evidence_id}:${right.location}`, + ), + ); + +/** Report original cases that are absent or lack comparable Evidence authority. */ +export const originalCaseDiagnostics = ( + candidate: ReconstructionObligationCandidate, + binding: Binding | null, + evidenceById: ReadonlyMap, +): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const originalCases = originalCasesFor(candidate, binding); + for (const caseKind of candidate.required_case_kinds) { + const cases = originalCases.filter(({ kind }) => kind === caseKind); + if (cases.length === 0) { + diagnostics.push({ + code: "missing-original-case", + detail: `Original ${caseKind} behavior has not been observed.`, + }); + continue; + } + if ( + !cases.some(({ evidence_id: evidenceId }) => { + const evidence = evidenceById.get(evidenceId); + return ( + evidence !== undefined && + evidenceSupportsOriginalAuthority( + evidence, + candidate.required_original_authority, + ) + ); + }) + ) + diagnostics.push({ + code: "weak-original-case-authority", + detail: `Original ${caseKind} behavior lacks ${candidate.required_original_authority} Evidence.`, + }); + } + return diagnostics; +}; diff --git a/src/application/ReconstructionObligationPolicies.ts b/src/application/ReconstructionObligationPolicies.ts new file mode 100644 index 000000000..09275584b --- /dev/null +++ b/src/application/ReconstructionObligationPolicies.ts @@ -0,0 +1,169 @@ +import type { ReconstructionObligation } from "../domain/reconstructionObligationLedgerSchemas.js"; + +export interface ReconstructionObligationCandidatePolicy { + readonly applicationLayer: ReconstructionObligation["application_layer"]; + readonly family: string; + readonly fixtureAuthority: ReconstructionObligation["required_fixture_authority"]; + readonly verifierAuthority: ReconstructionObligation["required_verifier_authority"]; + readonly originalAuthority: ReconstructionObligation["required_original_authority"]; + readonly cases: ReconstructionObligation["required_case_kinds"]; + readonly requiresParserType: boolean; +} + +const APPLICATION_NODE_POLICIES: Readonly< + Partial> +> = { + package: policy("packaging", "package-lifecycle", "packaged-process", [ + "positive", + "negative", + "teardown", + ]), + installer: policy("packaging", "installer", "packaged-process", [ + "positive", + "negative", + "teardown", + ]), + "context-bridge-api": policy("electron", "context-bridge", "renderer", [ + "positive", + "negative", + "malformed", + ]), + "ipc-channel": policy("electron", "ipc", "renderer", [ + "positive", + "negative", + "malformed", + ]), + "ipc-handler": policy("electron", "ipc", "renderer", [ + "positive", + "negative", + "malformed", + ]), + endpoint: policy("protocol", "endpoint", "protocol", [ + "positive", + "negative", + "malformed", + ]), + storage: policy("persistence", "persistence", "integration", [ + "positive", + "negative", + "malformed", + ]), + worker: policy("process", "worker-lifecycle", "packaged-process", [ + "positive", + "cancellation", + "teardown", + ]), + "service-worker": policy("process", "worker-lifecycle", "packaged-process", [ + "positive", + "cancellation", + "teardown", + ]), + "native-addon": nativePolicy("native-addon"), + "native-export": nativePolicy("native-export"), + "managed-pinvoke-import": nativePolicy("managed-pinvoke"), + "managed-native-implementation": nativePolicy("managed-native"), +}; + +const SEMANTIC_NODE_POLICIES: Readonly< + Partial> +> = { + "child-process": policy("process", "child-process", "packaged-process", [ + "positive", + "negative", + "cancellation", + "teardown", + ]), + stdio: policy("process", "stdio", "packaged-process", [ + "positive", + "negative", + "teardown", + ]), + signal: policy("process", "signal", "packaged-process", [ + "positive", + "cancellation", + "teardown", + ]), + "config-source": parserPolicy("cli", "configuration", "integration"), + request: parserPolicy("protocol", "request", "protocol"), + response: parserPolicy("protocol", "response", "protocol"), + boundary: parserPolicy("application", "parser-boundary", "integration"), + resource: policy("persistence", "resource-lifecycle", "integration", [ + "positive", + "negative", + "teardown", + ]), + event: policy("runtime", "event", "packaged-process", [ + "positive", + "negative", + "cancellation", + ]), + timer: policy("runtime", "timer", "packaged-process", [ + "positive", + "cancellation", + "teardown", + ]), + promise: policy("runtime", "promise-ownership", "packaged-process", [ + "positive", + "negative", + "cancellation", + ]), + task: policy("runtime", "task-lifecycle", "packaged-process", [ + "positive", + "cancellation", + "teardown", + ]), +}; + +export const applicationObligationPolicy = ( + nodeKind: string, +): ReconstructionObligationCandidatePolicy | undefined => + APPLICATION_NODE_POLICIES[nodeKind]; + +export const semanticObligationPolicy = ( + nodeKind: string, +): ReconstructionObligationCandidatePolicy | undefined => + SEMANTIC_NODE_POLICIES[nodeKind]; + +function policy( + applicationLayer: ReconstructionObligationCandidatePolicy["applicationLayer"], + family: string, + authority: ReconstructionObligationCandidatePolicy["fixtureAuthority"], + cases: ReconstructionObligationCandidatePolicy["cases"], +): ReconstructionObligationCandidatePolicy { + return { + applicationLayer, + family, + fixtureAuthority: authority, + verifierAuthority: authority, + originalAuthority: "runtime", + cases, + requiresParserType: false, + }; +} + +function parserPolicy( + applicationLayer: ReconstructionObligationCandidatePolicy["applicationLayer"], + family: string, + authority: ReconstructionObligationCandidatePolicy["fixtureAuthority"], +): ReconstructionObligationCandidatePolicy { + return { + ...policy(applicationLayer, family, authority, [ + "positive", + "negative", + "malformed", + ]), + requiresParserType: true, + }; +} + +function nativePolicy(family: string): ReconstructionObligationCandidatePolicy { + return { + applicationLayer: "native-abi", + family, + fixtureAuthority: "native-abi", + verifierAuthority: "native-abi", + originalAuthority: "static", + cases: ["positive", "negative"], + requiresParserType: false, + }; +} diff --git a/src/domain/reconstructionObligationLedgerSchemas.ts b/src/domain/reconstructionObligationLedgerSchemas.ts new file mode 100644 index 000000000..08245c0b2 --- /dev/null +++ b/src/domain/reconstructionObligationLedgerSchemas.ts @@ -0,0 +1,344 @@ +import canonicalize from "canonicalize"; +import { z } from "zod"; + +import { evidenceBundleSchema } from "./evidenceBundle.js"; + +const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); +const stableIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._:/-]{0,199}$/u); +const boundedTextSchema = z.string().trim().min(1).max(4_096); + +export const obligationStatusSchema = z.enum([ + "unowned", + "characterized", + "implemented", + "verified", + "contradicted", + "blocked", + "out-of-scope", + "unknown", +]); + +export const obligationLayerSchema = z.enum([ + "cli", + "protocol", + "electron", + "persistence", + "process", + "runtime", + "packaging", + "native-abi", + "application", + "other", +]); + +export const obligationCaseKindSchema = z.enum([ + "positive", + "negative", + "malformed", + "cancellation", + "teardown", +]); + +export const obligationProofAuthoritySchema = z.enum([ + "unit", + "integration", + "protocol", + "renderer", + "packaged-process", + "native-abi", + "live-observation", + "external", +]); + +export const obligationOriginalAuthoritySchema = z.enum([ + "static", + "runtime", + "process", + "external", +]); + +const obligationTargetSchema = z.strictObject({ + artifact_sha256: digestSchema.nullable(), + application_node_id: z + .string() + .regex(/^jag_node_[a-f0-9]{64}$/u) + .nullable(), + semantic_node_id: z + .string() + .regex(/^jsrg_node_[a-f0-9]{64}$/u) + .nullable(), + location: boundedTextSchema, +}); + +const authorityReferenceSchema = z.strictObject({ + evidence_id: evidenceIdSchema, + authority: z.enum([ + "shipped-artifact", + "controlled-replay", + "historical-reference", + "external-service", + "analyst-inference", + ]), + state: z.enum(["candidate", "observed", "reviewed", "unknown"]), + location: boundedTextSchema, +}); + +const observedCaseSchema = z.strictObject({ + kind: obligationCaseKindSchema, + evidence_id: evidenceIdSchema, + location: boundedTextSchema, +}); + +const ownerBindingSchema = z.strictObject({ + module_path: boundedTextSchema, + symbol: z.string().trim().min(1).max(1_024), + owner_sha256: digestSchema, +}); + +const parserTypeBindingSchema = z.strictObject({ + parser: boundedTextSchema, + schema: boundedTextSchema, + domain_type: boundedTextSchema, +}); + +const fixtureBindingSchema = z.strictObject({ + fixture_id: stableIdSchema, + case_kind: obligationCaseKindSchema, + authority: obligationProofAuthoritySchema, + evidence_ids: z.array(evidenceIdSchema).min(1).max(100), +}); + +const nondeterminismSchema = z.strictObject({ + mode: z.enum(["total-order", "partial-order", "finite-traces"]), + specification: boundedTextSchema, +}); + +const verifierBindingSchema = z.strictObject({ + verifier_id: stableIdSchema, + claim_id: stableIdSchema, + command: boundedTextSchema, + authority: obligationProofAuthoritySchema, + status: z.enum(["pass", "fail", "unknown", "unsupported", "skipped"]), + result_evidence_id: evidenceIdSchema, + enumerated_obligation_ids: z.array(stableIdSchema).min(1).max(10_000), + nondeterminism: nondeterminismSchema, +}); + +export const reconstructionObligationManifestBindingSchema = z.strictObject({ + obligation_id: stableIdSchema, + owner: ownerBindingSchema, + parser_type: parserTypeBindingSchema.nullable(), + original_cases: z.array(observedCaseSchema).max(100), + fixtures: z.array(fixtureBindingSchema).max(100), + verifier: verifierBindingSchema.nullable(), +}); + +export const reconstructionObligationManifestSchema = z.strictObject({ + schema_version: z.literal(1), + bindings: z.array(reconstructionObligationManifestBindingSchema).max(10_000), + contradictions: z + .array( + z.strictObject({ + obligation_id: stableIdSchema, + evidence_ids: z.array(evidenceIdSchema).min(2).max(100), + }), + ) + .max(10_000), +}); + +export const reviewedReconstructionObligationSchema = z.strictObject({ + obligation_id: stableIdSchema, + obligation_version: z.number().int().min(1), + title: z.string().trim().min(1).max(500), + application_layer: obligationLayerSchema, + family: z.string().trim().min(1).max(100), + target: obligationTargetSchema, + required: z.boolean(), + required_case_kinds: z.array(obligationCaseKindSchema).min(1).max(5), + required_original_authority: obligationOriginalAuthoritySchema, + required_fixture_authority: obligationProofAuthoritySchema, + required_verifier_authority: obligationProofAuthoritySchema, + requires_parser_type: z.boolean(), + dependency_obligation_ids: z.array(stableIdSchema).max(1_000), + residual_unknown_ids: z.array(stableIdSchema).max(1_000), + unavailable_authority: z.array(boundedTextSchema).max(100), + required_next_evidence: z.array(boundedTextSchema).max(100), + disposition: z.enum(["active", "blocked", "out-of-scope"]), + review_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), +}); + +const diagnosticSchema = z.strictObject({ + code: z.enum([ + "duplicate-definition", + "missing-owner", + "ambiguous-owner", + "missing-parser-type", + "missing-original-case", + "weak-original-case-authority", + "missing-case", + "weak-fixture-authority", + "missing-verifier", + "verifier-does-not-enumerate", + "missing-verifier-result", + "weak-verifier-authority", + "verifier-failed", + "static-only", + "contradiction", + "contradiction-evidence-missing", + "residual-unknown", + "dependency-missing", + "dependency-open", + "unavailable-authority", + "blocked", + "out-of-scope", + ]), + detail: boundedTextSchema, +}); + +export const reconstructionObligationSchema = z.strictObject({ + obligation_id: stableIdSchema, + obligation_version: z.number().int().min(1), + title: z.string().trim().min(1).max(500), + origin: z.enum(["generated", "reviewed"]), + application_layer: obligationLayerSchema, + family: z.string().trim().min(1).max(100), + target: obligationTargetSchema, + authority_references: z.array(authorityReferenceSchema).min(1).max(1_000), + source_state: z.enum(["candidate", "observed", "reviewed", "unknown"]), + observed_cases: z.array(observedCaseSchema).max(100), + required: z.boolean(), + required_case_kinds: z.array(obligationCaseKindSchema).min(1).max(5), + required_original_authority: obligationOriginalAuthoritySchema, + required_fixture_authority: obligationProofAuthoritySchema, + required_verifier_authority: obligationProofAuthoritySchema, + requires_parser_type: z.boolean(), + dependency_obligation_ids: z.array(stableIdSchema).max(1_000), + residual_unknown_ids: z.array(stableIdSchema).max(1_000), + unavailable_authority: z.array(boundedTextSchema).max(100), + required_next_evidence: z.array(boundedTextSchema).max(100), + binding: reconstructionObligationManifestBindingSchema.nullable(), + status: obligationStatusSchema, + diagnostics: z.array(diagnosticSchema).max(1_000), +}); + +const countBySchema = ( + key: Schema, +): z.ZodArray< + z.ZodObject<{ + key: Schema; + count: z.ZodNumber; + }> +> => + z.array( + z.strictObject({ + key, + count: z.number().int().min(0), + }), + ); + +export const reconstructionObligationLedgerSchema = z.strictObject({ + schema: z.literal("ReconstructionObligationLedger"), + schema_version: z.literal(1), + ledger_id: z.string().regex(/^rol_[a-f0-9]{64}$/u), + closure_digest: digestSchema, + status: z.enum(["ready", "open", "failed", "unknown"]), + coverage: z.strictObject({ + status: z.enum(["complete", "partial"]), + truncated: z.boolean(), + omitted_count: z.number().int().min(0), + max_obligations: z.number().int().min(1), + }), + summary: z.strictObject({ + total: z.number().int().min(0), + required: z.number().int().min(0), + verified: z.number().int().min(0), + required_open: z.number().int().min(0), + by_status: countBySchema(obligationStatusSchema), + by_application_layer: countBySchema(obligationLayerSchema), + by_evidence_authority: countBySchema( + z.enum(["candidate", "observed", "reviewed", "unknown"]), + ), + }), + reports: z.strictObject({ + missing_owner_obligation_ids: z.array(stableIdSchema), + missing_verifier_obligation_ids: z.array(stableIdSchema), + contradicted_obligation_ids: z.array(stableIdSchema), + residual_unknown_ids: z.array(stableIdSchema), + }), + ownership_graph: z.array( + z.strictObject({ + obligation_id: stableIdSchema, + module_path: boundedTextSchema, + symbol: z.string().trim().min(1).max(1_024), + }), + ), + dependency_graph: z.array( + z.strictObject({ + obligation_id: stableIdSchema, + depends_on_obligation_id: stableIdSchema, + }), + ), + obligations: z.array(reconstructionObligationSchema).max(10_000), + evidence_links: z.array(evidenceIdSchema).max(100_000), + limitations: z.array(boundedTextSchema).max(1_000), +}); + +export const reconstructionObligationLedgerInputSchema = z.strictObject({ + evidence_bundle: evidenceBundleSchema, + reviewed_obligations: z + .array(reviewedReconstructionObligationSchema) + .max(10_000), + manifest: reconstructionObligationManifestSchema, + limits: z.strictObject({ + max_obligations: z.number().int().min(1).max(10_000), + }), + page: z.strictObject({ + offset: z.number().int().min(0).max(10_000), + limit: z.number().int().min(1).max(100), + }), +}); + +export const reconstructionObligationLedgerPageSchema = z.strictObject({ + ...reconstructionObligationLedgerSchema.omit({ obligations: true }).shape, + page: z.strictObject({ + offset: z.number().int().min(0), + limit: z.number().int().min(1).max(100), + total: z.number().int().min(0), + returned: z.number().int().min(0), + next_offset: z.number().int().min(0).nullable(), + }), + obligations: z.array(reconstructionObligationSchema).max(100), +}); + +export type ReconstructionObligation = z.infer< + typeof reconstructionObligationSchema +>; +export type ReconstructionObligationLedger = z.infer< + typeof reconstructionObligationLedgerSchema +>; +export type ReconstructionObligationLedgerInput = z.infer< + typeof reconstructionObligationLedgerInputSchema +>; +export type ReconstructionObligationLedgerPage = z.infer< + typeof reconstructionObligationLedgerPageSchema +>; +export type ReviewedReconstructionObligation = z.infer< + typeof reviewedReconstructionObligationSchema +>; +export type ReconstructionObligationManifest = z.infer< + typeof reconstructionObligationManifestSchema +>; + +/** Parse and canonically serialize one complete reconstruction obligation ledger. */ +export const serializeReconstructionObligationLedger = ( + input: unknown, +): string => { + const ledger = reconstructionObligationLedgerSchema.parse(input); + const encoded = canonicalize(ledger); + if (encoded === undefined) + throw new TypeError( + "Reconstruction obligation ledger is not canonical JSON", + ); + return encoded; +}; diff --git a/src/server/registerApplicationTools/obligationLedger.ts b/src/server/registerApplicationTools/obligationLedger.ts new file mode 100644 index 000000000..2dd4cb50c --- /dev/null +++ b/src/server/registerApplicationTools/obligationLedger.ts @@ -0,0 +1,53 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +import { + buildReconstructionObligationLedgerEvidenceValidated, + resolveReconstructionObligationLedgerRequest, +} from "../../application/ReconstructionObligationLedgerService.js"; +import { applicationToolContract } from "../../contracts/applicationToolContracts.js"; +import { reconstructionObligationLedgerInputSchema } from "../../domain/reconstructionObligationLedgerSchemas.js"; +import { logToolExecution } from "../toolLogging.js"; +import { safeParseToolInput } from "../toolInputValidation.js"; +import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; +import { toCallToolResult } from "../toolResult.js"; +import { recordResult, recordSources } from "./helpers.js"; +import type { ApplicationToolRegistration } from "./types.js"; + +const contract = applicationToolContract( + "build_reconstruction_obligation_ledger", +); + +/** Register conservative reconstruction-obligation generation and closure. */ +export const registerReconstructionObligationLedgerTool = ( + server: McpServer, + options: ApplicationToolRegistration, +): void => { + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async (input) => { + const parsed = safeParseToolInput( + reconstructionObligationLedgerInputSchema, + input, + contract.name, + ); + if (!parsed.ok) return toCallToolResult(parsed, contract); + const resolved = resolveReconstructionObligationLedgerRequest( + parsed.value, + ); + if (!resolved.ok) return toCallToolResult(resolved, contract); + const result = await logToolExecution(options.logger, contract.name, () => + Promise.resolve( + buildReconstructionObligationLedgerEvidenceValidated(resolved.value), + ), + ); + if (!result.ok) return toCallToolResult(result, contract); + const recorded = recordSources( + options.recordEvidence, + resolved.value.evidence_bundle.records, + ); + if (!recorded.ok) return toCallToolResult(recorded, contract); + return recordResult(options, contract, result.value); + }, + ); +}; diff --git a/src/server/registerReconstructionObligationLedgerResource.ts b/src/server/registerReconstructionObligationLedgerResource.ts new file mode 100644 index 000000000..42114bd38 --- /dev/null +++ b/src/server/registerReconstructionObligationLedgerResource.ts @@ -0,0 +1,69 @@ +import { + ResourceNotFoundError, + ResourceTemplate, + type McpServer, +} from "@modelcontextprotocol/server"; + +import type { BinarySessionPort } from "../application/BinarySessionPort.js"; +import { reconstructionObligationLedgerPageSchema } from "../domain/reconstructionObligationLedgerSchemas.js"; + +/** Expose retained obligation-ledger pages as typed Evidence resources. */ +export const registerReconstructionObligationLedgerResource = ( + server: McpServer, + session: BinarySessionPort, +): void => { + server.registerResource( + "reconstruction-obligation-ledger-page", + new ResourceTemplate( + "rea://evidence/{evidenceId}/reconstruction-obligation-ledger", + { + list: undefined, + complete: { + evidenceId: (prefix) => + session + .exportEvidenceBundle() + .records.filter( + ({ predicate_type: predicateType }) => + predicateType === "rea.reconstruction-obligation-ledger/v1", + ) + .map(({ evidence_id: evidenceId }) => evidenceId) + .filter((evidenceId) => evidenceId.startsWith(prefix)), + }, + }, + ), + { + title: "Reconstruction obligation ledger page", + description: + "One deterministic Evidence v2-backed obligation page with full-ledger closure digest and summaries.", + mimeType: "application/json", + }, + (uri, variables) => { + const evidenceId = variables.evidenceId; + if (typeof evidenceId !== "string") + throw new ResourceNotFoundError(uri.href); + const evidence = session.evidenceById(evidenceId); + if ( + evidence === undefined || + evidence.predicate_type !== "rea.reconstruction-obligation-ledger/v1" + ) + throw new ResourceNotFoundError(uri.href); + const page = reconstructionObligationLedgerPageSchema.safeParse( + evidence.normalized_result, + ); + if (!page.success) throw new ResourceNotFoundError(uri.href); + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify( + { evidence_id: evidence.evidence_id, page: page.data }, + null, + 2, + ), + }, + ], + }; + }, + ); +}; diff --git a/tests/reconstructionObligationLedger.test.ts b/tests/reconstructionObligationLedger.test.ts new file mode 100644 index 000000000..cbcc43333 --- /dev/null +++ b/tests/reconstructionObligationLedger.test.ts @@ -0,0 +1,414 @@ +import { describe, expect, it } from "vitest"; + +import { PROCESS_PROVIDER } from "../src/application/ProcessEvidence.js"; +import { + buildReconstructionObligationLedgerEvidenceValidated, + resolveReconstructionObligationLedgerRequest, +} from "../src/application/ReconstructionObligationLedgerService.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../src/contracts/processCaptureExample.js"; +import { createEvidence, type Evidence } from "../src/domain/evidence.js"; +import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +import { jsonValueSchema } from "../src/domain/jsonValue.js"; +import { processCaptureSchema } from "../src/domain/processCapture.js"; +import { + reconstructionObligationLedgerPageSchema, + serializeReconstructionObligationLedger, + type ReconstructionObligationLedgerInput, + type ReconstructionObligationLedgerPage, + type ReviewedReconstructionObligation, +} from "../src/domain/reconstructionObligationLedgerSchemas.js"; + +const proofEvidence = (id: string) => + createEvidence( + undefined, + { id: `fixture-${id}`, name: "Fixture verifier", version: "1" }, + { + predicateType: "rea.fixture-verification/v1", + operation: "run_fixture_verifier", + parameters: { id }, + result: { passed: true }, + confidence: "observed", + authority: "controlled-replay", + }, + ); + +const processEvidence = () => + createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture/v4", + operation: "capture_process_scenario", + parameters: {}, + result: jsonValueSchema.parse( + processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE), + ), + confidence: "observed", + authority: "controlled-replay", + }); + +const request = ( + records: readonly Evidence[], + overrides: Partial = {}, +): ReconstructionObligationLedgerInput => ({ + evidence_bundle: createEvidenceBundle(records), + reviewed_obligations: [], + manifest: { schema_version: 1, bindings: [], contradictions: [] }, + limits: { max_obligations: 100 }, + page: { offset: 0, limit: 50 }, + ...overrides, +}); + +const build = ( + input: ReconstructionObligationLedgerInput, +): ReconstructionObligationLedgerPage => { + const parsed = resolveReconstructionObligationLedgerRequest(input); + if (!parsed.ok) throw parsed.error; + const result = buildReconstructionObligationLedgerEvidenceValidated( + parsed.value, + ); + if (!result.ok) throw result.error; + return reconstructionObligationLedgerPageSchema.parse( + result.value.normalized_result, + ); +}; + +const completeLedger = ( + page: ReconstructionObligationLedgerPage, + obligations: ReconstructionObligationLedgerPage["obligations"], +) => { + const { page: pagination, ...ledger } = page; + if (pagination.total !== obligations.length) + throw new TypeError("Incomplete obligation page set"); + return { ...ledger, obligations }; +}; + +const originalCases = ( + obligation: ReconstructionObligationLedgerPage["obligations"][number], + evidenceId: string, +) => + obligation.required_case_kinds.map((caseKind) => ({ + kind: caseKind, + evidence_id: evidenceId, + location: `/normalized_result/cases/${caseKind}`, + })); + +describe("reconstruction obligation ledger", () => { + it("emits deterministic closure identity and paging", () => { + const evidence = proofEvidence("review"); + const reviewed = [ + reviewedObligation("obl.review.one", evidence.evidence_id), + reviewedObligation("obl.review.two", evidence.evidence_id), + ]; + const first = build( + request([evidence], { + reviewed_obligations: reviewed, + page: { offset: 0, limit: 1 }, + }), + ); + const second = build( + request([evidence], { + reviewed_obligations: reviewed, + page: { offset: 1, limit: 1 }, + }), + ); + + expect(first.page).toEqual({ + offset: 0, + limit: 1, + total: 2, + returned: 1, + next_offset: 1, + }); + expect(second.page.next_offset).toBeNull(); + expect(second.ledger_id).toBe(first.ledger_id); + expect(second.closure_digest).toBe(first.closure_digest); + expect(first.obligations[0]?.source_state).toBe("reviewed"); + expect(first.status).toBe("open"); + expect( + serializeReconstructionObligationLedger( + completeLedger(first, [...first.obligations, ...second.obligations]), + ), + ).toBe( + serializeReconstructionObligationLedger( + completeLedger(second, [...first.obligations, ...second.obligations]), + ), + ); + }); + + it("does not let green unit evidence close a packaged-process obligation", () => { + const capture = processEvidence(); + const proof = proofEvidence("green-unit"); + const initial = build(request([capture, proof])); + const obligation = initial.obligations[0]; + if (obligation === undefined) + throw new Error("Expected process obligation"); + const unitBinding = { + obligation_id: obligation.obligation_id, + owner: { + module_path: "src/processOwner.ts", + symbol: "runProcess", + owner_sha256: "1".repeat(64), + }, + parser_type: null, + original_cases: originalCases(obligation, capture.evidence_id), + fixtures: obligation.required_case_kinds.map((caseKind) => ({ + fixture_id: `fixture.${caseKind}`, + case_kind: caseKind, + authority: "unit" as const, + evidence_ids: [proof.evidence_id], + })), + verifier: { + verifier_id: "verifier.unit", + claim_id: "claim.process", + command: "npm test -- processOwner", + authority: "unit" as const, + status: "pass" as const, + result_evidence_id: proof.evidence_id, + enumerated_obligation_ids: [obligation.obligation_id], + nondeterminism: { + mode: "total-order" as const, + specification: "Exact unit-call order.", + }, + }, + }; + + const result = build( + request([capture, proof], { + manifest: { + schema_version: 1, + bindings: [unitBinding], + contradictions: [], + }, + }), + ); + const evaluated = result.obligations[0]; + expect(evaluated?.status).toBe("implemented"); + expect(evaluated?.diagnostics.map(({ code }) => code)).toEqual( + expect.arrayContaining([ + "weak-fixture-authority", + "weak-verifier-authority", + ]), + ); + expect(result.status).toBe("open"); + }); + + it("keeps unobserved original cases open", () => { + const capture = processEvidence(); + const proof = proofEvidence("packaged-original-gap"); + const initial = build(request([capture, proof])); + const obligation = initial.obligations[0]; + if (obligation === undefined) + throw new Error("Expected process obligation"); + const result = build( + request([capture, proof], { + manifest: { + schema_version: 1, + bindings: [ + { + obligation_id: obligation.obligation_id, + owner: { + module_path: "src/processOwner.ts", + symbol: "runProcess", + owner_sha256: "4".repeat(64), + }, + parser_type: null, + original_cases: obligation.observed_cases, + fixtures: obligation.required_case_kinds.map((caseKind) => ({ + fixture_id: `packaged.${caseKind}`, + case_kind: caseKind, + authority: "packaged-process", + evidence_ids: [proof.evidence_id], + })), + verifier: { + verifier_id: "verifier.packaged", + claim_id: "claim.process", + command: "npm run verify:package -- process", + authority: "packaged-process", + status: "pass", + result_evidence_id: proof.evidence_id, + enumerated_obligation_ids: [obligation.obligation_id], + nondeterminism: { + mode: "partial-order", + specification: + "Startup precedes teardown; worker completion is unordered.", + }, + }, + }, + ], + contradictions: [], + }, + }), + ); + + expect(result.obligations[0]?.status).toBe("implemented"); + expect(result.obligations[0]?.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "missing-original-case", + detail: expect.stringContaining("negative"), + }), + expect.objectContaining({ + code: "missing-original-case", + detail: expect.stringContaining("cancellation"), + }), + ]), + ); + expect(result.status).toBe("open"); + }); + + it("closes only when every required case and verifier has comparable authority", () => { + const capture = processEvidence(); + const proof = proofEvidence("packaged"); + const initial = build(request([capture, proof])); + const obligation = initial.obligations[0]; + if (obligation === undefined) + throw new Error("Expected process obligation"); + const result = build( + request([capture, proof], { + manifest: { + schema_version: 1, + bindings: [ + { + obligation_id: obligation.obligation_id, + owner: { + module_path: "src/processOwner.ts", + symbol: "runProcess", + owner_sha256: "2".repeat(64), + }, + parser_type: null, + original_cases: originalCases(obligation, capture.evidence_id), + fixtures: obligation.required_case_kinds.map((caseKind) => ({ + fixture_id: `packaged.${caseKind}`, + case_kind: caseKind, + authority: "packaged-process", + evidence_ids: [proof.evidence_id], + })), + verifier: { + verifier_id: "verifier.packaged", + claim_id: "claim.process", + command: "npm run verify:package -- process", + authority: "packaged-process", + status: "pass", + result_evidence_id: proof.evidence_id, + enumerated_obligation_ids: [obligation.obligation_id], + nondeterminism: { + mode: "partial-order", + specification: + "Startup precedes teardown; worker completion is unordered.", + }, + }, + }, + ], + contradictions: [], + }, + }), + ); + + expect(result.obligations[0]).toMatchObject({ + status: "verified", + diagnostics: [], + }); + expect(result.status).toBe("ready"); + expect(result.summary.required_open).toBe(0); + }); + + it("fails closed on parser gaps, ambiguous ownership, and contradiction", () => { + const original = createEvidence( + undefined, + { id: "external", name: "External protocol observer", version: "1" }, + { + predicateType: "rea.protocol-observation/v1", + operation: "observe_protocol", + parameters: {}, + result: { status: 200 }, + confidence: "observed", + authority: "external-service", + }, + ); + const proof = proofEvidence("protocol"); + const reviewed: ReviewedReconstructionObligation = { + ...reviewedObligation("obl.protocol", original.evidence_id), + application_layer: "protocol" as const, + family: "json-rpc", + required_original_authority: "external" as const, + required_fixture_authority: "protocol" as const, + required_verifier_authority: "protocol" as const, + requires_parser_type: true, + required_case_kinds: ["positive", "negative", "malformed"], + }; + const binding = { + obligation_id: reviewed.obligation_id, + owner: { + module_path: "src/protocol.ts", + symbol: "handleRequest", + owner_sha256: "3".repeat(64), + }, + parser_type: null, + original_cases: reviewed.required_case_kinds.map((caseKind) => ({ + kind: caseKind, + evidence_id: original.evidence_id, + location: `/protocol/${caseKind}`, + })), + fixtures: [ + { + fixture_id: "fixture.positive", + case_kind: "positive" as const, + authority: "unit" as const, + evidence_ids: [proof.evidence_id], + }, + ], + verifier: null, + }; + const result = build( + request([original, proof], { + reviewed_obligations: [reviewed], + manifest: { + schema_version: 1, + bindings: [binding, binding], + contradictions: [ + { + obligation_id: reviewed.obligation_id, + evidence_ids: [original.evidence_id, proof.evidence_id], + }, + ], + }, + }), + ); + + expect(result.status).toBe("failed"); + expect(result.obligations[0]?.status).toBe("contradicted"); + expect(result.obligations[0]?.diagnostics.map(({ code }) => code)).toEqual( + expect.arrayContaining(["ambiguous-owner", "contradiction"]), + ); + expect(result.reports.contradicted_obligation_ids).toEqual([ + reviewed.obligation_id, + ]); + }); +}); + +const reviewedObligation = ( + obligationId: string, + evidenceId: string, +): ReviewedReconstructionObligation => ({ + obligation_id: obligationId, + obligation_version: 1, + title: `Reviewed obligation ${obligationId}`, + application_layer: "other", + family: "reviewed", + target: { + artifact_sha256: null, + application_node_id: null, + semantic_node_id: null, + location: `/reviewed/${obligationId}`, + }, + required: true, + required_case_kinds: ["positive"], + required_original_authority: "static", + required_fixture_authority: "unit", + required_verifier_authority: "unit", + requires_parser_type: false, + dependency_obligation_ids: [], + residual_unknown_ids: [], + unavailable_authority: [], + required_next_evidence: ["Bind an owner and verifier."], + disposition: "active", + review_evidence_ids: [evidenceId], +}); diff --git a/tests/reconstructionObligationLedgerCli.test.ts b/tests/reconstructionObligationLedgerCli.test.ts new file mode 100644 index 000000000..e4198d537 --- /dev/null +++ b/tests/reconstructionObligationLedgerCli.test.ts @@ -0,0 +1,47 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; + +const execute = promisify(execFile); + +describe("reconstruction obligation ledger CLI parity", () => { + it("builds the same Evidence-backed empty ledger through the CLI", async () => { + const input = { + evidence_bundle: createEvidenceBundle([]), + reviewed_obligations: [], + manifest: { + schema_version: 1, + bindings: [], + contradictions: [], + }, + limits: { max_obligations: 100 }, + page: { offset: 0, limit: 50 }, + }; + const { stdout } = await execute( + process.execPath, + [ + "scripts/rea.mjs", + "build-reconstruction-obligation-ledger", + JSON.stringify(input), + "--json", + ], + { + cwd: process.cwd(), + env: process.env, + maxBuffer: 16 * 1_024 * 1_024, + }, + ); + expect(JSON.parse(stdout)).toMatchObject({ + operation: "build_reconstruction_obligation_ledger", + predicate_type: "rea.reconstruction-obligation-ledger/v1", + normalized_result: { + schema: "ReconstructionObligationLedger", + status: "unknown", + summary: { total: 0 }, + }, + }); + }, 20_000); +}); diff --git a/tests/reconstructionObligationLedgerMcp.test.ts b/tests/reconstructionObligationLedgerMcp.test.ts new file mode 100644 index 000000000..bab69c6fb --- /dev/null +++ b/tests/reconstructionObligationLedgerMcp.test.ts @@ -0,0 +1,73 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { describe, expect, it } from "vitest"; + +import { BinarySession } from "../src/application/BinarySession.js"; +import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +import { createServer } from "../src/server/createServer.js"; +import { observed } from "./fixtures/analysisExecution.js"; + +describe("reconstruction obligation ledger MCP parity", () => { + it("builds an Evidence-backed page and exposes the retained resource", async () => { + const session = new BinarySession(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session); + const client = new Client({ name: "obligation-ledger-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "build_reconstruction_obligation_ledger", + arguments: { + evidence_bundle: createEvidenceBundle([]), + reviewed_obligations: [], + manifest: { + schema_version: 1, + bindings: [], + contradictions: [], + }, + limits: { max_obligations: 100 }, + page: { offset: 0, limit: 50 }, + }, + }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u), + evidence_uri: expect.stringMatching( + /^rea:\/\/evidence\/ev_[a-f0-9]{64}$/u, + ), + result: { + schema: "ReconstructionObligationLedger", + status: "unknown", + summary: { total: 0 }, + }, + }); + const evidence = result.structuredContent; + if ( + evidence === undefined || + evidence === null || + typeof evidence !== "object" || + !("evidence_id" in evidence) || + typeof evidence.evidence_id !== "string" + ) + throw new TypeError("Missing obligation ledger Evidence ID"); + const resource = await client.readResource({ + uri: `rea://evidence/${evidence.evidence_id}/reconstruction-obligation-ledger`, + }); + expect(resource.contents[0]).toEqual( + expect.objectContaining({ + text: expect.stringContaining( + '"schema": "ReconstructionObligationLedger"', + ), + }), + ); + } finally { + await client.close(); + await server.close(); + await session.close(); + } + }); +});