From 393dd96e7d18e8491d8d6daee94c117e07aba9d8 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 31 Aug 2026 09:46:07 +0000 Subject: [PATCH 1/3] feat(agent): add graph evidence protocol Closes: SAP-2985 --- .changeset/steady-graphs-prove.md | 7 + packages/agent/README.md | 61 +- packages/agent/src/index.ts | 52 +- .../agent/src/package-graph-evidence.spec.ts | 693 +++++++++ packages/agent/src/package-graph-evidence.ts | 1310 +++++++++++++++++ packages/agent/src/package-inventory.ts | 2 +- 6 files changed, 2122 insertions(+), 3 deletions(-) create mode 100644 .changeset/steady-graphs-prove.md create mode 100644 packages/agent/src/package-graph-evidence.spec.ts create mode 100644 packages/agent/src/package-graph-evidence.ts diff --git a/.changeset/steady-graphs-prove.md b/.changeset/steady-graphs-prove.md new file mode 100644 index 000000000..848f43d0d --- /dev/null +++ b/.changeset/steady-graphs-prove.md @@ -0,0 +1,7 @@ +--- +"@sapiom/agent": minor +--- + +Add the strict package graph-evidence protocol with deterministic static results, +idempotent runtime events, endpoint quarantine, lifecycle reference semantics, +and stable connector conformance helpers. diff --git a/packages/agent/README.md b/packages/agent/README.md index fe068eab6..86a2de86c 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -56,6 +56,65 @@ A step declares the transitions it may take (`next` / `terminal` / `canFail` / undeclared transition is a compile error. The build reads those same declarations to render the orchestration graph without executing anything. +## Package graph evidence + +`PackageInventory` answers which agents exist and where they live. The separately +versioned package graph-evidence protocol answers why two inventory agents are +connected. Protocol 1 admits four factual relation/basis pairs: + +| Relation | Basis | Evidence meaning | +| --------- | ------------------- | ------------------------------------------ | +| `invokes` | `static-invocation` | Source proves one agent starts another | +| `invokes` | `runtime-dispatch` | The engine observed a caller/callee pair | +| `feeds` | `static-dataflow` | Source provenance reaches another input | +| `feeds` | `runtime-handoff` | Runtime lineage proves a supported handoff | + +Every accepted record names `fromAgentKey` and `toAgentKey` explicitly. Static +results reuse the exact inventory version and additionally carry an analysis +fingerprint, producer identity/version, coverage, deterministic diagnostics, and +quarantine. Runtime evidence is an append-only bundle event keyed by an +authoritative event ID. The helpers expose reference replacement/idempotency +semantics only; persistence and production graph projection remain server concerns. + +```ts +import { + createPackageGraphEvidenceStaticResult, + type PackageInventory, +} from "@sapiom/agent"; + +export function directInvocationEvidence(inventory: PackageInventory) { + return createPackageGraphEvidenceStaticResult( + { + scope: inventory.version, + producer: { id: "acme.direct-invocation", version: "1.0.0" }, + analysisFingerprint: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + outcome: "success", + coverage: { status: "complete" }, + candidates: [ + { + fromAgentKey: "coordinator", + toAgentKey: "research", + relation: "invokes", + basis: "static-invocation", + mode: "blocking", + callsites: [ + { kind: "source-callsite", ref: "callsite:coordinator.research" }, + ], + }, + ], + }, + inventory, + ); +} +``` + +Evidence references are public-safe opaque handles. Absolute or relative paths, +execution IDs, lineage IDs, prompts, reports, inputs, outputs, and tool payloads +stay behind an authorized producer-owned resolver and must not be copied into a +public graph DTO. Graph evidence is explanatory metadata only: it cannot change +execution, routing, authorization, deployment, builds, or billing. + ## The entry input contract A step's `inputSchema` (a zod schema, imported from `zod/v4`) types and validates that @@ -175,7 +234,7 @@ Things to know: ctx.shared.set("codingRunId", run.runId); // readable from the resumed step return pauseUntilSignal(run, { resumeStep: "review" }); } - ``` +``` - **Outside an agent run nothing changes** — `await launch().wait()` the capability as usual; the pause wiring only engages when a step pauses on the handle. diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 6d857d5f0..ac45ce506 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -113,7 +113,7 @@ export { MANIFEST_PROTOCOL, agentManifestSchema } from './manifest.js'; export type { AgentManifest, AgentStepManifest, ManifestTransition } from './manifest.js'; // Multi-agent package inventory — separate from the single-agent build manifest. -export { PACKAGE_INVENTORY_PROTOCOL, packageInventorySchema } from './package-inventory.js'; +export { PACKAGE_INVENTORY_PROTOCOL, packageInventorySchema, packageInventoryVersionSchema } from './package-inventory.js'; export type { PackageInventory, PackageInventoryAgent, @@ -121,6 +121,56 @@ export type { PackageInventoryVersion, } from './package-inventory.js'; +// Package-scoped relationship evidence — separate from identity inventory. +export { + PACKAGE_GRAPH_EVIDENCE_PROTOCOL, + advancePackageGraphStaticEvidenceState, + appendPackageGraphRuntimeEvidenceEvent, + canonicalPackageGraphEvidenceJson, + createPackageGraphEvidenceStaticResult, + createPackageGraphRuntimeEvidenceEvent, + packageGraphEvidenceCandidateSchema, + packageGraphEvidenceCoverageGapSchema, + packageGraphEvidenceCoverageSchema, + packageGraphEvidenceDiagnosticCodeSchema, + packageGraphEvidenceDiagnosticSchema, + packageGraphEvidenceProducerSchema, + packageGraphEvidenceQuarantineSchema, + packageGraphEvidenceRecordSchema, + packageGraphEvidenceReferenceSchema, + packageGraphEvidenceSha256, + packageGraphEvidenceStaticResultSchema, + packageGraphRuntimeEvidenceEventSchema, + projectPackageGraphEvidence, +} from './package-graph-evidence.js'; +export type { + AppendPackageGraphRuntimeEvidenceEventResult, + CreatePackageGraphEvidenceStaticResultInput, + CreatePackageGraphRuntimeEvidenceEventResult, + PackageGraphEvidenceCandidate, + PackageGraphEvidenceConnector, + PackageGraphEvidenceCoverage, + PackageGraphEvidenceCoverageGap, + PackageGraphEvidenceDiagnostic, + PackageGraphEvidenceDiagnosticCode, + PackageGraphEvidenceDigest, + PackageGraphEvidenceProducer, + PackageGraphEvidenceProjectionSource, + PackageGraphEvidenceProjectedSupport, + PackageGraphEvidenceQuarantine, + PackageGraphEvidenceRecord, + PackageGraphEvidenceReference, + PackageGraphEvidenceReferenceProjection, + PackageGraphEvidenceStaticResult, + PackageGraphRuntimeEvidenceCandidate, + PackageGraphRuntimeEvidenceEvent, + PackageGraphRuntimeEvidenceRecord, + PackageGraphRuntimeEvidenceState, + PackageGraphStaticEvidenceCandidate, + PackageGraphStaticEvidenceRecord, + PackageGraphStaticEvidenceState, +} from './package-graph-evidence.js'; + // Manifest generator + graph validation — called by the build phase. export { buildManifest, validateGraph, assertValidGraph } from './build-manifest.js'; export type { GraphValidation } from './build-manifest.js'; diff --git a/packages/agent/src/package-graph-evidence.spec.ts b/packages/agent/src/package-graph-evidence.spec.ts new file mode 100644 index 000000000..9670c1d71 --- /dev/null +++ b/packages/agent/src/package-graph-evidence.spec.ts @@ -0,0 +1,693 @@ +import { + PACKAGE_GRAPH_EVIDENCE_PROTOCOL, + PACKAGE_INVENTORY_PROTOCOL, + advancePackageGraphStaticEvidenceState, + appendPackageGraphRuntimeEvidenceEvent, + canonicalPackageGraphEvidenceJson, + createPackageGraphEvidenceStaticResult, + createPackageGraphRuntimeEvidenceEvent, + packageGraphEvidenceCandidateSchema, + packageGraphEvidenceStaticResultSchema, + packageGraphRuntimeEvidenceEventSchema, + projectPackageGraphEvidence, + type PackageGraphEvidenceCandidate, + type PackageGraphEvidenceCoverage, + type PackageGraphEvidenceDiagnostic, + type PackageGraphEvidenceDigest, + type PackageGraphEvidenceProducer, + type PackageGraphEvidenceRecord, + type PackageGraphEvidenceStaticResult, + type PackageGraphRuntimeEvidenceEvent, + type PackageInventory, + type PackageInventoryVersion, +} from "./index.js"; + +const SHA_A = `sha256:${"a".repeat(64)}` as const; +const SHA_B = `sha256:${"b".repeat(64)}` as const; +const SHA_C = `sha256:${"c".repeat(64)}` as const; +const PRODUCER: PackageGraphEvidenceProducer = { + id: "sapiom.static-fixture", + version: "1.0.0", +}; + +function source(ref: string) { + return { kind: "source-callsite" as const, ref }; +} + +function execution(ref: string) { + return { kind: "execution" as const, ref }; +} + +function workingInventory( + overrides: Partial = {}, +): PackageInventory { + return { + protocol: PACKAGE_INVENTORY_PROTOCOL, + version: { + kind: "working-tree", + workspaceKey: "workspace-acme", + revision: SHA_A, + }, + status: "complete", + agents: [ + { + agentKey: "coordinator", + identityStatus: "canonical", + path: "agents/coordinator", + entrypoint: "index.ts", + }, + { + agentKey: "growth", + identityStatus: "canonical", + path: "agents/growth", + entrypoint: "index.ts", + }, + { + agentKey: "research", + identityStatus: "canonical", + path: "agents/research", + entrypoint: "index.ts", + }, + ], + ...overrides, + }; +} + +function bundleInventory(): PackageInventory { + return { + ...workingInventory(), + version: { kind: "bundle", bundleDigest: SHA_B }, + }; +} + +function invocation( + fromAgentKey: string, + toAgentKey: string, + mode: "blocking" | "async" = "blocking", + ref = `callsite:${fromAgentKey}.${toAgentKey}`, +): PackageGraphEvidenceCandidate { + return { + fromAgentKey, + toAgentKey, + relation: "invokes", + basis: "static-invocation", + mode, + callsites: [source(ref)], + }; +} + +function dataflow( + fromAgentKey: string, + toAgentKey: string, +): PackageGraphEvidenceCandidate { + return { + fromAgentKey, + toAgentKey, + relation: "feeds", + basis: "static-dataflow", + source: source("callsite:research.output"), + destination: source("callsite:growth.input"), + path: [ + { kind: "dataflow-path", ref: "path:formatter" }, + { kind: "dataflow-path", ref: "path:router" }, + ], + }; +} + +function staticResult( + candidates: readonly unknown[], + options: { + inventory?: PackageInventory; + scope?: PackageInventoryVersion; + analysisFingerprint?: PackageGraphEvidenceDigest; + outcome?: "success" | "failure"; + coverage?: PackageGraphEvidenceCoverage; + diagnostics?: readonly PackageGraphEvidenceDiagnostic[]; + } = {}, +): PackageGraphEvidenceStaticResult { + const inventory = options.inventory ?? workingInventory(); + return createPackageGraphEvidenceStaticResult( + { + scope: options.scope ?? inventory.version, + producer: PRODUCER, + analysisFingerprint: options.analysisFingerprint ?? SHA_A, + outcome: options.outcome ?? "success", + coverage: options.coverage ?? { status: "complete" }, + candidates, + diagnostics: options.diagnostics, + }, + inventory, + ); +} + +function acceptedRuntime( + eventId: string, + candidate: unknown, +): PackageGraphRuntimeEvidenceEvent { + const created = createPackageGraphRuntimeEvidenceEvent( + { + eventId, + scope: { kind: "bundle", bundleDigest: SHA_B }, + producer: { id: "sapiom.engine", version: "1.0.0" }, + candidate, + }, + bundleInventory(), + ); + if (created.status !== "accepted") throw new Error("fixture was quarantined"); + return created.event; +} + +describe("package graph evidence protocol 1", () => { + it("accepts exactly the four legal relation/basis variants", () => { + const candidates: PackageGraphEvidenceCandidate[] = [ + invocation("coordinator", "research"), + dataflow("research", "growth"), + { + fromAgentKey: "coordinator", + toAgentKey: "research", + relation: "invokes", + basis: "runtime-dispatch", + callerExecution: execution("execution:coordinator"), + calleeExecution: execution("execution:research"), + callsite: { kind: "runtime-callsite", ref: "callsite:dispatch" }, + }, + { + fromAgentKey: "research", + toAgentKey: "growth", + relation: "feeds", + basis: "runtime-handoff", + producerExecution: execution("execution:research"), + consumerExecution: execution("execution:growth"), + lineage: { kind: "lineage", ref: "lineage:report" }, + }, + ]; + + expect( + candidates.map( + (candidate) => + packageGraphEvidenceCandidateSchema.parse(candidate).basis, + ), + ).toEqual([ + "static-invocation", + "static-dataflow", + "runtime-dispatch", + "runtime-handoff", + ]); + expect(() => + packageGraphEvidenceCandidateSchema.parse({ + ...invocation("coordinator", "research"), + relation: "feeds", + }), + ).toThrow(); + expect(() => + packageGraphEvidenceCandidateSchema.parse({ + ...candidates[2], + mode: "async", + }), + ).toThrow(); + }); + + it("keeps direct sibling calls distinct from indirect output data flow", () => { + const direct = staticResult([ + invocation("coordinator", "research"), + invocation("coordinator", "growth"), + ]); + const directProjection = projectPackageGraphEvidence(workingInventory(), [ + direct, + ]); + + expect( + directProjection.connectors.map( + ({ fromAgentKey, toAgentKey, relation }) => [ + fromAgentKey, + toAgentKey, + relation, + ], + ), + ).toEqual([ + ["coordinator", "growth", "invokes"], + ["coordinator", "research", "invokes"], + ]); + expect(directProjection.connectors).not.toContainEqual( + expect.objectContaining({ + fromAgentKey: "research", + toAgentKey: "growth", + relation: "feeds", + }), + ); + + const withProvenFlow = staticResult([ + invocation("coordinator", "research"), + invocation("coordinator", "growth"), + dataflow("research", "growth"), + ]); + expect( + projectPackageGraphEvidence(workingInventory(), [withProvenFlow]) + .connectors, + ).toContainEqual( + expect.objectContaining({ + fromAgentKey: "research", + toAgentKey: "growth", + relation: "feeds", + bases: ["static-dataflow"], + }), + ); + }); + + it("derives byte-identical records and IDs from equivalent unordered inputs", () => { + const first = staticResult([ + { + ...invocation("coordinator", "research"), + callsites: [ + source("callsite:z"), + source("callsite:a"), + source("callsite:z"), + ], + }, + invocation("coordinator", "growth", "async"), + ]); + const second = staticResult([ + invocation("coordinator", "growth", "async"), + { + ...invocation("coordinator", "research"), + callsites: [source("callsite:a"), source("callsite:z")], + }, + ]); + + expect(canonicalPackageGraphEvidenceJson(first)).toBe( + canonicalPackageGraphEvidenceJson(second), + ); + expect(first.resultId).toBe(second.resultId); + expect(first.evidence.map(({ evidenceId }) => evidenceId)).toEqual( + second.evidence.map(({ evidenceId }) => evidenceId), + ); + }); + + it("keeps analysis freshness independent from inventory identity", () => { + const first = staticResult([invocation("coordinator", "research")]); + const changedAnalysis = staticResult( + [invocation("coordinator", "research")], + { analysisFingerprint: SHA_C }, + ); + + expect(changedAnalysis.scope).toEqual(first.scope); + expect(changedAnalysis.resultId).not.toBe(first.resultId); + expect(changedAnalysis.evidence[0]?.evidenceId).not.toBe( + first.evidence[0]?.evidenceId, + ); + }); + + it("preserves exact provisional working-tree identities without overloading inventory status", () => { + const inventory = workingInventory({ + status: "degraded", + agents: [ + workingInventory().agents[0]!, + { + agentKey: "local:agents/research", + identityStatus: "provisional", + identityIssue: "identity-unavailable", + path: "agents/research", + entrypoint: "index.ts", + }, + ], + }); + const result = staticResult( + [ + invocation( + "coordinator", + "local:agents/research", + "blocking", + "callsite:provisional-research", + ), + ], + { inventory }, + ); + + expect(result.outcome).toBe("success"); + expect(result.coverage.status).toBe("complete"); + expect(result.evidence[0]).toMatchObject({ + fromAgentKey: "coordinator", + toAgentKey: "local:agents/research", + }); + expect( + projectPackageGraphEvidence(inventory, [result]).inventoryStatus, + ).toBe("degraded"); + }); + + it("quarantines unknown, invalid, ambiguous, self, and cross-scope candidates", () => { + const ambiguousInventory = workingInventory({ + status: "degraded", + agents: [ + workingInventory().agents[0]!, + { + agentKey: "local:research-a", + identityStatus: "provisional", + identityIssue: "duplicate-agent-key", + candidateAgentKey: "research", + path: "research-a", + entrypoint: "index.ts", + }, + { + agentKey: "local:research-b", + identityStatus: "provisional", + identityIssue: "duplicate-agent-key", + candidateAgentKey: "research", + path: "research-b", + entrypoint: "index.ts", + }, + ], + }); + const rejected = staticResult( + [ + invocation("coordinator", "missing"), + invocation( + "coordinator", + "/private/agent", + "blocking", + "callsite:invalid-target", + ), + invocation("coordinator", "research"), + invocation("coordinator", "coordinator"), + ], + { inventory: ambiguousInventory }, + ); + + expect(rejected.evidence).toEqual([]); + expect(rejected.quarantine.map(({ code }) => code)).toEqual([ + "ambiguous-endpoint", + "illegal-self-relationship", + "invalid-endpoint", + "unknown-endpoint", + ]); + expect(rejected.quarantine.every((item) => !("agentKey" in item))).toBe( + true, + ); + + const otherScope = staticResult([invocation("coordinator", "research")], { + scope: { + kind: "working-tree", + workspaceKey: "workspace-other", + revision: SHA_A, + }, + }); + expect(otherScope.evidence).toEqual([]); + expect(otherScope.quarantine.map(({ code }) => code)).toEqual([ + "cross-scope", + ]); + expect(() => + projectPackageGraphEvidence(bundleInventory(), [ + staticResult([invocation("coordinator", "research")]), + ]), + ).toThrow(/cannot mix inventory and evidence scopes/); + }); + + it("rejects old bundles and local/bundle mixing without mutating identities", () => { + const inventory = bundleInventory(); + const canonical = staticResult([invocation("coordinator", "research")], { + inventory, + }); + expect(canonical).toMatchObject({ + scope: inventory.version, + outcome: "success", + coverage: { status: "complete" }, + }); + expect(canonical.evidence).toHaveLength(1); + + const stale = staticResult([invocation("coordinator", "research")], { + inventory, + scope: { kind: "bundle", bundleDigest: SHA_C }, + }); + expect(stale.evidence).toEqual([]); + expect(stale.quarantine[0]?.code).toBe("cross-scope"); + + expect(() => + createPackageGraphRuntimeEvidenceEvent( + { + eventId: "dispatch:1", + scope: workingInventory().version as never, + producer: PRODUCER, + candidate: { + fromAgentKey: "coordinator", + toAgentKey: "research", + relation: "invokes", + basis: "runtime-dispatch", + callerExecution: execution("execution:caller"), + calleeExecution: execution("execution:callee"), + }, + }, + workingInventory(), + ), + ).toThrow(/bundle scope/); + }); + + it("deduplicates identical records without dropping distinct callsite provenance", () => { + const duplicate = invocation("coordinator", "research"); + const result = staticResult([ + duplicate, + duplicate, + invocation( + "coordinator", + "research", + "blocking", + "callsite:coordinator.research.second", + ), + ]); + + expect(result.evidence).toHaveLength(2); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "duplicate-evidence", + severity: "warning", + }), + ); + }); + + it("rejects tampered IDs, unknown fields, raw payloads, and unsafe references", () => { + const result = staticResult([invocation("coordinator", "research")]); + expect(() => + packageGraphEvidenceStaticResultSchema.parse({ + ...result, + resultId: SHA_B, + }), + ).toThrow(/Result ID/); + expect(() => + packageGraphEvidenceStaticResultSchema.parse({ + ...result, + evidence: [{ ...result.evidence[0]!, evidenceId: SHA_B }], + }), + ).toThrow(/Evidence ID/); + expect(() => + packageGraphEvidenceCandidateSchema.parse({ + ...invocation("coordinator", "research"), + prompt: "raw customer content", + }), + ).toThrow(); + expect(() => + packageGraphEvidenceCandidateSchema.parse({ + ...invocation("coordinator", "research"), + callsites: [ + { + kind: "source-callsite", + ref: "agents/research/index.ts:1", + file: "/private/agents/research/index.ts", + line: 1, + }, + ], + }), + ).toThrow(); + }); + + it("keeps static replacement, partial, and failure semantics explicit", () => { + const complete = staticResult([invocation("coordinator", "research")]); + const initial = advancePackageGraphStaticEvidenceState(undefined, complete); + expect(initial.status).toBe("ready"); + + const retracted = staticResult([]); + const replaced = advancePackageGraphStaticEvidenceState(initial, retracted); + expect(replaced.status).toBe("ready"); + if (replaced.status === "failed") throw new Error("complete result failed"); + expect(replaced.accepted.evidence).toEqual([]); + + const partial = staticResult([invocation("coordinator", "growth")], { + coverage: { + status: "partial", + gaps: [{ code: "work-cap" }, { code: "opaque-boundary" }], + }, + diagnostics: [{ code: "incomplete-analysis", severity: "warning" }], + }); + const stale = advancePackageGraphStaticEvidenceState(initial, partial); + expect(stale).toMatchObject({ + status: "stale", + accepted: { resultId: complete.resultId }, + latestAttempt: { resultId: partial.resultId }, + }); + + const failed = staticResult([], { + outcome: "failure", + coverage: { status: "none", gaps: [{ code: "producer-failed" }] }, + diagnostics: [{ code: "producer-failed", severity: "error" }], + }); + expect(advancePackageGraphStaticEvidenceState(stale, failed)).toMatchObject( + { + status: "stale", + accepted: { resultId: complete.resultId }, + latestAttempt: { resultId: failed.resultId }, + }, + ); + expect( + advancePackageGraphStaticEvidenceState(undefined, partial).status, + ).toBe("partial"); + expect( + advancePackageGraphStaticEvidenceState(undefined, failed).status, + ).toBe("failed"); + }); + + it("requires explicit diagnostics for partial and failed producer outcomes", () => { + expect(() => + staticResult([], { + coverage: { status: "partial", gaps: [{ code: "work-cap" }] }, + }), + ).toThrow(/incomplete-analysis/); + expect(() => + staticResult([], { + outcome: "failure", + coverage: { status: "none", gaps: [{ code: "producer-failed" }] }, + }), + ).toThrow(/producer-failed diagnostic/); + expect(() => + staticResult([], { + diagnostics: [ + { + code: "dynamic-target", + severity: "warning", + reference: { kind: "source-callsite", ref: "callsite:dynamic" }, + }, + ], + }), + ).toThrow(/cannot claim complete coverage/); + }); + + it("deduplicates runtime retries by authoritative event ID and quarantines conflicts", () => { + const dispatch = acceptedRuntime("dispatch:stable", { + fromAgentKey: "coordinator", + toAgentKey: "research", + relation: "invokes", + basis: "runtime-dispatch", + callerExecution: execution("execution:coordinator"), + calleeExecution: execution("execution:research"), + }); + const empty = { events: [], diagnostics: [] }; + const accepted = appendPackageGraphRuntimeEvidenceEvent(empty, dispatch); + const duplicate = appendPackageGraphRuntimeEvidenceEvent( + accepted.state, + dispatch, + ); + expect(accepted.status).toBe("accepted"); + expect(duplicate.status).toBe("duplicate"); + expect(duplicate.state.events).toHaveLength(1); + + const conflicting = acceptedRuntime("dispatch:stable", { + fromAgentKey: "coordinator", + toAgentKey: "growth", + relation: "invokes", + basis: "runtime-dispatch", + callerExecution: execution("execution:coordinator"), + calleeExecution: execution("execution:growth"), + }); + const conflict = appendPackageGraphRuntimeEvidenceEvent( + duplicate.state, + conflicting, + ); + expect(conflict.status).toBe("conflict"); + expect(conflict.state.events).toEqual([dispatch]); + expect(conflict.state.diagnostics).toEqual([ + { + code: "runtime-event-conflict", + severity: "error", + eventId: "dispatch:stable", + }, + ]); + }); + + it("keeps runtime and static envelopes distinct and runtime mode-free", () => { + const runtime = acceptedRuntime("handoff:stable", { + fromAgentKey: "research", + toAgentKey: "growth", + relation: "feeds", + basis: "runtime-handoff", + producerExecution: execution("execution:research"), + consumerExecution: execution("execution:growth"), + lineage: { kind: "lineage", ref: "lineage:report" }, + }); + const staticEvidence = staticResult([dataflow("research", "growth")]); + + expect(packageGraphRuntimeEvidenceEventSchema.parse(runtime).kind).toBe( + "runtime-event", + ); + expect(() => + packageGraphRuntimeEvidenceEventSchema.parse(staticEvidence), + ).toThrow(); + expect(() => + packageGraphEvidenceStaticResultSchema.parse(runtime), + ).toThrow(); + expect(runtime.evidence).not.toHaveProperty("mode"); + }); + + it("projects multiple bases onto stable connectors while preserving all inventory nodes", () => { + const inventory = bundleInventory(); + const staticEvidence = staticResult( + [invocation("coordinator", "research"), dataflow("research", "growth")], + { inventory }, + ); + const runtimeDispatch = acceptedRuntime("dispatch:projection", { + fromAgentKey: "coordinator", + toAgentKey: "research", + relation: "invokes", + basis: "runtime-dispatch", + callerExecution: execution("execution:coordinator"), + calleeExecution: execution("execution:research"), + }); + const projection = projectPackageGraphEvidence(inventory, [ + runtimeDispatch, + staticEvidence, + ]); + + expect(projection.nodes.map(({ agentKey }) => agentKey)).toEqual([ + "coordinator", + "growth", + "research", + ]); + expect(projection.connectors[0]).toMatchObject({ + fromAgentKey: "coordinator", + toAgentKey: "research", + relation: "invokes", + bases: ["static-invocation", "runtime-dispatch"], + }); + expect(projection.connectors[0]?.support).toHaveLength(2); + }); + + it("rejects non-JSON canonical inputs instead of silently changing identity", () => { + expect(() => + canonicalPackageGraphEvidenceJson({ value: undefined }), + ).toThrow(/undefined/); + expect(() => canonicalPackageGraphEvidenceJson(Number.NaN)).toThrow( + /finite/, + ); + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + expect(() => canonicalPackageGraphEvidenceJson(cyclic)).toThrow(/cyclic/); + }); + + it("exports the protocol and explicit record type surface", () => { + expect(PACKAGE_GRAPH_EVIDENCE_PROTOCOL).toBe(1); + const records: PackageGraphEvidenceRecord[] = staticResult([ + invocation("coordinator", "research"), + ]).evidence; + expect(records[0]).toMatchObject({ + fromAgentKey: "coordinator", + toAgentKey: "research", + relation: "invokes", + basis: "static-invocation", + }); + }); +}); diff --git a/packages/agent/src/package-graph-evidence.ts b/packages/agent/src/package-graph-evidence.ts new file mode 100644 index 000000000..e08fbc860 --- /dev/null +++ b/packages/agent/src/package-graph-evidence.ts @@ -0,0 +1,1310 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod/v4"; + +import { + packageInventorySchema, + packageInventoryVersionSchema, + type PackageInventory, + type PackageInventoryVersion, +} from "./package-inventory.js"; + +/** Protocol version for package-scoped, cross-agent graph evidence. */ +export const PACKAGE_GRAPH_EVIDENCE_PROTOCOL = 1 as const; + +export type PackageGraphEvidenceDigest = `sha256:${string}`; + +const SHA256 = /^sha256:[0-9a-f]{64}$/; +const OPAQUE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,255}$/; +const PRODUCER_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,127}$/; +const ZERO_DIGEST = `sha256:${"0".repeat(64)}` as const; + +const digestSchema = z + .string() + .regex(SHA256, "Expected lowercase sha256:<64 hex characters>") + .transform((value) => value as PackageGraphEvidenceDigest); +const opaqueReferenceValueSchema = z + .string() + .regex(OPAQUE_REFERENCE, "Expected a public-safe opaque reference"); +const producerComponentSchema = z + .string() + .regex(PRODUCER_COMPONENT, "Expected a safe producer identifier"); +const candidateEndpointSchema = z.string().max(512); + +function referenceSchema(kind: Kind) { + return z + .object({ + kind: z.literal(kind), + ref: opaqueReferenceValueSchema, + }) + .strict(); +} + +const sourceCallsiteReferenceSchema = referenceSchema("source-callsite"); +const dataflowPathReferenceSchema = referenceSchema("dataflow-path"); +const runtimeCallsiteReferenceSchema = referenceSchema("runtime-callsite"); +const executionReferenceSchema = referenceSchema("execution"); +const lineageReferenceSchema = referenceSchema("lineage"); + +/** + * Public-safe handle for evidence details retained behind an authorized + * producer-owned resolver. Paths, execution IDs, lineage IDs, and payloads do + * not enter the graph-evidence wire contract itself. + */ +export const packageGraphEvidenceReferenceSchema = z.discriminatedUnion( + "kind", + [ + sourceCallsiteReferenceSchema, + dataflowPathReferenceSchema, + runtimeCallsiteReferenceSchema, + executionReferenceSchema, + lineageReferenceSchema, + ], +); +export type PackageGraphEvidenceReference = z.infer< + typeof packageGraphEvidenceReferenceSchema +>; + +export const packageGraphEvidenceProducerSchema = z + .object({ + id: producerComponentSchema, + version: producerComponentSchema, + }) + .strict(); +export type PackageGraphEvidenceProducer = z.infer< + typeof packageGraphEvidenceProducerSchema +>; + +const staticInvocationCandidateSchema = z + .object({ + fromAgentKey: candidateEndpointSchema, + toAgentKey: candidateEndpointSchema, + relation: z.literal("invokes"), + basis: z.literal("static-invocation"), + mode: z.enum(["blocking", "async"]), + callsites: z.array(sourceCallsiteReferenceSchema).min(1), + }) + .strict(); + +const staticDataflowCandidateSchema = z + .object({ + fromAgentKey: candidateEndpointSchema, + toAgentKey: candidateEndpointSchema, + relation: z.literal("feeds"), + basis: z.literal("static-dataflow"), + source: sourceCallsiteReferenceSchema, + destination: sourceCallsiteReferenceSchema, + path: z.array(dataflowPathReferenceSchema), + }) + .strict(); + +const runtimeDispatchCandidateSchema = z + .object({ + fromAgentKey: candidateEndpointSchema, + toAgentKey: candidateEndpointSchema, + relation: z.literal("invokes"), + basis: z.literal("runtime-dispatch"), + callerExecution: executionReferenceSchema, + calleeExecution: executionReferenceSchema, + callsite: runtimeCallsiteReferenceSchema.optional(), + }) + .strict(); + +const runtimeHandoffCandidateSchema = z + .object({ + fromAgentKey: candidateEndpointSchema, + toAgentKey: candidateEndpointSchema, + relation: z.literal("feeds"), + basis: z.literal("runtime-handoff"), + producerExecution: executionReferenceSchema, + consumerExecution: executionReferenceSchema, + lineage: lineageReferenceSchema, + callsite: runtimeCallsiteReferenceSchema.optional(), + }) + .strict(); + +/** The only four legal relation/basis combinations in protocol 1. */ +export const packageGraphEvidenceCandidateSchema = z.discriminatedUnion( + "basis", + [ + staticInvocationCandidateSchema, + staticDataflowCandidateSchema, + runtimeDispatchCandidateSchema, + runtimeHandoffCandidateSchema, + ], +); +export type PackageGraphEvidenceCandidate = z.infer< + typeof packageGraphEvidenceCandidateSchema +>; +export type PackageGraphStaticEvidenceCandidate = z.infer< + typeof staticInvocationCandidateSchema | typeof staticDataflowCandidateSchema +>; +export type PackageGraphRuntimeEvidenceCandidate = z.infer< + typeof runtimeDispatchCandidateSchema | typeof runtimeHandoffCandidateSchema +>; + +const staticInvocationRecordSchema = staticInvocationCandidateSchema.extend({ + evidenceId: digestSchema, +}); +const staticDataflowRecordSchema = staticDataflowCandidateSchema.extend({ + evidenceId: digestSchema, +}); +const runtimeDispatchRecordSchema = runtimeDispatchCandidateSchema.extend({ + evidenceId: digestSchema, +}); +const runtimeHandoffRecordSchema = runtimeHandoffCandidateSchema.extend({ + evidenceId: digestSchema, +}); + +export const packageGraphEvidenceRecordSchema = z.discriminatedUnion("basis", [ + staticInvocationRecordSchema, + staticDataflowRecordSchema, + runtimeDispatchRecordSchema, + runtimeHandoffRecordSchema, +]); +export type PackageGraphEvidenceRecord = z.infer< + typeof packageGraphEvidenceRecordSchema +>; +export type PackageGraphStaticEvidenceRecord = z.infer< + typeof staticInvocationRecordSchema | typeof staticDataflowRecordSchema +>; +export type PackageGraphRuntimeEvidenceRecord = z.infer< + typeof runtimeDispatchRecordSchema | typeof runtimeHandoffRecordSchema +>; + +export const packageGraphEvidenceDiagnosticCodeSchema = z.enum([ + "invalid-candidate", + "invalid-endpoint", + "unknown-endpoint", + "ambiguous-endpoint", + "illegal-self-relationship", + "cross-scope", + "duplicate-evidence", + "dynamic-target", + "incomplete-analysis", + "producer-failed", + "runtime-event-conflict", +]); +export type PackageGraphEvidenceDiagnosticCode = z.infer< + typeof packageGraphEvidenceDiagnosticCodeSchema +>; + +export const packageGraphEvidenceDiagnosticSchema = z + .object({ + code: packageGraphEvidenceDiagnosticCodeSchema, + severity: z.enum(["warning", "error"]), + candidateFingerprint: digestSchema.optional(), + quarantineId: digestSchema.optional(), + evidenceId: digestSchema.optional(), + eventId: opaqueReferenceValueSchema.optional(), + endpoint: z.enum(["from", "to"]).optional(), + reference: packageGraphEvidenceReferenceSchema.optional(), + }) + .strict(); +export type PackageGraphEvidenceDiagnostic = z.infer< + typeof packageGraphEvidenceDiagnosticSchema +>; + +const quarantineCodeSchema = z.enum([ + "invalid-candidate", + "invalid-endpoint", + "unknown-endpoint", + "ambiguous-endpoint", + "illegal-self-relationship", + "cross-scope", +]); + +export const packageGraphEvidenceQuarantineSchema = z + .object({ + quarantineId: digestSchema, + candidateFingerprint: digestSchema, + code: quarantineCodeSchema, + endpoint: z.enum(["from", "to"]).optional(), + }) + .strict(); +export type PackageGraphEvidenceQuarantine = z.infer< + typeof packageGraphEvidenceQuarantineSchema +>; + +export const packageGraphEvidenceCoverageGapSchema = z + .object({ + code: z.enum([ + "unreadable-source", + "work-cap", + "opaque-boundary", + "dynamic-source", + "producer-failed", + "other", + ]), + reference: packageGraphEvidenceReferenceSchema.optional(), + }) + .strict(); +export type PackageGraphEvidenceCoverageGap = z.infer< + typeof packageGraphEvidenceCoverageGapSchema +>; + +export const packageGraphEvidenceCoverageSchema = z.discriminatedUnion( + "status", + [ + z.object({ status: z.literal("complete") }).strict(), + z + .object({ + status: z.literal("partial"), + gaps: z.array(packageGraphEvidenceCoverageGapSchema).min(1), + }) + .strict(), + z + .object({ + status: z.literal("none"), + gaps: z.array(packageGraphEvidenceCoverageGapSchema).min(1), + }) + .strict(), + ], +); +export type PackageGraphEvidenceCoverage = z.infer< + typeof packageGraphEvidenceCoverageSchema +>; + +/** Canonical JSON for already-validated graph-evidence values. */ +export function canonicalPackageGraphEvidenceJson(value: unknown): string { + const seen = new Set(); + + const visit = (current: unknown): string => { + if ( + current === null || + typeof current === "string" || + typeof current === "boolean" + ) { + return JSON.stringify(current); + } + if (typeof current === "number") { + if (!Number.isFinite(current)) + throw new TypeError("Canonical JSON requires finite numbers"); + return JSON.stringify(current); + } + if (typeof current !== "object") { + throw new TypeError("Canonical JSON accepts JSON values only"); + } + if (seen.has(current)) + throw new TypeError("Canonical JSON rejects cyclic values"); + seen.add(current); + try { + if (Array.isArray(current)) return `[${current.map(visit).join(",")}]`; + const prototype = Object.getPrototypeOf(current); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("Canonical JSON accepts plain objects only"); + } + const entries = Object.entries(current as Record).sort( + ([left], [right]) => compareText(left, right), + ); + if (entries.some(([, child]) => child === undefined)) { + throw new TypeError("Canonical JSON rejects undefined object fields"); + } + return `{${entries + .map(([key, child]) => `${JSON.stringify(key)}:${visit(child)}`) + .join(",")}}`; + } finally { + seen.delete(current); + } + }; + + return visit(value); +} + +/** Full SHA-256 identity for canonical, already-validated semantic content. */ +export function packageGraphEvidenceSha256( + value: unknown, +): PackageGraphEvidenceDigest { + return `sha256:${createHash("sha256").update(canonicalPackageGraphEvidenceJson(value)).digest("hex")}`; +} + +function compareText(left: string, right: string): number { + return left === right ? 0 : left < right ? -1 : 1; +} + +const BASIS_ORDER: Record = { + "static-invocation": 0, + "runtime-dispatch": 1, + "static-dataflow": 2, + "runtime-handoff": 3, +}; + +function compareReference( + left: PackageGraphEvidenceReference, + right: PackageGraphEvidenceReference, +): number { + return compareText(left.kind, right.kind) || compareText(left.ref, right.ref); +} + +function normalizeReferenceSet( + references: readonly T[], +): T[] { + return [ + ...new Map( + [...references] + .sort(compareReference) + .map((reference) => [ + `${reference.kind}\u0000${reference.ref}`, + reference, + ]), + ).values(), + ]; +} + +function normalizeCandidate( + candidate: PackageGraphEvidenceCandidate, +): PackageGraphEvidenceCandidate { + switch (candidate.basis) { + case "static-invocation": + return { + ...candidate, + callsites: normalizeReferenceSet(candidate.callsites), + }; + case "static-dataflow": + return { ...candidate, path: [...candidate.path] }; + case "runtime-dispatch": + case "runtime-handoff": + return { ...candidate }; + } +} + +function candidateFromRecord( + record: PackageGraphEvidenceRecord, +): PackageGraphEvidenceCandidate { + return Object.fromEntries( + Object.entries(record).filter(([key]) => key !== "evidenceId"), + ) as PackageGraphEvidenceCandidate; +} + +function compareRecord( + left: PackageGraphEvidenceRecord, + right: PackageGraphEvidenceRecord, +): number { + return ( + compareText(left.fromAgentKey, right.fromAgentKey) || + compareText(left.toAgentKey, right.toAgentKey) || + compareText(left.relation, right.relation) || + BASIS_ORDER[left.basis] - BASIS_ORDER[right.basis] || + compareText(left.evidenceId, right.evidenceId) + ); +} + +function compareDiagnostic( + left: PackageGraphEvidenceDiagnostic, + right: PackageGraphEvidenceDiagnostic, +): number { + return ( + compareText(left.code, right.code) || + compareText(left.severity, right.severity) || + compareText( + left.candidateFingerprint ?? "", + right.candidateFingerprint ?? "", + ) || + compareText(left.quarantineId ?? "", right.quarantineId ?? "") || + compareText(left.evidenceId ?? "", right.evidenceId ?? "") || + compareText(left.eventId ?? "", right.eventId ?? "") || + compareText(left.endpoint ?? "", right.endpoint ?? "") || + compareText(left.reference?.kind ?? "", right.reference?.kind ?? "") || + compareText(left.reference?.ref ?? "", right.reference?.ref ?? "") + ); +} + +function normalizeDiagnostics( + diagnostics: readonly PackageGraphEvidenceDiagnostic[], +): PackageGraphEvidenceDiagnostic[] { + return [ + ...new Map( + [...diagnostics] + .map((diagnostic) => + packageGraphEvidenceDiagnosticSchema.parse(diagnostic), + ) + .sort(compareDiagnostic) + .map((diagnostic) => [ + canonicalPackageGraphEvidenceJson(diagnostic), + diagnostic, + ]), + ).values(), + ]; +} + +function compareQuarantine( + left: PackageGraphEvidenceQuarantine, + right: PackageGraphEvidenceQuarantine, +): number { + return ( + compareText(left.code, right.code) || + compareText(left.candidateFingerprint, right.candidateFingerprint) || + compareText(left.endpoint ?? "", right.endpoint ?? "") || + compareText(left.quarantineId, right.quarantineId) + ); +} + +function normalizeQuarantine( + quarantine: readonly PackageGraphEvidenceQuarantine[], +): PackageGraphEvidenceQuarantine[] { + return [ + ...new Map( + [...quarantine] + .map((item) => packageGraphEvidenceQuarantineSchema.parse(item)) + .sort(compareQuarantine) + .map((item) => [item.quarantineId, item]), + ).values(), + ]; +} + +function compareCoverageGap( + left: PackageGraphEvidenceCoverageGap, + right: PackageGraphEvidenceCoverageGap, +): number { + return ( + compareText(left.code, right.code) || + compareText(left.reference?.kind ?? "", right.reference?.kind ?? "") || + compareText(left.reference?.ref ?? "", right.reference?.ref ?? "") + ); +} + +function normalizeCoverage( + coverage: PackageGraphEvidenceCoverage, +): PackageGraphEvidenceCoverage { + if (coverage.status === "complete") return coverage; + return { + ...coverage, + gaps: [ + ...new Map( + [...coverage.gaps] + .map((gap) => packageGraphEvidenceCoverageGapSchema.parse(gap)) + .sort(compareCoverageGap) + .map((gap) => [canonicalPackageGraphEvidenceJson(gap), gap]), + ).values(), + ], + }; +} + +function scopeMatches( + left: PackageInventoryVersion, + right: PackageInventoryVersion, +): boolean { + return ( + canonicalPackageGraphEvidenceJson(left) === + canonicalPackageGraphEvidenceJson(right) + ); +} + +function packageInventoryKeyIsValid(agentKey: string): boolean { + const canonical = packageInventorySchema.safeParse({ + protocol: 1, + version: { + kind: "working-tree", + workspaceKey: "graph-evidence-key-check", + revision: ZERO_DIGEST, + }, + status: "complete", + agents: [ + { + agentKey, + identityStatus: "canonical", + path: ".", + entrypoint: "index.ts", + }, + ], + }); + if (canonical.success) return true; + return packageInventorySchema.safeParse({ + protocol: 1, + version: { + kind: "working-tree", + workspaceKey: "graph-evidence-key-check", + revision: ZERO_DIGEST, + }, + status: "degraded", + agents: [ + { + agentKey, + identityStatus: "provisional", + identityIssue: "identity-unavailable", + path: ".", + entrypoint: "index.ts", + }, + ], + }).success; +} + +type QuarantineCode = z.infer; + +interface CandidateIdentityContext { + protocol: typeof PACKAGE_GRAPH_EVIDENCE_PROTOCOL; + scope: PackageInventoryVersion; + producer: PackageGraphEvidenceProducer; + analysisFingerprint?: PackageGraphEvidenceDigest; + eventId?: string; +} + +function quarantineCandidate( + identity: CandidateIdentityContext, + candidateFingerprint: PackageGraphEvidenceDigest, + code: QuarantineCode, + endpoint?: "from" | "to", +): { + quarantine: PackageGraphEvidenceQuarantine; + diagnostic: PackageGraphEvidenceDiagnostic; +} { + const quarantineId = packageGraphEvidenceSha256({ + ...identity, + candidateFingerprint, + code, + ...(endpoint === undefined ? {} : { endpoint }), + }); + return { + quarantine: { + quarantineId, + candidateFingerprint, + code, + ...(endpoint === undefined ? {} : { endpoint }), + }, + diagnostic: { + code, + severity: "error", + candidateFingerprint, + quarantineId, + ...(endpoint === undefined ? {} : { endpoint }), + }, + }; +} + +interface CandidateValidationResult { + records: PackageGraphEvidenceRecord[]; + diagnostics: PackageGraphEvidenceDiagnostic[]; + quarantine: PackageGraphEvidenceQuarantine[]; +} + +function validateCandidates( + rawCandidates: readonly unknown[], + inventory: PackageInventory, + identity: CandidateIdentityContext, + allowedBases: ReadonlySet, +): CandidateValidationResult { + const records: PackageGraphEvidenceRecord[] = []; + const diagnostics: PackageGraphEvidenceDiagnostic[] = []; + const quarantine: PackageGraphEvidenceQuarantine[] = []; + const inventoryKeys = new Set( + inventory.agents.map((agent) => agent.agentKey), + ); + const ambiguousCandidates = new Set( + inventory.agents.flatMap((agent) => + agent.identityIssue === "duplicate-agent-key" && agent.candidateAgentKey + ? [agent.candidateAgentKey] + : [], + ), + ); + const seenEvidence = new Set(); + const sameScope = scopeMatches(identity.scope, inventory.version); + + for (const rawCandidate of rawCandidates) { + const parsed = packageGraphEvidenceCandidateSchema.safeParse(rawCandidate); + if (!parsed.success || !allowedBases.has(parsed.data.basis)) { + const candidateFingerprint = packageGraphEvidenceSha256({ + invalid: true, + issues: parsed.success + ? [{ code: "unsupported-basis", path: ["basis"] }] + : parsed.error.issues.map((issue) => ({ + code: issue.code, + path: issue.path, + })), + }); + const rejected = quarantineCandidate( + identity, + candidateFingerprint, + "invalid-candidate", + ); + quarantine.push(rejected.quarantine); + diagnostics.push(rejected.diagnostic); + continue; + } + + const candidate = normalizeCandidate(parsed.data); + const candidateFingerprint = packageGraphEvidenceSha256(candidate); + if (!sameScope) { + const rejected = quarantineCandidate( + identity, + candidateFingerprint, + "cross-scope", + ); + quarantine.push(rejected.quarantine); + diagnostics.push(rejected.diagnostic); + continue; + } + + let rejected = false; + for (const [endpoint, agentKey] of [ + ["from", candidate.fromAgentKey], + ["to", candidate.toAgentKey], + ] as const) { + if (inventoryKeys.has(agentKey)) continue; + const code: QuarantineCode = ambiguousCandidates.has(agentKey) + ? "ambiguous-endpoint" + : packageInventoryKeyIsValid(agentKey) + ? "unknown-endpoint" + : "invalid-endpoint"; + const result = quarantineCandidate( + identity, + candidateFingerprint, + code, + endpoint, + ); + quarantine.push(result.quarantine); + diagnostics.push(result.diagnostic); + rejected = true; + } + if (rejected) continue; + if (candidate.fromAgentKey === candidate.toAgentKey) { + const result = quarantineCandidate( + identity, + candidateFingerprint, + "illegal-self-relationship", + ); + quarantine.push(result.quarantine); + diagnostics.push(result.diagnostic); + continue; + } + + const evidenceId = packageGraphEvidenceSha256({ ...identity, candidate }); + if (seenEvidence.has(evidenceId)) { + diagnostics.push({ + code: "duplicate-evidence", + severity: "warning", + evidenceId, + }); + continue; + } + seenEvidence.add(evidenceId); + records.push({ ...candidate, evidenceId } as PackageGraphEvidenceRecord); + } + + return { + records: records.sort(compareRecord), + diagnostics: normalizeDiagnostics(diagnostics), + quarantine: normalizeQuarantine(quarantine), + }; +} + +function expectedStaticEvidenceId( + result: Pick< + PackageGraphEvidenceStaticResult, + "protocol" | "scope" | "producer" | "analysisFingerprint" + >, + record: PackageGraphStaticEvidenceRecord, +): PackageGraphEvidenceDigest { + return packageGraphEvidenceSha256({ + protocol: result.protocol, + scope: result.scope, + producer: result.producer, + analysisFingerprint: result.analysisFingerprint, + candidate: candidateFromRecord(record), + }); +} + +const staticResultBaseSchema = z + .object({ + protocol: z.literal(PACKAGE_GRAPH_EVIDENCE_PROTOCOL), + kind: z.literal("static-result"), + resultId: digestSchema, + scope: packageInventoryVersionSchema, + producer: packageGraphEvidenceProducerSchema, + analysisFingerprint: digestSchema, + outcome: z.enum(["success", "failure"]), + coverage: packageGraphEvidenceCoverageSchema, + evidence: z.array( + z.discriminatedUnion("basis", [ + staticInvocationRecordSchema, + staticDataflowRecordSchema, + ]), + ), + diagnostics: z.array(packageGraphEvidenceDiagnosticSchema), + quarantine: z.array(packageGraphEvidenceQuarantineSchema), + }) + .strict(); + +export type PackageGraphEvidenceStaticResult = z.infer< + typeof staticResultBaseSchema +>; + +function staticResultWithoutId( + result: PackageGraphEvidenceStaticResult, +): Omit { + return Object.fromEntries( + Object.entries(result).filter(([key]) => key !== "resultId"), + ) as Omit; +} + +export const packageGraphEvidenceStaticResultSchema = + staticResultBaseSchema.superRefine((result, context) => { + if (result.outcome === "failure") { + if (result.coverage.status !== "none") { + context.addIssue({ + code: "custom", + path: ["coverage"], + message: "A failed static result requires no coverage", + }); + } + if (result.evidence.length !== 0) { + context.addIssue({ + code: "custom", + path: ["evidence"], + message: "A failed static result cannot carry accepted evidence", + }); + } + if ( + !result.diagnostics.some( + (diagnostic) => diagnostic.code === "producer-failed", + ) + ) { + context.addIssue({ + code: "custom", + path: ["diagnostics"], + message: + "A failed static result requires a producer-failed diagnostic", + }); + } + } else if (result.coverage.status === "none") { + context.addIssue({ + code: "custom", + path: ["coverage"], + message: + "A successful static result requires complete or partial coverage", + }); + } + if ( + result.coverage.status === "partial" && + !result.diagnostics.some( + (diagnostic) => diagnostic.code === "incomplete-analysis", + ) + ) { + context.addIssue({ + code: "custom", + path: ["diagnostics"], + message: "Partial coverage requires an incomplete-analysis diagnostic", + }); + } + if ( + result.coverage.status === "complete" && + result.diagnostics.some( + (diagnostic) => + diagnostic.code === "dynamic-target" || + diagnostic.code === "incomplete-analysis", + ) + ) { + context.addIssue({ + code: "custom", + path: ["coverage"], + message: + "Dynamic or incomplete analysis cannot claim complete coverage", + }); + } + + const normalizedCoverage = normalizeCoverage(result.coverage); + if ( + canonicalPackageGraphEvidenceJson(normalizedCoverage) !== + canonicalPackageGraphEvidenceJson(result.coverage) + ) { + context.addIssue({ + code: "custom", + path: ["coverage"], + message: "Coverage gaps must be unique and canonically ordered", + }); + } + + const normalizedEvidence = [...result.evidence].sort(compareRecord); + if ( + new Set(normalizedEvidence.map((record) => record.evidenceId)).size !== + normalizedEvidence.length || + canonicalPackageGraphEvidenceJson(normalizedEvidence) !== + canonicalPackageGraphEvidenceJson(result.evidence) + ) { + context.addIssue({ + code: "custom", + path: ["evidence"], + message: "Evidence must be unique and canonically ordered", + }); + } + for (const [index, record] of result.evidence.entries()) { + if (record.evidenceId !== expectedStaticEvidenceId(result, record)) { + context.addIssue({ + code: "custom", + path: ["evidence", index, "evidenceId"], + message: "Evidence ID does not match semantic content", + }); + } + } + + if ( + canonicalPackageGraphEvidenceJson( + normalizeDiagnostics(result.diagnostics), + ) !== canonicalPackageGraphEvidenceJson(result.diagnostics) + ) { + context.addIssue({ + code: "custom", + path: ["diagnostics"], + message: "Diagnostics must be unique and canonically ordered", + }); + } + if ( + canonicalPackageGraphEvidenceJson( + normalizeQuarantine(result.quarantine), + ) !== canonicalPackageGraphEvidenceJson(result.quarantine) + ) { + context.addIssue({ + code: "custom", + path: ["quarantine"], + message: "Quarantine entries must be unique and canonically ordered", + }); + } + for (const [index, item] of result.quarantine.entries()) { + if ( + !result.diagnostics.some( + (diagnostic) => + diagnostic.quarantineId === item.quarantineId && + diagnostic.code === item.code, + ) + ) { + context.addIssue({ + code: "custom", + path: ["quarantine", index], + message: "Every quarantine entry requires a matching diagnostic", + }); + } + } + if ( + result.resultId !== + packageGraphEvidenceSha256(staticResultWithoutId(result)) + ) { + context.addIssue({ + code: "custom", + path: ["resultId"], + message: "Result ID does not match canonical result content", + }); + } + }); + +export interface CreatePackageGraphEvidenceStaticResultInput { + scope: PackageInventoryVersion; + producer: PackageGraphEvidenceProducer; + analysisFingerprint: PackageGraphEvidenceDigest; + outcome: "success" | "failure"; + coverage: PackageGraphEvidenceCoverage; + candidates: readonly unknown[]; + diagnostics?: readonly PackageGraphEvidenceDiagnostic[]; +} + +const createStaticResultInputSchema = z + .object({ + scope: packageInventoryVersionSchema, + producer: packageGraphEvidenceProducerSchema, + analysisFingerprint: digestSchema, + outcome: z.enum(["success", "failure"]), + coverage: packageGraphEvidenceCoverageSchema, + candidates: z.array(z.unknown()), + diagnostics: z.array(packageGraphEvidenceDiagnosticSchema).optional(), + }) + .strict(); + +/** + * Validate candidates against one exact inventory, quarantine rejected + * endpoints, normalize every set-like field, and derive canonical IDs. + */ +export function createPackageGraphEvidenceStaticResult( + input: CreatePackageGraphEvidenceStaticResultInput, + inventoryInput: PackageInventory, +): PackageGraphEvidenceStaticResult { + const parsedInput = createStaticResultInputSchema.parse(input); + const inventory = packageInventorySchema.parse(inventoryInput); + if ( + parsedInput.outcome === "failure" && + parsedInput.candidates.length !== 0 + ) { + throw new TypeError( + "A failed static result cannot accept evidence candidates", + ); + } + const identity: CandidateIdentityContext = { + protocol: PACKAGE_GRAPH_EVIDENCE_PROTOCOL, + scope: parsedInput.scope, + producer: parsedInput.producer, + analysisFingerprint: parsedInput.analysisFingerprint, + }; + const validated = validateCandidates( + parsedInput.candidates, + inventory, + identity, + new Set(["static-invocation", "static-dataflow"]), + ); + const diagnostics = normalizeDiagnostics([ + ...(parsedInput.diagnostics ?? []), + ...validated.diagnostics, + ]); + const draft: PackageGraphEvidenceStaticResult = { + protocol: PACKAGE_GRAPH_EVIDENCE_PROTOCOL, + kind: "static-result", + resultId: ZERO_DIGEST, + scope: parsedInput.scope, + producer: parsedInput.producer, + analysisFingerprint: parsedInput.analysisFingerprint, + outcome: parsedInput.outcome, + coverage: normalizeCoverage(parsedInput.coverage), + evidence: validated.records as PackageGraphStaticEvidenceRecord[], + diagnostics, + quarantine: validated.quarantine, + }; + const result = { + ...draft, + resultId: packageGraphEvidenceSha256(staticResultWithoutId(draft)), + }; + return packageGraphEvidenceStaticResultSchema.parse(result); +} + +const bundleScopeSchema = packageInventoryVersionSchema.refine( + (scope): scope is Extract => + scope.kind === "bundle", + "Runtime evidence requires an immutable bundle scope", +); + +const runtimeEventBaseSchema = z + .object({ + protocol: z.literal(PACKAGE_GRAPH_EVIDENCE_PROTOCOL), + kind: z.literal("runtime-event"), + eventId: opaqueReferenceValueSchema, + scope: bundleScopeSchema, + producer: packageGraphEvidenceProducerSchema, + evidence: z.discriminatedUnion("basis", [ + runtimeDispatchRecordSchema, + runtimeHandoffRecordSchema, + ]), + }) + .strict(); +export type PackageGraphRuntimeEvidenceEvent = z.infer< + typeof runtimeEventBaseSchema +>; + +function expectedRuntimeEvidenceId( + event: Pick< + PackageGraphRuntimeEvidenceEvent, + "protocol" | "scope" | "producer" | "eventId" + >, + record: PackageGraphRuntimeEvidenceRecord, +): PackageGraphEvidenceDigest { + return packageGraphEvidenceSha256({ + protocol: event.protocol, + scope: event.scope, + producer: event.producer, + eventId: event.eventId, + candidate: candidateFromRecord(record), + }); +} + +export const packageGraphRuntimeEvidenceEventSchema = + runtimeEventBaseSchema.superRefine((event, context) => { + if ( + event.evidence.evidenceId !== + expectedRuntimeEvidenceId(event, event.evidence) + ) { + context.addIssue({ + code: "custom", + path: ["evidence", "evidenceId"], + message: "Evidence ID does not match the authoritative runtime event", + }); + } + }); + +export type CreatePackageGraphRuntimeEvidenceEventResult = + | { status: "accepted"; event: PackageGraphRuntimeEvidenceEvent } + | { + status: "quarantined"; + diagnostics: readonly PackageGraphEvidenceDiagnostic[]; + quarantine: readonly PackageGraphEvidenceQuarantine[]; + }; + +const createRuntimeEventInputSchema = z + .object({ + eventId: opaqueReferenceValueSchema, + scope: bundleScopeSchema, + producer: packageGraphEvidenceProducerSchema, + candidate: z.unknown(), + }) + .strict(); + +/** Build one idempotent runtime event, or a redacted quarantine result. */ +export function createPackageGraphRuntimeEvidenceEvent( + input: { + eventId: string; + scope: Extract; + producer: PackageGraphEvidenceProducer; + candidate: unknown; + }, + inventoryInput: PackageInventory, +): CreatePackageGraphRuntimeEvidenceEventResult { + const parsedInput = createRuntimeEventInputSchema.parse(input); + const inventory = packageInventorySchema.parse(inventoryInput); + const identity: CandidateIdentityContext = { + protocol: PACKAGE_GRAPH_EVIDENCE_PROTOCOL, + scope: parsedInput.scope, + producer: parsedInput.producer, + eventId: parsedInput.eventId, + }; + const validated = validateCandidates( + [parsedInput.candidate], + inventory, + identity, + new Set(["runtime-dispatch", "runtime-handoff"]), + ); + if (validated.records.length !== 1 || validated.quarantine.length !== 0) { + return { + status: "quarantined", + diagnostics: validated.diagnostics, + quarantine: validated.quarantine, + }; + } + const event = { + protocol: PACKAGE_GRAPH_EVIDENCE_PROTOCOL, + kind: "runtime-event" as const, + eventId: parsedInput.eventId, + scope: parsedInput.scope, + producer: parsedInput.producer, + evidence: validated.records[0] as PackageGraphRuntimeEvidenceRecord, + }; + return { + status: "accepted", + event: packageGraphRuntimeEvidenceEventSchema.parse(event), + }; +} + +export type PackageGraphStaticEvidenceState = + | { + status: "ready" | "partial" | "stale"; + accepted: PackageGraphEvidenceStaticResult; + latestAttempt: PackageGraphEvidenceStaticResult; + } + | { + status: "failed"; + accepted?: never; + latestAttempt: PackageGraphEvidenceStaticResult; + }; + +function sameStaticProducerSlot( + left: PackageGraphEvidenceStaticResult, + right: PackageGraphEvidenceStaticResult, +): boolean { + return ( + scopeMatches(left.scope, right.scope) && + left.producer.id === right.producer.id && + left.producer.version === right.producer.version + ); +} + +function completeStaticResult( + result: PackageGraphEvidenceStaticResult, +): boolean { + return result.outcome === "success" && result.coverage.status === "complete"; +} + +/** + * Reference replacement semantics only; SAP-2950 owns persistence. Complete + * successes replace, while partial/failed attempts retain the accepted result. + */ +export function advancePackageGraphStaticEvidenceState( + current: PackageGraphStaticEvidenceState | undefined, + incomingInput: PackageGraphEvidenceStaticResult, +): PackageGraphStaticEvidenceState { + const incoming = packageGraphEvidenceStaticResultSchema.parse(incomingInput); + if (current && !sameStaticProducerSlot(current.latestAttempt, incoming)) { + throw new TypeError( + "Static evidence state cannot mix scopes or producer versions", + ); + } + if (completeStaticResult(incoming)) { + return { status: "ready", accepted: incoming, latestAttempt: incoming }; + } + if (incoming.outcome === "success") { + if (current && current.status !== "failed") { + return { + status: "stale", + accepted: current.accepted, + latestAttempt: incoming, + }; + } + return { status: "partial", accepted: incoming, latestAttempt: incoming }; + } + if (current && current.status !== "failed") { + return { + status: "stale", + accepted: current.accepted, + latestAttempt: incoming, + }; + } + return { status: "failed", latestAttempt: incoming }; +} + +export interface PackageGraphRuntimeEvidenceState { + readonly events: readonly PackageGraphRuntimeEvidenceEvent[]; + readonly diagnostics: readonly PackageGraphEvidenceDiagnostic[]; +} + +export type AppendPackageGraphRuntimeEvidenceEventResult = { + status: "accepted" | "duplicate" | "conflict"; + state: PackageGraphRuntimeEvidenceState; +}; + +/** Append by authoritative event ID; identical retries are no-ops, conflicts never replace. */ +export function appendPackageGraphRuntimeEvidenceEvent( + current: PackageGraphRuntimeEvidenceState, + incomingInput: PackageGraphRuntimeEvidenceEvent, +): AppendPackageGraphRuntimeEvidenceEventResult { + const incoming = packageGraphRuntimeEvidenceEventSchema.parse(incomingInput); + const existing = current.events.find( + (event) => event.eventId === incoming.eventId, + ); + if (existing) { + if ( + canonicalPackageGraphEvidenceJson(existing) === + canonicalPackageGraphEvidenceJson(incoming) + ) { + return { status: "duplicate", state: current }; + } + const diagnostics = normalizeDiagnostics([ + ...current.diagnostics, + { + code: "runtime-event-conflict", + severity: "error", + eventId: incoming.eventId, + }, + ]); + return { + status: "conflict", + state: { events: current.events, diagnostics }, + }; + } + return { + status: "accepted", + state: { + events: [...current.events, incoming].sort((left, right) => + compareText(left.eventId, right.eventId), + ), + diagnostics: normalizeDiagnostics(current.diagnostics), + }, + }; +} + +export interface PackageGraphEvidenceProjectedSupport { + readonly evidenceId: PackageGraphEvidenceDigest; + readonly basis: PackageGraphEvidenceCandidate["basis"]; + readonly mode?: "blocking" | "async"; +} + +export interface PackageGraphEvidenceConnector { + readonly fromAgentKey: string; + readonly toAgentKey: string; + readonly relation: "invokes" | "feeds"; + readonly bases: readonly PackageGraphEvidenceCandidate["basis"][]; + readonly support: readonly PackageGraphEvidenceProjectedSupport[]; +} + +export interface PackageGraphEvidenceReferenceProjection { + readonly scope: PackageInventoryVersion; + readonly inventoryStatus: PackageInventory["status"]; + readonly nodes: readonly { readonly agentKey: string }[]; + readonly connectors: readonly PackageGraphEvidenceConnector[]; +} + +export type PackageGraphEvidenceProjectionSource = + | PackageGraphEvidenceStaticResult + | PackageGraphRuntimeEvidenceEvent; + +/** + * Small conformance projector: retain every inventory node, group accepted + * evidence by explicit endpoints/relation, and preserve every supporting basis. + */ +export function projectPackageGraphEvidence( + inventoryInput: PackageInventory, + sourcesInput: readonly PackageGraphEvidenceProjectionSource[], +): PackageGraphEvidenceReferenceProjection { + const inventory = packageInventorySchema.parse(inventoryInput); + const inventoryKeys = new Set( + inventory.agents.map((agent) => agent.agentKey), + ); + const evidenceById = new Map< + PackageGraphEvidenceDigest, + PackageGraphEvidenceRecord + >(); + for (const source of sourcesInput) { + const parsed = + source.kind === "static-result" + ? packageGraphEvidenceStaticResultSchema.parse(source) + : packageGraphRuntimeEvidenceEventSchema.parse(source); + if (!scopeMatches(parsed.scope, inventory.version)) { + throw new TypeError( + "Reference projection cannot mix inventory and evidence scopes", + ); + } + const records = + parsed.kind === "static-result" ? parsed.evidence : [parsed.evidence]; + for (const record of records) { + const existing = evidenceById.get(record.evidenceId); + if ( + existing && + canonicalPackageGraphEvidenceJson(existing) !== + canonicalPackageGraphEvidenceJson(record) + ) { + throw new TypeError("One evidence ID cannot name different records"); + } + evidenceById.set(record.evidenceId, record); + } + } + + const groups = new Map(); + for (const record of evidenceById.values()) { + if ( + !inventoryKeys.has(record.fromAgentKey) || + !inventoryKeys.has(record.toAgentKey) || + record.fromAgentKey === record.toAgentKey + ) { + throw new TypeError( + "Reference projection accepts validated evidence only", + ); + } + const key = `${record.fromAgentKey}\u0000${record.toAgentKey}\u0000${record.relation}`; + const records = groups.get(key) ?? []; + records.push(record); + groups.set(key, records); + } + + const connectors = [...groups.values()] + .map((records): PackageGraphEvidenceConnector => { + records.sort(compareRecord); + const first = records[0]!; + const support = records.map((record) => ({ + evidenceId: record.evidenceId, + basis: record.basis, + ...(record.basis === "static-invocation" ? { mode: record.mode } : {}), + })); + return { + fromAgentKey: first.fromAgentKey, + toAgentKey: first.toAgentKey, + relation: first.relation, + bases: [...new Set(records.map((record) => record.basis))].sort( + (left, right) => BASIS_ORDER[left] - BASIS_ORDER[right], + ), + support, + }; + }) + .sort( + (left, right) => + compareText(left.fromAgentKey, right.fromAgentKey) || + compareText(left.toAgentKey, right.toAgentKey) || + compareText(left.relation, right.relation), + ); + + return { + scope: inventory.version, + inventoryStatus: inventory.status, + nodes: inventory.agents.map(({ agentKey }) => ({ agentKey })), + connectors, + }; +} diff --git a/packages/agent/src/package-inventory.ts b/packages/agent/src/package-inventory.ts index e2d860ff9..e2b27967e 100644 --- a/packages/agent/src/package-inventory.ts +++ b/packages/agent/src/package-inventory.ts @@ -220,7 +220,7 @@ const packageInventoryAgentSchema = z } }); -const packageInventoryVersionSchema = z.discriminatedUnion("kind", [ +export const packageInventoryVersionSchema = z.discriminatedUnion("kind", [ z .object({ kind: z.literal("working-tree"), From ebc7f06d10983dcae5379e9b090ca830b4c21b4a Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 31 Aug 2026 17:43:21 +0000 Subject: [PATCH 2/3] fix(agent): harden graph evidence protocol Make projection tolerate settled identity changes, enforce canonical callsite sets on the wire, reject cross-bundle runtime state, and narrow the public API before release.\n\nRefs: SAP-2985 --- packages/agent/README.md | 15 +- packages/agent/src/index.ts | 22 +-- .../agent/src/package-graph-evidence.spec.ts | 151 ++++++++++++++++-- packages/agent/src/package-graph-evidence.ts | 83 ++++++++-- 4 files changed, 222 insertions(+), 49 deletions(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index 86a2de86c..3f95ccc92 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -109,11 +109,13 @@ export function directInvocationEvidence(inventory: PackageInventory) { } ``` -Evidence references are public-safe opaque handles. Absolute or relative paths, -execution IDs, lineage IDs, prompts, reports, inputs, outputs, and tool payloads -stay behind an authorized producer-owned resolver and must not be copied into a -public graph DTO. Graph evidence is explanatory metadata only: it cannot change -execution, routing, authorization, deployment, builds, or billing. +Evidence producers must create opaque handles and keep absolute or relative +paths, execution IDs, lineage IDs, prompts, reports, inputs, outputs, and tool +payloads behind an authorized producer-owned resolver. The schema enforces a +restricted public-safe character set for those handles; it cannot determine +whether a permitted string contains a sensitive identifier. Graph evidence is +explanatory metadata only: it cannot change execution, routing, authorization, +deployment, builds, or billing. ## The entry input contract @@ -234,7 +236,8 @@ Things to know: ctx.shared.set("codingRunId", run.runId); // readable from the resumed step return pauseUntilSignal(run, { resumeStep: "review" }); } -``` + ``` + - **Outside an agent run nothing changes** — `await launch().wait()` the capability as usual; the pause wiring only engages when a step pauses on the handle. diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index ac45ce506..e1e31bd2b 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -113,7 +113,7 @@ export { MANIFEST_PROTOCOL, agentManifestSchema } from './manifest.js'; export type { AgentManifest, AgentStepManifest, ManifestTransition } from './manifest.js'; // Multi-agent package inventory — separate from the single-agent build manifest. -export { PACKAGE_INVENTORY_PROTOCOL, packageInventorySchema, packageInventoryVersionSchema } from './package-inventory.js'; +export { PACKAGE_INVENTORY_PROTOCOL, packageInventorySchema } from './package-inventory.js'; export type { PackageInventory, PackageInventoryAgent, @@ -126,19 +126,8 @@ export { PACKAGE_GRAPH_EVIDENCE_PROTOCOL, advancePackageGraphStaticEvidenceState, appendPackageGraphRuntimeEvidenceEvent, - canonicalPackageGraphEvidenceJson, createPackageGraphEvidenceStaticResult, createPackageGraphRuntimeEvidenceEvent, - packageGraphEvidenceCandidateSchema, - packageGraphEvidenceCoverageGapSchema, - packageGraphEvidenceCoverageSchema, - packageGraphEvidenceDiagnosticCodeSchema, - packageGraphEvidenceDiagnosticSchema, - packageGraphEvidenceProducerSchema, - packageGraphEvidenceQuarantineSchema, - packageGraphEvidenceRecordSchema, - packageGraphEvidenceReferenceSchema, - packageGraphEvidenceSha256, packageGraphEvidenceStaticResultSchema, packageGraphRuntimeEvidenceEventSchema, projectPackageGraphEvidence, @@ -148,26 +137,17 @@ export type { CreatePackageGraphEvidenceStaticResultInput, CreatePackageGraphRuntimeEvidenceEventResult, PackageGraphEvidenceCandidate, - PackageGraphEvidenceConnector, PackageGraphEvidenceCoverage, PackageGraphEvidenceCoverageGap, PackageGraphEvidenceDiagnostic, - PackageGraphEvidenceDiagnosticCode, PackageGraphEvidenceDigest, PackageGraphEvidenceProducer, - PackageGraphEvidenceProjectionSource, - PackageGraphEvidenceProjectedSupport, - PackageGraphEvidenceQuarantine, - PackageGraphEvidenceRecord, - PackageGraphEvidenceReference, PackageGraphEvidenceReferenceProjection, PackageGraphEvidenceStaticResult, PackageGraphRuntimeEvidenceCandidate, PackageGraphRuntimeEvidenceEvent, - PackageGraphRuntimeEvidenceRecord, PackageGraphRuntimeEvidenceState, PackageGraphStaticEvidenceCandidate, - PackageGraphStaticEvidenceRecord, PackageGraphStaticEvidenceState, } from './package-graph-evidence.js'; diff --git a/packages/agent/src/package-graph-evidence.spec.ts b/packages/agent/src/package-graph-evidence.spec.ts index 9670c1d71..b77ac64df 100644 --- a/packages/agent/src/package-graph-evidence.spec.ts +++ b/packages/agent/src/package-graph-evidence.spec.ts @@ -3,10 +3,8 @@ import { PACKAGE_INVENTORY_PROTOCOL, advancePackageGraphStaticEvidenceState, appendPackageGraphRuntimeEvidenceEvent, - canonicalPackageGraphEvidenceJson, createPackageGraphEvidenceStaticResult, createPackageGraphRuntimeEvidenceEvent, - packageGraphEvidenceCandidateSchema, packageGraphEvidenceStaticResultSchema, packageGraphRuntimeEvidenceEventSchema, projectPackageGraphEvidence, @@ -15,12 +13,16 @@ import { type PackageGraphEvidenceDiagnostic, type PackageGraphEvidenceDigest, type PackageGraphEvidenceProducer, - type PackageGraphEvidenceRecord, type PackageGraphEvidenceStaticResult, type PackageGraphRuntimeEvidenceEvent, type PackageInventory, type PackageInventoryVersion, } from "./index.js"; +import { + canonicalPackageGraphEvidenceJson, + packageGraphEvidenceCandidateSchema, + packageGraphEvidenceSha256, +} from "./package-graph-evidence.js"; const SHA_A = `sha256:${"a".repeat(64)}` as const; const SHA_B = `sha256:${"b".repeat(64)}` as const; @@ -73,10 +75,12 @@ function workingInventory( }; } -function bundleInventory(): PackageInventory { +function bundleInventory( + bundleDigest: PackageGraphEvidenceDigest = SHA_B, +): PackageInventory { return { ...workingInventory(), - version: { kind: "bundle", bundleDigest: SHA_B }, + version: { kind: "bundle", bundleDigest }, }; } @@ -143,15 +147,17 @@ function staticResult( function acceptedRuntime( eventId: string, candidate: unknown, + bundleDigest: PackageGraphEvidenceDigest = SHA_B, ): PackageGraphRuntimeEvidenceEvent { + const inventory = bundleInventory(bundleDigest); const created = createPackageGraphRuntimeEvidenceEvent( { eventId, - scope: { kind: "bundle", bundleDigest: SHA_B }, + scope: { kind: "bundle", bundleDigest }, producer: { id: "sapiom.engine", version: "1.0.0" }, candidate, }, - bundleInventory(), + inventory, ); if (created.status !== "accepted") throw new Error("fixture was quarantined"); return created.event; @@ -283,6 +289,53 @@ describe("package graph evidence protocol 1", () => { ); }); + it.each([ + [source("callsite:z"), source("callsite:a")], + [source("callsite:a"), source("callsite:a"), source("callsite:z")], + ])( + "rejects non-normalized callsite references even when their IDs match the wire content", + (...callsites) => { + const canonical = staticResult([ + { + ...invocation("coordinator", "research"), + callsites: [source("callsite:a"), source("callsite:z")], + }, + ]); + const candidate = { + ...invocation("coordinator", "research"), + callsites, + }; + const evidenceId = packageGraphEvidenceSha256({ + protocol: canonical.protocol, + scope: canonical.scope, + producer: canonical.producer, + analysisFingerprint: canonical.analysisFingerprint, + candidate, + }); + const record = { ...candidate, evidenceId }; + const draft = { + protocol: canonical.protocol, + kind: canonical.kind, + scope: canonical.scope, + producer: canonical.producer, + analysisFingerprint: canonical.analysisFingerprint, + outcome: canonical.outcome, + coverage: canonical.coverage, + evidence: [record], + diagnostics: canonical.diagnostics, + quarantine: canonical.quarantine, + }; + const forged = { + ...draft, + resultId: packageGraphEvidenceSha256(draft), + }; + + expect(() => + packageGraphEvidenceStaticResultSchema.parse(forged), + ).toThrow(/unique and canonically ordered/); + }, + ); + it("keeps analysis freshness independent from inventory identity", () => { const first = staticResult([invocation("coordinator", "research")]); const changedAnalysis = staticResult( @@ -334,6 +387,42 @@ describe("package graph evidence protocol 1", () => { ).toBe("degraded"); }); + it("drops stale endpoints with diagnostics when identity changes at the same inventory version", () => { + const canonicalInventory = workingInventory(); + const result = staticResult([invocation("coordinator", "research")], { + inventory: canonicalInventory, + }); + const degradedInventory = workingInventory({ + status: "degraded", + agents: [ + canonicalInventory.agents[0]!, + canonicalInventory.agents[1]!, + { + agentKey: "local:agents/research", + identityStatus: "provisional", + identityIssue: "identity-unavailable", + path: "agents/research", + entrypoint: "index.ts", + }, + ], + }); + + const projection = projectPackageGraphEvidence(degradedInventory, [result]); + + expect(projection.connectors).toEqual([]); + expect(projection.nodes.map(({ agentKey }) => agentKey)).toEqual([ + "coordinator", + "growth", + "local:agents/research", + ]); + expect(projection.diagnostics).toContainEqual({ + code: "unknown-endpoint", + severity: "error", + evidenceId: result.evidence[0]!.evidenceId, + endpoint: "to", + }); + }); + it("quarantines unknown, invalid, ambiguous, self, and cross-scope candidates", () => { const ambiguousInventory = workingInventory({ status: "degraded", @@ -609,6 +698,46 @@ describe("package graph evidence protocol 1", () => { ]); }); + it("rejects runtime events from a different bundle before they enter state", () => { + const first = acceptedRuntime("dispatch:first", { + fromAgentKey: "coordinator", + toAgentKey: "research", + relation: "invokes", + basis: "runtime-dispatch", + callerExecution: execution("execution:coordinator"), + calleeExecution: execution("execution:research"), + }); + const otherBundle = acceptedRuntime( + "dispatch:other-bundle", + { + fromAgentKey: "coordinator", + toAgentKey: "research", + relation: "invokes", + basis: "runtime-dispatch", + callerExecution: execution("execution:coordinator-other"), + calleeExecution: execution("execution:research-other"), + }, + SHA_C, + ); + const accepted = appendPackageGraphRuntimeEvidenceEvent( + { events: [], diagnostics: [] }, + first, + ); + + const conflict = appendPackageGraphRuntimeEvidenceEvent( + accepted.state, + otherBundle, + ); + + expect(conflict.status).toBe("conflict"); + expect(conflict.state.events).toEqual([first]); + expect(conflict.state.diagnostics).toContainEqual({ + code: "cross-scope", + severity: "error", + eventId: "dispatch:other-bundle", + }); + }); + it("keeps runtime and static envelopes distinct and runtime mode-free", () => { const runtime = acceptedRuntime("handoff:stable", { fromAgentKey: "research", @@ -678,12 +807,12 @@ describe("package graph evidence protocol 1", () => { expect(() => canonicalPackageGraphEvidenceJson(cyclic)).toThrow(/cyclic/); }); - it("exports the protocol and explicit record type surface", () => { + it("exports the protocol and top-level envelope type surface", () => { expect(PACKAGE_GRAPH_EVIDENCE_PROTOCOL).toBe(1); - const records: PackageGraphEvidenceRecord[] = staticResult([ + const result: PackageGraphEvidenceStaticResult = staticResult([ invocation("coordinator", "research"), - ]).evidence; - expect(records[0]).toMatchObject({ + ]); + expect(result.evidence[0]).toMatchObject({ fromAgentKey: "coordinator", toAgentKey: "research", relation: "invokes", diff --git a/packages/agent/src/package-graph-evidence.ts b/packages/agent/src/package-graph-evidence.ts index e08fbc860..7947b3c47 100644 --- a/packages/agent/src/package-graph-evidence.ts +++ b/packages/agent/src/package-graph-evidence.ts @@ -47,9 +47,11 @@ const executionReferenceSchema = referenceSchema("execution"); const lineageReferenceSchema = referenceSchema("lineage"); /** - * Public-safe handle for evidence details retained behind an authorized - * producer-owned resolver. Paths, execution IDs, lineage IDs, and payloads do - * not enter the graph-evidence wire contract itself. + * Handle for evidence details retained behind an authorized producer-owned + * resolver. Producers must use opaque references and keep paths, execution + * IDs, lineage IDs, and payloads behind that resolver. The schema enforces a + * restricted public-safe character set; it cannot prove that a value is + * opaque or free of sensitive identifiers. */ export const packageGraphEvidenceReferenceSchema = z.discriminatedUnion( "kind", @@ -827,6 +829,18 @@ export const packageGraphEvidenceStaticResultSchema = }); } for (const [index, record] of result.evidence.entries()) { + const candidate = candidateFromRecord(record); + if ( + canonicalPackageGraphEvidenceJson(normalizeCandidate(candidate)) !== + canonicalPackageGraphEvidenceJson(candidate) + ) { + context.addIssue({ + code: "custom", + path: ["evidence", index, "callsites"], + message: + "Set-like evidence references must be unique and canonically ordered", + }); + } if (record.evidenceId !== expectedStaticEvidenceId(result, record)) { context.addIssue({ code: "custom", @@ -1158,6 +1172,22 @@ export function appendPackageGraphRuntimeEvidenceEvent( incomingInput: PackageGraphRuntimeEvidenceEvent, ): AppendPackageGraphRuntimeEvidenceEventResult { const incoming = packageGraphRuntimeEvidenceEventSchema.parse(incomingInput); + if ( + current.events.some((event) => !scopeMatches(event.scope, incoming.scope)) + ) { + const diagnostics = normalizeDiagnostics([ + ...current.diagnostics, + { + code: "cross-scope", + severity: "error", + eventId: incoming.eventId, + }, + ]); + return { + status: "conflict", + state: { events: current.events, diagnostics }, + }; + } const existing = current.events.find( (event) => event.eventId === incoming.eventId, ); @@ -1211,6 +1241,7 @@ export interface PackageGraphEvidenceReferenceProjection { readonly inventoryStatus: PackageInventory["status"]; readonly nodes: readonly { readonly agentKey: string }[]; readonly connectors: readonly PackageGraphEvidenceConnector[]; + readonly diagnostics: readonly PackageGraphEvidenceDiagnostic[]; } export type PackageGraphEvidenceProjectionSource = @@ -1229,6 +1260,14 @@ export function projectPackageGraphEvidence( const inventoryKeys = new Set( inventory.agents.map((agent) => agent.agentKey), ); + const ambiguousCandidates = new Set( + inventory.agents.flatMap((agent) => + agent.identityIssue === "duplicate-agent-key" && agent.candidateAgentKey + ? [agent.candidateAgentKey] + : [], + ), + ); + const diagnostics: PackageGraphEvidenceDiagnostic[] = []; const evidenceById = new Map< PackageGraphEvidenceDigest, PackageGraphEvidenceRecord @@ -1245,6 +1284,9 @@ export function projectPackageGraphEvidence( } const records = parsed.kind === "static-result" ? parsed.evidence : [parsed.evidence]; + if (parsed.kind === "static-result") { + diagnostics.push(...parsed.diagnostics); + } for (const record of records) { const existing = evidenceById.get(record.evidenceId); if ( @@ -1260,15 +1302,33 @@ export function projectPackageGraphEvidence( const groups = new Map(); for (const record of evidenceById.values()) { - if ( - !inventoryKeys.has(record.fromAgentKey) || - !inventoryKeys.has(record.toAgentKey) || - record.fromAgentKey === record.toAgentKey - ) { - throw new TypeError( - "Reference projection accepts validated evidence only", - ); + let rejected = false; + for (const [endpoint, agentKey] of [ + ["from", record.fromAgentKey], + ["to", record.toAgentKey], + ] as const) { + if (inventoryKeys.has(agentKey)) continue; + diagnostics.push({ + code: ambiguousCandidates.has(agentKey) + ? "ambiguous-endpoint" + : packageInventoryKeyIsValid(agentKey) + ? "unknown-endpoint" + : "invalid-endpoint", + severity: "error", + evidenceId: record.evidenceId, + endpoint, + }); + rejected = true; } + if (record.fromAgentKey === record.toAgentKey) { + diagnostics.push({ + code: "illegal-self-relationship", + severity: "error", + evidenceId: record.evidenceId, + }); + rejected = true; + } + if (rejected) continue; const key = `${record.fromAgentKey}\u0000${record.toAgentKey}\u0000${record.relation}`; const records = groups.get(key) ?? []; records.push(record); @@ -1306,5 +1366,6 @@ export function projectPackageGraphEvidence( inventoryStatus: inventory.status, nodes: inventory.agents.map(({ agentKey }) => ({ agentKey })), connectors, + diagnostics: normalizeDiagnostics(diagnostics), }; } From ef35e82b8da311ef102d96718de36adbafd5638d Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 31 Aug 2026 18:06:07 +0000 Subject: [PATCH 3/3] fix(agent): clarify graph evidence projection --- packages/agent/README.md | 7 ++++-- packages/agent/src/index.ts | 6 +++++ .../agent/src/package-graph-evidence.spec.ts | 23 +++++++++++++++---- packages/agent/src/package-graph-evidence.ts | 23 +++++++++++++++---- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index 3f95ccc92..88fb759a4 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -73,8 +73,11 @@ Every accepted record names `fromAgentKey` and `toAgentKey` explicitly. Static results reuse the exact inventory version and additionally carry an analysis fingerprint, producer identity/version, coverage, deterministic diagnostics, and quarantine. Runtime evidence is an append-only bundle event keyed by an -authoritative event ID. The helpers expose reference replacement/idempotency -semantics only; persistence and production graph projection remain server concerns. +authoritative event ID. Keep one runtime evidence state per immutable bundle and +start a fresh state when the bundle digest changes; cross-bundle appends conflict +and leave the existing state unchanged. The helpers expose reference +replacement/idempotency semantics only; persistence and production graph +projection remain server concerns. ```ts import { diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index e1e31bd2b..70b6d7541 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -137,11 +137,17 @@ export type { CreatePackageGraphEvidenceStaticResultInput, CreatePackageGraphRuntimeEvidenceEventResult, PackageGraphEvidenceCandidate, + PackageGraphEvidenceConnector, PackageGraphEvidenceCoverage, PackageGraphEvidenceCoverageGap, PackageGraphEvidenceDiagnostic, + PackageGraphEvidenceDiagnosticCode, PackageGraphEvidenceDigest, PackageGraphEvidenceProducer, + PackageGraphEvidenceProjectedSupport, + PackageGraphEvidenceProjectionSource, + PackageGraphEvidenceQuarantine, + PackageGraphEvidenceRecord, PackageGraphEvidenceReferenceProjection, PackageGraphEvidenceStaticResult, PackageGraphRuntimeEvidenceCandidate, diff --git a/packages/agent/src/package-graph-evidence.spec.ts b/packages/agent/src/package-graph-evidence.spec.ts index b77ac64df..1d6b7775b 100644 --- a/packages/agent/src/package-graph-evidence.spec.ts +++ b/packages/agent/src/package-graph-evidence.spec.ts @@ -471,6 +471,16 @@ describe("package graph evidence protocol 1", () => { expect(rejected.quarantine.every((item) => !("agentKey" in item))).toBe( true, ); + const rejectedProjection = projectPackageGraphEvidence( + ambiguousInventory, + [rejected], + ); + expect(rejectedProjection.diagnostics.length).toBeGreaterThan(0); + expect( + rejectedProjection.diagnostics.every( + (diagnostic) => !("quarantineId" in diagnostic), + ), + ).toBe(true); const otherScope = staticResult([invocation("coordinator", "research")], { scope: { @@ -483,11 +493,14 @@ describe("package graph evidence protocol 1", () => { expect(otherScope.quarantine.map(({ code }) => code)).toEqual([ "cross-scope", ]); - expect(() => - projectPackageGraphEvidence(bundleInventory(), [ - staticResult([invocation("coordinator", "research")]), - ]), - ).toThrow(/cannot mix inventory and evidence scopes/); + const crossScopeProjection = projectPackageGraphEvidence(bundleInventory(), [ + staticResult([invocation("coordinator", "research")]), + ]); + expect(crossScopeProjection.connectors).toEqual([]); + expect(crossScopeProjection.diagnostics).toContainEqual({ + code: "cross-scope", + severity: "error", + }); }); it("rejects old bundles and local/bundle mixing without mutating identities", () => { diff --git a/packages/agent/src/package-graph-evidence.ts b/packages/agent/src/package-graph-evidence.ts index 7947b3c47..3186affcf 100644 --- a/packages/agent/src/package-graph-evidence.ts +++ b/packages/agent/src/package-graph-evidence.ts @@ -1166,7 +1166,11 @@ export type AppendPackageGraphRuntimeEvidenceEventResult = { state: PackageGraphRuntimeEvidenceState; }; -/** Append by authoritative event ID; identical retries are no-ops, conflicts never replace. */ +/** + * Append by authoritative event ID; identical retries are no-ops and conflicts + * never replace. State is scoped to one immutable bundle: start a fresh state + * when the bundle digest changes. + */ export function appendPackageGraphRuntimeEvidenceEvent( current: PackageGraphRuntimeEvidenceState, incomingInput: PackageGraphRuntimeEvidenceEvent, @@ -1278,14 +1282,23 @@ export function projectPackageGraphEvidence( ? packageGraphEvidenceStaticResultSchema.parse(source) : packageGraphRuntimeEvidenceEventSchema.parse(source); if (!scopeMatches(parsed.scope, inventory.version)) { - throw new TypeError( - "Reference projection cannot mix inventory and evidence scopes", - ); + diagnostics.push({ + code: "cross-scope", + severity: "error", + ...(parsed.kind === "runtime-event" + ? { eventId: parsed.eventId } + : {}), + }); + continue; } const records = parsed.kind === "static-result" ? parsed.evidence : [parsed.evidence]; if (parsed.kind === "static-result") { - diagnostics.push(...parsed.diagnostics); + diagnostics.push( + ...parsed.diagnostics.map(({ quarantineId: _quarantineId, ...item }) => + item, + ), + ); } for (const record of records) { const existing = evidenceById.get(record.evidenceId);