diff --git a/.changeset/agent-map-revision-contracts.md b/.changeset/agent-map-revision-contracts.md new file mode 100644 index 00000000..76c9d75d --- /dev/null +++ b/.changeset/agent-map-revision-contracts.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": minor +--- + +Add public contracts for immutable Agent Map revisions, branded architecture digests, architecture approval evidence, trusted human-message receipts, and confirmations. Establish the harness's internal V1 domain-separated SHA-256 canonicalization for later confirmation and build-planning slices. diff --git a/packages/harness/src/core/agent-map-proposal-service.ts b/packages/harness/src/core/agent-map-proposal-service.ts index 10308a73..904b92c0 100644 --- a/packages/harness/src/core/agent-map-proposal-service.ts +++ b/packages/harness/src/core/agent-map-proposal-service.ts @@ -20,9 +20,9 @@ import { type StudioProjectId, } from "../shared/agent-map.js"; import { parseProposalActor } from "../shared/agent-map-codec.js"; +import { canonicalizeAgentMapGraph } from "../shared/agent-map-canonical.js"; import { parseProposalBatchRequest } from "./agent-map-proposal-schema.js"; import { - canonicalizeAgentMapGraph, derivePersistedMapOperationTouchSet, materializeValidatedMapBatch, proposalTouchSetsOverlap, diff --git a/packages/harness/src/core/agent-map-proposal-validator.ts b/packages/harness/src/core/agent-map-proposal-validator.ts index b7f5b6a1..8765ead0 100644 --- a/packages/harness/src/core/agent-map-proposal-validator.ts +++ b/packages/harness/src/core/agent-map-proposal-validator.ts @@ -1,50 +1,26 @@ -import type { - AgentMapGraph, - DraftRef, - MapOperation, - MapOperationInput, - PlanNode, - PlanNodeId, - PlanNodeKind, - PlanRelationship, - PlanRelationshipId, - ProposalBatchRequest, - ProposalValidationIssue, - ProposalValidationResult, - RelationshipKind, +import { + AGENT_MAP_RELATIONSHIP_ENDPOINT_MATRIX, + type AgentMapGraph, + type DraftRef, + type MapOperation, + type MapOperationInput, + type PlanNode, + type PlanNodeId, + type PlanRelationship, + type PlanRelationshipId, + type ProposalBatchRequest, + type ProposalValidationIssue, + type ProposalValidationResult, } from "../shared/agent-map.js"; +import { + canonicalizeAgentMapGraph, + canonicalizeAgentMapStrings, + compareAgentMapStrings, +} from "../shared/agent-map-canonical.js"; -const ACTOR_KINDS = new Set(["agent", "subagent"]); -const ALL_NODE_KINDS = new Set([ - "agent", - "subagent", - "resource", - "connector", - "artifact", -]); - -export const RELATIONSHIP_ENDPOINT_MATRIX: Readonly< - Record< - RelationshipKind, - { from: ReadonlySet; to: ReadonlySet } - > -> = { - invokes: { from: ACTOR_KINDS, to: ACTOR_KINDS }, - feeds: { from: ALL_NODE_KINDS, to: ACTOR_KINDS }, - reads: { - from: ACTOR_KINDS, - to: new Set(["resource", "artifact"]), - }, - writes: { - from: ACTOR_KINDS, - to: new Set(["resource", "artifact"]), - }, - uses: { - from: ACTOR_KINDS, - to: new Set(["resource", "connector"]), - }, - triggers: { from: ALL_NODE_KINDS, to: ACTOR_KINDS }, -}; +/** @deprecated Import the shared policy for new code. */ +export const RELATIONSHIP_ENDPOINT_MATRIX = + AGENT_MAP_RELATIONSHIP_ENDPOINT_MATRIX; export interface ProposalTouchSet { entityKeys: string[]; @@ -97,11 +73,8 @@ const nodeDraftKey = (draftRef: DraftRef): string => `draft-node:${draftRef}`; const relationshipDraftKey = (draftRef: DraftRef): string => `draft-relationship:${draftRef}`; -const compareStrings = (left: string, right: string): number => - left < right ? -1 : left > right ? 1 : 0; - -const canonicalStrings = (values: readonly string[]): string[] => - [...values].sort(compareStrings); +const compareStrings = compareAgentMapStrings; +const canonicalStrings = canonicalizeAgentMapStrings; const stripUndefinedProperties = >( value: T, @@ -110,25 +83,7 @@ const stripUndefinedProperties = >( Object.entries(value).filter(([, fieldValue]) => fieldValue !== undefined), ) as T; -const canonicalNode = (node: PlanNode): PlanNode => ({ - ...node, - contractRefs: canonicalStrings(node.contractRefs), -}); - -const canonicalRelationship = ( - relationship: PlanRelationship, -): PlanRelationship => ({ ...relationship }); - -export function canonicalizeAgentMapGraph(graph: AgentMapGraph): AgentMapGraph { - return { - nodes: graph.nodes - .map(canonicalNode) - .sort((left, right) => compareStrings(left.id, right.id)), - relationships: graph.relationships - .map(canonicalRelationship) - .sort((left, right) => compareStrings(left.id, right.id)), - }; -} +export { canonicalizeAgentMapGraph }; export function semanticRelationshipKey( relationship: Pick< diff --git a/packages/harness/src/core/agent-map-revision.test.ts b/packages/harness/src/core/agent-map-revision.test.ts new file mode 100644 index 00000000..67ad12df --- /dev/null +++ b/packages/harness/src/core/agent-map-revision.test.ts @@ -0,0 +1,642 @@ +import { describe, expect, it } from "vitest"; + +import type { + AgentMapGraph, + AgentMapGraphDigest, + AgentMapRevision, + AgentMapRevisionId, + ConfirmArchitectureRequest, + MapChangeProposal, + MapProposalId, + PlanNodeId, + PlanRelationshipId, + PlannerUserMessageReceipt, + ProposalOperationId, +} from "../shared/agent-map.js"; +import { + AgentMapRevisionContractError, + classifyAgentMapConfirmationBoundary, + digestAgentMapArchitecture, + digestConfirmArchitectureRequest, + materializeAgentMapRevision, + validateAgentMapRevision, + validateAgentMapRevisionChain, + type MaterializeAgentMapRevisionInput, +} from "./agent-map-revision.js"; + +const projectId = "project-stock-research"; +const acceptedAt = "2026-09-03T12:00:00.000Z"; +const createdAt = "2026-09-03T12:00:01.000Z"; +const researchId = "node_018f0000-0000-7000-8000-000000000001" as PlanNodeId; +const marketingId = "node_018f0000-0000-7000-8000-000000000002" as PlanNodeId; +const analystId = "node_018f0000-0000-7000-8000-000000000003" as PlanNodeId; +const reportId = "node_018f0000-0000-7000-8000-000000000004" as PlanNodeId; +const invokesId = + "rel_018f0000-0000-7000-8000-000000000001" as PlanRelationshipId; +const writesId = + "rel_018f0000-0000-7000-8000-000000000002" as PlanRelationshipId; +const readsId = + "rel_018f0000-0000-7000-8000-000000000003" as PlanRelationshipId; +const proposalId = + "proposal_018f0000-0000-7000-8000-000000000001" as MapProposalId; +const revisionId = + "revision_018f0000-0000-7000-8000-000000000001" as AgentMapRevisionId; + +const stockResearchGraph = (): AgentMapGraph => ({ + nodes: [ + { + id: reportId, + kind: "resource", + name: "Shared report", + purpose: "Carry approved research into marketing", + ownerAgentId: null, + contractRefs: ["report/market-v1"], + }, + { + id: analystId, + kind: "subagent", + name: "Research analyst", + purpose: "Collect market evidence", + ownerAgentId: researchId, + contractRefs: ["evidence/v1", "query/v1"], + }, + { + id: marketingId, + kind: "agent", + name: "Marketing", + purpose: "Turn findings into campaigns", + ownerAgentId: null, + contractRefs: ["campaign/v1"], + }, + { + id: researchId, + kind: "agent", + name: "Research", + purpose: "Own market research", + ownerAgentId: null, + contractRefs: ["research/v1"], + }, + ], + relationships: [ + { + id: readsId, + fromNodeId: marketingId, + toNodeId: reportId, + kind: "reads", + executionMode: "asynchronous", + contractRef: "report/market-v1", + description: "Consumes approved findings", + }, + { + id: writesId, + fromNodeId: analystId, + toNodeId: reportId, + kind: "writes", + executionMode: "asynchronous", + contractRef: "report/market-v1", + description: "Publishes findings", + }, + { + id: invokesId, + fromNodeId: researchId, + toNodeId: analystId, + kind: "invokes", + executionMode: "synchronous", + contractRef: "query/v1", + description: "Delegates evidence collection", + }, + ], +}); + +const proposalFor = ( + id = proposalId, + graph = stockResearchGraph(), +): MapChangeProposal => ({ + schemaVersion: 1, + id, + projectId, + baseRevisionId: null, + version: 1, + ...graph, + history: [ + { + id: "operation_018f0000-0000-7000-8000-000000000001" as ProposalOperationId, + requestId: "proposal-request-1", + acceptedVersion: 1, + operation: { kind: "add-node", node: graph.nodes[0]! }, + actor: { + userId: "user-1", + sessionId: "planner-session-1", + role: "map-planner", + assignment: null, + }, + acceptedAt, + }, + ], + createdAt: acceptedAt, + updatedAt: acceptedAt, +}); + +const receiptFor = ( + messageId = "message-approval-1", +): PlannerUserMessageReceipt => ({ + messageId, + projectId, + userId: "user-1", + sessionId: "planner-session-1", + origin: "human", + acceptedAt, +}); + +const requestFor = ( + digest: AgentMapGraphDigest, + sourceId = proposalId, + messageId = "message-approval-1", +): ConfirmArchitectureRequest => ({ + schemaVersion: 1, + requestId: "confirm-request-1", + proposalId: sourceId, + expectedVersion: 1, + expectedDigest: digest, + approvingMessageId: messageId, +}); + +const materializationInput = (): MaterializeAgentMapRevisionInput => { + const proposal = proposalFor(); + const digest = digestAgentMapArchitecture(projectId, proposal); + return { + proposal, + request: requestFor(digest), + receipt: receiptFor(), + principal: { + role: "map-planner", + projectId, + userId: "user-1", + sessionId: "planner-session-1", + }, + revisionId, + revisionNumber: 1, + parentRevisionId: null, + createdAt, + }; +}; + +const mutateGraph = (mutate: (graph: AgentMapGraph) => void): AgentMapGraph => { + const graph = structuredClone(stockResearchGraph()); + mutate(graph); + return graph; +}; + +describe("Agent Map architecture digest", () => { + it("handles empty and single-node architectures", () => { + expect( + digestAgentMapArchitecture(projectId, { nodes: [], relationships: [] }), + ).toMatch(/^sha256:[0-9a-f]{64}$/u); + expect( + digestAgentMapArchitecture(projectId, { + nodes: [stockResearchGraph().nodes[0]!], + relationships: [], + }), + ).not.toBe( + digestAgentMapArchitecture(projectId, { nodes: [], relationships: [] }), + ); + }); + + it("is stable across graph and contract-reference ordering", () => { + const graph = stockResearchGraph(); + const before = JSON.stringify(graph); + const reordered = structuredClone(graph); + reordered.nodes.reverse(); + reordered.relationships.reverse(); + reordered.nodes.find(({ id }) => id === analystId)?.contractRefs.reverse(); + + expect(digestAgentMapArchitecture(projectId, reordered)).toBe( + digestAgentMapArchitecture(projectId, graph), + ); + expect(JSON.stringify(graph)).toBe(before); + }); + + it("pins the stock-research protocol digest", () => { + expect(digestAgentMapArchitecture(projectId, stockResearchGraph())).toBe( + "sha256:e62c2ca18e1ddc05f7cfbe610eff8e876fb5d7a5afcd31d0d232adee31cd2b0f", + ); + }); + + it.each([ + [ + "node ID", + (graph: AgentMapGraph) => { + const replacement = + "node_018f0000-0000-7000-8000-000000000099" as PlanNodeId; + graph.nodes.find(({ id }) => id === researchId)!.id = replacement; + graph.nodes.find(({ id }) => id === analystId)!.ownerAgentId = + replacement; + graph.relationships.find(({ id }) => id === invokesId)!.fromNodeId = + replacement; + }, + ], + [ + "node kind", + (graph: AgentMapGraph) => { + graph.nodes.find(({ id }) => id === reportId)!.kind = "artifact"; + }, + ], + [ + "node name", + (graph: AgentMapGraph) => { + graph.nodes.find(({ id }) => id === researchId)!.name = "Research 2"; + }, + ], + [ + "node purpose", + (graph: AgentMapGraph) => { + graph.nodes.find(({ id }) => id === researchId)!.purpose = + "New purpose"; + }, + ], + [ + "node owner", + (graph: AgentMapGraph) => { + graph.nodes.find(({ id }) => id === analystId)!.ownerAgentId = + marketingId; + }, + ], + [ + "node contract reference", + (graph: AgentMapGraph) => { + graph.nodes.find(({ id }) => id === researchId)!.contractRefs = [ + "research/v2", + ]; + }, + ], + [ + "relationship ID", + (graph: AgentMapGraph) => { + graph.relationships.find(({ id }) => id === invokesId)!.id = + "rel_018f0000-0000-7000-8000-000000000099" as PlanRelationshipId; + }, + ], + [ + "relationship source", + (graph: AgentMapGraph) => { + graph.relationships.find(({ id }) => id === invokesId)!.fromNodeId = + marketingId; + }, + ], + [ + "relationship target", + (graph: AgentMapGraph) => { + graph.relationships.find(({ id }) => id === invokesId)!.toNodeId = + marketingId; + }, + ], + [ + "relationship kind", + (graph: AgentMapGraph) => { + graph.relationships.find(({ id }) => id === invokesId)!.kind = + "triggers"; + }, + ], + [ + "relationship execution mode", + (graph: AgentMapGraph) => { + graph.relationships.find(({ id }) => id === invokesId)!.executionMode = + "scheduled"; + }, + ], + [ + "relationship contract", + (graph: AgentMapGraph) => { + graph.relationships.find(({ id }) => id === invokesId)!.contractRef = + "query/v2"; + }, + ], + [ + "relationship description", + (graph: AgentMapGraph) => { + graph.relationships.find(({ id }) => id === invokesId)!.description = + "Changed"; + }, + ], + ])("changes when the %s changes", (_name, mutate) => { + expect(digestAgentMapArchitecture(projectId, mutateGraph(mutate))).not.toBe( + digestAgentMapArchitecture(projectId, stockResearchGraph()), + ); + }); + + it("separates identical-looking graphs by project", () => { + expect( + digestAgentMapArchitecture("project-other", stockResearchGraph()), + ).not.toBe(digestAgentMapArchitecture(projectId, stockResearchGraph())); + }); +}); + +describe("Agent Map revision materialization", () => { + it("preserves graph identity and binds trusted content-free approval", () => { + const input = materializationInput(); + const before = JSON.stringify(input); + const revision = materializeAgentMapRevision(input); + + expect(revision.digest).toBe(input.request.expectedDigest); + expect(revision.nodes.map(({ id }) => id).sort()).toEqual( + input.proposal.nodes.map(({ id }) => id).sort(), + ); + expect(revision.relationships.map(({ id }) => id).sort()).toEqual( + input.proposal.relationships.map(({ id }) => id).sort(), + ); + expect( + revision.nodes.find(({ id }) => id === analystId)?.ownerAgentId, + ).toBe(researchId); + expect(revision.approval).toEqual({ + approvedProposalId: proposalId, + approvedProposalVersion: 1, + approvingUserId: "user-1", + approvingSessionId: "planner-session-1", + approvingMessageId: "message-approval-1", + approvedAt: acceptedAt, + }); + expect(JSON.stringify(revision)).not.toMatch( + /(?:messageText|prompt|transcript|sourcePath|secret|providerPayload)/iu, + ); + expect(JSON.stringify(input)).toBe(before); + }); + + it("keeps runtime mechanics out of the project architecture", () => { + const revision = materializeAgentMapRevision(materializationInput()); + expect(revision.nodes.map(({ name }) => name)).toEqual([ + "Research", + "Marketing", + "Research analyst", + "Shared report", + ]); + expect(JSON.stringify(revision.nodes)).not.toMatch( + /(?:LLM call|MCP invocation|workflow step)/iu, + ); + }); + + it("excludes proposal identity, history, and timestamps", () => { + const proposal = proposalFor(); + const changedMetadata = { + ...proposal, + id: "proposal_018f0000-0000-7000-8000-000000000099" as MapProposalId, + baseRevisionId: revisionId, + version: 99, + history: [], + createdAt: "2020-01-01T00:00:00.000Z", + updatedAt: "2030-01-01T00:00:00.000Z", + }; + expect(digestAgentMapArchitecture(projectId, changedMetadata)).toBe( + digestAgentMapArchitecture(projectId, proposal), + ); + }); + + it.each([ + [ + "stale source", + (input: MaterializeAgentMapRevisionInput) => { + input.request = { ...input.request, expectedVersion: 2 }; + }, + "stale_proposal", + ], + [ + "digest mismatch", + (input: MaterializeAgentMapRevisionInput) => { + input.request = { + ...input.request, + expectedDigest: `sha256:${"f".repeat(64)}` as AgentMapGraphDigest, + }; + }, + "proposal_digest_mismatch", + ], + [ + "wrong user", + (input: MaterializeAgentMapRevisionInput) => { + input.receipt = { ...input.receipt, userId: "user-2" }; + }, + "approval_message_invalid", + ], + [ + "wrong message", + (input: MaterializeAgentMapRevisionInput) => { + input.receipt = { ...input.receipt, messageId: "message-other" }; + }, + "approval_message_invalid", + ], + [ + "cross-project receipt", + (input: MaterializeAgentMapRevisionInput) => { + input.receipt = { ...input.receipt, projectId: "project-other" }; + }, + "cross_project", + ], + [ + "approval after commit", + (input: MaterializeAgentMapRevisionInput) => { + input.receipt = { + ...input.receipt, + acceptedAt: "2026-09-03T12:00:02.000Z", + }; + }, + "approval_message_invalid", + ], + [ + "approval before the proposal source", + (input: MaterializeAgentMapRevisionInput) => { + input.receipt = { + ...input.receipt, + acceptedAt: "2026-09-03T11:59:59.000Z", + }; + }, + "approval_message_invalid", + ], + [ + "non-human approval", + (input: MaterializeAgentMapRevisionInput) => { + input.receipt = { ...input.receipt, origin: "assistant" as "human" }; + }, + "approval_message_invalid", + ], + ])("rejects %s with a bounded failure", (_name, mutate, code) => { + const input = materializationInput(); + mutate(input); + try { + materializeAgentMapRevision(input); + expect.fail("expected materialization to fail"); + } catch (error) { + expect(error).toBeInstanceOf(AgentMapRevisionContractError); + expect(error).toMatchObject({ code }); + expect(JSON.stringify(error)).not.toContain(input.receipt.messageId); + } + }); +}); + +describe("Agent Map revision chain", () => { + const twoRevisions = (): [AgentMapRevision, AgentMapRevision] => { + const first = materializeAgentMapRevision(materializationInput()); + const secondInput = materializationInput(); + const secondProposalId = + "proposal_018f0000-0000-7000-8000-000000000002" as MapProposalId; + secondInput.proposal = { + ...proposalFor(secondProposalId), + baseRevisionId: first.id, + }; + secondInput.receipt = { + ...receiptFor("message-approval-2"), + acceptedAt: "2026-09-03T12:00:01.000Z", + }; + secondInput.request = { + ...requestFor(first.digest, secondProposalId, "message-approval-2"), + requestId: "confirm-request-2", + }; + secondInput.revisionId = + "revision_018f0000-0000-7000-8000-000000000002" as AgentMapRevisionId; + secondInput.revisionNumber = 2; + secondInput.parentRevisionId = first.id; + secondInput.createdAt = "2026-09-03T12:00:02.000Z"; + return [first, materializeAgentMapRevision(secondInput)]; + }; + + it("validates revision one and exact contiguous ancestry", () => { + const revisions = twoRevisions(); + expect(validateAgentMapRevision(revisions[0], projectId)).toEqual( + revisions[0], + ); + expect(validateAgentMapRevisionChain(revisions, projectId)).toEqual( + revisions, + ); + expect(revisions[1].digest).toBe(revisions[0].digest); + expect(revisions[1].id).not.toBe(revisions[0].id); + }); + + it.each([ + [ + "rewritten parent", + (revisions: AgentMapRevision[]) => + (revisions[1]!.parentRevisionId = + "revision_018f0000-0000-7000-8000-000000000099" as AgentMapRevisionId), + ], + [ + "skipped number", + (revisions: AgentMapRevision[]) => (revisions[1]!.revisionNumber = 3), + ], + [ + "duplicate number", + (revisions: AgentMapRevision[]) => (revisions[1]!.revisionNumber = 1), + ], + [ + "duplicate ID", + (revisions: AgentMapRevision[]) => (revisions[1]!.id = revisions[0]!.id), + ], + [ + "wrong digest", + (revisions: AgentMapRevision[]) => + (revisions[1]!.digest = + `sha256:${"0".repeat(64)}` as AgentMapGraphDigest), + ], + [ + "cross-project substitution", + (revisions: AgentMapRevision[]) => + (revisions[1]!.projectId = "project-other"), + ], + [ + "reused approval message", + (revisions: AgentMapRevision[]) => + (revisions[1]!.approval.approvingMessageId = + revisions[0]!.approval.approvingMessageId), + ], + [ + "reconfirmed proposal source", + (revisions: AgentMapRevision[]) => { + revisions[1]!.approval.approvedProposalId = + revisions[0]!.approval.approvedProposalId; + revisions[1]!.approval.approvedProposalVersion = + revisions[0]!.approval.approvedProposalVersion; + }, + ], + ])("rejects a chain with %s", (_name, mutate) => { + const revisions = twoRevisions(); + mutate(revisions); + try { + validateAgentMapRevisionChain(revisions, projectId); + expect.fail("expected chain validation to fail"); + } catch (error) { + expect(error).toBeInstanceOf(AgentMapRevisionContractError); + expect(error).toMatchObject({ + code: "invalid_revision_chain", + recovery: "reread", + }); + } + }); +}); + +describe("Agent Map confirmation retry boundary", () => { + it("distinguishes replay-equivalent requests from changed bodies", () => { + const digest = digestAgentMapArchitecture(projectId, stockResearchGraph()); + const request = requestFor(digest); + expect(digestConfirmArchitectureRequest(structuredClone(request))).toBe( + digestConfirmArchitectureRequest(request), + ); + for (const changed of [ + { ...request, requestId: "confirm-request-2" }, + { + ...request, + proposalId: + "proposal_018f0000-0000-7000-8000-000000000002" as MapProposalId, + }, + { ...request, expectedVersion: 2 }, + { + ...request, + expectedDigest: `sha256:${"e".repeat(64)}` as AgentMapGraphDigest, + }, + { ...request, approvingMessageId: "message-approval-2" }, + ]) { + expect(digestConfirmArchitectureRequest(changed)).not.toBe( + digestConfirmArchitectureRequest(request), + ); + } + }); + + it.each([ + [ + "the current proposal operation commits first", + { committedFirst: "proposal-operation" } as const, + { + confirmation: { + outcome: "failed", + failure: { code: "stale_proposal", recovery: "reread" }, + }, + proposalOperation: "committed", + }, + ], + [ + "confirmation commits before an exact-source operation", + { + committedFirst: "confirmation", + confirmedSource: { proposalId, version: 1 }, + operationSource: { proposalId, version: 1 }, + } as const, + { + confirmation: { outcome: "confirmed" }, + proposalOperation: "rebase-eligible", + }, + ], + [ + "confirmation commits before an older-source operation", + { + committedFirst: "confirmation", + confirmedSource: { proposalId, version: 1 }, + operationSource: { + proposalId: + "proposal_018f0000-0000-7000-8000-000000000099" as MapProposalId, + version: 1, + }, + } as const, + { + confirmation: { outcome: "confirmed" }, + proposalOperation: "stale", + }, + ], + ])("classifies when %s", (_name, input, expected) => { + expect(classifyAgentMapConfirmationBoundary(input)).toEqual(expected); + }); +}); diff --git a/packages/harness/src/core/agent-map-revision.ts b/packages/harness/src/core/agent-map-revision.ts new file mode 100644 index 00000000..4c6a8319 --- /dev/null +++ b/packages/harness/src/core/agent-map-revision.ts @@ -0,0 +1,340 @@ +import { createHash } from "node:crypto"; + +import { + AGENT_MAP_REVISION_SCHEMA_VERSION, + type AgentMapGraph, + type AgentMapGraphDigest, + type AgentMapRevision, + type AgentMapRevisionId, + type AgentMapRevisionRef, + type ConfirmArchitectureFailure, + type ConfirmArchitectureRequest, + type MapChangeProposal, + type MapProposalId, + type PlannerUserMessageReceipt, + type PlanningSessionIdentity, + type StudioProjectId, +} from "../shared/agent-map.js"; +import { + canonicalAgentMapArchitecturePayload, + canonicalizeAgentMapGraph, +} from "../shared/agent-map-canonical.js"; +import { + isAgentMapBoundedText, + parseAgentMapGraph, + parseAgentMapRevision, + parseAgentMapRevisionRef, + parseConfirmArchitectureRequest, + parseMapChangeProposal, + parsePlannerUserMessageReceipt, +} from "../shared/agent-map-codec.js"; + +type MapPlannerIdentity = Extract< + PlanningSessionIdentity, + { role: "map-planner" } +>; + +export interface MaterializeAgentMapRevisionInput { + proposal: MapChangeProposal; + request: ConfirmArchitectureRequest; + receipt: PlannerUserMessageReceipt; + principal: MapPlannerIdentity; + revisionId: AgentMapRevisionId; + revisionNumber: number; + parentRevisionId: AgentMapRevisionId | null; + createdAt: string; +} + +export type AgentMapConfirmationBoundaryInput = + | { committedFirst: "proposal-operation" } + | { + committedFirst: "confirmation"; + confirmedSource: { proposalId: MapProposalId; version: number }; + operationSource: { proposalId: MapProposalId; version: number }; + }; + +export type AgentMapConfirmationBoundaryDecision = + | { + confirmation: { + outcome: "failed"; + failure: Extract< + ConfirmArchitectureFailure, + { code: "stale_proposal" } + >; + }; + proposalOperation: "committed"; + } + | { + confirmation: { outcome: "confirmed" }; + proposalOperation: "rebase-eligible" | "stale"; + }; + +/** A bounded failure whose message never includes caller-controlled values. */ +export class AgentMapRevisionContractError extends Error { + readonly code: ConfirmArchitectureFailure["code"]; + readonly recovery: ConfirmArchitectureFailure["recovery"]; + + constructor(readonly failure: ConfirmArchitectureFailure) { + super(`Agent Map revision rejected: ${failure.code}`); + this.name = "AgentMapRevisionContractError"; + this.code = failure.code; + this.recovery = failure.recovery; + } +} + +const reject = (failure: ConfirmArchitectureFailure): never => { + throw new AgentMapRevisionContractError(failure); +}; + +/** Hash the exact domain-separated, UTF-8 JSON architecture payload. */ +export function digestAgentMapArchitecture( + projectId: StudioProjectId, + graphInput: AgentMapGraph, +): AgentMapGraphDigest { + if (!isAgentMapBoundedText(projectId, 128)) + return reject({ code: "malformed_input", recovery: "reread" }); + let graph: AgentMapGraph; + try { + graph = parseAgentMapGraph({ + nodes: graphInput.nodes, + relationships: graphInput.relationships, + }); + } catch { + return reject({ code: "malformed_input", recovery: "reread" }); + } + const payload = canonicalAgentMapArchitecturePayload(projectId, graph); + return `sha256:${createHash("sha256") + .update(JSON.stringify(payload), "utf8") + .digest("hex")}` as AgentMapGraphDigest; +} + +/** + * Digest used alongside (project, planner session, request ID) to distinguish + * an idempotent replay from request-ID reuse with different confirmation data. + */ +export function digestConfirmArchitectureRequest( + requestInput: ConfirmArchitectureRequest, +): string { + let request: ConfirmArchitectureRequest; + try { + request = parseConfirmArchitectureRequest(requestInput); + } catch { + return reject({ code: "malformed_input", recovery: "reread" }); + } + return createHash("sha256") + .update( + JSON.stringify([ + "sapiom.agent-map.confirmation-request", + AGENT_MAP_REVISION_SCHEMA_VERSION, + request.requestId, + request.proposalId, + request.expectedVersion, + request.expectedDigest, + request.approvingMessageId, + ]), + "utf8", + ) + .digest("hex"); +} + +/** + * Pure description of the confirmation transaction's linearization boundary. + * `proposal-operation` means the transaction has already validated and + * committed an operation against the one current proposal source being + * confirmed. An operation against an older or different proposal cannot + * inhabit that branch. After confirmation commits, SAP-3063 must still + * validate an exact-source operation before conservatively rebasing it; this + * helper keeps only those source-ordering outcomes fixed. + */ +export function classifyAgentMapConfirmationBoundary( + input: AgentMapConfirmationBoundaryInput, +): AgentMapConfirmationBoundaryDecision { + if (input.committedFirst === "proposal-operation") + return { + confirmation: { + outcome: "failed", + failure: { code: "stale_proposal", recovery: "reread" }, + }, + proposalOperation: "committed", + }; + return { + confirmation: { outcome: "confirmed" }, + proposalOperation: + input.operationSource.proposalId === input.confirmedSource.proposalId && + input.operationSource.version === input.confirmedSource.version + ? "rebase-eligible" + : "stale", + }; +} + +/** Validate syntax, graph semantics, and the stored architecture digest. */ +export function validateAgentMapRevision( + value: unknown, + expectedProjectId: StudioProjectId, +): AgentMapRevision { + let revision: AgentMapRevision; + try { + revision = parseAgentMapRevision(value, expectedProjectId); + } catch { + return reject({ code: "invalid_revision_chain", recovery: "reread" }); + } + let digest: AgentMapGraphDigest; + try { + digest = digestAgentMapArchitecture(revision.projectId, revision); + } catch { + return reject({ code: "invalid_revision_chain", recovery: "reread" }); + } + if (digest !== revision.digest) + return reject({ code: "invalid_revision_chain", recovery: "reread" }); + return revision; +} + +/** Validate a complete oldest-to-newest project revision chain. */ +export function validateAgentMapRevisionChain( + values: readonly unknown[], + expectedProjectId: StudioProjectId, +): AgentMapRevision[] { + const revisions = values.map((value) => + validateAgentMapRevision(value, expectedProjectId), + ); + const ids = new Set(); + const approvingMessageKeys = new Set(); + const approvedProposalSources = new Set(); + revisions.forEach((revision, index) => { + const previous = revisions[index - 1]; + const approvedProposalSource = JSON.stringify([ + revision.approval.approvedProposalId, + revision.approval.approvedProposalVersion, + ]); + const approvingMessageKey = JSON.stringify([ + revision.approval.approvingUserId, + revision.approval.approvingSessionId, + revision.approval.approvingMessageId, + ]); + if ( + ids.has(revision.id) || + approvingMessageKeys.has(approvingMessageKey) || + approvedProposalSources.has(approvedProposalSource) || + revision.revisionNumber !== index + 1 || + (index === 0 + ? revision.parentRevisionId !== null + : revision.parentRevisionId !== previous?.id) + ) + return reject({ code: "invalid_revision_chain", recovery: "reread" }); + ids.add(revision.id); + approvingMessageKeys.add(approvingMessageKey); + approvedProposalSources.add(approvedProposalSource); + }); + return revisions; +} + +/** + * Promote one exact validated proposal source into an immutable graph snapshot. + * IDs are copied, never allocated or derived, by this pure boundary. + */ +export function materializeAgentMapRevision( + input: MaterializeAgentMapRevisionInput, +): AgentMapRevision { + if ( + input.principal.role !== "map-planner" || + !isAgentMapBoundedText(input.principal.projectId, 128) || + !isAgentMapBoundedText(input.principal.userId, 256) || + !isAgentMapBoundedText(input.principal.sessionId, 256) + ) + return reject({ code: "malformed_input", recovery: "reread" }); + if ( + input.proposal.projectId !== input.principal.projectId || + input.receipt.projectId !== input.principal.projectId + ) + return reject({ code: "cross_project", recovery: "reread" }); + + let request: ConfirmArchitectureRequest; + let proposal: MapChangeProposal; + try { + request = parseConfirmArchitectureRequest(input.request); + proposal = parseMapChangeProposal( + input.proposal, + input.principal.projectId, + ); + } catch { + return reject({ code: "malformed_input", recovery: "reread" }); + } + let receipt: PlannerUserMessageReceipt; + try { + receipt = parsePlannerUserMessageReceipt( + input.receipt, + input.principal.projectId, + ); + } catch { + return reject({ code: "approval_message_invalid", recovery: "ask_again" }); + } + + if ( + request.proposalId !== proposal.id || + request.expectedVersion !== proposal.version + ) + return reject({ code: "stale_proposal", recovery: "reread" }); + + let graph: AgentMapGraph; + try { + graph = canonicalizeAgentMapGraph( + parseAgentMapGraph({ + nodes: proposal.nodes, + relationships: proposal.relationships, + }), + ); + } catch { + return reject({ code: "malformed_input", recovery: "reread" }); + } + const digest = digestAgentMapArchitecture(proposal.projectId, graph); + if (request.expectedDigest !== digest) + return reject({ code: "proposal_digest_mismatch", recovery: "reread" }); + if ( + request.approvingMessageId !== receipt.messageId || + receipt.userId !== input.principal.userId || + receipt.sessionId !== input.principal.sessionId + ) + return reject({ code: "approval_message_invalid", recovery: "ask_again" }); + + let revisionRef: AgentMapRevisionRef; + try { + revisionRef = parseAgentMapRevisionRef({ + id: input.revisionId, + revisionNumber: input.revisionNumber, + parentRevisionId: input.parentRevisionId, + digest, + createdAt: input.createdAt, + }); + } catch { + return reject({ code: "invalid_revision_chain", recovery: "reread" }); + } + // This is the enforceable temporal lower bound at the pure contract layer. + // SAP-3065 must additionally prove a trusted read of this exact source. + if ( + receipt.acceptedAt < proposal.updatedAt || + receipt.acceptedAt > revisionRef.createdAt + ) + return reject({ code: "approval_message_invalid", recovery: "ask_again" }); + + return validateAgentMapRevision( + { + schemaVersion: AGENT_MAP_REVISION_SCHEMA_VERSION, + id: revisionRef.id, + projectId: proposal.projectId, + revisionNumber: revisionRef.revisionNumber, + parentRevisionId: revisionRef.parentRevisionId, + ...graph, + digest, + approval: { + approvedProposalId: proposal.id, + approvedProposalVersion: proposal.version, + approvingUserId: receipt.userId, + approvingSessionId: receipt.sessionId, + approvingMessageId: receipt.messageId, + approvedAt: receipt.acceptedAt, + }, + createdAt: revisionRef.createdAt, + }, + proposal.projectId, + ); +} diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 8330ab4f..7ff7404e 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -6,12 +6,23 @@ export * from "./shared/types.js"; export { AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + AGENT_MAP_REVISION_SCHEMA_VERSION, EXECUTION_MODES, PLAN_NODE_KINDS, RELATIONSHIP_KINDS, } from "./shared/agent-map.js"; export type { AcceptedProposalDelta, + AgentMapGraphDigest, + AgentMapRevision, + AgentMapRevisionId, + AgentMapRevisionRef, + ArchitectureApproval, + ConfirmArchitectureFailure, + ConfirmArchitectureErrorCode, + ConfirmArchitectureRecovery, + ConfirmArchitectureRequest, + ConfirmArchitectureResult, ExecutionMode, MapOperation, MapProposalId, @@ -23,6 +34,7 @@ export type { PlanRelationshipId, ProposalActor, ProposalOperationId, + PlannerUserMessageReceipt, RelationshipChanges, RelationshipKind, StudioProjectId, diff --git a/packages/harness/src/shared/agent-map-canonical.test.ts b/packages/harness/src/shared/agent-map-canonical.test.ts new file mode 100644 index 00000000..21c44605 --- /dev/null +++ b/packages/harness/src/shared/agent-map-canonical.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import type { + AgentMapGraph, + PlanNodeId, + PlanRelationshipId, +} from "./agent-map.js"; +import { + canonicalAgentMapArchitecturePayload, + canonicalizeAgentMapGraph, +} from "./agent-map-canonical.js"; + +const agentA = "node_018f0000-0000-7000-8000-000000000001" as PlanNodeId; +const agentB = "node_018f0000-0000-7000-8000-000000000002" as PlanNodeId; + +const shuffledGraph = (): AgentMapGraph => ({ + nodes: [ + { + id: agentB, + kind: "agent", + name: "Marketing", + purpose: "Publish research", + ownerAgentId: null, + contractRefs: ["report/v2", "brief/v1"], + }, + { + id: agentA, + kind: "agent", + name: "Research", + purpose: "Find market signals", + ownerAgentId: null, + contractRefs: [], + }, + ], + relationships: [], +}); + +describe("Agent Map canonical architecture protocol", () => { + it("orders IDs and contract references without mutating input", () => { + const graph = shuffledGraph(); + const before = JSON.stringify(graph); + const canonical = canonicalizeAgentMapGraph(graph); + + expect(canonical.nodes.map(({ id }) => id)).toEqual([agentA, agentB]); + expect(canonical.nodes[1]?.contractRefs).toEqual(["brief/v1", "report/v2"]); + expect(JSON.stringify(graph)).toBe(before); + expect(canonical.nodes[1]).not.toBe(graph.nodes[0]); + expect(canonical.nodes[1]?.contractRefs).not.toBe( + graph.nodes[0]?.contractRefs, + ); + }); + + it("encodes the fixed domain-separated tuple with explicit nulls", () => { + expect( + canonicalAgentMapArchitecturePayload("project-1", shuffledGraph()), + ).toEqual([ + "sapiom.agent-map.architecture", + 1, + "project-1", + [ + [agentA, "agent", "Research", "Find market signals", null, []], + [ + agentB, + "agent", + "Marketing", + "Publish research", + null, + ["brief/v1", "report/v2"], + ], + ], + [], + ]); + }); + + it.each([ + ["node IDs", (graph: AgentMapGraph) => graph.nodes.push(graph.nodes[0]!)], + [ + "relationship IDs", + (graph: AgentMapGraph) => { + const relationship = { + id: "rel_018f0000-0000-7000-8000-000000000001" as PlanRelationshipId, + fromNodeId: agentA, + toNodeId: agentB, + kind: "invokes" as const, + executionMode: null, + contractRef: null, + description: "Delegates", + }; + graph.relationships.push(relationship, { ...relationship }); + }, + ], + [ + "contract references", + (graph: AgentMapGraph) => graph.nodes[0]?.contractRefs.push("report/v2"), + ], + ])("rejects duplicate %s before serialization", (_name, mutate) => { + const graph = shuffledGraph(); + mutate(graph); + expect(() => + canonicalAgentMapArchitecturePayload("project-1", graph), + ).toThrow(/duplicate Agent Map/u); + }); +}); diff --git a/packages/harness/src/shared/agent-map-canonical.ts b/packages/harness/src/shared/agent-map-canonical.ts new file mode 100644 index 00000000..72f755ab --- /dev/null +++ b/packages/harness/src/shared/agent-map-canonical.ts @@ -0,0 +1,121 @@ +import type { + AgentMapGraph, + ExecutionMode, + PlanNode, + PlanNodeId, + PlanNodeKind, + PlanRelationship, + PlanRelationshipId, + RelationshipKind, + StudioProjectId, +} from "./agent-map.js"; + +/** + * Changing this tuple is an Agent Map digest protocol change. Object property + * order, locale collation, and incidental proposal metadata must never affect + * the architecture identity. + */ +export type CanonicalAgentMapArchitectureV1 = readonly [ + "sapiom.agent-map.architecture", + 1, + StudioProjectId, + readonly (readonly [ + PlanNodeId, + PlanNodeKind, + string, + string, + PlanNodeId | null, + readonly string[], + ])[], + readonly (readonly [ + PlanRelationshipId, + PlanNodeId, + PlanNodeId, + RelationshipKind, + ExecutionMode | null, + string | null, + string, + ])[], +]; + +export const compareAgentMapStrings = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; + +export const canonicalizeAgentMapStrings = ( + values: readonly string[], +): string[] => [...values].sort(compareAgentMapStrings); + +const canonicalNode = (node: PlanNode): PlanNode => ({ + ...node, + contractRefs: canonicalizeAgentMapStrings(node.contractRefs), +}); + +const canonicalRelationship = ( + relationship: PlanRelationship, +): PlanRelationship => ({ ...relationship }); + +/** Return a deep-enough defensive graph copy in protocol ordering. */ +export function canonicalizeAgentMapGraph(graph: AgentMapGraph): AgentMapGraph { + return { + nodes: graph.nodes + .map(canonicalNode) + .sort((left, right) => compareAgentMapStrings(left.id, right.id)), + relationships: graph.relationships + .map(canonicalRelationship) + .sort((left, right) => compareAgentMapStrings(left.id, right.id)), + }; +} + +function assertUniqueCanonicalInputs(graph: AgentMapGraph): void { + if (new Set(graph.nodes.map(({ id }) => id)).size !== graph.nodes.length) + throw new Error("duplicate Agent Map node ID"); + if ( + new Set(graph.relationships.map(({ id }) => id)).size !== + graph.relationships.length + ) + throw new Error("duplicate Agent Map relationship ID"); + if ( + graph.nodes.some( + ({ contractRefs }) => new Set(contractRefs).size !== contractRefs.length, + ) + ) + throw new Error("duplicate Agent Map contract reference"); +} + +/** Build the exact JSON-serializable V1 architecture digest payload. */ +export function canonicalAgentMapArchitecturePayload( + projectId: StudioProjectId, + graph: AgentMapGraph, +): CanonicalAgentMapArchitectureV1 { + assertUniqueCanonicalInputs(graph); + const canonical = canonicalizeAgentMapGraph(graph); + return [ + "sapiom.agent-map.architecture", + 1, + projectId, + canonical.nodes.map( + ({ id, kind, name, purpose, ownerAgentId, contractRefs }) => + [id, kind, name, purpose, ownerAgentId, contractRefs] as const, + ), + canonical.relationships.map( + ({ + id, + fromNodeId, + toNodeId, + kind, + executionMode, + contractRef, + description, + }) => + [ + id, + fromNodeId, + toNodeId, + kind, + executionMode, + contractRef, + description, + ] as const, + ), + ]; +} diff --git a/packages/harness/src/shared/agent-map-codec.test.ts b/packages/harness/src/shared/agent-map-codec.test.ts index 09f4c96d..6842b82c 100644 --- a/packages/harness/src/shared/agent-map-codec.test.ts +++ b/packages/harness/src/shared/agent-map-codec.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from "vitest"; import { + parseAgentMapRevision, + parseAgentMapRevisionRef, parseAgentMapProposalReceipt, + parseArchitectureApproval, + parseConfirmArchitectureRequest, + parseConfirmArchitectureResult, parseMapChangeProposal, + parsePlannerUserMessageReceipt, parseProposalActor, } from "./agent-map-codec.js"; @@ -56,6 +62,44 @@ const receipt = { allocatedNodeIds: { research: nodeId }, allocatedRelationshipIds: {}, }; +const revisionId = "revision_018f0000-0000-7000-8000-000000000004"; +const digest = `sha256:${"a".repeat(64)}`; +const approval = { + approvedProposalId: proposalId, + approvedProposalVersion: 1, + approvingUserId: actor.userId, + approvingSessionId: actor.sessionId, + approvingMessageId: "message-1", + approvedAt: acceptedAt, +}; +const plannerReceipt = { + messageId: approval.approvingMessageId, + projectId: proposal.projectId, + userId: actor.userId, + sessionId: actor.sessionId, + origin: "human", + acceptedAt, +}; +const confirmRequest = { + schemaVersion: 1, + requestId: "confirm-1", + proposalId, + expectedVersion: 1, + expectedDigest: digest, + approvingMessageId: approval.approvingMessageId, +}; +const revision = { + schemaVersion: 1, + id: revisionId, + projectId: proposal.projectId, + revisionNumber: 1, + parentRevisionId: null, + nodes: proposal.nodes, + relationships: [], + digest, + approval, + createdAt: acceptedAt, +}; describe("Agent Map persisted/public codecs", () => { it("accepts the complete exact nested proposal and receipt", () => { @@ -94,4 +138,171 @@ describe("Agent Map persisted/public codecs", () => { parseProposalActor({ ...actor, sessionId: "session\u007f1" }), ).toThrow(); }); + + it("strictly parses content-free approval and confirmation contracts", () => { + expect(parseArchitectureApproval(approval)).toEqual(approval); + expect( + parsePlannerUserMessageReceipt(plannerReceipt, proposal.projectId), + ).toEqual(plannerReceipt); + expect(parseConfirmArchitectureRequest(confirmRequest)).toEqual( + confirmRequest, + ); + expect(parseAgentMapRevision(revision, proposal.projectId)).toEqual( + revision, + ); + expect( + parseConfirmArchitectureResult({ + schemaVersion: 1, + outcome: "confirmed", + approvedProposal: { id: proposalId, version: 1, digest }, + revision: { + id: revisionId, + revisionNumber: 1, + parentRevisionId: null, + digest, + createdAt: acceptedAt, + }, + workspaceRecordVersion: 2, + }), + ).toMatchObject({ outcome: "confirmed" }); + }); + + it("returns defensive revision and receipt clones", () => { + const parsedRevision = parseAgentMapRevision(revision, proposal.projectId); + const parsedReceipt = parsePlannerUserMessageReceipt( + plannerReceipt, + proposal.projectId, + ); + expect(parsedRevision).not.toBe(revision); + expect(parsedRevision.nodes).not.toBe(revision.nodes); + expect(parsedRevision.nodes[0]).not.toBe(revision.nodes[0]); + expect(parsedReceipt).not.toBe(plannerReceipt); + }); + + it.each([ + [ + "authority on model input", + () => + parseConfirmArchitectureRequest({ ...confirmRequest, projectId: "x" }), + ], + [ + "unsupported schema", + () => + parseConfirmArchitectureRequest({ + ...confirmRequest, + schemaVersion: 2, + }), + ], + [ + "unsafe proposal version", + () => + parseConfirmArchitectureRequest({ + ...confirmRequest, + expectedVersion: Number.MAX_SAFE_INTEGER + 1, + }), + ], + [ + "malformed digest", + () => + parseConfirmArchitectureRequest({ + ...confirmRequest, + expectedDigest: "A", + }), + ], + [ + "cross-project receipt", + () => parsePlannerUserMessageReceipt(plannerReceipt, "another-project"), + ], + [ + "non-human receipt", + () => + parsePlannerUserMessageReceipt( + { ...plannerReceipt, origin: "assistant" }, + proposal.projectId, + ), + ], + [ + "future approval", + () => + parseAgentMapRevision( + { + ...revision, + approval: { ...approval, approvedAt: "2026-09-02T12:00:01.000Z" }, + }, + proposal.projectId, + ), + ], + [ + "broken graph reference", + () => + parseAgentMapRevision( + { + ...revision, + relationships: [ + { + id: "rel_018f0000-0000-7000-8000-000000000001", + fromNodeId: nodeId, + toNodeId: "node_018f0000-0000-7000-8000-000000000099", + kind: "invokes", + executionMode: null, + contractRef: null, + description: "Broken", + }, + ], + }, + proposal.projectId, + ), + ], + [ + "malformed revision identity", + () => + parseAgentMapRevision( + { ...revision, id: "revision_by_name" }, + proposal.projectId, + ), + ], + [ + "control character in approval identity", + () => + parseArchitectureApproval({ + ...approval, + approvingMessageId: "message\u0000unsafe", + }), + ], + [ + "missing first parent", + () => + parseAgentMapRevisionRef({ + id: revisionId, + revisionNumber: 2, + parentRevisionId: null, + digest, + createdAt: acceptedAt, + }), + ], + [ + "unknown nested result field", + () => + parseConfirmArchitectureResult({ + schemaVersion: 1, + outcome: "replayed", + approvedProposal: { + id: proposalId, + version: 1, + digest, + path: "/tmp", + }, + revision: { + id: revisionId, + revisionNumber: 1, + parentRevisionId: null, + digest, + createdAt: acceptedAt, + }, + workspaceRecordVersion: 2, + }), + ], + ])("rejects %s", (_name, parse) => { + expect(parse).toThrow(); + }); }); diff --git a/packages/harness/src/shared/agent-map-codec.ts b/packages/harness/src/shared/agent-map-codec.ts index f43796cc..89559c7d 100644 --- a/packages/harness/src/shared/agent-map-codec.ts +++ b/packages/harness/src/shared/agent-map-codec.ts @@ -1,10 +1,20 @@ import { AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + AGENT_MAP_RELATIONSHIP_ENDPOINT_MATRIX, + AGENT_MAP_REVISION_SCHEMA_VERSION, EXECUTION_MODES, PLAN_NODE_KINDS, RELATIONSHIP_KINDS, type DraftRef, type AcceptedProposalDelta, + type AgentMapGraph, + type AgentMapGraphDigest, + type AgentMapRevision, + type AgentMapRevisionId, + type AgentMapRevisionRef, + type ArchitectureApproval, + type ConfirmArchitectureRequest, + type ConfirmArchitectureResult, type MapChangeProposal, type MapOperation, type PlanNode, @@ -13,7 +23,9 @@ import { type PlanRelationshipId, type ProposalActor, type ProposalBatchResult, + type PlannerUserMessageReceipt, } from "./agent-map.js"; +import { canonicalizeAgentMapGraph } from "./agent-map-canonical.js"; export const AGENT_MAP_UUID_V7_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"; @@ -58,6 +70,9 @@ const isPlanId = (value: unknown, prefix: string): value is string => typeof value === "string" && new RegExp(`^${prefix}_${AGENT_MAP_UUID_V7_PATTERN}$`, "u").test(value); +const isAgentMapDigest = (value: unknown): value is AgentMapGraphDigest => + typeof value === "string" && /^sha256:[0-9a-f]{64}$/u.test(value); + const isTimestamp = (value: unknown): value is string => { if (typeof value !== "string") return false; try { @@ -73,7 +88,7 @@ const isContractRefs = (value: unknown): value is string[] => value.every((entry) => isAgentMapBoundedText(entry, 512)) && new Set(value).size === value.length; -function parseNode(value: unknown): PlanNode { +export function parseAgentMapNode(value: unknown): PlanNode { if ( !isRecord(value) || !hasExactKeys(value, [ @@ -95,7 +110,7 @@ function parseNode(value: unknown): PlanNode { return structuredClone(value) as unknown as PlanNode; } -function parseRelationship(value: unknown): PlanRelationship { +export function parseAgentMapRelationship(value: unknown): PlanRelationship { if ( !isRecord(value) || !hasExactKeys(value, [ @@ -125,6 +140,66 @@ function parseRelationship(value: unknown): PlanRelationship { return structuredClone(value) as unknown as PlanRelationship; } +const relationshipSemanticKey = (relationship: PlanRelationship): string => + JSON.stringify([ + relationship.fromNodeId, + relationship.toNodeId, + relationship.kind, + relationship.executionMode, + relationship.contractRef, + ]); + +/** Strict graph parser shared by immutable revision boundaries. */ +export function parseAgentMapGraph(value: unknown): AgentMapGraph { + if ( + !isRecord(value) || + !hasExactKeys(value, ["nodes", "relationships"]) || + !Array.isArray(value.nodes) || + !Array.isArray(value.relationships) + ) + throw new Error("invalid Agent Map graph"); + + const nodes = value.nodes.map(parseAgentMapNode); + const relationships = value.relationships.map(parseAgentMapRelationship); + const nodesById = new Map(nodes.map((node) => [node.id, node])); + const semanticRelationships = new Set(); + if ( + nodesById.size !== nodes.length || + new Set(relationships.map(({ id }) => id)).size !== relationships.length + ) + throw new Error("inconsistent Agent Map graph"); + + for (const node of nodes) { + const owner = + node.ownerAgentId === null ? undefined : nodesById.get(node.ownerAgentId); + if ( + (node.kind === "subagent" && + (node.ownerAgentId === node.id || owner?.kind !== "agent")) || + (node.kind !== "subagent" && node.ownerAgentId !== null) + ) + throw new Error("inconsistent Agent Map graph"); + } + + for (const relationship of relationships) { + const from = nodesById.get(relationship.fromNodeId); + const to = nodesById.get(relationship.toNodeId); + const rule = AGENT_MAP_RELATIONSHIP_ENDPOINT_MATRIX[relationship.kind]; + const semanticKey = relationshipSemanticKey(relationship); + if ( + !from || + !to || + from.id === to.id || + !rule.from.has(from.kind) || + !rule.to.has(to.kind) || + semanticRelationships.has(semanticKey) + ) + throw new Error("inconsistent Agent Map graph"); + semanticRelationships.add(semanticKey); + } + + return { nodes, relationships }; +} + function parseNodeChanges(value: unknown) { if ( !isRecord(value) || @@ -169,7 +244,7 @@ export function parseMapOperation(value: unknown): MapOperation { case "add-node": if (!hasExactKeys(value, ["kind", "node"])) throw new Error("invalid Agent Map operation"); - return { kind: value.kind, node: parseNode(value.node) }; + return { kind: value.kind, node: parseAgentMapNode(value.node) }; case "update-node": if ( !hasExactKeys(value, ["kind", "nodeId", "changes"]) || @@ -193,7 +268,7 @@ export function parseMapOperation(value: unknown): MapOperation { throw new Error("invalid Agent Map operation"); return { kind: value.kind, - relationship: parseRelationship(value.relationship), + relationship: parseAgentMapRelationship(value.relationship), }; case "update-relationship": if ( @@ -333,8 +408,8 @@ export function parseMapChangeProposal( ) throw new Error("invalid Agent Map proposal"); - const nodes = value.nodes.map(parseNode); - const relationships = value.relationships.map(parseRelationship); + const nodes = value.nodes.map(parseAgentMapNode); + const relationships = value.relationships.map(parseAgentMapRelationship); const history = value.history.map((record) => { if ( !isRecord(record) || @@ -461,3 +536,196 @@ export function parseAgentMapProposalReceipt( ) as ProposalBatchResult["allocatedRelationshipIds"], }; } + +export function parseArchitectureApproval( + value: unknown, +): ArchitectureApproval { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "approvedProposalId", + "approvedProposalVersion", + "approvingUserId", + "approvingSessionId", + "approvingMessageId", + "approvedAt", + ]) || + !isPlanId(value.approvedProposalId, "proposal") || + !Number.isSafeInteger(value.approvedProposalVersion) || + (value.approvedProposalVersion as number) < 1 || + !isAgentMapBoundedText(value.approvingUserId, 256) || + !isAgentMapBoundedText(value.approvingSessionId, 256) || + !isAgentMapBoundedText(value.approvingMessageId, 256) || + !isTimestamp(value.approvedAt) + ) + throw new Error("invalid Agent Map architecture approval"); + return structuredClone(value) as unknown as ArchitectureApproval; +} + +export function parsePlannerUserMessageReceipt( + value: unknown, + expectedProjectId: string, +): PlannerUserMessageReceipt { + if ( + !isAgentMapBoundedText(expectedProjectId, 128) || + !isRecord(value) || + !hasExactKeys(value, [ + "messageId", + "projectId", + "userId", + "sessionId", + "origin", + "acceptedAt", + ]) || + !isAgentMapBoundedText(value.messageId, 256) || + !isAgentMapBoundedText(value.projectId, 128) || + value.projectId !== expectedProjectId || + !isAgentMapBoundedText(value.userId, 256) || + !isAgentMapBoundedText(value.sessionId, 256) || + value.origin !== "human" || + !isTimestamp(value.acceptedAt) + ) + throw new Error("invalid Agent Map planner message receipt"); + return structuredClone(value) as unknown as PlannerUserMessageReceipt; +} + +export function parseConfirmArchitectureRequest( + value: unknown, +): ConfirmArchitectureRequest { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "schemaVersion", + "requestId", + "proposalId", + "expectedVersion", + "expectedDigest", + "approvingMessageId", + ]) || + value.schemaVersion !== AGENT_MAP_REVISION_SCHEMA_VERSION || + !isAgentMapBoundedText(value.requestId, 128) || + !isPlanId(value.proposalId, "proposal") || + !Number.isSafeInteger(value.expectedVersion) || + (value.expectedVersion as number) < 1 || + !isAgentMapDigest(value.expectedDigest) || + !isAgentMapBoundedText(value.approvingMessageId, 256) + ) + throw new Error("invalid Agent Map confirmation request"); + return structuredClone(value) as unknown as ConfirmArchitectureRequest; +} + +export function parseAgentMapRevisionRef(value: unknown): AgentMapRevisionRef { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "id", + "revisionNumber", + "parentRevisionId", + "digest", + "createdAt", + ]) || + !isPlanId(value.id, "revision") || + !Number.isSafeInteger(value.revisionNumber) || + (value.revisionNumber as number) < 1 || + (value.parentRevisionId !== null && + !isPlanId(value.parentRevisionId, "revision")) || + (value.revisionNumber === 1) !== (value.parentRevisionId === null) || + !isAgentMapDigest(value.digest) || + !isTimestamp(value.createdAt) + ) + throw new Error("invalid Agent Map revision reference"); + return structuredClone(value) as unknown as AgentMapRevisionRef; +} + +export function parseAgentMapRevision( + value: unknown, + expectedProjectId: string, +): AgentMapRevision { + if ( + !isAgentMapBoundedText(expectedProjectId, 128) || + !isRecord(value) || + !hasExactKeys(value, [ + "schemaVersion", + "id", + "projectId", + "revisionNumber", + "parentRevisionId", + "nodes", + "relationships", + "digest", + "approval", + "createdAt", + ]) || + value.schemaVersion !== AGENT_MAP_REVISION_SCHEMA_VERSION || + value.projectId !== expectedProjectId || + !isAgentMapBoundedText(value.projectId, 128) || + !isPlanId(value.id, "revision") || + !Number.isSafeInteger(value.revisionNumber) || + (value.revisionNumber as number) < 1 || + (value.parentRevisionId !== null && + !isPlanId(value.parentRevisionId, "revision")) || + (value.revisionNumber === 1) !== (value.parentRevisionId === null) || + !isAgentMapDigest(value.digest) || + !isTimestamp(value.createdAt) + ) + throw new Error("invalid Agent Map revision"); + + const graph = canonicalizeAgentMapGraph( + parseAgentMapGraph({ + nodes: value.nodes, + relationships: value.relationships, + }), + ); + const approval = parseArchitectureApproval(value.approval); + if (approval.approvedAt > value.createdAt) + throw new Error("invalid Agent Map revision"); + return { + schemaVersion: AGENT_MAP_REVISION_SCHEMA_VERSION, + id: value.id as AgentMapRevisionId, + projectId: value.projectId, + revisionNumber: value.revisionNumber as number, + parentRevisionId: value.parentRevisionId as AgentMapRevisionId | null, + ...graph, + digest: value.digest, + approval, + createdAt: value.createdAt, + }; +} + +export function parseConfirmArchitectureResult( + value: unknown, +): ConfirmArchitectureResult { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "schemaVersion", + "outcome", + "approvedProposal", + "revision", + "workspaceRecordVersion", + ]) || + value.schemaVersion !== AGENT_MAP_REVISION_SCHEMA_VERSION || + (value.outcome !== "confirmed" && value.outcome !== "replayed") || + !isRecord(value.approvedProposal) || + !hasExactKeys(value.approvedProposal, ["id", "version", "digest"]) || + !isPlanId(value.approvedProposal.id, "proposal") || + !Number.isSafeInteger(value.approvedProposal.version) || + (value.approvedProposal.version as number) < 1 || + !isAgentMapDigest(value.approvedProposal.digest) || + !Number.isSafeInteger(value.workspaceRecordVersion) || + (value.workspaceRecordVersion as number) < 1 + ) + throw new Error("invalid Agent Map confirmation result"); + const revision = parseAgentMapRevisionRef(value.revision); + if (value.approvedProposal.digest !== revision.digest) + throw new Error("invalid Agent Map confirmation result"); + return { + schemaVersion: AGENT_MAP_REVISION_SCHEMA_VERSION, + outcome: value.outcome, + approvedProposal: structuredClone( + value.approvedProposal, + ) as ConfirmArchitectureResult["approvedProposal"], + revision, + workspaceRecordVersion: value.workspaceRecordVersion as number, + }; +} diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index dba53628..6564c0b7 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -13,6 +13,7 @@ export const AGENT_MAP_WORKSPACE_SCHEMA_VERSION = 1; export const AGENT_MAP_INITIAL_RECORD_VERSION = 1; export const STUDIO_WORKSPACE_PREFERENCE_SCHEMA_VERSION = 1; export const AGENT_MAP_PROPOSAL_SCHEMA_VERSION = 1 as const; +export const AGENT_MAP_REVISION_SCHEMA_VERSION = 1 as const; type AgentMapBrand = string & { readonly __brand: TBrand; @@ -23,6 +24,8 @@ export type PlanNodeId = AgentMapBrand<"PlanNodeId">; export type PlanRelationshipId = AgentMapBrand<"PlanRelationshipId">; export type MapProposalId = AgentMapBrand<"MapProposalId">; export type ProposalOperationId = AgentMapBrand<"ProposalOperationId">; +export type AgentMapRevisionId = AgentMapBrand<"AgentMapRevisionId">; +export type AgentMapGraphDigest = AgentMapBrand<"AgentMapGraphDigest">; /** A caller-authored alias whose lifetime is exactly one operation batch. */ export type DraftRef = AgentMapBrand<"DraftRef">; @@ -53,6 +56,33 @@ export const EXECUTION_MODES = [ ] as const; export type ExecutionMode = (typeof EXECUTION_MODES)[number]; +const AGENT_MAP_ACTOR_KINDS = new Set(["agent", "subagent"]); +const AGENT_MAP_ALL_NODE_KINDS = new Set(PLAN_NODE_KINDS); + +/** The single endpoint policy shared by proposal and revision validation. */ +export const AGENT_MAP_RELATIONSHIP_ENDPOINT_MATRIX: Readonly< + Record< + RelationshipKind, + { from: ReadonlySet; to: ReadonlySet } + > +> = { + invokes: { from: AGENT_MAP_ACTOR_KINDS, to: AGENT_MAP_ACTOR_KINDS }, + feeds: { from: AGENT_MAP_ALL_NODE_KINDS, to: AGENT_MAP_ACTOR_KINDS }, + reads: { + from: AGENT_MAP_ACTOR_KINDS, + to: new Set(["resource", "artifact"]), + }, + writes: { + from: AGENT_MAP_ACTOR_KINDS, + to: new Set(["resource", "artifact"]), + }, + uses: { + from: AGENT_MAP_ACTOR_KINDS, + to: new Set(["resource", "connector"]), + }, + triggers: { from: AGENT_MAP_ALL_NODE_KINDS, to: AGENT_MAP_ACTOR_KINDS }, +}; + export interface PlanNode { id: PlanNodeId; kind: PlanNodeKind; @@ -326,6 +356,87 @@ export interface MapChangeProposal { updatedAt: string; } +/** Minimal, content-free evidence binding one human approval to one source. */ +export interface ArchitectureApproval { + approvedProposalId: MapProposalId; + approvedProposalVersion: number; + approvingUserId: string; + approvingSessionId: string; + approvingMessageId: string; + approvedAt: string; +} + +/** Trusted host receipt; message text is deliberately never retained here. */ +export interface PlannerUserMessageReceipt { + messageId: string; + projectId: StudioProjectId; + userId: string; + sessionId: string; + origin: "human"; + acceptedAt: string; +} + +/** Model-controlled confirmation input. All authority is service-derived. */ +export interface ConfirmArchitectureRequest { + schemaVersion: typeof AGENT_MAP_REVISION_SCHEMA_VERSION; + requestId: string; + proposalId: MapProposalId; + expectedVersion: number; + expectedDigest: AgentMapGraphDigest; + approvingMessageId: string; +} + +/** A bounded revision projection suitable for later architecture sources. */ +export interface AgentMapRevisionRef { + id: AgentMapRevisionId; + revisionNumber: number; + parentRevisionId: AgentMapRevisionId | null; + digest: AgentMapGraphDigest; + createdAt: string; +} + +/** Immutable complete architecture snapshot. It is never an operation delta. */ +export interface AgentMapRevision { + schemaVersion: typeof AGENT_MAP_REVISION_SCHEMA_VERSION; + id: AgentMapRevisionId; + projectId: StudioProjectId; + revisionNumber: number; + parentRevisionId: AgentMapRevisionId | null; + nodes: PlanNode[]; + relationships: PlanRelationship[]; + digest: AgentMapGraphDigest; + approval: ArchitectureApproval; + createdAt: string; +} + +/** Confirmation returns identity and source evidence, never the full graph. */ +export interface ConfirmArchitectureResult { + schemaVersion: typeof AGENT_MAP_REVISION_SCHEMA_VERSION; + outcome: "confirmed" | "replayed"; + approvedProposal: { + id: MapProposalId; + version: number; + digest: AgentMapGraphDigest; + }; + revision: AgentMapRevisionRef; + workspaceRecordVersion: number; +} + +export type ConfirmArchitectureFailure = + | { code: "malformed_input"; recovery: "reread" } + | { code: "stale_proposal"; recovery: "reread" } + | { code: "proposal_digest_mismatch"; recovery: "reread" } + | { code: "approval_message_invalid"; recovery: "ask_again" } + | { code: "approval_message_reused"; recovery: "ask_again" } + | { code: "request_id_reused"; recovery: "new_request" } + | { code: "cross_project"; recovery: "reread" } + | { code: "invalid_revision_chain"; recovery: "reread" } + | { code: "storage_unavailable"; recovery: "retry" }; + +export type ConfirmArchitectureErrorCode = ConfirmArchitectureFailure["code"]; +export type ConfirmArchitectureRecovery = + ConfirmArchitectureFailure["recovery"]; + export interface AgentMapReadSnapshot { schemaVersion: typeof AGENT_MAP_PROPOSAL_SCHEMA_VERSION; project: StudioProjectSummary;