Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/immutable-project-version-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": minor
---

Publish immutable Agent Map, build-plan and brief contracts with strict codecs and canonical digest helpers. The public types, exact-reference helpers, codecs and digest functions support offline contract validation independently of later storage and tool activation; documented compatibility aliases remain supported.
14 changes: 14 additions & 0 deletions packages/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,20 @@ current owners and durable historical aliases cannot be adopted into another
session. Duplicate persisted provider IDs are repaired conservatively during
boot, preserving the first owner and clearing the later duplicate pointer.

### Project contract helpers

`@sapiom/harness` exports immutable map, plan and brief record types, exact-version
references, strict codecs and canonical digest helpers for offline validation.
For example, use `parseProjectBuildPlanVersion` to validate a plan record and
`computeBuildPlanSemanticDigest` to compare its authored meaning independently
of timestamps or attribution. These data contracts do not require a live session
or an active MCP tool. Store and tool activation are separate integrations.

`BuildPlanId`, `ArchitectureSourceRef`, `AgentMapRevisionId`,
`AgentBriefVersionRecord`, and `computeArchitectureGraphDigest` are supported
aliases for the corresponding neutral plan, map and brief contracts; they do
not introduce a second data model.

### Agent Map MCP

Studio exposes a stateful Streamable HTTP MCP endpoint at `/mcp/agent-map` for
Expand Down
29 changes: 7 additions & 22 deletions packages/harness/src/core/agent-map-proposal-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import type {
ProposalValidationResult,
RelationshipKind,
} from "../shared/agent-map.js";
import {
canonicalizeAgentMapGraph,
compareCanonicalStrings,
} from "../shared/agent-map-canonical.js";

export { canonicalizeAgentMapGraph } from "../shared/agent-map-canonical.js";

