Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/durable-build-planning-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@sapiom/harness": minor
---

Add durable, exact-source build-plan, focused-brief, planning-assignment, and builder-submission contracts with strict codecs, canonical digests, validation, and crash-atomic project history.

Expose a deliberate, transitively usable v1 handoff surface for hosts and downstream E4/E5 integrations.

Agent Map workspace files are migrated from aggregate storage schema v1 to v2 in place. The migration preserves existing architecture proposal history and receipts, but it is intentionally downgrade-incompatible: older `@sapiom/harness` versions cannot read a workspace after the v2 aggregate has been written.
33 changes: 26 additions & 7 deletions packages/harness/src/core/agent-map-proposal-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,31 @@ function applyOperations(
});
}

/**
* Materialize one exact proposal version from its immutable operation history.
* Shared with build planning so exact-source reads cannot drift from E2.
*/
export function materializeAgentMapProposalVersion(
base: AgentMapGraph,
proposal: MapChangeProposal | null,
version: number,
): AgentMapGraph {
if (!Number.isSafeInteger(version) || version < 0)
throw new RangeError("proposal version must be a non-negative integer");
if (!proposal) {
if (version !== 0) throw new RangeError("proposal version is unavailable");
return canonicalizeAgentMapGraph(base);
}
if (version > proposal.version)
throw new RangeError("proposal version is unavailable");
const operations: MapOperation[] = [];
for (const record of proposal.history) {
if (record.acceptedVersion > version) break;
operations.push(record.operation);
}
return applyOperations(base, operations);
}

function affectedFromTouchSets(
left: ProposalTouchSet,
right: ProposalTouchSet,
Expand Down Expand Up @@ -296,13 +321,7 @@ export class AgentMapProposalService {
proposal: MapChangeProposal | null,
version: number,
): AgentMapGraph {
if (!proposal || version === 0) return base;
const operations: MapOperation[] = [];
for (const record of proposal.history) {
if (record.acceptedVersion > version) break;
operations.push(record.operation);
}
return applyOperations(base, operations);
return materializeAgentMapProposalVersion(base, proposal, version);
}

/** History is authoritative; receipt retention cannot change stale conflicts. */
Expand Down
76 changes: 75 additions & 1 deletion packages/harness/src/core/agent-map-workspace-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";

import type { DraftRef, PlanningSessionIdentity } from "../shared/agent-map.js";
import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js";
import { emptyBuildPlanningAggregate } from "../shared/build-plan.js";
import { AgentMapProposalService } from "./agent-map-proposal-service.js";

const projectId = "project_00000000-0000-4000-8000-000000000001";

Expand Down Expand Up @@ -130,13 +133,84 @@ describe("AgentMapWorkspaceStore", () => {
new AgentMapWorkspaceStore(root).readOrCreate(projectId),
).resolves.toEqual(workspace);
expect(JSON.parse(await fs.readFile(workspacePath, "utf8"))).toEqual({
storageSchemaVersion: 1,
storageSchemaVersion: 2,
workspace,
proposal: null,
receipts: [],
buildPlanning: emptyBuildPlanningAggregate(),
});
});

it("migrates an E2 aggregate without changing architecture state", async () => {
const root = await fixture();
const workspacePath = path.join(
root,
"projects",
projectId,
"workspace.json",
);
const store = new AgentMapWorkspaceStore(root, {
now: () => new Date("2026-09-01T12:00:00.000Z"),
});
const service = new AgentMapProposalService(store, {
now: () => new Date("2026-09-01T12:01:00.000Z"),
});
const identity: PlanningSessionIdentity = {
projectId,
userId: "user-1",
sessionId: "session-1",
role: "map-planner",
};
await service.propose(identity, {
schemaVersion: 1,
proposalId: null,
expectedVersion: 0,
requestId: "request-1",
operations: [
{
kind: "add-node",
draftRef: "draft-1" as DraftRef,
node: {
kind: "agent",
name: "Builder",
purpose: "Build the system",
ownerAgent: null,
contractRefs: ["contract-1"],
},
},
],
});
const current = JSON.parse(await fs.readFile(workspacePath, "utf8")) as {
workspace: unknown;
proposal: { history: unknown[] };
receipts: unknown[];
};
const legacy = {
storageSchemaVersion: 1,
workspace: current.workspace,
proposal: current.proposal,
receipts: current.receipts,
};
await fs.writeFile(workspacePath, `${JSON.stringify(legacy)}\n`);

const aggregate = await new AgentMapWorkspaceStore(root).readAggregate(
projectId,
);

expect(aggregate).toEqual({
storageSchemaVersion: 2,
workspace: current.workspace,
proposal: current.proposal,
receipts: current.receipts,
buildPlanning: emptyBuildPlanningAggregate(),
});
expect(current.proposal.history).toHaveLength(1);
expect(current.receipts).toHaveLength(1);
expect(JSON.parse(await fs.readFile(workspacePath, "utf8"))).toEqual(
aggregate,
);
});

it("rejects future aggregate schemas without rewriting them", async () => {
const root = await fixture();
const workspacePath = path.join(
Expand Down
Loading
Loading