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), + ); + } + } + }, +);