const ACTOR_KINDS = new Set<PlanNodeKind>(["agent", "subagent"]);
const ALL_NODE_KINDS = new Set<PlanNodeKind>([
Expand Down Expand Up @@ -97,8 +103,7 @@ 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 compareStrings = compareCanonicalStrings;

const canonicalStrings = (values: readonly string[]): string[] =>
[...values].sort(compareStrings);
Expand All @@ -110,26 +115,6 @@ const stripUndefinedProperties = <T extends Record<string, unknown>>(
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 function semanticRelationshipKey(
relationship: Pick<
PlanRelationship,
Expand Down
37 changes: 37 additions & 0 deletions packages/harness/src/core/agent-map-version-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { AgentMapVersion, AgentMapVersionRef, StudioProjectId } from "../shared/agent-map.js";
import { validateAgentMapVersionHistory } from "./agent-map-version.js";

export class AgentMapVersionResolutionError extends Error {
constructor(readonly code: "version_not_found" | "source_mismatch" | "cross_project_reference") {
super(code.replace(/_/gu, " "));
this.name = "AgentMapVersionResolutionError";
}
}

export class AgentMapVersionResolver {
constructor(
private readonly projectId: StudioProjectId,
private readonly versions: readonly AgentMapVersion[],
private readonly current: AgentMapVersionRef | null,
) {
validateAgentMapVersionHistory(versions, projectId);
const tail = versions.at(-1);
if ((tail === undefined) !== (current === null) || (tail && current && (
tail.projectId !== current.projectId ||
tail.versionId !== current.versionId ||
tail.contentDigest !== current.contentDigest
))) throw new AgentMapVersionResolutionError("source_mismatch");
}

readCurrent(): AgentMapVersion | null {
return this.current ? this.readExact(this.current) : null;
}

readExact(ref: AgentMapVersionRef): AgentMapVersion {
if (ref.projectId !== this.projectId) throw new AgentMapVersionResolutionError("cross_project_reference");
const version = this.versions.find(({ versionId }) => versionId === ref.versionId);
if (!version) throw new AgentMapVersionResolutionError("version_not_found");
if (version.contentDigest !== ref.contentDigest) throw new AgentMapVersionResolutionError("source_mismatch");
return structuredClone(version);
}
}
170 changes: 170 additions & 0 deletions packages/harness/src/core/agent-map-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";

import type {
AgentMapVersion,
AgentMapVersionId,
PlanNode,
PlanNodeId,
ProposalOperationId,
StudioProjectId,
} from "../shared/agent-map.js";
import {
agentMapVersionRef,
appendRestoredAgentMapVersion,
createAgentMapVersion,
validateAgentMapVersionHistory,
} from "./agent-map-version.js";
import { computeAgentMapVersionRecordDigest } from "../shared/agent-map-canonical.js";
import { AgentMapVersionResolver } from "./agent-map-version-resolver.js";

const projectId = "project_018f0000-0000-4000-8000-000000000001" as StudioProjectId;
const actor = { userId: "user", sessionId: "session" };
const at = "2026-01-02T03:04:05.000Z";
const origin = (digit: string) => ({
kind: "request" as const,
requestDigest: `sha256:${digit.repeat(64)}`,
operationIds: [`operation_018f0000-0000-7000-8000-00000000000${digit}` as ProposalOperationId],
touchKeys: [`node:node-${digit}`],
});
const node = (name: string): PlanNode => ({
id: "node_018f0000-0000-7000-8000-000000000010" as PlanNodeId,
kind: "agent",
name,
purpose: "Research stocks",
ownerAgentId: null,
contractRefs: [],
});
const versionId = (suffix: string) =>
`mapv_018f0000-0000-7000-8000-0000000000${suffix}` as AgentMapVersionId;

describe("immutable Agent Map versions", () => {
it("resolves current and exact history while rejecting cross-project and digest-mismatched refs", () => {
const first = createAgentMapVersion({
projectId,
versionId: versionId("20"),
version: 1,
parentVersionId: null,
graph: { nodes: [node("Research")], relationships: [] },
changeKind: "created",
restoredFromVersionId: null,
authoredBy: actor,
createdAt: at,
origin: origin("1"),
});
const second = createAgentMapVersion({
projectId,
versionId: versionId("21"),
version: 2,
parentVersionId: first.versionId,
graph: { nodes: [node("Market Research")], relationships: [] },
changeKind: "edited",
restoredFromVersionId: null,
authoredBy: actor,
createdAt: "2026-01-02T03:05:05.000Z",
origin: origin("2"),
});
const resolver = new AgentMapVersionResolver(projectId, [first, second], agentMapVersionRef(second));
expect(resolver.readCurrent()).toEqual(second);
expect(resolver.readExact(agentMapVersionRef(first))).toEqual(first);
expect(() => resolver.readExact({ ...agentMapVersionRef(first), contentDigest: second.contentDigest }))
.toThrowError(expect.objectContaining({ code: "source_mismatch" }));
expect(() => resolver.readExact({
...agentMapVersionRef(first),
projectId: "project_018f0000-0000-4000-8000-000000000002" as StudioProjectId,
})).toThrowError(expect.objectContaining({ code: "cross_project_reference" }));
});

it("restores by appending a new child with copied semantics and explicit provenance", () => {
const first = createAgentMapVersion({
projectId,
versionId: versionId("20"),
version: 1,
parentVersionId: null,
graph: { nodes: [node("Research")], relationships: [] },
changeKind: "created",
restoredFromVersionId: null,
authoredBy: actor,
createdAt: at,
origin: origin("1"),
});
const second = createAgentMapVersion({
projectId,
versionId: versionId("21"),
version: 2,
parentVersionId: first.versionId,
graph: { nodes: [node("Market Research")], relationships: [] },
changeKind: "edited",
restoredFromVersionId: null,
authoredBy: actor,
createdAt: "2026-01-02T03:05:05.000Z",
origin: origin("2"),
});
const restored = appendRestoredAgentMapVersion({
projectId,
versions: [first, second],
expectedCurrent: agentMapVersionRef(second),
historical: agentMapVersionRef(first),
versionId: versionId("22"),
actor: { userId: "restorer", sessionId: "restore-session" },
createdAt: "2026-01-02T03:06:05.000Z",
origin: origin("3"),
});
expect(restored).toMatchObject({
version: 3,
parentVersionId: second.versionId,
changeKind: "restored",
restoredFromVersionId: first.versionId,
graph: first.graph,
contentDigest: first.contentDigest,
authoredBy: { userId: "restorer", sessionId: "restore-session" },
});
expect(restored.recordDigest).not.toBe(first.recordDigest);
expect(() => validateAgentMapVersionHistory([first, second, restored], projectId)).not.toThrow();
});

it.each(["skipped version", "repointed parent", "duplicate version id", "forged record digest", "unknown restore source"])(
"rejects history with %s", (corruption) => {
const first = createAgentMapVersion({ projectId, versionId: versionId("20"), version: 1,
parentVersionId: null, graph: { nodes: [node("Research")], relationships: [] }, changeKind: "created",
restoredFromVersionId: null, authoredBy: actor, createdAt: at, origin: origin("1") });
const second = createAgentMapVersion({ projectId, versionId: versionId("21"), version: 2,
parentVersionId: first.versionId, graph: { nodes: [node("Updated")], relationships: [] }, changeKind: "edited",
restoredFromVersionId: null, authoredBy: actor, createdAt: at, origin: origin("2") });
const changes = corruption === "skipped version" ? { version: 3 }
: corruption === "repointed parent" ? { parentVersionId: versionId("99") }
: corruption === "duplicate version id" ? { versionId: first.versionId }
: corruption === "unknown restore source" ? { changeKind: "restored" as const, restoredFromVersionId: versionId("99") }
: {};
const changed = { ...second, ...changes } as AgentMapVersion;
const corrupted = { ...changed, recordDigest: corruption === "forged record digest"
? `sha256:${"0".repeat(64)}` as AgentMapVersion["recordDigest"] : computeAgentMapVersionRecordDigest(changed) };
expect(() => validateAgentMapVersionHistory([first, corrupted], projectId)).toThrow();
},
);

it("rejects invalid graph topology", () => {
expect(() => createAgentMapVersion({
projectId,
versionId: versionId("20"),
version: 1,
parentVersionId: null,
graph: {
nodes: [node("Research")],
relationships: [{
id: "rel_018f0000-0000-7000-8000-000000000030" as never,
fromNodeId: node("Research").id,
toNodeId: node("Research").id,
kind: "invokes",
executionMode: null,
contractRef: null,
description: "self",
}],
},
changeKind: "created",
restoredFromVersionId: null,
authoredBy: actor,
createdAt: at,
origin: origin("1"),
})).toThrow(/relationship/u);
});
});
Loading
Loading