From a35b9335a0124674ee5e3abe8c075f1fff6b4fa6 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:29:08 +0800 Subject: [PATCH] fix(reconstruction): evaluate one effective completion scope Include required-claim surfaces and reachable dependencies in ownership, verifier commitments, contradiction, package proof and summary checks. Preserve all applicable package outcomes instead of accepting any passing proof. Adds authenticated synthetic scope cases and a shared public CLI/MCP regression workflow. Validation is pending the centrally coordinated lane; single-file formatting and lint completed. --- docs/reconstruction-coverage.md | 23 +++ src/domain/reconstructionCoverage.test.ts | 26 +++ src/domain/reconstructionCoverage.ts | 6 +- .../reconstructionCoverageEvaluation.ts | 87 +++++---- .../reconstructionCoverageScope.fixture.ts | 184 ++++++++++++++++++ src/domain/reconstructionCoverageScope.ts | 64 ++++++ .../cli/reconstructionCoverageCli.test.ts | 38 ++++ 7 files changed, 386 insertions(+), 42 deletions(-) create mode 100644 src/domain/reconstructionCoverageScope.fixture.ts create mode 100644 src/domain/reconstructionCoverageScope.ts diff --git a/docs/reconstruction-coverage.md b/docs/reconstruction-coverage.md index d9912db92d..2d4d132a82 100644 --- a/docs/reconstruction-coverage.md +++ b/docs/reconstruction-coverage.md @@ -11,6 +11,23 @@ record can still be insufficient proof: a passing verifier summary cannot promote inferred Evidence into a replay observation. That case returns `unknown`, while a missing or tampered record remains an input error. +## Effective completion scope + +The selected boundary requires the union of its explicit surface IDs, the +surfaces of every required claim, and every declared dependency reachable from +those surfaces. Cycles are visited once. Missing reachable dependencies remain +visible as `dependency-surface-missing`; they keep closure partial. The +`required_surfaces` summary counts this complete set, including absent IDs, +and `required_claims` counts unique required claims. + +Every reachable surface receives the same ownership, path, distribution, +disposition and authority-independence checks. Active contradictions on any of +these surfaces affect the boundary even when the caller did not repeat the +surface ID in `required_surface_ids`. Package proof must cover the artifacts of +this effective scope. A claim verifier must commit to its own surfaces and +their reachable dependencies, including their artifact and implemented-owner +digests. Callers do not need to duplicate the transitive closure in the request. + ## Comparable verifier Evidence At least one record explicitly cited by the latest verifier observation must @@ -41,3 +58,9 @@ neither a passing nor a failed package workflow. The evaluator preserves Evidence provenance and returns an actionable unknown when qualification is unavailable. It does not change the cited records to make their authority match a caller's declaration. + +All applicable package-proof outcomes participate. A failed applicable proof +fails closure even when another proof passes; unknown, unsupported or skipped +applicable outcomes keep it unknown. The result cites all applicable outcomes. +Proof array order does not establish freshness or supersession. A proof for +unrelated artifact digests does not contradict the selected boundary. diff --git a/src/domain/reconstructionCoverage.test.ts b/src/domain/reconstructionCoverage.test.ts index e1a84e46c3..9d1685cdbf 100644 --- a/src/domain/reconstructionCoverage.test.ts +++ b/src/domain/reconstructionCoverage.test.ts @@ -1,3 +1,4 @@ +import { reconstructionCoverageScopeCases } from "./reconstructionCoverageScope.fixture.js"; import { describe, expect, it } from "vitest"; import { @@ -608,3 +609,28 @@ describe("reconstruction coverage reference constraints", () => { }, ); }); + +describe("effective reconstruction boundary", () => { + for (const { + label, + coverage, + status, + codes, + surfaces, + evidenceIds, + } of reconstructionCoverageScopeCases()) + it(label, () => { + const result = assertReconstructionCoverageEvaluation( + coverage, + "replacement.cli", + RECONSTRUCTION_COVERAGE_NOW, + ); + expect(result.status).toBe(status); + expect(result.summary.required_surfaces).toBe(surfaces); + expect(result.reasons.map(({ code }) => code).sort()).toEqual( + [...codes].sort(), + ); + expect(result.evidence_ids).toEqual(expect.arrayContaining(evidenceIds)); + expect(result.recommended_probes).toHaveLength(codes.length); + }); +}); diff --git a/src/domain/reconstructionCoverage.ts b/src/domain/reconstructionCoverage.ts index 5fd1c73e85..5b3c144249 100644 --- a/src/domain/reconstructionCoverage.ts +++ b/src/domain/reconstructionCoverage.ts @@ -1,3 +1,4 @@ +import { reconstructionCoverageScope } from "./reconstructionCoverageScope.js"; import { z } from "zod"; import { AnalysisInputError } from "./analysisErrorCore.js"; import type { EvidenceIntegrityError } from "./evidenceErrors.js"; @@ -345,6 +346,7 @@ export const evaluateParsedReconstructionClosure = ( const context: ReconstructionEvaluationContext = { ...parsed, boundary, + scope: reconstructionCoverageScope(parsed, boundary), nowEpochMs, nowInstant, reasons: [], @@ -360,8 +362,8 @@ export const evaluateParsedReconstructionClosure = ( boundary_id: boundary.boundary_id, status: closureStatus(reasons), summary: { - required_surfaces: boundary.required_surface_ids.length, - required_claims: boundary.required_claim_ids.length, + required_surfaces: context.scope.surfaceIds.size, + required_claims: context.scope.claimIds.size, reasons: reasons.length, }, reasons, diff --git a/src/domain/reconstructionCoverageEvaluation.ts b/src/domain/reconstructionCoverageEvaluation.ts index fb92ace929..dfd0c4063b 100644 --- a/src/domain/reconstructionCoverageEvaluation.ts +++ b/src/domain/reconstructionCoverageEvaluation.ts @@ -1,3 +1,4 @@ +import type { ReconstructionCoverageScope } from "./reconstructionCoverageScope.js"; import type { ReconstructionClosureResult, ReconstructionCoverageData, @@ -42,6 +43,7 @@ type Boundary = ReconstructionCoverageData["boundaries"][number]; type ClosureReason = ReconstructionClosureResult["reasons"][number]; export interface ReconstructionEvaluationContext extends ParsedReconstructionCoverage { readonly boundary: Boundary; + readonly scope: ReconstructionCoverageScope; readonly nowEpochMs: number; readonly nowInstant: OffsetDateTimeInstant; readonly reasons: ClosureReason[]; @@ -51,26 +53,28 @@ export interface ReconstructionEvaluationContext extends ParsedReconstructionCov export const evaluateReconstructionSurfaces = ( context: ReconstructionEvaluationContext, ): void => { - for (const surfaceId of context.boundary.required_surface_ids) { + for (const surfaceId of context.scope.surfaceIds) { const surface = context.surfaces.get(surfaceId); if (surface === undefined) { - addReason( - context, - "surface-missing", - surfaceId, - "Required surface is absent from the authoritative inventory.", - ); + const dependents = context.scope.missingDependencies.get(surfaceId); + if (dependents === undefined) + addReason( + context, + "surface-missing", + surfaceId, + "Required surface is absent from the authoritative inventory.", + ); + else + for (const dependentId of dependents) + addReason( + context, + "dependency-surface-missing", + dependentId, + `Declared dependency surface is absent: ${surfaceId}`, + ); continue; } addEvidence(context, surface.evidence_ids); - for (const dependencyId of surface.dependency_surface_ids) - if (!context.surfaces.has(dependencyId)) - addReason( - context, - "dependency-surface-missing", - surfaceId, - `Declared dependency surface is absent: ${dependencyId}`, - ); evaluateOwner(context, surfaceId); } }; @@ -152,7 +156,7 @@ export const evaluateReconstructionClaims = ( context: ReconstructionEvaluationContext, ): void => { const indexes = createVerifierIndexes(context); - for (const claimId of context.boundary.required_claim_ids) { + for (const claimId of context.scope.claimIds) { const claim = context.claims.get(claimId); if (claim === undefined) { addReason( @@ -189,6 +193,7 @@ export const evaluateReconstructionClaims = ( }; interface VerifierIndexes { + readonly claimSurfaceIds: ReconstructionCoverageScope["claimSurfaceIds"]; readonly contractsByClaim: ReadonlyMap< string, readonly ReconstructionVerifierContract[] @@ -226,6 +231,7 @@ const createVerifierIndexes = ( } return { + claimSurfaceIds: context.scope.claimSurfaceIds, contractsByClaim, latestResultsByVerifier: latestVerifierObservationsByVerifier( coverage.verifier_results, @@ -342,7 +348,10 @@ const verifierResultMatchesContract = ( result: ReconstructionCoverageData["verifier_results"][number], indexes: VerifierIndexes, ): boolean => { - const expectedArtifactDigests = claim.surface_ids.flatMap((surfaceId) => { + const requiredSurfaceIds = [ + ...(indexes.claimSurfaceIds.get(claim.claim_id) ?? []), + ]; + const expectedArtifactDigests = requiredSurfaceIds.flatMap((surfaceId) => { const surface = indexes.surfaces.get(surfaceId); const artifact = surface === undefined @@ -350,7 +359,7 @@ const verifierResultMatchesContract = ( : indexes.artifacts.get(surface.artifact_id); return artifact === undefined ? [] : [artifact.artifact_sha256]; }); - const expectedOwnerDigests = claim.surface_ids.flatMap((surfaceId) => + const expectedOwnerDigests = requiredSurfaceIds.flatMap((surfaceId) => (indexes.ownersBySurface.get(surfaceId) ?? []).flatMap(({ ownership }) => ownership.disposition === "implemented" ? [ownership.owner_sha256] : [], ), @@ -399,11 +408,9 @@ export const evaluateReconstructionCoverageRisks = ( if ( contradiction.status === "active" && (contradiction.surface_ids.some((id) => - context.boundary.required_surface_ids.includes(id), + context.scope.surfaceIds.has(id), ) || - contradiction.claim_ids.some((id) => - context.boundary.required_claim_ids.includes(id), - )) + contradiction.claim_ids.some((id) => context.scope.claimIds.has(id))) ) { addEvidence(context, contradiction.evidence_ids); addReason( @@ -418,7 +425,7 @@ export const evaluateReconstructionCoverageRisks = ( export const evaluateReconstructionPackageProofs = ( context: ReconstructionEvaluationContext, ): void => { - const requiredArtifactDigests = context.boundary.required_surface_ids.flatMap( + const requiredArtifactDigests = [...context.scope.surfaceIds].flatMap( (surfaceId) => { const surface = context.surfaces.get(surfaceId); const artifact = @@ -438,34 +445,34 @@ export const evaluateReconstructionPackageProofs = ( requiredArtifactDigests.every((digest) => proof.artifact_sha256s.includes(digest), ) && packageProofHasComparableEvidence(proof, context.evidenceById); - const passing = proofs.find( - (proof) => proof.status === "pass" && applicable(proof), + const applicableProofs = proofs.filter(applicable); + addEvidence( + context, + applicableProofs.flatMap((proof) => proof.evidence_ids), ); - if (passing !== undefined) { - addEvidence(context, passing.evidence_ids); - continue; - } - const failing = proofs.find( - (proof) => proof.status === "fail" && applicable(proof), - ); - if (failing !== undefined) { - addEvidence(context, failing.evidence_ids); + if (applicableProofs.some((proof) => proof.status === "fail")) { addReason( context, "package-proof-failed", kind, - "Required package or integration proof failed.", + "Required package or integration proof failed; all applicable outcomes are retained.", ); + } else if ( + applicableProofs.length > 0 && + applicableProofs.every((proof) => proof.status === "pass") + ) { + continue; } else if (proofs.length > 0) { - addEvidence( - context, - proofs.flatMap(({ evidence_ids }) => evidence_ids), - ); + if (applicableProofs.length === 0) + addEvidence( + context, + proofs.flatMap((proof) => proof.evidence_ids), + ); addReason( context, "package-proof-unknown", kind, - "Required package or integration proof has no qualifying passing Evidence.", + "Required package or integration proof has unresolved or unqualified outcomes.", ); } else addReason( diff --git a/src/domain/reconstructionCoverageScope.fixture.ts b/src/domain/reconstructionCoverageScope.fixture.ts new file mode 100644 index 0000000000..78356e3a8d --- /dev/null +++ b/src/domain/reconstructionCoverageScope.fixture.ts @@ -0,0 +1,184 @@ +import { createEvidence } from "./evidence.js"; +import { createEvidenceBundle } from "./evidenceBundle.js"; +import { + completeReconstructionCoverageData, + reconstructionCoverageEvidenceId, +} from "./reconstructionCoverage.fixture.js"; +import type { + ReconstructionCoverageData, + ReconstructionClosureResult, +} from "./reconstructionCoverage.js"; + +const indirectSurface = (coverage: ReconstructionCoverageData): void => { + coverage.surfaces.push({ + ...coverage.surfaces[0]!, + surface_id: "cli.indirect", + }); + coverage.owners.push({ ...coverage.owners[0]!, surface_id: "cli.indirect" }); +}; + +const conflictingPackageProof = ( + coverage: ReconstructionCoverageData, + status: "fail" | "unknown", +): string => { + const evidence = createEvidence( + undefined, + { + id: "package-proof-fixture", + name: "Package proof fixture", + version: "1", + }, + { + operation: "record_package_proof", + parameters: {}, + result: { status }, + authority: "controlled-replay", + confidence: "observed", + }, + ); + coverage.evidence_bundle = createEvidenceBundle([ + ...coverage.evidence_bundle.records, + evidence, + ]); + coverage.package_proofs.push({ + ...coverage.package_proofs[0]!, + proof_id: `proof.additional.${status}`, + status, + evidence_ids: [evidence.evidence_id], + }); + return evidence.evidence_id; +}; + +const cases: { + label: string; + status: ReconstructionClosureResult["status"]; + codes: readonly string[]; + surfaces: number; + change: (coverage: ReconstructionCoverageData) => readonly string[]; +}[] = [ + { + label: "complete control", + status: "ready", + codes: [], + surfaces: 1, + change: () => [], + }, + { + label: "reachable dependency cycle", + status: "ready", + codes: [], + surfaces: 2, + change: (coverage) => { + indirectSurface(coverage); + coverage.surfaces[0]!.dependency_surface_ids = ["cli.indirect"]; + coverage.surfaces[1]!.dependency_surface_ids = ["cli.help"]; + return []; + }, + }, + { + label: "missing transitive dependency", + status: "partial", + codes: ["dependency-surface-missing"], + surfaces: 3, + change: (coverage) => { + indirectSurface(coverage); + coverage.surfaces[0]!.dependency_surface_ids = ["cli.indirect"]; + coverage.surfaces[1]!.dependency_surface_ids = ["cli.absent"]; + return []; + }, + }, + { + label: "claim-only authority routing", + status: "failed", + codes: ["authority-routing-detected"], + surfaces: 2, + change: (coverage) => { + indirectSurface(coverage); + coverage.claims[0]!.surface_ids = ["cli.indirect"]; + const owner = coverage.owners[1]!.ownership; + if (owner.disposition === "implemented") + owner.authority_route = "detected"; + return []; + }, + }, + { + label: "claim-only active contradiction", + status: "failed", + codes: ["active-contradiction"], + surfaces: 2, + change: (coverage) => { + indirectSurface(coverage); + coverage.claims[0]!.surface_ids = ["cli.indirect"]; + const evidenceIds = [ + reconstructionCoverageEvidenceId("1"), + reconstructionCoverageEvidenceId("5"), + ]; + coverage.contradictions = [ + { + contradiction_id: "contradiction.indirect", + status: "active", + surface_ids: ["cli.indirect"], + claim_ids: [], + evidence_ids: evidenceIds, + }, + ]; + return evidenceIds; + }, + }, + { + label: "claim-only artifact package coverage", + status: "unknown", + codes: ["package-proof-unknown", "package-proof-unknown"], + surfaces: 2, + change: (coverage) => { + indirectSurface(coverage); + coverage.artifacts.push({ + ...coverage.artifacts[0]!, + artifact_id: "authority.v2", + artifact_sha256: "9".repeat(64), + }); + coverage.surfaces[1]!.artifact_id = "authority.v2"; + coverage.claims[0]!.surface_ids = ["cli.indirect"]; + coverage.verifier_results[0]!.artifact_sha256s.push("9".repeat(64)); + return []; + }, + }, + { + label: "transitive owner commitment", + status: "unknown", + codes: ["verifier-result-incompatible"], + surfaces: 2, + change: (coverage) => { + indirectSurface(coverage); + coverage.surfaces[0]!.dependency_surface_ids = ["cli.indirect"]; + const owner = coverage.owners[1]!.ownership; + if (owner.disposition === "implemented") + owner.owner_sha256 = "9".repeat(64); + return []; + }, + }, + ...([false, true] as const).map((reverse) => ({ + label: `conflicting package proofs ${reverse ? "reversed" : "forward"}`, + status: "failed" as const, + codes: ["package-proof-failed"], + surfaces: 1, + change: (coverage: ReconstructionCoverageData) => { + const evidenceId = conflictingPackageProof(coverage, "fail"); + if (reverse) coverage.package_proofs.reverse(); + return [evidenceId]; + }, + })), + { + label: "unresolved applicable package proof", + status: "unknown", + codes: ["package-proof-unknown"], + surfaces: 1, + change: (coverage) => [conflictingPackageProof(coverage, "unknown")], + }, +]; +/** Small authenticated synthetic workspaces shared by domain and public workflows. */ +export const reconstructionCoverageScopeCases = () => + cases.map(({ change, ...expectation }) => { + const coverage = completeReconstructionCoverageData(); + return { ...expectation, coverage, evidenceIds: change(coverage) }; + }); diff --git a/src/domain/reconstructionCoverageScope.ts b/src/domain/reconstructionCoverageScope.ts new file mode 100644 index 0000000000..1ab21b1179 --- /dev/null +++ b/src/domain/reconstructionCoverageScope.ts @@ -0,0 +1,64 @@ +import type { + ParsedReconstructionCoverage, + ReconstructionCoverageData, +} from "./reconstructionCoverage.js"; + +/** One completion scope shared by ownership, proof, risk and summary checks. */ +export interface ReconstructionCoverageScope { + readonly surfaceIds: ReadonlySet; + readonly claimIds: ReadonlySet; + readonly claimSurfaceIds: ReadonlyMap>; + readonly missingDependencies: ReadonlyMap>; +} + +export const reconstructionCoverageScope = ( + parsed: ParsedReconstructionCoverage, + boundary: ReconstructionCoverageData["boundaries"][number], +): ReconstructionCoverageScope => { + const claimIds = new Set(boundary.required_claim_ids); + const claimSurfaceIds = new Map>(); + for (const claimId of claimIds) { + const claim = parsed.claims.get(claimId); + if (claim !== undefined) + claimSurfaceIds.set( + claimId, + reachableSurfaces(claim.surface_ids, parsed.surfaces).surfaceIds, + ); + } + return { + ...reachableSurfaces( + [ + ...boundary.required_surface_ids, + ...[...claimSurfaceIds.values()].flatMap((ids) => [...ids]), + ], + parsed.surfaces, + ), + claimIds, + claimSurfaceIds, + }; +}; + +const reachableSurfaces = ( + roots: readonly string[], + surfaces: ParsedReconstructionCoverage["surfaces"], +): Pick => { + const surfaceIds = new Set(roots); + const pending = [...surfaceIds]; + const missingDependencies = new Map>(); + for (let index = 0; index < pending.length; index += 1) { + const surface = surfaces.get(pending[index]!); + if (surface === undefined) continue; + for (const dependencyId of surface.dependency_surface_ids) { + if (!surfaces.has(dependencyId)) { + const dependents = missingDependencies.get(dependencyId) ?? new Set(); + dependents.add(surface.surface_id); + missingDependencies.set(dependencyId, dependents); + } + if (!surfaceIds.has(dependencyId)) { + surfaceIds.add(dependencyId); + pending.push(dependencyId); + } + } + } + return { surfaceIds, missingDependencies }; +}; diff --git a/tests/boundary/cli/reconstructionCoverageCli.test.ts b/tests/boundary/cli/reconstructionCoverageCli.test.ts index 618625fb74..ce09c3c41b 100644 --- a/tests/boundary/cli/reconstructionCoverageCli.test.ts +++ b/tests/boundary/cli/reconstructionCoverageCli.test.ts @@ -1,3 +1,5 @@ +import { reconstructionClosureResultSchema } from "../../../src/domain/reconstructionCoverage.js"; +import { reconstructionCoverageScopeCases } from "../../../src/domain/reconstructionCoverageScope.fixture.js"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; @@ -115,3 +117,39 @@ cliTest( }); }, ); + +cliTest( + "accounts for effective scope and conflicting proofs through CLI and MCP", + async ({ cli }) => { + const call = await cli.sharedMcpCall?.(); + if (call === undefined) throw new Error("Expected shared file MCP session"); + for (const { + label, + coverage, + status, + codes, + surfaces, + evidenceIds, + } of reconstructionCoverageScopeCases()) { + coverage.verifier_results[0]!.observed_at = new Date().toISOString(); + const cliResult = await evaluate(cli, coverage); + expect(cliResult.exitCode, label).toBe(0); + const mcpResult = await call("evaluate_reconstruction_coverage", { + coverage, + boundary_id: "replacement.cli", + }); + expect(mcpResult.isError, label).not.toBe(true); + for (const output of [cliResult.json, mcpResult.structuredContent]) { + const result = reconstructionClosureResultSchema.parse(output); + expect(result.status, label).toBe(status); + expect(result.summary.required_surfaces, label).toBe(surfaces); + expect(result.reasons.map(({ code }) => code).sort(), label).toEqual( + [...codes].sort(), + ); + expect(result.evidence_ids, label).toEqual( + expect.arrayContaining(evidenceIds), + ); + } + } + }, +);