From 04cde241fc0df8ba70543f6eec7298ee128501c8 Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 3 Sep 2026 09:38:27 +0000 Subject: [PATCH 1/4] feat(harness): define immutable Agent Map revisions Establish the canonical architecture digest protocol, strict revision and approval evidence contracts, pure materialization and chain validation, and replay boundary fixtures without adding persistence or transport behavior. Closes: SAP-3062 --- .changeset/agent-map-revision-contracts.md | 5 + .../src/core/agent-map-proposal-service.ts | 2 +- .../src/core/agent-map-proposal-validator.ts | 93 +-- .../src/core/agent-map-revision.test.ts | 638 ++++++++++++++++++ .../harness/src/core/agent-map-revision.ts | 280 ++++++++ packages/harness/src/index.ts | 12 + .../src/shared/agent-map-canonical.test.ts | 103 +++ .../harness/src/shared/agent-map-canonical.ts | 121 ++++ .../src/shared/agent-map-codec.test.ts | 211 ++++++ .../harness/src/shared/agent-map-codec.ts | 280 +++++++- packages/harness/src/shared/agent-map.ts | 111 +++ 11 files changed, 1780 insertions(+), 76 deletions(-) create mode 100644 .changeset/agent-map-revision-contracts.md create mode 100644 packages/harness/src/core/agent-map-revision.test.ts create mode 100644 packages/harness/src/core/agent-map-revision.ts create mode 100644 packages/harness/src/shared/agent-map-canonical.test.ts create mode 100644 packages/harness/src/shared/agent-map-canonical.ts diff --git a/.changeset/agent-map-revision-contracts.md b/.changeset/agent-map-revision-contracts.md new file mode 100644 index 000000000..2003918dd --- /dev/null +++ b/.changeset/agent-map-revision-contracts.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": minor +--- + +Add immutable Agent Map revision, architecture approval, trusted human-message receipt, and confirmation contracts. Architecture snapshots now have a versioned, domain-separated canonical SHA-256 identity that preserves stable graph IDs and can be consumed by 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 10308a737..904b92c07 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 b7f5b6a15..8765ead0c 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 000000000..e0e1e3c83 --- /dev/null +++ b/packages/harness/src/core/agent-map-revision.test.ts @@ -0,0 +1,638 @@ +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, + 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", + ], + [ + "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); + expect(() => validateAgentMapRevisionChain(revisions, projectId)).toThrow( + AgentMapRevisionContractError, + ); + }); +}); + +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([ + ["same request ID and body", "replayed", "original revision"], + ["same request ID with changed body", "request_id_reused", "new_request"], + [ + "same proposal and message after a lost response", + "replayed", + "original revision", + ], + [ + "same message against another source", + "approval_message_reused", + "ask_again", + ], + ["same proposal with different approval", "stale_proposal", "reread"], + ])("pins %s as %s", (attempt, outcome, recoveryOrIdentity) => { + const expected = new Map([ + ["same request ID and body", ["replayed", "original revision"]], + [ + "same request ID with changed body", + ["request_id_reused", "new_request"], + ], + [ + "same proposal and message after a lost response", + ["replayed", "original revision"], + ], + [ + "same message against another source", + ["approval_message_reused", "ask_again"], + ], + ["same proposal with different approval", ["stale_proposal", "reread"]], + ]); + expect([outcome, recoveryOrIdentity]).toEqual(expected.get(attempt)); + }); + + it.each([ + ["proposal operation", "stale_proposal", "reread exact new source"], + [ + "confirmation", + "confirmed exact approved version", + "rebase exact-version write", + ], + ])( + "pins the %s-first linearization outcome", + (committedFirst, confirmationOutcome, followingWrite) => { + if (committedFirst === "proposal operation") { + expect([confirmationOutcome, followingWrite]).toEqual([ + "stale_proposal", + "reread exact new source", + ]); + } else { + expect([confirmationOutcome, followingWrite]).toEqual([ + "confirmed exact approved version", + "rebase exact-version write", + ]); + } + }, + ); +}); 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 000000000..d067df381 --- /dev/null +++ b/packages/harness/src/core/agent-map-revision.ts @@ -0,0 +1,280 @@ +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 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; +} + +/** 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"); +} + +/** 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: "retry" }); + } + let digest: AgentMapGraphDigest; + try { + digest = digestAgentMapArchitecture(revision.projectId, revision); + } catch { + return reject({ code: "invalid_revision_chain", recovery: "retry" }); + } + if (digest !== revision.digest) + return reject({ code: "invalid_revision_chain", recovery: "retry" }); + 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: "retry" }); + 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: "retry" }); + } + if (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 8330ab4f9..7ff7404e2 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 000000000..21c446053 --- /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 000000000..72f755abc --- /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 09f4c96d2..6842b82c6 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 f43796cc3..89559c7d4 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 dba536280..4307980a9 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: "retry" } + | { 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; From c9047148afe08bdd9d89b0064a47b6ce9de4ab7e Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 3 Sep 2026 09:49:42 +0000 Subject: [PATCH 2/4] fix(harness): harden Agent Map approval boundaries Enforce approval chronology, exercise real confirmation boundary logic, classify corrupt chains as reread-required, and clarify the public contract changeset. Refs: SAP-3062 --- .changeset/agent-map-revision-contracts.md | 2 +- .../src/core/agent-map-revision.test.ts | 120 +++++++++--------- .../harness/src/core/agent-map-revision.ts | 68 +++++++++- packages/harness/src/shared/agent-map.ts | 2 +- 4 files changed, 126 insertions(+), 66 deletions(-) diff --git a/.changeset/agent-map-revision-contracts.md b/.changeset/agent-map-revision-contracts.md index 2003918dd..76c9d75d3 100644 --- a/.changeset/agent-map-revision-contracts.md +++ b/.changeset/agent-map-revision-contracts.md @@ -2,4 +2,4 @@ "@sapiom/harness": minor --- -Add immutable Agent Map revision, architecture approval, trusted human-message receipt, and confirmation contracts. Architecture snapshots now have a versioned, domain-separated canonical SHA-256 identity that preserves stable graph IDs and can be consumed by later confirmation and build-planning slices. +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-revision.test.ts b/packages/harness/src/core/agent-map-revision.test.ts index e0e1e3c83..a1725a5c4 100644 --- a/packages/harness/src/core/agent-map-revision.test.ts +++ b/packages/harness/src/core/agent-map-revision.test.ts @@ -15,6 +15,7 @@ import type { } from "../shared/agent-map.js"; import { AgentMapRevisionContractError, + classifyAgentMapConfirmationBoundary, digestAgentMapArchitecture, digestConfirmArchitectureRequest, materializeAgentMapRevision, @@ -438,6 +439,16 @@ describe("Agent Map revision materialization", () => { }, "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) => { @@ -545,9 +556,16 @@ describe("Agent Map revision chain", () => { ])("rejects a chain with %s", (_name, mutate) => { const revisions = twoRevisions(); mutate(revisions); - expect(() => validateAgentMapRevisionChain(revisions, projectId)).toThrow( - AgentMapRevisionContractError, - ); + 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", + }); + } }); }); @@ -579,60 +597,46 @@ describe("Agent Map confirmation retry boundary", () => { }); it.each([ - ["same request ID and body", "replayed", "original revision"], - ["same request ID with changed body", "request_id_reused", "new_request"], - [ - "same proposal and message after a lost response", - "replayed", - "original revision", - ], - [ - "same message against another source", - "approval_message_reused", - "ask_again", - ], - ["same proposal with different approval", "stale_proposal", "reread"], - ])("pins %s as %s", (attempt, outcome, recoveryOrIdentity) => { - const expected = new Map([ - ["same request ID and body", ["replayed", "original revision"]], - [ - "same request ID with changed body", - ["request_id_reused", "new_request"], - ], - [ - "same proposal and message after a lost response", - ["replayed", "original revision"], - ], - [ - "same message against another source", - ["approval_message_reused", "ask_again"], - ], - ["same proposal with different approval", ["stale_proposal", "reread"]], - ]); - expect([outcome, recoveryOrIdentity]).toEqual(expected.get(attempt)); + [ + "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); }); - - it.each([ - ["proposal operation", "stale_proposal", "reread exact new source"], - [ - "confirmation", - "confirmed exact approved version", - "rebase exact-version write", - ], - ])( - "pins the %s-first linearization outcome", - (committedFirst, confirmationOutcome, followingWrite) => { - if (committedFirst === "proposal operation") { - expect([confirmationOutcome, followingWrite]).toEqual([ - "stale_proposal", - "reread exact new source", - ]); - } else { - expect([confirmationOutcome, followingWrite]).toEqual([ - "confirmed exact approved version", - "rebase exact-version write", - ]); - } - }, - ); }); diff --git a/packages/harness/src/core/agent-map-revision.ts b/packages/harness/src/core/agent-map-revision.ts index d067df381..9bf250bee 100644 --- a/packages/harness/src/core/agent-map-revision.ts +++ b/packages/harness/src/core/agent-map-revision.ts @@ -10,6 +10,7 @@ import { type ConfirmArchitectureFailure, type ConfirmArchitectureRequest, type MapChangeProposal, + type MapProposalId, type PlannerUserMessageReceipt, type PlanningSessionIdentity, type StudioProjectId, @@ -44,6 +45,30 @@ export interface MaterializeAgentMapRevisionInput { 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"]; @@ -112,6 +137,32 @@ export function digestConfirmArchitectureRequest( .digest("hex"); } +/** + * Pure description of the confirmation transaction's linearization boundary. + * SAP-3063 owns the transaction and must still validate a rebase-eligible + * operation; this helper keeps only the 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, @@ -121,16 +172,16 @@ export function validateAgentMapRevision( try { revision = parseAgentMapRevision(value, expectedProjectId); } catch { - return reject({ code: "invalid_revision_chain", recovery: "retry" }); + 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: "retry" }); + return reject({ code: "invalid_revision_chain", recovery: "reread" }); } if (digest !== revision.digest) - return reject({ code: "invalid_revision_chain", recovery: "retry" }); + return reject({ code: "invalid_revision_chain", recovery: "reread" }); return revision; } @@ -165,7 +216,7 @@ export function validateAgentMapRevisionChain( ? revision.parentRevisionId !== null : revision.parentRevisionId !== previous?.id) ) - return reject({ code: "invalid_revision_chain", recovery: "retry" }); + return reject({ code: "invalid_revision_chain", recovery: "reread" }); ids.add(revision.id); approvingMessageKeys.add(approvingMessageKey); approvedProposalSources.add(approvedProposalSource); @@ -251,9 +302,14 @@ export function materializeAgentMapRevision( createdAt: input.createdAt, }); } catch { - return reject({ code: "invalid_revision_chain", recovery: "retry" }); + return reject({ code: "invalid_revision_chain", recovery: "reread" }); } - if (receipt.acceptedAt > revisionRef.createdAt) + // 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( diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index 4307980a9..6564c0b7c 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -430,7 +430,7 @@ export type ConfirmArchitectureFailure = | { code: "approval_message_reused"; recovery: "ask_again" } | { code: "request_id_reused"; recovery: "new_request" } | { code: "cross_project"; recovery: "reread" } - | { code: "invalid_revision_chain"; recovery: "retry" } + | { code: "invalid_revision_chain"; recovery: "reread" } | { code: "storage_unavailable"; recovery: "retry" }; export type ConfirmArchitectureErrorCode = ConfirmArchitectureFailure["code"]; From 1d97f86c8a7c24cbfc70632948198b872a29944f Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 3 Sep 2026 09:54:55 +0000 Subject: [PATCH 3/4] fix(harness): distinguish unrelated map races Carry both proposal sources through the confirmation boundary so unrelated operations do not invalidate an otherwise current confirmation. Refs: SAP-3062 --- .../src/core/agent-map-revision.test.ts | 24 +++++++++++++++-- .../harness/src/core/agent-map-revision.ts | 27 +++++++++++++------ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/harness/src/core/agent-map-revision.test.ts b/packages/harness/src/core/agent-map-revision.test.ts index a1725a5c4..4622ea739 100644 --- a/packages/harness/src/core/agent-map-revision.test.ts +++ b/packages/harness/src/core/agent-map-revision.test.ts @@ -598,8 +598,12 @@ describe("Agent Map confirmation retry boundary", () => { it.each([ [ - "proposal operation commits first", - { committedFirst: "proposal-operation" } as const, + "an exact-source proposal operation commits first", + { + committedFirst: "proposal-operation", + confirmedSource: { proposalId, version: 1 }, + operationSource: { proposalId, version: 1 }, + } as const, { confirmation: { outcome: "failed", @@ -608,6 +612,22 @@ describe("Agent Map confirmation retry boundary", () => { proposalOperation: "committed", }, ], + [ + "an unrelated proposal operation commits first", + { + committedFirst: "proposal-operation", + confirmedSource: { proposalId, version: 1 }, + operationSource: { + proposalId: + "proposal_018f0000-0000-7000-8000-000000000099" as MapProposalId, + version: 1, + }, + } as const, + { + confirmation: { outcome: "confirmed" }, + proposalOperation: "committed", + }, + ], [ "confirmation commits before an exact-source operation", { diff --git a/packages/harness/src/core/agent-map-revision.ts b/packages/harness/src/core/agent-map-revision.ts index 9bf250bee..296d2d681 100644 --- a/packages/harness/src/core/agent-map-revision.ts +++ b/packages/harness/src/core/agent-map-revision.ts @@ -46,7 +46,11 @@ export interface MaterializeAgentMapRevisionInput { } export type AgentMapConfirmationBoundaryInput = - | { committedFirst: "proposal-operation" } + | { + committedFirst: "proposal-operation"; + confirmedSource: { proposalId: MapProposalId; version: number }; + operationSource: { proposalId: MapProposalId; version: number }; + } | { committedFirst: "confirmation"; confirmedSource: { proposalId: MapProposalId; version: number }; @@ -66,7 +70,7 @@ export type AgentMapConfirmationBoundaryDecision = } | { confirmation: { outcome: "confirmed" }; - proposalOperation: "rebase-eligible" | "stale"; + proposalOperation: "committed" | "rebase-eligible" | "stale"; }; /** A bounded failure whose message never includes caller-controlled values. */ @@ -145,7 +149,15 @@ export function digestConfirmArchitectureRequest( export function classifyAgentMapConfirmationBoundary( input: AgentMapConfirmationBoundaryInput, ): AgentMapConfirmationBoundaryDecision { - if (input.committedFirst === "proposal-operation") + const operationTargetsConfirmedSource = + input.operationSource.proposalId === input.confirmedSource.proposalId && + input.operationSource.version === input.confirmedSource.version; + if (input.committedFirst === "proposal-operation") { + if (!operationTargetsConfirmedSource) + return { + confirmation: { outcome: "confirmed" }, + proposalOperation: "committed", + }; return { confirmation: { outcome: "failed", @@ -153,13 +165,12 @@ export function classifyAgentMapConfirmationBoundary( }, proposalOperation: "committed", }; + } return { confirmation: { outcome: "confirmed" }, - proposalOperation: - input.operationSource.proposalId === input.confirmedSource.proposalId && - input.operationSource.version === input.confirmedSource.version - ? "rebase-eligible" - : "stale", + proposalOperation: operationTargetsConfirmedSource + ? "rebase-eligible" + : "stale", }; } From 044c2664a8ac8bc94433f684fa85030671e75e10 Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 3 Sep 2026 10:00:11 +0000 Subject: [PATCH 4/4] fix(harness): preserve single-proposal race semantics Document that operation-first classification accepts only a transaction-validated operation against the one current proposal, while post-confirmation operations require exact-source rebasing. Refs: SAP-3062 --- .../src/core/agent-map-revision.test.ts | 24 ++----------- .../harness/src/core/agent-map-revision.ts | 35 ++++++++----------- 2 files changed, 16 insertions(+), 43 deletions(-) diff --git a/packages/harness/src/core/agent-map-revision.test.ts b/packages/harness/src/core/agent-map-revision.test.ts index 4622ea739..67ad12df7 100644 --- a/packages/harness/src/core/agent-map-revision.test.ts +++ b/packages/harness/src/core/agent-map-revision.test.ts @@ -598,12 +598,8 @@ describe("Agent Map confirmation retry boundary", () => { it.each([ [ - "an exact-source proposal operation commits first", - { - committedFirst: "proposal-operation", - confirmedSource: { proposalId, version: 1 }, - operationSource: { proposalId, version: 1 }, - } as const, + "the current proposal operation commits first", + { committedFirst: "proposal-operation" } as const, { confirmation: { outcome: "failed", @@ -612,22 +608,6 @@ describe("Agent Map confirmation retry boundary", () => { proposalOperation: "committed", }, ], - [ - "an unrelated proposal operation commits first", - { - committedFirst: "proposal-operation", - confirmedSource: { proposalId, version: 1 }, - operationSource: { - proposalId: - "proposal_018f0000-0000-7000-8000-000000000099" as MapProposalId, - version: 1, - }, - } as const, - { - confirmation: { outcome: "confirmed" }, - proposalOperation: "committed", - }, - ], [ "confirmation commits before an exact-source operation", { diff --git a/packages/harness/src/core/agent-map-revision.ts b/packages/harness/src/core/agent-map-revision.ts index 296d2d681..4c6a8319d 100644 --- a/packages/harness/src/core/agent-map-revision.ts +++ b/packages/harness/src/core/agent-map-revision.ts @@ -46,11 +46,7 @@ export interface MaterializeAgentMapRevisionInput { } export type AgentMapConfirmationBoundaryInput = - | { - committedFirst: "proposal-operation"; - confirmedSource: { proposalId: MapProposalId; version: number }; - operationSource: { proposalId: MapProposalId; version: number }; - } + | { committedFirst: "proposal-operation" } | { committedFirst: "confirmation"; confirmedSource: { proposalId: MapProposalId; version: number }; @@ -70,7 +66,7 @@ export type AgentMapConfirmationBoundaryDecision = } | { confirmation: { outcome: "confirmed" }; - proposalOperation: "committed" | "rebase-eligible" | "stale"; + proposalOperation: "rebase-eligible" | "stale"; }; /** A bounded failure whose message never includes caller-controlled values. */ @@ -143,21 +139,17 @@ export function digestConfirmArchitectureRequest( /** * Pure description of the confirmation transaction's linearization boundary. - * SAP-3063 owns the transaction and must still validate a rebase-eligible - * operation; this helper keeps only the source-ordering outcomes fixed. + * `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 { - const operationTargetsConfirmedSource = - input.operationSource.proposalId === input.confirmedSource.proposalId && - input.operationSource.version === input.confirmedSource.version; - if (input.committedFirst === "proposal-operation") { - if (!operationTargetsConfirmedSource) - return { - confirmation: { outcome: "confirmed" }, - proposalOperation: "committed", - }; + if (input.committedFirst === "proposal-operation") return { confirmation: { outcome: "failed", @@ -165,12 +157,13 @@ export function classifyAgentMapConfirmationBoundary( }, proposalOperation: "committed", }; - } return { confirmation: { outcome: "confirmed" }, - proposalOperation: operationTargetsConfirmedSource - ? "rebase-eligible" - : "stale", + proposalOperation: + input.operationSource.proposalId === input.confirmedSource.proposalId && + input.operationSource.version === input.confirmedSource.version + ? "rebase-eligible" + : "stale", }; }