From 4223343950adadc95eeaa50b9ca1637428caa327 Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 3 Sep 2026 09:41:48 +0000 Subject: [PATCH 1/4] feat(harness): add durable build planning contracts Define strict versioned plan, brief, assignment, and submission records over exact Agent Map sources. Add canonical semantic and record digests, aggregate migration and integrity checks, exact-source resolution, validators, and crash-atomic persistence primitives. Closes: SAP-3067 --- .../src/core/agent-map-proposal-service.ts | 33 +- .../core/agent-map-workspace-store.test.ts | 44 +- .../src/core/agent-map-workspace-store.ts | 143 +++- .../core/architecture-source-resolver.test.ts | 115 +++ .../src/core/architecture-source-resolver.ts | 95 +++ .../core/build-plan-canonicalization.test.ts | 100 +++ .../src/core/build-plan-canonicalization.ts | 226 ++++++ .../build-plan-contract-validator.test.ts | 71 ++ .../src/core/build-plan-contract-validator.ts | 375 +++++++++ .../harness/src/core/build-plan-store.test.ts | 237 ++++++ packages/harness/src/core/build-plan-store.ts | 419 ++++++++++ .../src/core/build-plan.test-support.ts | 155 ++++ packages/harness/src/index.ts | 6 + .../src/shared/build-plan-codec.test.ts | 52 ++ .../harness/src/shared/build-plan-codec.ts | 755 ++++++++++++++++++ packages/harness/src/shared/build-plan.ts | 404 ++++++++++ packages/harness/tsconfig.build.json | 2 +- 17 files changed, 3194 insertions(+), 38 deletions(-) create mode 100644 packages/harness/src/core/architecture-source-resolver.test.ts create mode 100644 packages/harness/src/core/architecture-source-resolver.ts create mode 100644 packages/harness/src/core/build-plan-canonicalization.test.ts create mode 100644 packages/harness/src/core/build-plan-canonicalization.ts create mode 100644 packages/harness/src/core/build-plan-contract-validator.test.ts create mode 100644 packages/harness/src/core/build-plan-contract-validator.ts create mode 100644 packages/harness/src/core/build-plan-store.test.ts create mode 100644 packages/harness/src/core/build-plan-store.ts create mode 100644 packages/harness/src/core/build-plan.test-support.ts create mode 100644 packages/harness/src/shared/build-plan-codec.test.ts create mode 100644 packages/harness/src/shared/build-plan-codec.ts create mode 100644 packages/harness/src/shared/build-plan.ts diff --git a/packages/harness/src/core/agent-map-proposal-service.ts b/packages/harness/src/core/agent-map-proposal-service.ts index 10308a737..166431945 100644 --- a/packages/harness/src/core/agent-map-proposal-service.ts +++ b/packages/harness/src/core/agent-map-proposal-service.ts @@ -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, @@ -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. */ diff --git a/packages/harness/src/core/agent-map-workspace-store.test.ts b/packages/harness/src/core/agent-map-workspace-store.test.ts index becef8a12..1958e95a8 100644 --- a/packages/harness/src/core/agent-map-workspace-store.test.ts +++ b/packages/harness/src/core/agent-map-workspace-store.test.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; +import { emptyBuildPlanningAggregate } from "../shared/build-plan.js"; const projectId = "project_00000000-0000-4000-8000-000000000001"; @@ -130,13 +131,54 @@ 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 workspace = { + projectId, + schemaVersion: 1, + recordVersion: 3, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + createdAt: "2026-09-01T12:00:00.000Z", + updatedAt: "2026-09-01T12:01:00.000Z", + }; + await fs.mkdir(path.dirname(workspacePath), { recursive: true }); + await fs.writeFile( + workspacePath, + `${JSON.stringify({ storageSchemaVersion: 1, workspace, proposal: null, receipts: [] })}\n`, + ); + + const aggregate = await new AgentMapWorkspaceStore(root).readAggregate( + projectId, + ); + + expect(aggregate).toEqual({ + storageSchemaVersion: 2, + workspace, + proposal: null, + receipts: [], + buildPlanning: emptyBuildPlanningAggregate(), + }); + 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( diff --git a/packages/harness/src/core/agent-map-workspace-store.ts b/packages/harness/src/core/agent-map-workspace-store.ts index ef78d0e02..6fc15e907 100644 --- a/packages/harness/src/core/agent-map-workspace-store.ts +++ b/packages/harness/src/core/agent-map-workspace-store.ts @@ -15,10 +15,22 @@ import { parseMapChangeProposal, type PersistedAgentMapProposalReceipt, } from "../shared/agent-map-codec.js"; +import { + emptyBuildPlanningAggregate, + type BuildPlanningAggregateV1, +} from "../shared/build-plan.js"; +import { parseBuildPlanningAggregate } from "../shared/build-plan-codec.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeBuildPlanRecordDigest, + computeBuildPlanSemanticDigest, + computePlanningSubmissionSemanticDigest, +} from "./build-plan-canonicalization.js"; import { DurableFileLock } from "./durable-file-lock.js"; import { isStudioProjectId } from "./studio-project-catalog.js"; -export const AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION = 1; +export const AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION = 2; export type AgentMapProposalReceipt = PersistedAgentMapProposalReceipt; @@ -27,6 +39,7 @@ export interface AgentMapProjectAggregate { workspace: AgentMapWorkspaceState; proposal: MapChangeProposal | null; receipts: AgentMapProposalReceipt[]; + buildPlanning: BuildPlanningAggregateV1; } export interface AgentMapStoreSnapshot { @@ -142,31 +155,11 @@ export function parseAgentMapWorkspaceState( const storageError = () => new AgentMapWorkspaceStoreError("storage_unavailable"); -function parseAggregate( +function parseArchitectureFields( value: unknown, projectId: StudioProjectId, -): AgentMapProjectAggregate { - if ( - isRecord(value) && - Number.isSafeInteger(value.storageSchemaVersion) && - (value.storageSchemaVersion as number) > - AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION - ) - throw new AgentMapWorkspaceStoreError( - "unsupported_schema", - value.storageSchemaVersion as number, - ); - if ( - !isRecord(value) || - !hasExactKeys(value, [ - "storageSchemaVersion", - "workspace", - "proposal", - "receipts", - ]) || - value.storageSchemaVersion !== AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION || - !Array.isArray(value.receipts) - ) +): Pick { + if (!isRecord(value) || !Array.isArray(value.receipts)) throw new AgentMapWorkspaceStoreError("malformed_state"); const workspace = parseAgentMapWorkspaceState(value.workspace, projectId); let proposal: MapChangeProposal | null = null; @@ -228,12 +221,96 @@ function parseAggregate( ).size !== receipts.length ) throw new AgentMapWorkspaceStoreError("malformed_state"); + return { workspace, proposal, receipts }; +} + +function parseAggregate( + value: unknown, + projectId: StudioProjectId, +): AgentMapProjectAggregate { + if ( + isRecord(value) && + Number.isSafeInteger(value.storageSchemaVersion) && + (value.storageSchemaVersion as number) > + AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION + ) + throw new AgentMapWorkspaceStoreError( + "unsupported_schema", + value.storageSchemaVersion as number, + ); + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "storageSchemaVersion", + "workspace", + "proposal", + "receipts", + "buildPlanning", + ]) || + value.storageSchemaVersion !== AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION + ) + throw new AgentMapWorkspaceStoreError("malformed_state"); + const architecture = parseArchitectureFields(value, projectId); + let buildPlanning: BuildPlanningAggregateV1; + try { + buildPlanning = parseBuildPlanningAggregate(value.buildPlanning, projectId); + } catch { + throw new AgentMapWorkspaceStoreError("malformed_state"); + } + if ( + buildPlanning.planVersions.some( + (plan) => + computeBuildPlanSemanticDigest(plan) !== plan.semanticDigest || + computeBuildPlanRecordDigest(plan) !== plan.recordDigest, + ) || + Object.values(buildPlanning.briefVersionsById) + .flat() + .some( + (brief) => + computeAgentBriefSemanticDigest(brief) !== brief.semanticDigest || + computeAgentBriefRecordDigest(brief) !== brief.recordDigest, + ) || + Object.values(buildPlanning.submissionsByAssignmentId) + .flat() + .some( + (submission) => + computePlanningSubmissionSemanticDigest(submission) !== + submission.semanticDigest, + ) + ) + throw new AgentMapWorkspaceStoreError("malformed_state"); + if (architecture.workspace.projectBuildPlanId !== buildPlanning.planId) + throw new AgentMapWorkspaceStoreError("malformed_state"); return structuredClone({ storageSchemaVersion: AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, - workspace, - proposal, - receipts, - }) as AgentMapProjectAggregate; + ...architecture, + buildPlanning, + }); +} + +function migrateAggregateV1( + value: unknown, + projectId: StudioProjectId, +): AgentMapProjectAggregate { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "storageSchemaVersion", + "workspace", + "proposal", + "receipts", + ]) || + value.storageSchemaVersion !== 1 + ) + throw new AgentMapWorkspaceStoreError("malformed_state"); + const architecture = parseArchitectureFields(value, projectId); + if (architecture.workspace.projectBuildPlanId !== null) + throw new AgentMapWorkspaceStoreError("malformed_state"); + return { + storageSchemaVersion: AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, + ...architecture, + buildPlanning: emptyBuildPlanningAggregate(), + }; } /** Crash-atomic owner of workspace, active proposal, history, and private receipts. */ @@ -285,6 +362,7 @@ export class AgentMapWorkspaceStore { }, proposal: null, receipts: [], + buildPlanning: emptyBuildPlanningAggregate(), }; } @@ -313,16 +391,23 @@ export class AgentMapWorkspaceStore { const workspace = parseAgentMapWorkspaceState(decoded, projectId); return { aggregate: { - storageSchemaVersion: 1, + storageSchemaVersion: AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, workspace, proposal: null, receipts: [], + buildPlanning: emptyBuildPlanningAggregate(), }, needsWrite: true, created: false, }; } catch (error) { if (isRecord(decoded) && "storageSchemaVersion" in decoded) { + if (decoded.storageSchemaVersion === 1) + return { + aggregate: migrateAggregateV1(decoded, projectId), + needsWrite: true, + created: false, + }; return { aggregate: parseAggregate(decoded, projectId), needsWrite: false, diff --git a/packages/harness/src/core/architecture-source-resolver.test.ts b/packages/harness/src/core/architecture-source-resolver.test.ts new file mode 100644 index 000000000..a447cb977 --- /dev/null +++ b/packages/harness/src/core/architecture-source-resolver.test.ts @@ -0,0 +1,115 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { DraftRef, PlanningSessionIdentity } from "../shared/agent-map.js"; +import type { AgentMapRevisionId, GraphDigest } from "../shared/build-plan.js"; +import { ArchitectureSourceResolver } from "./architecture-source-resolver.js"; +import { AgentMapProposalService } from "./agent-map-proposal-service.js"; +import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; +import { computeArchitectureGraphDigest } from "./build-plan-canonicalization.js"; +import { PROJECT_ID } from "./build-plan.test-support.js"; + +describe("ArchitectureSourceResolver", () => { + const roots: string[] = []; + afterEach(async () => + Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ), + ); + + it("resolves the exact historical proposal version and verifies its digest", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "source-resolver-")); + roots.push(root); + const store = new AgentMapWorkspaceStore(root); + const service = new AgentMapProposalService(store); + const identity: PlanningSessionIdentity = { + projectId: PROJECT_ID, + userId: "user-1", + sessionId: "session-1", + role: "map-planner", + }; + const created = 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: "Original", + purpose: "Build", + ownerAgent: null, + contractRefs: [], + }, + }, + ], + }); + const versionOneGraph = (await store.readAggregate(PROJECT_ID)).proposal!; + const graph = { + nodes: versionOneGraph.nodes, + relationships: versionOneGraph.relationships, + }; + const nodeId = created.allocatedNodeIds["draft-1" as DraftRef]!; + await service.propose(identity, { + schemaVersion: 1, + proposalId: created.proposalId, + expectedVersion: 1, + requestId: "request-2", + operations: [ + { kind: "update-node", nodeId, changes: { name: "Changed" } }, + ], + }); + const source = { + kind: "proposal" as const, + proposalId: created.proposalId, + version: 1, + graphDigest: computeArchitectureGraphDigest(graph), + }; + + const resolved = await new ArchitectureSourceResolver(store).resolve( + PROJECT_ID, + source, + ); + + expect(resolved.graph.nodes[0]?.name).toBe("Original"); + await expect( + new ArchitectureSourceResolver(store).resolve(PROJECT_ID, { + ...source, + graphDigest: `sha256:${"f".repeat(64)}` as GraphDigest, + }), + ).rejects.toMatchObject({ code: "source_digest_mismatch" }); + }); + + it("resolves revisions exactly and rejects cross-project snapshots", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "source-resolver-")); + roots.push(root); + const revisionId = + "revision_00000000-0000-7000-8000-000000000006" as AgentMapRevisionId; + const graph = { nodes: [], relationships: [] }; + const resolver = new ArchitectureSourceResolver( + new AgentMapWorkspaceStore(root), + async () => ({ + projectId: "project_00000000-0000-4000-8000-000000000009", + revisionId, + revisionNumber: 2, + graph, + }), + ); + + await expect( + resolver.resolve(PROJECT_ID, { + kind: "revision", + revisionId, + revisionNumber: 2, + graphDigest: computeArchitectureGraphDigest(graph), + }), + ).rejects.toMatchObject({ code: "cross_project" }); + }); +}); diff --git a/packages/harness/src/core/architecture-source-resolver.ts b/packages/harness/src/core/architecture-source-resolver.ts new file mode 100644 index 000000000..86114de6c --- /dev/null +++ b/packages/harness/src/core/architecture-source-resolver.ts @@ -0,0 +1,95 @@ +import type { AgentMapGraph, StudioProjectId } from "../shared/agent-map.js"; +import type { + AgentMapRevisionId, + ArchitectureSourceRef, +} from "../shared/build-plan.js"; +import { parseArchitectureSourceRef } from "../shared/build-plan-codec.js"; +import { materializeAgentMapProposalVersion } from "./agent-map-proposal-service.js"; +import { canonicalizeAgentMapGraph } from "./agent-map-proposal-validator.js"; +import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; +import { computeArchitectureGraphDigest } from "./build-plan-canonicalization.js"; + +export interface AgentMapRevisionSnapshot { + projectId: StudioProjectId; + revisionId: AgentMapRevisionId; + revisionNumber: number; + graph: AgentMapGraph; +} + +export interface ResolvedArchitectureSource { + projectId: StudioProjectId; + source: ArchitectureSourceRef; + graph: AgentMapGraph; +} + +export type ArchitectureSourceResolutionErrorCode = + | "source_not_found" + | "source_digest_mismatch" + | "cross_project"; + +export class ArchitectureSourceResolutionError extends Error { + constructor(readonly code: ArchitectureSourceResolutionErrorCode) { + super( + code === "source_not_found" + ? "Architecture source was not found" + : code === "source_digest_mismatch" + ? "Architecture source digest does not match" + : "Architecture source belongs to another project", + ); + this.name = "ArchitectureSourceResolutionError"; + } +} + +/** Exact-only resolver: the API intentionally has no current/latest overload. */ +export class ArchitectureSourceResolver { + constructor( + private readonly store: AgentMapWorkspaceStore, + private readonly readRevision: ( + revisionId: AgentMapRevisionId, + ) => Promise = async () => null, + ) {} + + async resolve( + projectId: StudioProjectId, + input: ArchitectureSourceRef, + ): Promise { + const source = parseArchitectureSourceRef(input); + let graph: AgentMapGraph; + if (source.kind === "revision") { + const revision = await this.readRevision(source.revisionId); + if (!revision || revision.revisionNumber !== source.revisionNumber) + throw new ArchitectureSourceResolutionError("source_not_found"); + if (revision.projectId !== projectId) + throw new ArchitectureSourceResolutionError("cross_project"); + graph = canonicalizeAgentMapGraph(revision.graph); + } else { + const aggregate = await this.store.readAggregate(projectId); + const proposal = aggregate.proposal; + if ( + !proposal || + proposal.id !== source.proposalId || + source.version > proposal.version + ) + throw new ArchitectureSourceResolutionError("source_not_found"); + let base: AgentMapGraph = { nodes: [], relationships: [] }; + if (proposal.baseRevisionId !== null) { + const revision = await this.readRevision( + proposal.baseRevisionId as AgentMapRevisionId, + ); + if (!revision) + throw new ArchitectureSourceResolutionError("source_not_found"); + if (revision.projectId !== projectId) + throw new ArchitectureSourceResolutionError("cross_project"); + base = revision.graph; + } + graph = materializeAgentMapProposalVersion( + canonicalizeAgentMapGraph(base), + proposal, + source.version, + ); + } + if (computeArchitectureGraphDigest(graph) !== source.graphDigest) + throw new ArchitectureSourceResolutionError("source_digest_mismatch"); + return { projectId, source: structuredClone(source), graph }; + } +} diff --git a/packages/harness/src/core/build-plan-canonicalization.test.ts b/packages/harness/src/core/build-plan-canonicalization.test.ts new file mode 100644 index 000000000..8f070402f --- /dev/null +++ b/packages/harness/src/core/build-plan-canonicalization.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentMapRevisionId } from "../shared/build-plan.js"; +import { makeBrief, makePlan } from "./build-plan.test-support.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeBuildPlanRecordDigest, + computeBuildPlanSemanticDigest, +} from "./build-plan-canonicalization.js"; + +describe("build planning canonical digests", () => { + it("matches the versioned golden digest vectors", () => { + const plan = makePlan(); + const brief = makeBrief(plan); + + expect(plan.semanticDigest).toBe( + "sha256:93e773caa817b2dd7127a347067f679eb67b2ee1930285ae535a4a0df8a84770", + ); + expect(plan.recordDigest).toBe( + "sha256:c1cebc5b437ab52c744b2fe058264510a26e11fe02c24d048c9e6ad52844325d", + ); + expect(brief.semanticDigest).toBe( + "sha256:b017596fdf7600bd1a5d3637399776dca020c0da3bd1d4a59036a09179a38994", + ); + expect(brief.recordDigest).toBe( + "sha256:c96971676b99b99d2a2b0fe1c5f277796512f853494d15f6d3b504b931cb9cf5", + ); + }); + + it("separates semantic identity from record/source metadata", () => { + const proposal = makePlan(); + const rebound = makePlan({ + changeKind: "source-rebound", + source: { + kind: "revision", + revisionId: + "revision_00000000-0000-7000-8000-000000000006" as AgentMapRevisionId, + revisionNumber: 1, + graphDigest: proposal.source.graphDigest, + }, + authoredBy: { + userId: "user-2", + sessionId: "session-2", + role: "map-planner", + }, + createdAt: "2026-09-03T10:00:00.000Z", + }); + + expect(computeBuildPlanSemanticDigest(rebound)).toBe( + proposal.semanticDigest, + ); + expect(computeBuildPlanRecordDigest(rebound)).not.toBe( + proposal.recordDigest, + ); + }); + + it("changes semantic digests for meaning and preserves set ordering", () => { + const plan = makePlan(); + const reordered = makePlan({ + assignments: [ + { + ...plan.assignments[0]!, + scope: { inScope: ["B", "A"], nonGoals: ["Y", "X"] }, + }, + ], + }); + const sameReordered = makePlan({ + assignments: [ + { + ...plan.assignments[0]!, + scope: { inScope: ["A", "B"], nonGoals: ["X", "Y"] }, + }, + ], + }); + const edited = makePlan({ outcome: { summary: "A different outcome" } }); + + expect(reordered.semanticDigest).toBe(sameReordered.semanticDigest); + expect(edited.semanticDigest).not.toBe(plan.semanticDigest); + }); + + it("keeps brief semantics stable across exact source-only rebinding", () => { + const plan = makePlan(); + const brief = makeBrief(plan); + const rebound = { + ...brief, + source: { + kind: "revision" as const, + revisionId: + "revision_00000000-0000-7000-8000-000000000006" as AgentMapRevisionId, + revisionNumber: 1, + graphDigest: brief.source.graphDigest, + }, + createdAt: "2026-09-03T10:00:00.000Z", + }; + + expect(computeAgentBriefSemanticDigest(rebound)).toBe(brief.semanticDigest); + expect(computeAgentBriefRecordDigest(rebound)).not.toBe(brief.recordDigest); + }); +}); diff --git a/packages/harness/src/core/build-plan-canonicalization.ts b/packages/harness/src/core/build-plan-canonicalization.ts new file mode 100644 index 000000000..0b0960f08 --- /dev/null +++ b/packages/harness/src/core/build-plan-canonicalization.ts @@ -0,0 +1,226 @@ +import { createHash } from "node:crypto"; + +import type { AgentMapGraph } from "../shared/agent-map.js"; +import type { + AgentBriefSemanticDigest, + AgentBriefVersionRecord, + BuildPlanSemanticDigest, + BuilderPlanningSubmission, + GraphDigest, + PlanningSubmissionDigest, + ProjectBuildPlanVersion, + RecordDigest, +} from "../shared/build-plan.js"; +import { canonicalizeAgentMapGraph } from "./agent-map-proposal-validator.js"; + +const compare = (left: string, right: string) => + left < right ? -1 : left > right ? 1 : 0; +const by = (value: readonly T[], key: (entry: T) => string): T[] => + [...value].sort((left, right) => compare(key(left), key(right))); +const ordered = (value: readonly T[]): T[] => + [...value].sort((left, right) => left.ordinal - right.ordinal); +const lines = (value: string): string => value.replace(/\r\n?/gu, "\n"); +const omit = ( + value: T, + keys: readonly K[], +): Omit => + Object.fromEntries( + Object.entries(value).filter(([key]) => !keys.includes(key as K)), + ) as Omit; + +function canonicalValue(value: unknown): unknown { + if (typeof value === "string") return lines(value); + if (Array.isArray(value)) return value.map(canonicalValue); + if (typeof value === "object" && value !== null) + return Object.fromEntries( + Object.entries(value) + .filter(([, field]) => field !== undefined) + .sort(([left], [right]) => compare(left, right)) + .map(([key, field]) => [key, canonicalValue(field)]), + ); + return value; +} + +export const canonicalJson = (value: unknown): string => + JSON.stringify(canonicalValue(value)); + +const hash = (domain: string, value: unknown): string => + `sha256:${createHash("sha256") + .update(domain) + .update("\0") + .update(canonicalJson(value)) + .digest("hex")}`; + +const decision = (entries: readonly T[]) => + by(entries, (entry) => entry.decisionId); +const constraints = ( + entries: readonly T[], +) => by(entries, (entry) => entry.constraintId); +const deliverables = < + T extends { + deliverableId: string; + artifactNodeIds: readonly string[]; + acceptanceCriterionIds: readonly string[]; + }, +>( + entries: readonly T[], +) => + by(entries, (entry) => entry.deliverableId).map((entry) => ({ + ...entry, + artifactNodeIds: [...entry.artifactNodeIds].sort(compare), + acceptanceCriterionIds: [...entry.acceptanceCriterionIds].sort(compare), + })); + +export function buildPlanSemanticProjection(plan: ProjectBuildPlanVersion) { + return { + schemaVersion: plan.schemaVersion, + projectId: plan.projectId, + planId: plan.planId, + outcome: plan.outcome, + milestones: ordered(plan.milestones).map((milestone) => ({ + ...milestone, + dependsOn: [...milestone.dependsOn].sort(compare), + })), + sharedConstraints: constraints(plan.sharedConstraints), + repositoryIntents: by( + plan.repositoryIntents, + (entry) => entry.repositoryIntentId, + ), + integrationCriteria: ordered(plan.integrationCriteria), + assignments: by(plan.assignments, (entry) => entry.plannedAgentId).map( + (assignment) => ({ + ...assignment, + scope: { + inScope: [...assignment.scope.inScope].sort(compare), + nonGoals: [...assignment.scope.nonGoals].sort(compare), + }, + deliverables: deliverables(assignment.deliverables), + constraints: constraints(assignment.constraints), + acceptanceCriteria: ordered(assignment.acceptanceCriteria), + milestoneIds: [...assignment.milestoneIds].sort(compare), + unresolvedDecisions: decision(assignment.unresolvedDecisions), + }), + ), + unresolvedDecisions: decision(plan.unresolvedDecisions), + }; +} + +export const computeBuildPlanSemanticDigest = ( + plan: ProjectBuildPlanVersion, +): BuildPlanSemanticDigest => + hash( + "sapiom.build-plan.semantic.v1", + buildPlanSemanticProjection(plan), + ) as BuildPlanSemanticDigest; + +export const computeBuildPlanRecordDigest = ( + plan: ProjectBuildPlanVersion, +): RecordDigest => + hash( + "sapiom.build-plan.record.v1", + omit(plan, ["recordDigest"]), + ) as RecordDigest; + +export function agentBriefSemanticProjection(brief: AgentBriefVersionRecord) { + return { + schemaVersion: brief.schemaVersion, + projectId: brief.projectId, + plannedAgentId: brief.plannedAgentId, + plan: { + planId: brief.plan.planId, + semanticDigest: brief.plan.semanticDigest, + }, + mission: brief.mission, + scope: { + inScope: [...brief.scope.inScope].sort(compare), + nonGoals: [...brief.scope.nonGoals].sort(compare), + }, + ownedNodeIds: [...brief.ownedNodeIds].sort(compare), + relevantNodeIds: [...brief.relevantNodeIds].sort(compare), + inputs: by( + brief.inputs, + (entry) => `${entry.contractId}\0${entry.nodeId}`, + ).map((entry) => ({ + ...entry, + relationshipIds: [...entry.relationshipIds].sort(compare), + })), + outputs: by( + brief.outputs, + (entry) => `${entry.contractId}\0${entry.nodeId}`, + ).map((entry) => ({ + ...entry, + relationshipIds: [...entry.relationshipIds].sort(compare), + })), + dependencies: by(brief.dependencies, (entry) => entry.dependencyId).map( + (entry) => ({ + ...entry, + relationshipIds: [...entry.relationshipIds].sort(compare), + contractIds: [...entry.contractIds].sort(compare), + requiredByMilestoneIds: [...entry.requiredByMilestoneIds].sort(compare), + }), + ), + deliverables: deliverables(brief.deliverables), + acceptanceCriteria: ordered(brief.acceptanceCriteria), + constraints: constraints(brief.constraints), + milestones: [...brief.milestones].sort(compare), + unresolvedDecisions: decision(brief.unresolvedDecisions), + changeProtocol: { + ...brief.changeProtocol, + instructions: [...brief.changeProtocol.instructions], + }, + }; +} + +export const computeAgentBriefSemanticDigest = ( + brief: AgentBriefVersionRecord, +): AgentBriefSemanticDigest => + hash( + "sapiom.agent-brief.semantic.v1", + agentBriefSemanticProjection(brief), + ) as AgentBriefSemanticDigest; + +export const computeAgentBriefRecordDigest = ( + brief: AgentBriefVersionRecord, +): RecordDigest => + hash( + "sapiom.agent-brief.record.v1", + omit(brief, ["recordDigest"]), + ) as RecordDigest; + +export const computePlanningSubmissionSemanticDigest = ( + submission: BuilderPlanningSubmission, +): PlanningSubmissionDigest => { + const meaning = omit(submission, [ + "submissionId", + "sessionId", + "submittedAt", + "supersedesSubmissionId", + "semanticDigest", + "source", + ]); + return hash("sapiom.planning-submission.semantic.v1", { + ...meaning, + plan: { + planId: submission.plan.planId, + semanticDigest: submission.plan.semanticDigest, + }, + brief: { + briefId: submission.brief.briefId, + semanticDigest: submission.brief.semanticDigest, + }, + implementationPlan: ordered(submission.implementationPlan), + risks: by(submission.risks, (entry) => entry.riskId), + questions: by(submission.questions, (entry) => entry.questionId), + proposedMapOperationIds: [...submission.proposedMapOperationIds].sort( + compare, + ), + }) as PlanningSubmissionDigest; +}; + +export const computeArchitectureGraphDigest = ( + graph: AgentMapGraph, +): GraphDigest => + hash( + "sapiom.agent-map.graph.v1", + canonicalizeAgentMapGraph(graph), + ) as GraphDigest; diff --git a/packages/harness/src/core/build-plan-contract-validator.test.ts b/packages/harness/src/core/build-plan-contract-validator.test.ts new file mode 100644 index 000000000..d46f6e0c6 --- /dev/null +++ b/packages/harness/src/core/build-plan-contract-validator.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import type { PlanDecisionId } from "../shared/build-plan.js"; +import { + AGENT_ID, + graph, + makeBrief, + makePlan, + PROJECT_ID, +} from "./build-plan.test-support.js"; +import { BuildPlanContractValidator } from "./build-plan-contract-validator.js"; + +const validator = new BuildPlanContractValidator({ + resolve: async (_projectId, source) => ({ + projectId: PROJECT_ID, + source, + graph, + }), +}); + +describe("BuildPlanContractValidator", () => { + it("computes proposal planning eligibility without forging implementation readiness", async () => { + const plan = makePlan(); + const result = await validator.validate(plan, [makeBrief(plan)]); + + expect(result).toEqual({ + completeness: { status: "complete", issues: [] }, + eligibility: { + planningEligible: true, + implementationEligible: false, + reasons: ["source-not-confirmed"], + }, + }); + }); + + it("returns deterministic path-addressable missing and unresolved diagnostics", async () => { + const plan = makePlan({ + assignments: [ + { + ...makePlan().assignments[0]!, + unresolvedDecisions: [ + { + decisionId: + "decision_00000000-0000-7000-8000-000000000007" as PlanDecisionId, + question: "Which database?", + required: true, + status: "open", + resolution: null, + }, + ], + }, + ], + }); + const result = await validator.validate(plan, []); + + expect(result.completeness.status).toBe("incomplete"); + expect( + result.completeness.issues.map(({ code, relatedIds }) => ({ + code, + relatedIds, + })), + ).toEqual([ + { + code: "unresolved-required-decision", + relatedIds: ["decision_00000000-0000-7000-8000-000000000007"], + }, + { code: "missing-brief", relatedIds: [AGENT_ID] }, + ]); + expect(result.eligibility.planningEligible).toBe(false); + }); +}); diff --git a/packages/harness/src/core/build-plan-contract-validator.ts b/packages/harness/src/core/build-plan-contract-validator.ts new file mode 100644 index 000000000..e9904d7c8 --- /dev/null +++ b/packages/harness/src/core/build-plan-contract-validator.ts @@ -0,0 +1,375 @@ +import type { AgentMapGraph, PlanNodeId } from "../shared/agent-map.js"; +import type { + AgentBriefVersionRecord, + ArchitectureSourceRef, + BriefFreshness, + BuildPlanCompleteness, + BuildPlanDiagnostic, + BuildPlanEligibility, + ProjectBuildPlanVersion, +} from "../shared/build-plan.js"; +import { + ArchitectureSourceResolutionError, + type ResolvedArchitectureSource, +} from "./architecture-source-resolver.js"; + +export const BUILD_PLAN_DIAGNOSTIC_LIMIT = 64; + +export interface ExactArchitectureSourceResolver { + resolve( + projectId: string, + source: ArchitectureSourceRef, + ): Promise; +} + +const compare = (left: string, right: string) => + left < right ? -1 : left > right ? 1 : 0; +const sameSource = ( + left: ArchitectureSourceRef, + right: ArchitectureSourceRef, +) => JSON.stringify(left) === JSON.stringify(right); + +function diagnostic( + code: BuildPlanDiagnostic["code"], + path: string, + relatedIds: readonly string[] = [], + severity: BuildPlanDiagnostic["severity"] = "error", +): BuildPlanDiagnostic { + const messages: Record = { + "missing-agent-assignment": "A top-level agent requires an assignment", + "unknown-node-reference": "A referenced architecture node does not exist", + "cross-project-reference": "A reference belongs to another project", + "missing-brief": "A current assignment requires a focused brief", + "incompatible-contract-direction": + "A contract port direction conflicts with the architecture", + "invalid-dependency": + "A dependency is not supported by the referenced architecture", + "unresolved-required-decision": "A required decision remains unresolved", + "source-not-found": "The exact architecture source was not found", + "source-digest-mismatch": + "The exact architecture source digest does not match", + }; + return { + code, + severity, + path: path.slice(0, 512), + message: messages[code], + relatedIds: [...relatedIds].slice(0, 16), + }; +} + +function finalize(issues: BuildPlanDiagnostic[]): BuildPlanDiagnostic[] { + const unique = new Map(); + for (const issue of issues) + unique.set( + JSON.stringify([issue.path, issue.code, issue.relatedIds]), + issue, + ); + return [...unique.values()] + .sort( + (left, right) => + compare(left.path, right.path) || + compare(left.code, right.code) || + compare(left.relatedIds.join("\0"), right.relatedIds.join("\0")), + ) + .slice(0, BUILD_PLAN_DIAGNOSTIC_LIMIT); +} + +function validateBrief( + brief: AgentBriefVersionRecord, + plan: ProjectBuildPlanVersion, + graph: AgentMapGraph, + index: number, +): BuildPlanDiagnostic[] { + const issues: BuildPlanDiagnostic[] = []; + const prefix = `briefs[${index}]`; + if (brief.projectId !== plan.projectId) + issues.push( + diagnostic("cross-project-reference", `${prefix}.projectId`, [ + brief.briefId, + ]), + ); + if ( + brief.plan.planId !== plan.planId || + brief.plan.version !== plan.version || + brief.plan.semanticDigest !== plan.semanticDigest + ) + issues.push( + diagnostic("invalid-dependency", `${prefix}.plan`, [brief.plan.planId]), + ); + if (!sameSource(brief.source, plan.source)) + issues.push( + diagnostic("source-digest-mismatch", `${prefix}.source`, [brief.briefId]), + ); + const nodes = new Map(graph.nodes.map((node) => [node.id, node])); + const relationships = new Map( + graph.relationships.map((entry) => [entry.id, entry]), + ); + const knownContracts = new Set([ + ...graph.nodes.flatMap((node) => node.contractRefs), + ...graph.relationships.flatMap((entry) => + entry.contractRef === null ? [] : [entry.contractRef], + ), + ]); + const owned = new Set(brief.ownedNodeIds); + const belongsToPlannedAgent = (nodeId: PlanNodeId): boolean => { + const visited = new Set(); + let current = nodes.get(nodeId); + while (current && current.ownerAgentId !== null) { + if (visited.has(current.id)) return false; + visited.add(current.id); + current = nodes.get(current.ownerAgentId); + } + return current?.id === brief.plannedAgentId; + }; + const plannedNode = nodes.get(brief.plannedAgentId); + if ( + !plannedNode || + plannedNode.kind !== "agent" || + plannedNode.ownerAgentId !== null + ) + issues.push( + diagnostic("unknown-node-reference", `${prefix}.plannedAgentId`, [ + brief.plannedAgentId, + ]), + ); + for (const [field, ids] of [ + ["ownedNodeIds", brief.ownedNodeIds], + ["relevantNodeIds", brief.relevantNodeIds], + ] as const) + ids.forEach((id, itemIndex) => { + if (!nodes.has(id)) + issues.push( + diagnostic( + "unknown-node-reference", + `${prefix}.${field}[${itemIndex}]`, + [id], + ), + ); + else if (field === "ownedNodeIds" && !belongsToPlannedAgent(id)) + issues.push( + diagnostic("invalid-dependency", `${prefix}.${field}[${itemIndex}]`, [ + id, + ]), + ); + }); + [...brief.inputs, ...brief.outputs].forEach((port, portIndex) => { + if (!nodes.has(port.nodeId)) + issues.push( + diagnostic( + "unknown-node-reference", + `${prefix}.ports[${portIndex}].nodeId`, + [port.nodeId], + ), + ); + if (!knownContracts.has(port.contractId)) + issues.push( + diagnostic( + "invalid-dependency", + `${prefix}.ports[${portIndex}].contractId`, + [port.contractId], + ), + ); + port.relationshipIds.forEach((id) => { + const relation = relationships.get(id); + const isInput = portIndex < brief.inputs.length; + if ( + !relation || + (isInput + ? !owned.has(relation.toNodeId) + : !owned.has(relation.fromNodeId)) + ) + issues.push( + diagnostic( + "incompatible-contract-direction", + `${prefix}.ports[${portIndex}].relationshipIds`, + [id], + ), + ); + }); + }); + brief.dependencies.forEach((dependency, dependencyIndex) => { + const counterpart = nodes.get(dependency.counterpartAgentId); + const supported = dependency.relationshipIds.every((id) => { + const relation = relationships.get(id); + return ( + relation && + ((owned.has(relation.fromNodeId) && + relation.toNodeId === dependency.counterpartAgentId) || + (owned.has(relation.toNodeId) && + relation.fromNodeId === dependency.counterpartAgentId)) + ); + }); + const contractsExist = dependency.contractIds.every((id) => + knownContracts.has(id), + ); + if ( + !counterpart || + counterpart.kind !== "agent" || + !supported || + !contractsExist + ) + issues.push( + diagnostic( + "invalid-dependency", + `${prefix}.dependencies[${dependencyIndex}]`, + [dependency.counterpartAgentId, ...dependency.relationshipIds], + ), + ); + }); + brief.unresolvedDecisions.forEach((decision, decisionIndex) => { + if (decision.required && decision.status === "open") + issues.push( + diagnostic( + "unresolved-required-decision", + `${prefix}.unresolvedDecisions[${decisionIndex}]`, + [decision.decisionId], + ), + ); + }); + return issues; +} + +export class BuildPlanContractValidator { + constructor(private readonly resolver: ExactArchitectureSourceResolver) {} + + async validate( + plan: ProjectBuildPlanVersion, + briefs: readonly AgentBriefVersionRecord[], + ): Promise<{ + completeness: BuildPlanCompleteness; + eligibility: BuildPlanEligibility; + }> { + let resolved: ResolvedArchitectureSource; + try { + resolved = await this.resolver.resolve(plan.projectId, plan.source); + } catch (error) { + const code = + error instanceof ArchitectureSourceResolutionError + ? error.code === "cross_project" + ? "cross-project-reference" + : error.code === "source_digest_mismatch" + ? "source-digest-mismatch" + : "source-not-found" + : "source-not-found"; + const issues = [diagnostic(code, "source")]; + return { + completeness: { status: "incomplete", issues }, + eligibility: { + planningEligible: false, + implementationEligible: false, + reasons: ["plan-incomplete"], + }, + }; + } + const issues: BuildPlanDiagnostic[] = []; + const nodes = new Map(resolved.graph.nodes.map((node) => [node.id, node])); + const topLevel = resolved.graph.nodes.filter( + (node) => node.kind === "agent" && node.ownerAgentId === null, + ); + const assignments = new Map( + plan.assignments.map((entry) => [entry.plannedAgentId, entry]), + ); + topLevel.forEach((node) => { + if (!assignments.has(node.id)) + issues.push( + diagnostic("missing-agent-assignment", "assignments", [node.id]), + ); + }); + plan.assignments.forEach((assignment, index) => { + const node = nodes.get(assignment.plannedAgentId); + if (!node || node.kind !== "agent" || node.ownerAgentId !== null) + issues.push( + diagnostic( + "unknown-node-reference", + `assignments[${index}].plannedAgentId`, + [assignment.plannedAgentId], + ), + ); + assignment.unresolvedDecisions.forEach((decision, decisionIndex) => { + if (decision.required && decision.status === "open") + issues.push( + diagnostic( + "unresolved-required-decision", + `assignments[${index}].unresolvedDecisions[${decisionIndex}]`, + [decision.decisionId], + ), + ); + }); + }); + plan.repositoryIntents.forEach((intent, index) => { + const node = nodes.get(intent.plannedAgentId); + if (!node || node.kind !== "agent" || node.ownerAgentId !== null) + issues.push( + diagnostic( + "unknown-node-reference", + `repositoryIntents[${index}].plannedAgentId`, + [intent.plannedAgentId], + ), + ); + }); + plan.unresolvedDecisions.forEach((decision, index) => { + if (decision.required && decision.status === "open") + issues.push( + diagnostic( + "unresolved-required-decision", + `unresolvedDecisions[${index}]`, + [decision.decisionId], + ), + ); + }); + const briefsByAgent = new Map(); + briefs.forEach((brief, index) => { + if (briefsByAgent.has(brief.plannedAgentId)) + issues.push( + diagnostic("invalid-dependency", `briefs[${index}].plannedAgentId`, [ + brief.plannedAgentId, + ]), + ); + briefsByAgent.set(brief.plannedAgentId, brief); + issues.push(...validateBrief(brief, plan, resolved.graph, index)); + }); + topLevel.forEach((node) => { + if (!briefsByAgent.has(node.id)) + issues.push(diagnostic("missing-brief", "briefs", [node.id])); + }); + const bounded = finalize(issues); + const complete = bounded.every((entry) => entry.severity !== "error"); + const reasons: BuildPlanEligibility["reasons"][number][] = []; + if (!complete) reasons.push("plan-incomplete"); + if (bounded.some((entry) => entry.code === "missing-brief")) + reasons.push("brief-missing"); + if (plan.source.kind !== "revision") reasons.push("source-not-confirmed"); + return { + completeness: { + status: complete ? "complete" : "incomplete", + issues: bounded, + }, + eligibility: { + planningEligible: complete, + implementationEligible: complete && plan.source.kind === "revision", + reasons, + }, + }; + } +} + +export function computeBriefFreshness( + brief: AgentBriefVersionRecord, + evaluatedAgainst: ArchitectureSourceRef, +): BriefFreshness { + if (sameSource(brief.source, evaluatedAgainst)) + return { status: "current", evaluatedAgainst, reasons: [] }; + return { + status: "stale", + evaluatedAgainst, + reasons: [ + { + code: "source-changed", + affectedNodeIds: [brief.plannedAgentId], + affectedRelationshipIds: [], + affectedContractIds: [], + }, + ], + }; +} diff --git a/packages/harness/src/core/build-plan-store.test.ts b/packages/harness/src/core/build-plan-store.test.ts new file mode 100644 index 000000000..5def5cbf9 --- /dev/null +++ b/packages/harness/src/core/build-plan-store.test.ts @@ -0,0 +1,237 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { + AgentBriefId, + BuilderPlanningSubmission, + BuilderPlanningSubmissionId, + PlanningAssignmentId, +} from "../shared/build-plan.js"; +import type { BuildPlanVersion } from "../shared/build-plan.js"; +import { + AGENT_ID, + ASSIGNMENT_ID, + BRIEF_ID, + graph, + makeBrief, + makePlan, + PROJECT_ID, + proposalSource, +} from "./build-plan.test-support.js"; +import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; +import { BuildPlanStore } from "./build-plan-store.js"; +import { + computeArchitectureGraphDigest, + computePlanningSubmissionSemanticDigest, +} from "./build-plan-canonicalization.js"; + +const request = { + sessionId: "session-1", + requestId: "request-1", + requestDigest: `sha256:${"a".repeat(64)}`, +}; + +describe("BuildPlanStore", () => { + const roots: string[] = []; + afterEach(async () => + Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ), + ); + async function fixture( + options: ConstructorParameters[1] = {}, + ) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "build-plan-store-")); + roots.push(root); + const workspaceStore = new AgentMapWorkspaceStore(root, options); + const buildPlanStore = new BuildPlanStore(workspaceStore, { + allocator: { + allocateBuildPlanId: () => makePlan().planId, + allocateBriefId: () => BRIEF_ID as AgentBriefId, + allocateAssignmentId: () => ASSIGNMENT_ID as PlanningAssignmentId, + }, + now: () => new Date("2026-09-03T09:05:00.000Z"), + }); + return { root, workspaceStore, buildPlanStore }; + } + + it("persists stable assignments, immutable brief history, replay, and restart", async () => { + const { root, buildPlanStore } = await fixture(); + const plan = makePlan(); + const first = await buildPlanStore.commitPlanVersion(plan, graph, request); + const replay = await buildPlanStore.commitPlanVersion(plan, graph, request); + const brief = makeBrief(plan, { + briefId: first.assignments[0]!.briefId, + assignmentId: first.assignments[0]!.assignmentId, + }); + await buildPlanStore.commitBriefVersions(PROJECT_ID, [brief]); + const submission = { + schemaVersion: 1, + submissionId: + "submission_00000000-0000-7000-8000-000000000008" as BuilderPlanningSubmissionId, + projectId: PROJECT_ID, + assignmentId: first.assignments[0]!.assignmentId, + sessionId: "builder-session-1", + source: plan.source, + plan: first.plan, + brief: { + briefId: brief.briefId, + version: brief.version, + semanticDigest: brief.semanticDigest, + }, + status: "ready", + implementationPlan: [ + { + stepId: "step-1", + ordinal: 1, + description: "Implement", + verification: "Run tests", + }, + ], + risks: [], + questions: [], + proposedMapOperationIds: [], + supersedesSubmissionId: null, + semanticDigest: `sha256:${"0".repeat(64)}`, + submittedAt: "2026-09-03T09:10:00.000Z", + } as unknown as BuilderPlanningSubmission; + submission.semanticDigest = + computePlanningSubmissionSemanticDigest(submission); + await buildPlanStore.commitSubmission(submission); + + const restarted = new BuildPlanStore(new AgentMapWorkspaceStore(root)); + const planning = await restarted.read(PROJECT_ID); + + expect(first.replayed).toBe(false); + expect(replay.replayed).toBe(true); + expect(planning.planVersions).toHaveLength(1); + expect(planning.briefVersionsById[BRIEF_ID]).toEqual([brief]); + expect(planning.submissionsByAssignmentId[ASSIGNMENT_ID]).toEqual([ + submission, + ]); + expect(await restarted.readPlanForProject(PROJECT_ID, first.plan)).toEqual( + plan, + ); + expect( + await restarted.readBriefForProject( + PROJECT_ID, + planning.currentBriefByAgentId[AGENT_ID]!, + ), + ).toEqual(brief); + }); + + it("rejects record corruption after restart", async () => { + const { root, buildPlanStore } = await fixture(); + await buildPlanStore.commitPlanVersion(makePlan(), graph, request); + const file = path.join(root, "projects", PROJECT_ID, "workspace.json"); + const persisted = JSON.parse(await fs.readFile(file, "utf8")) as { + buildPlanning: { planVersions: Array<{ outcome: { summary: string } }> }; + }; + persisted.buildPlanning.planVersions[0]!.outcome.summary = "Tampered"; + await fs.writeFile(file, `${JSON.stringify(persisted)}\n`); + + await expect( + new AgentMapWorkspaceStore(root).readAggregate(PROJECT_ID), + ).rejects.toMatchObject({ + code: "malformed_state", + }); + }); + + it("retires and restores the same assignment identity without erasing history", async () => { + const { buildPlanStore } = await fixture(); + const first = makePlan(); + await buildPlanStore.commitPlanVersion(first, graph, request); + const removed = makePlan({ + version: 2 as BuildPlanVersion, + parentVersion: 1 as BuildPlanVersion, + changeKind: "edited", + assignments: [], + outcome: { summary: "No independent builders" }, + source: { + ...proposalSource(), + version: 2, + graphDigest: computeArchitectureGraphDigest({ + nodes: [], + relationships: [], + }), + }, + }); + await buildPlanStore.commitPlanVersion( + removed, + { nodes: [], relationships: [] }, + { + ...request, + requestId: "request-2", + requestDigest: `sha256:${"b".repeat(64)}`, + }, + ); + const restored = makePlan({ + version: 3 as BuildPlanVersion, + parentVersion: 2 as BuildPlanVersion, + changeKind: "restored", + outcome: { summary: "Restore the builder" }, + source: { ...proposalSource(), version: 3 }, + }); + await buildPlanStore.commitPlanVersion(restored, graph, { + ...request, + requestId: "request-3", + requestDigest: `sha256:${"c".repeat(64)}`, + }); + + expect( + (await buildPlanStore.read(PROJECT_ID)).assignmentByAgentId[AGENT_ID], + ).toMatchObject({ + assignmentId: ASSIGNMENT_ID, + briefId: BRIEF_ID, + status: "active", + retiredAt: null, + }); + }); + + it("does not publish IDs or versions when the atomic replace fails", async () => { + let fail = false; + const { root, workspaceStore, buildPlanStore } = await fixture({ + beforePersistStep: (step) => { + if (fail && step === "rename") throw new Error("injected"); + }, + }); + await workspaceStore.readOrCreate(PROJECT_ID); + fail = true; + + await expect( + buildPlanStore.commitPlanVersion(makePlan(), graph, request), + ).rejects.toMatchObject({ code: "storage_unavailable" }); + expect( + (await new AgentMapWorkspaceStore(root).readAggregate(PROJECT_ID)) + .buildPlanning.planVersions, + ).toEqual([]); + }); + + it("selects one plan-version winner across independent store instances", async () => { + const { root, buildPlanStore } = await fixture(); + const competing = new BuildPlanStore(new AgentMapWorkspaceStore(root)); + + const outcomes = await Promise.allSettled([ + buildPlanStore.commitPlanVersion(makePlan(), graph, request), + competing.commitPlanVersion(makePlan(), graph, { + ...request, + requestId: "request-competing", + requestDigest: `sha256:${"d".repeat(64)}`, + }), + ]); + + expect( + outcomes.filter(({ status }) => status === "fulfilled"), + ).toHaveLength(1); + expect(outcomes.filter(({ status }) => status === "rejected")).toHaveLength( + 1, + ); + expect((await buildPlanStore.read(PROJECT_ID)).planVersions).toHaveLength( + 1, + ); + }); +}); diff --git a/packages/harness/src/core/build-plan-store.ts b/packages/harness/src/core/build-plan-store.ts new file mode 100644 index 000000000..ebae60f18 --- /dev/null +++ b/packages/harness/src/core/build-plan-store.ts @@ -0,0 +1,419 @@ +import { v7 as uuidv7 } from "uuid"; + +import type { + AgentMapGraph, + PlanNodeId, + StudioProjectId, +} from "../shared/agent-map.js"; +import type { + AgentBriefId, + AgentBriefRef, + AgentBriefVersionRecord, + BuildPlanIdempotencyReceipt, + BuildPlanId, + BuildPlanRef, + BuilderPlanningSubmission, + PlanningAssignmentId, + PlanningAssignmentRef, + PlanningAssignmentRecord, + ProjectBuildPlanVersion, +} from "../shared/build-plan.js"; +import { + parseAgentBriefVersionRecord, + parseBuilderPlanningSubmission, + parseProjectBuildPlanVersion, +} from "../shared/build-plan-codec.js"; +import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeBuildPlanRecordDigest, + computeBuildPlanSemanticDigest, + computeArchitectureGraphDigest, + computePlanningSubmissionSemanticDigest, +} from "./build-plan-canonicalization.js"; + +export class BuildPlanStoreConflictError extends Error { + constructor(readonly code: "version_conflict" | "request_id_reused") { + super( + code === "version_conflict" + ? "Build planning version changed" + : "Build planning request ID was reused", + ); + this.name = "BuildPlanStoreConflictError"; + } +} + +export interface BuildPlanIdentityAllocator { + allocateBuildPlanId(): BuildPlanId; + allocateBriefId(): AgentBriefId; + allocateAssignmentId(): PlanningAssignmentId; +} + +export class UuidV7BuildPlanIdentityAllocator implements BuildPlanIdentityAllocator { + allocateBuildPlanId = () => `build-plan_${uuidv7()}` as BuildPlanId; + allocateBriefId = () => `brief_${uuidv7()}` as AgentBriefId; + allocateAssignmentId = () => `assignment_${uuidv7()}` as PlanningAssignmentId; +} + +export interface BuildPlanCommitIdentity { + sessionId: string; + requestId: string; + requestDigest: string; +} + +const sameSource = (left: unknown, right: unknown) => + JSON.stringify(left) === JSON.stringify(right); + +/** Persistence primitives over the same crash-atomic E2 project aggregate. */ +export class BuildPlanStore { + private readonly allocator: BuildPlanIdentityAllocator; + private readonly now: () => Date; + + constructor( + private readonly store: AgentMapWorkspaceStore, + options: { allocator?: BuildPlanIdentityAllocator; now?: () => Date } = {}, + ) { + this.allocator = + options.allocator ?? new UuidV7BuildPlanIdentityAllocator(); + this.now = options.now ?? (() => new Date()); + } + + async read(projectId: StudioProjectId) { + return (await this.store.readAggregate(projectId)).buildPlanning; + } + + allocateBuildPlanId(): BuildPlanId { + return this.allocator.allocateBuildPlanId(); + } + + async readPlanForProject(projectId: StudioProjectId, ref: BuildPlanRef) { + const planning = await this.read(projectId); + const plan = planning.planVersions.find( + (entry) => entry.planId === ref.planId && entry.version === ref.version, + ); + return plan?.semanticDigest === ref.semanticDigest ? plan : null; + } + + async readBriefForProject(projectId: StudioProjectId, ref: AgentBriefRef) { + const planning = await this.read(projectId); + const brief = planning.briefVersionsById[ref.briefId]?.find( + (entry) => entry.version === ref.version, + ); + return brief?.semanticDigest === ref.semanticDigest ? brief : null; + } + + async readSubmission( + projectId: StudioProjectId, + assignmentId: PlanningAssignmentId, + submissionId: string, + ) { + return ( + (await this.read(projectId)).submissionsByAssignmentId[ + assignmentId + ]?.find((entry) => entry.submissionId === submissionId) ?? null + ); + } + + async commitPlanVersion( + input: ProjectBuildPlanVersion, + graph: AgentMapGraph, + request: BuildPlanCommitIdentity, + ): Promise<{ + plan: BuildPlanRef; + assignments: PlanningAssignmentRef[]; + replayed: boolean; + }> { + const plan = parseProjectBuildPlanVersion(input); + if ( + computeBuildPlanSemanticDigest(plan) !== plan.semanticDigest || + computeBuildPlanRecordDigest(plan) !== plan.recordDigest || + computeArchitectureGraphDigest(graph) !== plan.source.graphDigest + ) + throw new Error("invalid build plan digest"); + const topLevelAgentIds = graph.nodes + .filter((node) => node.kind === "agent" && node.ownerAgentId === null) + .map((node) => node.id) + .sort(); + const authoredAgentIds = plan.assignments + .map((entry) => entry.plannedAgentId) + .sort(); + if (JSON.stringify(topLevelAgentIds) !== JSON.stringify(authoredAgentIds)) + throw new Error( + "build plan assignments must exactly match top-level agents", + ); + return this.store.transact<{ + plan: BuildPlanRef; + assignments: PlanningAssignmentRef[]; + replayed: boolean; + }>(plan.projectId, async (aggregate) => { + const planning = aggregate.buildPlanning; + const priorReceipt = planning.idempotencyReceipts.find( + (entry) => + entry.sessionId === request.sessionId && + entry.requestId === request.requestId, + ); + if (priorReceipt) { + if ( + priorReceipt.requestDigest !== request.requestDigest || + priorReceipt.resultRecordDigest !== plan.recordDigest + ) + throw new BuildPlanStoreConflictError("request_id_reused"); + const assignments = plan.assignments.map((entry) => + this.assignmentRef( + planning.assignmentByAgentId[entry.plannedAgentId]!, + ), + ); + return { + value: { plan: this.planRef(plan), assignments, replayed: true }, + }; + } + if ( + (planning.planId !== null && planning.planId !== plan.planId) || + plan.version !== planning.planVersions.length + 1 || + plan.parentVersion !== planning.currentPlanVersion + ) + throw new BuildPlanStoreConflictError("version_conflict"); + const timestamp = this.now().toISOString(); + const active = new Set(topLevelAgentIds); + const assignmentByAgentId = { ...planning.assignmentByAgentId }; + const currentBriefByAgentId = { ...planning.currentBriefByAgentId }; + for (const [agentId, existing] of Object.entries(assignmentByAgentId)) { + if ( + !active.has(agentId as PlanNodeId) && + existing.status === "active" + ) { + assignmentByAgentId[agentId] = { + ...existing, + status: "retired", + retiredAt: timestamp, + transitions: [ + ...existing.transitions, + { + status: "retired", + at: timestamp, + planVersion: plan.version, + }, + ], + }; + delete currentBriefByAgentId[agentId]; + } + } + for (const agentId of topLevelAgentIds) { + const existing = assignmentByAgentId[agentId]; + assignmentByAgentId[agentId] = existing + ? existing.status === "retired" + ? { + ...existing, + status: "active", + retiredAt: null, + transitions: [ + ...existing.transitions, + { + status: "active", + at: timestamp, + planVersion: plan.version, + }, + ], + } + : existing + : { + schemaVersion: 1, + projectId: plan.projectId, + assignmentId: this.allocator.allocateAssignmentId(), + briefId: this.allocator.allocateBriefId(), + plannedAgentId: agentId, + status: "active", + createdAt: timestamp, + retiredAt: null, + transitions: [ + { status: "active", at: timestamp, planVersion: plan.version }, + ], + }; + } + const receipt: BuildPlanIdempotencyReceipt = { + ...request, + resultRecordDigest: plan.recordDigest, + createdAt: timestamp, + }; + const nextPlanning = { + ...planning, + planId: plan.planId, + currentPlanVersion: plan.version, + planVersions: [...planning.planVersions, plan], + currentBriefByAgentId, + assignmentByAgentId, + idempotencyReceipts: [...planning.idempotencyReceipts, receipt].slice( + -256, + ), + }; + const next = { + ...aggregate, + workspace: { + ...aggregate.workspace, + projectBuildPlanId: plan.planId, + recordVersion: aggregate.workspace.recordVersion + 1, + updatedAt: timestamp, + }, + buildPlanning: nextPlanning, + }; + return { + value: { + plan: this.planRef(plan), + assignments: topLevelAgentIds.map((id) => + this.assignmentRef(assignmentByAgentId[id]!), + ), + replayed: false, + }, + next, + }; + }); + } + + async commitBriefVersions( + projectId: StudioProjectId, + input: readonly AgentBriefVersionRecord[], + ): Promise { + const briefs = input.map(parseAgentBriefVersionRecord); + for (const brief of briefs) { + if ( + brief.projectId !== projectId || + computeAgentBriefSemanticDigest(brief) !== brief.semanticDigest || + computeAgentBriefRecordDigest(brief) !== brief.recordDigest + ) + throw new Error("invalid agent brief digest"); + } + return this.store.transact(projectId, async (aggregate) => { + const planning = aggregate.buildPlanning; + const histories = { ...planning.briefVersionsById }; + const current = { ...planning.currentBriefByAgentId }; + for (const brief of briefs) { + const assignment = planning.assignmentByAgentId[brief.plannedAgentId]; + const history = histories[brief.briefId] ?? []; + const plan = planning.planVersions.find( + (entry) => + entry.planId === brief.plan.planId && + entry.version === brief.plan.version, + ); + if ( + !assignment || + assignment.status !== "active" || + assignment.assignmentId !== brief.assignmentId || + assignment.briefId !== brief.briefId || + brief.version !== history.length + 1 || + brief.parentVersion !== (history.at(-1)?.version ?? null) || + !plan || + plan.semanticDigest !== brief.plan.semanticDigest || + !sameSource(plan.source, brief.source) + ) + throw new BuildPlanStoreConflictError("version_conflict"); + histories[brief.briefId] = [...history, brief]; + current[brief.plannedAgentId] = this.briefRef(brief); + } + const timestamp = this.now().toISOString(); + return { + value: briefs.map((brief) => this.briefRef(brief)), + next: { + ...aggregate, + workspace: { + ...aggregate.workspace, + recordVersion: aggregate.workspace.recordVersion + 1, + updatedAt: timestamp, + }, + buildPlanning: { + ...planning, + briefVersionsById: histories, + currentBriefByAgentId: current, + }, + }, + }; + }); + } + + async commitSubmission(input: BuilderPlanningSubmission): Promise { + const submission = parseBuilderPlanningSubmission(input); + if ( + computePlanningSubmissionSemanticDigest(submission) !== + submission.semanticDigest + ) + throw new Error("invalid planning submission digest"); + await this.store.transact(submission.projectId, async (aggregate) => { + const planning = aggregate.buildPlanning; + const assignment = + planning.assignmentByAgentId[ + Object.keys(planning.assignmentByAgentId).find( + (agentId) => + planning.assignmentByAgentId[agentId]?.assignmentId === + submission.assignmentId, + ) ?? "" + ]; + const history = + planning.submissionsByAssignmentId[submission.assignmentId] ?? []; + const plan = planning.planVersions.find( + (entry) => + entry.planId === submission.plan.planId && + entry.version === submission.plan.version, + ); + const brief = planning.briefVersionsById[submission.brief.briefId]?.find( + (entry) => entry.version === submission.brief.version, + ); + if ( + !assignment || + !plan || + plan.semanticDigest !== submission.plan.semanticDigest || + !sameSource(plan.source, submission.source) || + !brief || + brief.semanticDigest !== submission.brief.semanticDigest || + brief.assignmentId !== submission.assignmentId || + (history.length === 0 + ? submission.supersedesSubmissionId !== null + : submission.supersedesSubmissionId !== history.at(-1)?.submissionId) + ) + throw new BuildPlanStoreConflictError("version_conflict"); + return { + value: undefined, + next: { + ...aggregate, + workspace: { + ...aggregate.workspace, + recordVersion: aggregate.workspace.recordVersion + 1, + updatedAt: submission.submittedAt, + }, + buildPlanning: { + ...planning, + submissionsByAssignmentId: { + ...planning.submissionsByAssignmentId, + [submission.assignmentId]: [...history, submission], + }, + }, + }, + }; + }); + } + + private planRef(plan: ProjectBuildPlanVersion): BuildPlanRef { + return { + planId: plan.planId, + version: plan.version, + semanticDigest: plan.semanticDigest, + }; + } + + private briefRef(brief: AgentBriefVersionRecord): AgentBriefRef { + return { + briefId: brief.briefId, + version: brief.version, + semanticDigest: brief.semanticDigest, + }; + } + + private assignmentRef( + assignment: PlanningAssignmentRecord, + ): PlanningAssignmentRef { + return { + assignmentId: assignment.assignmentId, + briefId: assignment.briefId, + plannedAgentId: assignment.plannedAgentId, + }; + } +} diff --git a/packages/harness/src/core/build-plan.test-support.ts b/packages/harness/src/core/build-plan.test-support.ts new file mode 100644 index 000000000..740a65223 --- /dev/null +++ b/packages/harness/src/core/build-plan.test-support.ts @@ -0,0 +1,155 @@ +import type { + AgentMapGraph, + MapProposalId, + PlanNodeId, + StudioProjectId, +} from "../shared/agent-map.js"; +import type { + AgentBriefId, + AgentBriefVersionRecord, + ArchitectureSourceRef, + BuildPlanId, + PlanningAssignmentId, + ProjectBuildPlanVersion, +} from "../shared/build-plan.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeArchitectureGraphDigest, + computeBuildPlanRecordDigest, + computeBuildPlanSemanticDigest, +} from "./build-plan-canonicalization.js"; + +export const PROJECT_ID = + "project_00000000-0000-4000-8000-000000000001" as StudioProjectId; +export const AGENT_ID = + "node_00000000-0000-7000-8000-000000000001" as PlanNodeId; +export const PLAN_ID = + "build-plan_00000000-0000-7000-8000-000000000002" as BuildPlanId; +export const BRIEF_ID = + "brief_00000000-0000-7000-8000-000000000003" as AgentBriefId; +export const ASSIGNMENT_ID = + "assignment_00000000-0000-7000-8000-000000000004" as PlanningAssignmentId; + +export const graph: AgentMapGraph = { + nodes: [ + { + id: AGENT_ID, + kind: "agent", + name: "Builder", + purpose: "Build the system", + ownerAgentId: null, + contractRefs: [], + }, + ], + relationships: [], +}; + +export const proposalSource = (): Extract< + ArchitectureSourceRef, + { kind: "proposal" } +> => ({ + kind: "proposal", + proposalId: "proposal_00000000-0000-7000-8000-000000000005" as MapProposalId, + version: 1, + graphDigest: computeArchitectureGraphDigest(graph), +}); + +export function makePlan( + overrides: Partial = {}, +): ProjectBuildPlanVersion { + const draft = { + schemaVersion: 1, + projectId: PROJECT_ID, + planId: PLAN_ID, + version: 1, + parentVersion: null, + changeKind: "created", + source: proposalSource(), + outcome: { summary: "Ship a durable product" }, + milestones: [], + sharedConstraints: [], + repositoryIntents: [], + integrationCriteria: [], + assignments: [ + { + plannedAgentId: AGENT_ID, + mission: "Implement the product", + scope: { inScope: ["Core"], nonGoals: ["Deployment"] }, + deliverables: [], + constraints: [], + acceptanceCriteria: [], + milestoneIds: [], + unresolvedDecisions: [], + }, + ], + unresolvedDecisions: [], + semanticDigest: + "sha256:0000000000000000000000000000000000000000000000000000000000000000", + recordDigest: + "sha256:0000000000000000000000000000000000000000000000000000000000000000", + authoredBy: { + userId: "user-1", + sessionId: "session-1", + role: "map-planner", + }, + createdAt: "2026-09-03T09:00:00.000Z", + ...overrides, + } as ProjectBuildPlanVersion; + draft.semanticDigest = computeBuildPlanSemanticDigest(draft); + draft.recordDigest = computeBuildPlanRecordDigest(draft); + return draft; +} + +export function makeBrief( + plan: ProjectBuildPlanVersion, + overrides: Partial = {}, +): AgentBriefVersionRecord { + const draft = { + schemaVersion: 1, + projectId: PROJECT_ID, + briefId: BRIEF_ID, + version: 1, + parentVersion: null, + plannedAgentId: AGENT_ID, + assignmentId: ASSIGNMENT_ID, + plan: { + planId: plan.planId, + version: plan.version, + semanticDigest: plan.semanticDigest, + }, + source: plan.source, + mission: "Implement the product", + scope: { inScope: ["Core"], nonGoals: ["Deployment"] }, + ownedNodeIds: [AGENT_ID], + relevantNodeIds: [AGENT_ID], + inputs: [], + outputs: [], + dependencies: [], + deliverables: [], + acceptanceCriteria: [], + constraints: [], + milestones: [], + unresolvedDecisions: [], + changeProtocol: { + proposeArchitectureChanges: true, + instructions: ["Propose boundary changes"], + }, + compilerVersion: "1.0.0", + dependencyFingerprints: [], + semanticDigest: + "sha256:0000000000000000000000000000000000000000000000000000000000000000", + recordDigest: + "sha256:0000000000000000000000000000000000000000000000000000000000000000", + authoredBy: { + userId: "user-1", + sessionId: "session-1", + role: "map-planner", + }, + createdAt: "2026-09-03T09:00:00.000Z", + ...overrides, + } as AgentBriefVersionRecord; + draft.semanticDigest = computeAgentBriefSemanticDigest(draft); + draft.recordDigest = computeAgentBriefRecordDigest(draft); + return draft; +} diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 8330ab4f9..720ef3fac 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -27,6 +27,12 @@ export type { RelationshipKind, StudioProjectId, } from "./shared/agent-map.js"; +export { + BUILD_PLAN_SCHEMA_VERSION, + BUILD_PLANNING_AGGREGATE_SCHEMA_VERSION, + emptyBuildPlanningAggregate, +} from "./shared/build-plan.js"; +export type * from "./shared/build-plan.js"; export type { WorkspaceScopeSummary } from "./shared/system-graph.js"; export { AGENT_STUDIO_PRODUCT_NAME } from "./shared/branding.js"; export { diff --git a/packages/harness/src/shared/build-plan-codec.test.ts b/packages/harness/src/shared/build-plan-codec.test.ts new file mode 100644 index 000000000..418f249b5 --- /dev/null +++ b/packages/harness/src/shared/build-plan-codec.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { + makeBrief, + makePlan, + PROJECT_ID, +} from "../core/build-plan.test-support.js"; +import { emptyBuildPlanningAggregate } from "./build-plan.js"; +import { + parseAgentBriefVersionRecord, + parseArchitectureSourceRef, + parseBuildPlanningAggregate, + parseProjectBuildPlanVersion, +} from "./build-plan-codec.js"; + +describe("build planning strict codecs", () => { + it("round trips exact plan and brief records", () => { + const plan = makePlan(); + expect(parseProjectBuildPlanVersion(plan)).toEqual(plan); + expect(parseAgentBriefVersionRecord(makeBrief(plan))).toEqual( + makeBrief(plan), + ); + }); + + it("rejects unknown fields, bad discriminants, versions, and duplicates", () => { + const plan = makePlan(); + expect(() => + parseProjectBuildPlanVersion({ ...plan, forgedReady: true }), + ).toThrow(); + expect(() => + parseArchitectureSourceRef({ ...plan.source, kind: "latest" }), + ).toThrow(); + expect(() => + parseArchitectureSourceRef({ ...plan.source, version: 0 }), + ).toThrow(); + expect(() => + parseProjectBuildPlanVersion({ + ...plan, + assignments: [plan.assignments[0], plan.assignments[0]], + }), + ).toThrow(); + }); + + it("rejects dangling current pointers instead of repairing them", () => { + expect(() => + parseBuildPlanningAggregate( + { ...emptyBuildPlanningAggregate(), currentPlanVersion: 1 }, + PROJECT_ID, + ), + ).toThrow("invalid build planning aggregate"); + }); +}); diff --git a/packages/harness/src/shared/build-plan-codec.ts b/packages/harness/src/shared/build-plan-codec.ts new file mode 100644 index 000000000..a14376b4d --- /dev/null +++ b/packages/harness/src/shared/build-plan-codec.ts @@ -0,0 +1,755 @@ +import { z } from "zod"; + +import type { + AgentBriefVersionRecord, + ArchitectureSourceRef, + BuilderPlanningSubmission, + BuildPlanningAggregateV1, + PlanningAssignmentRecord, + ProjectBuildPlanVersion, +} from "./build-plan.js"; + +const UUID_V7 = + "[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"; +const generatedId = (prefix: string) => + z.string().regex(new RegExp(`^${prefix}_${UUID_V7}$`, "u")); +const opaqueId = z + .string() + .min(1) + .max(512) + .refine( + (value) => + value.trim() === value && + !value.includes("/") && + !value.includes("\\") && + ![...value].some((character) => { + const point = character.codePointAt(0) ?? 0; + return point <= 0x1f || point === 0x7f; + }), + ); +const projectId = z + .string() + .regex( + /^project_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); +const nodeId = generatedId("node"); +const relationshipId = generatedId("rel"); +const operationId = generatedId("operation"); +const version = z.number().int().safe().positive(); +const digest = z.string().regex(/^sha256:[0-9a-f]{64}$/u); +const timestamp = z.string().datetime({ offset: true }); +const text = (maximum: number) => + z + .string() + .min(1) + .max(maximum) + .refine((value) => value.trim().length > 0) + .refine( + (value) => + ![...value].some((character) => { + const point = character.codePointAt(0) ?? 0; + return ( + (point <= 0x1f && + point !== 0x09 && + point !== 0x0a && + point !== 0x0d) || + point === 0x7f || + (point >= 0xd800 && point <= 0xdfff) + ); + }), + ); +const unique = ( + schema: T, + key: (entry: z.infer) => string, +) => + z + .array(schema) + .max(256) + .superRefine((entries, context) => { + const seen = new Set(); + entries.forEach((entry, index) => { + const id = key(entry); + if (seen.has(id)) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: [index], + message: "duplicate identity", + }); + seen.add(id); + }); + }); +const hasDuplicateOrdinals = (entries: readonly { ordinal: number }[]) => + new Set(entries.map((entry) => entry.ordinal)).size !== entries.length; + +export const architectureSourceRefSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("proposal"), + proposalId: generatedId("proposal"), + version, + graphDigest: digest, + }) + .strict(), + z + .object({ + kind: z.literal("revision"), + revisionId: generatedId("revision"), + revisionNumber: version, + graphDigest: digest, + }) + .strict(), +]); + +const buildPlanRefSchema = z + .object({ + planId: generatedId("build-plan"), + version, + semanticDigest: digest, + }) + .strict(); +const briefRefSchema = z + .object({ + briefId: generatedId("brief"), + version, + semanticDigest: digest, + }) + .strict(); +const actorSchema = z + .object({ + userId: opaqueId, + sessionId: opaqueId, + role: z.enum(["map-planner", "agent-builder"]), + }) + .strict(); +const scopeSchema = z + .object({ + inScope: unique(text(2_000), (entry) => entry), + nonGoals: unique(text(2_000), (entry) => entry), + }) + .strict(); +const milestoneSchema = z + .object({ + milestoneId: generatedId("milestone"), + ordinal: version, + title: text(240), + outcome: text(2_000), + dependsOn: unique(generatedId("milestone"), (entry) => entry), + }) + .strict(); +const constraintSchema = z + .object({ + constraintId: opaqueId, + description: text(2_000), + required: z.boolean(), + }) + .strict(); +const criterionSchema = z + .object({ + criterionId: generatedId("criterion"), + ordinal: version, + description: text(2_000), + verification: text(2_000), + }) + .strict(); +const decisionSchema = z + .object({ + decisionId: generatedId("decision"), + question: text(2_000), + required: z.boolean(), + status: z.enum(["open", "resolved"]), + resolution: text(2_000).nullable(), + }) + .strict() + .superRefine((decision, context) => { + if ((decision.status === "resolved") !== (decision.resolution !== null)) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["resolution"], + message: "decision status mismatch", + }); + }); +const deliverableSchema = z + .object({ + deliverableId: generatedId("deliverable"), + description: text(2_000), + artifactNodeIds: unique(nodeId, (entry) => entry), + acceptanceCriterionIds: unique(generatedId("criterion"), (entry) => entry), + }) + .strict(); +const assignmentIntentSchema = z + .object({ + plannedAgentId: nodeId, + mission: text(4_000), + scope: scopeSchema, + deliverables: unique(deliverableSchema, (entry) => entry.deliverableId), + constraints: unique(constraintSchema, (entry) => entry.constraintId), + acceptanceCriteria: unique(criterionSchema, (entry) => entry.criterionId), + milestoneIds: unique(generatedId("milestone"), (entry) => entry), + unresolvedDecisions: unique(decisionSchema, (entry) => entry.decisionId), + }) + .strict(); + +export const projectBuildPlanVersionSchema = z + .object({ + schemaVersion: z.literal(1), + projectId, + planId: generatedId("build-plan"), + version, + parentVersion: version.nullable(), + changeKind: z.enum([ + "created", + "edited", + "recompiled", + "source-rebound", + "restored", + ]), + source: architectureSourceRefSchema, + outcome: z.object({ summary: text(4_000) }).strict(), + milestones: unique(milestoneSchema, (entry) => entry.milestoneId), + sharedConstraints: unique(constraintSchema, (entry) => entry.constraintId), + repositoryIntents: unique( + z + .object({ + repositoryIntentId: opaqueId, + plannedAgentId: nodeId, + action: z.enum(["create", "bind", "reuse"]), + repositoryName: text(240), + notes: text(2_000), + }) + .strict(), + (entry) => entry.repositoryIntentId, + ), + integrationCriteria: unique(criterionSchema, (entry) => entry.criterionId), + assignments: unique( + assignmentIntentSchema, + (entry) => entry.plannedAgentId, + ), + unresolvedDecisions: unique(decisionSchema, (entry) => entry.decisionId), + semanticDigest: digest, + recordDigest: digest, + authoredBy: actorSchema, + createdAt: timestamp, + }) + .strict() + .superRefine((plan, context) => { + if (plan.version === 1 && plan.parentVersion !== null) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["parentVersion"], + message: "first version has no parent", + }); + if (plan.version > 1 && plan.parentVersion !== plan.version - 1) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["parentVersion"], + message: "parent must be previous version", + }); + if ( + hasDuplicateOrdinals(plan.milestones) || + hasDuplicateOrdinals(plan.integrationCriteria) || + plan.assignments.some((assignment) => + hasDuplicateOrdinals(assignment.acceptanceCriteria), + ) + ) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["ordinal"], + message: "ordered records require unique ordinals", + }); + const milestoneIds = new Set( + plan.milestones.map((milestone) => milestone.milestoneId), + ); + plan.milestones.forEach((milestone, index) => { + if ( + milestone.dependsOn.includes(milestone.milestoneId) || + milestone.dependsOn.some((id) => !milestoneIds.has(id)) + ) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["milestones", index, "dependsOn"], + message: "invalid milestone dependency", + }); + }); + plan.assignments.forEach((assignment, index) => { + if (assignment.milestoneIds.some((id) => !milestoneIds.has(id))) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["assignments", index, "milestoneIds"], + message: "unknown milestone", + }); + const criterionIds = new Set( + assignment.acceptanceCriteria.map((entry) => entry.criterionId), + ); + if ( + assignment.deliverables.some((deliverable) => + deliverable.acceptanceCriterionIds.some( + (id) => !criterionIds.has(id), + ), + ) + ) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["assignments", index, "deliverables"], + message: "unknown acceptance criterion", + }); + }); + }); + +const contractPortSchema = z + .object({ + contractId: opaqueId, + nodeId, + relationshipIds: unique(relationshipId, (entry) => entry), + description: text(2_000), + }) + .strict(); +const dependencySchema = z + .object({ + dependencyId: generatedId("dependency"), + kind: z.enum([ + "consumes-output", + "provides-input", + "shared-resource", + "sequence-gate", + "coordination", + ]), + direction: z.enum(["upstream", "downstream", "bidirectional"]), + counterpartAgentId: nodeId, + relationshipIds: unique(relationshipId, (entry) => entry), + contractIds: unique(opaqueId, (entry) => entry), + requiredByMilestoneIds: unique(generatedId("milestone"), (entry) => entry), + blocking: z.boolean(), + description: text(2_000), + }) + .strict(); +const fingerprintSchema = z + .object({ + kind: z.enum(["node", "relationship", "contract", "plan"]), + id: opaqueId, + digest, + }) + .strict(); + +export const agentBriefVersionRecordSchema = z + .object({ + schemaVersion: z.literal(1), + projectId, + briefId: generatedId("brief"), + version, + parentVersion: version.nullable(), + plannedAgentId: nodeId, + assignmentId: generatedId("assignment"), + plan: buildPlanRefSchema, + source: architectureSourceRefSchema, + mission: text(4_000), + scope: scopeSchema, + ownedNodeIds: unique(nodeId, (entry) => entry), + relevantNodeIds: unique(nodeId, (entry) => entry), + inputs: unique( + contractPortSchema, + (entry) => `${entry.contractId}\0${entry.nodeId}`, + ), + outputs: unique( + contractPortSchema, + (entry) => `${entry.contractId}\0${entry.nodeId}`, + ), + dependencies: unique(dependencySchema, (entry) => entry.dependencyId), + deliverables: unique(deliverableSchema, (entry) => entry.deliverableId), + acceptanceCriteria: unique(criterionSchema, (entry) => entry.criterionId), + constraints: unique(constraintSchema, (entry) => entry.constraintId), + milestones: unique(generatedId("milestone"), (entry) => entry), + unresolvedDecisions: unique(decisionSchema, (entry) => entry.decisionId), + changeProtocol: z + .object({ + proposeArchitectureChanges: z.boolean(), + instructions: unique(text(2_000), (entry) => entry), + }) + .strict(), + compilerVersion: opaqueId, + dependencyFingerprints: unique( + fingerprintSchema, + (entry) => `${entry.kind}\0${entry.id}`, + ), + semanticDigest: digest, + recordDigest: digest, + authoredBy: actorSchema, + createdAt: timestamp, + }) + .strict() + .superRefine((brief, context) => { + if (brief.version === 1 && brief.parentVersion !== null) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["parentVersion"], + message: "first version has no parent", + }); + if (brief.version > 1 && brief.parentVersion !== brief.version - 1) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["parentVersion"], + message: "parent must be previous version", + }); + if (!brief.ownedNodeIds.includes(brief.plannedAgentId)) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["ownedNodeIds"], + message: "brief must own its planned agent", + }); + if (hasDuplicateOrdinals(brief.acceptanceCriteria)) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["acceptanceCriteria"], + message: "ordered records require unique ordinals", + }); + const criterionIds = new Set( + brief.acceptanceCriteria.map((entry) => entry.criterionId), + ); + if ( + brief.deliverables.some((deliverable) => + deliverable.acceptanceCriterionIds.some((id) => !criterionIds.has(id)), + ) + ) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["deliverables"], + message: "unknown acceptance criterion", + }); + }); + +export const planningAssignmentRecordSchema = z + .object({ + schemaVersion: z.literal(1), + projectId, + assignmentId: generatedId("assignment"), + briefId: generatedId("brief"), + plannedAgentId: nodeId, + status: z.enum(["active", "retired"]), + createdAt: timestamp, + retiredAt: timestamp.nullable(), + transitions: z + .array( + z + .object({ + status: z.enum(["active", "retired"]), + at: timestamp, + planVersion: version, + }) + .strict(), + ) + .min(1) + .max(1_024), + }) + .strict() + .superRefine((assignment, context) => { + if ((assignment.status === "retired") !== (assignment.retiredAt !== null)) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["retiredAt"], + message: "assignment status mismatch", + }); + if ( + assignment.transitions[0]?.status !== "active" || + assignment.transitions.at(-1)?.status !== assignment.status || + assignment.transitions.some( + (entry, index) => + index > 0 && + entry.status === assignment.transitions[index - 1]?.status, + ) + ) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["transitions"], + message: "invalid assignment lifecycle history", + }); + }); + +export const builderPlanningSubmissionSchema = z + .object({ + schemaVersion: z.literal(1), + submissionId: generatedId("submission"), + projectId, + assignmentId: generatedId("assignment"), + sessionId: opaqueId, + source: architectureSourceRefSchema, + plan: buildPlanRefSchema, + brief: briefRefSchema, + status: z.enum(["ready", "blocked", "changes-proposed"]), + implementationPlan: unique( + z + .object({ + stepId: opaqueId, + ordinal: version, + description: text(2_000), + verification: text(2_000), + }) + .strict(), + (entry) => entry.stepId, + ), + risks: unique( + z + .object({ + riskId: opaqueId, + description: text(2_000), + mitigation: text(2_000), + }) + .strict(), + (entry) => entry.riskId, + ), + questions: unique( + z.object({ questionId: opaqueId, question: text(2_000) }).strict(), + (entry) => entry.questionId, + ), + proposedMapOperationIds: unique(operationId, (entry) => entry), + supersedesSubmissionId: generatedId("submission").nullable(), + semanticDigest: digest, + submittedAt: timestamp, + }) + .strict() + .superRefine((submission, context) => { + if (hasDuplicateOrdinals(submission.implementationPlan)) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["implementationPlan"], + message: "ordered records require unique ordinals", + }); + }); + +const receiptSchema = z + .object({ + sessionId: opaqueId, + requestId: opaqueId, + requestDigest: digest, + resultRecordDigest: digest, + createdAt: timestamp, + }) + .strict(); + +const buildPlanningAggregateSchema = z + .object({ + schemaVersion: z.literal(1), + planId: generatedId("build-plan").nullable(), + currentPlanVersion: version.nullable(), + planVersions: z.array(projectBuildPlanVersionSchema).max(1_024), + currentBriefByAgentId: z.record(nodeId, briefRefSchema), + briefVersionsById: z.record( + generatedId("brief"), + z.array(agentBriefVersionRecordSchema).max(1_024), + ), + assignmentByAgentId: z.record(nodeId, planningAssignmentRecordSchema), + submissionsByAssignmentId: z.record( + generatedId("assignment"), + z.array(builderPlanningSubmissionSchema).max(1_024), + ), + idempotencyReceipts: z.array(receiptSchema).max(256), + }) + .strict(); + +const sameSource = ( + left: ArchitectureSourceRef, + right: ArchitectureSourceRef, +) => JSON.stringify(left) === JSON.stringify(right); + +export function parseArchitectureSourceRef( + value: unknown, +): ArchitectureSourceRef { + return architectureSourceRefSchema.parse(value) as ArchitectureSourceRef; +} + +export function parseProjectBuildPlanVersion( + value: unknown, +): ProjectBuildPlanVersion { + return projectBuildPlanVersionSchema.parse( + value, + ) as unknown as ProjectBuildPlanVersion; +} + +export function parseAgentBriefVersionRecord( + value: unknown, +): AgentBriefVersionRecord { + return agentBriefVersionRecordSchema.parse( + value, + ) as unknown as AgentBriefVersionRecord; +} + +export function parsePlanningAssignmentRecord( + value: unknown, +): PlanningAssignmentRecord { + return planningAssignmentRecordSchema.parse( + value, + ) as unknown as PlanningAssignmentRecord; +} + +export function parseBuilderPlanningSubmission( + value: unknown, +): BuilderPlanningSubmission { + return builderPlanningSubmissionSchema.parse( + value, + ) as unknown as BuilderPlanningSubmission; +} + +/** Strict persistence parser. It rejects dangling/mismatched pointers and histories. */ +export function parseBuildPlanningAggregate( + value: unknown, + expectedProjectId: string, +): BuildPlanningAggregateV1 { + const aggregate = buildPlanningAggregateSchema.parse( + value, + ) as unknown as BuildPlanningAggregateV1; + const fail = () => { + throw new Error("invalid build planning aggregate"); + }; + if ( + (aggregate.planId === null) !== (aggregate.currentPlanVersion === null) || + (aggregate.planId === null) !== (aggregate.planVersions.length === 0) + ) + fail(); + aggregate.planVersions.forEach((plan, index) => { + if ( + plan.projectId !== expectedProjectId || + plan.planId !== aggregate.planId || + plan.version !== index + 1 + ) + fail(); + }); + if ( + aggregate.currentPlanVersion !== null && + !aggregate.planVersions.some( + (plan) => plan.version === aggregate.currentPlanVersion, + ) + ) + fail(); + if ( + aggregate.currentPlanVersion !== null && + aggregate.currentPlanVersion !== aggregate.planVersions.length + ) + fail(); + + const currentPlan = aggregate.planVersions.find( + (plan) => plan.version === aggregate.currentPlanVersion, + ); + + const plans = new Map( + aggregate.planVersions.map((plan) => [ + `${plan.planId}\0${plan.version}`, + plan, + ]), + ); + const assignments = new Map(); + for (const [agentId, assignment] of Object.entries( + aggregate.assignmentByAgentId, + )) { + if ( + assignment.projectId !== expectedProjectId || + assignment.plannedAgentId !== agentId || + assignments.has(assignment.assignmentId) + ) + fail(); + if ( + assignment.transitions.some( + (transition, index) => + transition.planVersion > (aggregate.currentPlanVersion ?? 0) || + (index > 0 && + transition.planVersion <= + assignment.transitions[index - 1]!.planVersion), + ) + ) + fail(); + assignments.set(assignment.assignmentId, assignment); + } + const activeAgentIds = Object.entries(aggregate.assignmentByAgentId) + .filter(([, assignment]) => assignment.status === "active") + .map(([agentId]) => agentId) + .sort(); + const plannedAgentIds = (currentPlan?.assignments ?? []) + .map((assignment) => assignment.plannedAgentId) + .sort(); + if (JSON.stringify(activeAgentIds) !== JSON.stringify(plannedAgentIds)) + fail(); + const briefs = new Map(); + for (const [briefId, history] of Object.entries( + aggregate.briefVersionsById, + )) { + history.forEach((brief, index) => { + const plan = plans.get(`${brief.plan.planId}\0${brief.plan.version}`); + const assignment = assignments.get(brief.assignmentId); + if ( + brief.projectId !== expectedProjectId || + brief.briefId !== briefId || + brief.version !== index + 1 || + !plan || + plan.semanticDigest !== brief.plan.semanticDigest || + !sameSource(plan.source, brief.source) || + !assignment || + assignment.briefId !== brief.briefId || + assignment.plannedAgentId !== brief.plannedAgentId + ) + fail(); + briefs.set(`${briefId}\0${brief.version}`, brief); + }); + } + for (const [agentId, ref] of Object.entries( + aggregate.currentBriefByAgentId, + )) { + const brief = briefs.get(`${ref.briefId}\0${ref.version}`); + const assignment = aggregate.assignmentByAgentId[agentId]; + if ( + !brief || + brief.semanticDigest !== ref.semanticDigest || + brief.plannedAgentId !== agentId || + !assignment || + assignment.status !== "active" || + ref.version !== aggregate.briefVersionsById[ref.briefId]?.length + ) + fail(); + } + const submissionIds = new Set(); + for (const [assignmentId, history] of Object.entries( + aggregate.submissionsByAssignmentId, + )) { + const assignment = assignments.get(assignmentId); + if (!assignment) fail(); + history.forEach((submission, index) => { + const plan = plans.get( + `${submission.plan.planId}\0${submission.plan.version}`, + ); + const brief = briefs.get( + `${submission.brief.briefId}\0${submission.brief.version}`, + ); + if ( + submissionIds.has(submission.submissionId) || + submission.projectId !== expectedProjectId || + submission.assignmentId !== assignmentId || + !plan || + plan.semanticDigest !== submission.plan.semanticDigest || + !sameSource(plan.source, submission.source) || + !brief || + brief.semanticDigest !== submission.brief.semanticDigest || + brief.assignmentId !== assignmentId || + (index === 0 + ? submission.supersedesSubmissionId !== null + : submission.supersedesSubmissionId !== + history[index - 1]?.submissionId) + ) + fail(); + submissionIds.add(submission.submissionId); + }); + } + if ( + new Set( + aggregate.idempotencyReceipts.map( + (entry) => `${entry.sessionId}\0${entry.requestId}`, + ), + ).size !== aggregate.idempotencyReceipts.length + ) + fail(); + if ( + aggregate.idempotencyReceipts.some( + (receipt) => + !aggregate.planVersions.some( + (plan) => plan.recordDigest === receipt.resultRecordDigest, + ), + ) + ) + fail(); + return structuredClone(aggregate); +} diff --git a/packages/harness/src/shared/build-plan.ts b/packages/harness/src/shared/build-plan.ts new file mode 100644 index 000000000..77051515b --- /dev/null +++ b/packages/harness/src/shared/build-plan.ts @@ -0,0 +1,404 @@ +import type { + MapProposalId, + PlanNodeId, + PlanRelationshipId, + ProposalOperationId, + StudioProjectId, +} from "./agent-map.js"; + +export const BUILD_PLAN_SCHEMA_VERSION = 1 as const; +export const BUILD_PLANNING_AGGREGATE_SCHEMA_VERSION = 1 as const; + +type BuildPlanBrand = T & { + readonly __brand: TBrand; +}; + +export type BuildPlanId = BuildPlanBrand; +export type BuildPlanVersion = BuildPlanBrand; +export type BuildPlanSemanticDigest = BuildPlanBrand< + string, + "BuildPlanSemanticDigest" +>; +export type AgentBriefId = BuildPlanBrand; +export type AgentBriefVersion = BuildPlanBrand; +export type AgentBriefSemanticDigest = BuildPlanBrand< + string, + "AgentBriefSemanticDigest" +>; +export type PlanningAssignmentId = BuildPlanBrand< + string, + "PlanningAssignmentId" +>; +export type BuilderPlanningSubmissionId = BuildPlanBrand< + string, + "BuilderPlanningSubmissionId" +>; +export type AgentMapRevisionId = BuildPlanBrand; +export type PlanContractId = BuildPlanBrand; +export type MilestoneId = BuildPlanBrand; +export type DeliverableId = BuildPlanBrand; +export type AcceptanceCriterionId = BuildPlanBrand< + string, + "AcceptanceCriterionId" +>; +export type PlanDecisionId = BuildPlanBrand; +export type BriefDependencyId = BuildPlanBrand; +export type GraphDigest = BuildPlanBrand; +export type RecordDigest = BuildPlanBrand; +export type PlanningSubmissionDigest = BuildPlanBrand< + string, + "PlanningSubmissionDigest" +>; + +export type ArchitectureSourceRef = + | Readonly<{ + kind: "proposal"; + proposalId: MapProposalId; + version: number; + graphDigest: GraphDigest; + }> + | Readonly<{ + kind: "revision"; + revisionId: AgentMapRevisionId; + revisionNumber: number; + graphDigest: GraphDigest; + }>; + +export interface BuildPlanRef { + planId: BuildPlanId; + version: BuildPlanVersion; + semanticDigest: BuildPlanSemanticDigest; +} + +export interface AgentBriefRef { + briefId: AgentBriefId; + version: AgentBriefVersion; + semanticDigest: AgentBriefSemanticDigest; +} + +export interface PlanningAssignmentRef { + assignmentId: PlanningAssignmentId; + briefId: AgentBriefId; + plannedAgentId: PlanNodeId; +} + +/** Trusted, path-free identity carried into a future builder planning session. */ +export interface BuilderPlanningContextRef { + projectId: StudioProjectId; + source: ArchitectureSourceRef; + plan: BuildPlanRef; + brief: AgentBriefRef; + assignment: PlanningAssignmentRef; +} + +export interface PlanningActorRef { + userId: string; + sessionId: string; + role: "map-planner" | "agent-builder"; +} + +export interface ProjectOutcome { + summary: string; +} + +export interface BuildMilestone { + milestoneId: MilestoneId; + ordinal: number; + title: string; + outcome: string; + dependsOn: readonly MilestoneId[]; +} + +export interface PlanConstraint { + constraintId: string; + description: string; + required: boolean; +} + +export interface RepositoryIntent { + repositoryIntentId: string; + plannedAgentId: PlanNodeId; + action: "create" | "bind" | "reuse"; + repositoryName: string; + notes: string; +} + +export interface AcceptanceCriterion { + criterionId: AcceptanceCriterionId; + ordinal: number; + description: string; + verification: string; +} + +export interface PlanDecision { + decisionId: PlanDecisionId; + question: string; + required: boolean; + status: "open" | "resolved"; + resolution: string | null; +} + +export interface BriefDeliverable { + deliverableId: DeliverableId; + description: string; + artifactNodeIds: readonly PlanNodeId[]; + acceptanceCriterionIds: readonly AcceptanceCriterionId[]; +} + +export interface AgentAssignmentIntent { + plannedAgentId: PlanNodeId; + mission: string; + scope: Readonly<{ inScope: readonly string[]; nonGoals: readonly string[] }>; + deliverables: readonly BriefDeliverable[]; + constraints: readonly PlanConstraint[]; + acceptanceCriteria: readonly AcceptanceCriterion[]; + milestoneIds: readonly MilestoneId[]; + unresolvedDecisions: readonly PlanDecision[]; +} + +export interface ProjectBuildPlanVersion { + schemaVersion: typeof BUILD_PLAN_SCHEMA_VERSION; + projectId: StudioProjectId; + planId: BuildPlanId; + version: BuildPlanVersion; + parentVersion: BuildPlanVersion | null; + changeKind: + | "created" + | "edited" + | "recompiled" + | "source-rebound" + | "restored"; + source: ArchitectureSourceRef; + outcome: ProjectOutcome; + milestones: readonly BuildMilestone[]; + sharedConstraints: readonly PlanConstraint[]; + repositoryIntents: readonly RepositoryIntent[]; + integrationCriteria: readonly AcceptanceCriterion[]; + assignments: readonly AgentAssignmentIntent[]; + unresolvedDecisions: readonly PlanDecision[]; + semanticDigest: BuildPlanSemanticDigest; + recordDigest: RecordDigest; + authoredBy: PlanningActorRef; + createdAt: string; +} + +export interface BriefContractPort { + contractId: PlanContractId; + nodeId: PlanNodeId; + relationshipIds: readonly PlanRelationshipId[]; + description: string; +} + +export interface BriefDependency { + dependencyId: BriefDependencyId; + kind: + | "consumes-output" + | "provides-input" + | "shared-resource" + | "sequence-gate" + | "coordination"; + direction: "upstream" | "downstream" | "bidirectional"; + counterpartAgentId: PlanNodeId; + relationshipIds: readonly PlanRelationshipId[]; + contractIds: readonly PlanContractId[]; + requiredByMilestoneIds: readonly MilestoneId[]; + blocking: boolean; + description: string; +} + +export interface DependencyFingerprint { + kind: "node" | "relationship" | "contract" | "plan"; + id: string; + digest: string; +} + +export interface BriefChangeProtocol { + proposeArchitectureChanges: boolean; + instructions: readonly string[]; +} + +export interface AgentBriefVersionRecord { + schemaVersion: typeof BUILD_PLAN_SCHEMA_VERSION; + projectId: StudioProjectId; + briefId: AgentBriefId; + version: AgentBriefVersion; + parentVersion: AgentBriefVersion | null; + plannedAgentId: PlanNodeId; + assignmentId: PlanningAssignmentId; + plan: BuildPlanRef; + source: ArchitectureSourceRef; + mission: string; + scope: Readonly<{ inScope: readonly string[]; nonGoals: readonly string[] }>; + ownedNodeIds: readonly PlanNodeId[]; + relevantNodeIds: readonly PlanNodeId[]; + inputs: readonly BriefContractPort[]; + outputs: readonly BriefContractPort[]; + dependencies: readonly BriefDependency[]; + deliverables: readonly BriefDeliverable[]; + acceptanceCriteria: readonly AcceptanceCriterion[]; + constraints: readonly PlanConstraint[]; + milestones: readonly MilestoneId[]; + unresolvedDecisions: readonly PlanDecision[]; + changeProtocol: BriefChangeProtocol; + compilerVersion: string; + dependencyFingerprints: readonly DependencyFingerprint[]; + semanticDigest: AgentBriefSemanticDigest; + recordDigest: RecordDigest; + authoredBy: PlanningActorRef; + createdAt: string; +} + +export interface BuildPlanDiagnostic { + code: + | "missing-agent-assignment" + | "unknown-node-reference" + | "cross-project-reference" + | "missing-brief" + | "incompatible-contract-direction" + | "invalid-dependency" + | "unresolved-required-decision" + | "source-not-found" + | "source-digest-mismatch"; + severity: "error" | "warning"; + path: string; + message: string; + relatedIds: readonly string[]; +} + +export interface BriefStaleReason { + code: + | "source-changed" + | "agent-added" + | "agent-removed" + | "ownership-changed" + | "contract-changed" + | "relationship-changed" + | "relevant-node-changed" + | "shared-plan-content-changed" + | "assignment-content-changed"; + affectedNodeIds: readonly PlanNodeId[]; + affectedRelationshipIds: readonly PlanRelationshipId[]; + affectedContractIds: readonly PlanContractId[]; + previousFingerprint?: string; + currentFingerprint?: string; +} + +export type BuildPlanCompleteness = Readonly<{ + status: "incomplete" | "complete"; + issues: readonly BuildPlanDiagnostic[]; +}>; + +export type BriefFreshness = Readonly<{ + status: "current" | "stale"; + evaluatedAgainst: ArchitectureSourceRef; + reasons: readonly BriefStaleReason[]; +}>; + +export type EligibilityReason = + | "plan-incomplete" + | "brief-missing" + | "brief-stale" + | "source-not-confirmed"; + +export type BuildPlanEligibility = Readonly<{ + planningEligible: boolean; + implementationEligible: boolean; + reasons: readonly EligibilityReason[]; +}>; + +export interface ImplementationPlanStep { + stepId: string; + ordinal: number; + description: string; + verification: string; +} + +export interface PlanningRisk { + riskId: string; + description: string; + mitigation: string; +} + +export interface PlanningQuestion { + questionId: string; + question: string; +} + +export interface BuilderPlanningSubmission { + schemaVersion: typeof BUILD_PLAN_SCHEMA_VERSION; + submissionId: BuilderPlanningSubmissionId; + projectId: StudioProjectId; + assignmentId: PlanningAssignmentId; + sessionId: string; + source: ArchitectureSourceRef; + plan: BuildPlanRef; + brief: AgentBriefRef; + status: "ready" | "blocked" | "changes-proposed"; + implementationPlan: readonly ImplementationPlanStep[]; + risks: readonly PlanningRisk[]; + questions: readonly PlanningQuestion[]; + proposedMapOperationIds: readonly ProposalOperationId[]; + supersedesSubmissionId: BuilderPlanningSubmissionId | null; + semanticDigest: PlanningSubmissionDigest; + submittedAt: string; +} + +export interface PlanningAssignmentRecord { + schemaVersion: typeof BUILD_PLAN_SCHEMA_VERSION; + projectId: StudioProjectId; + assignmentId: PlanningAssignmentId; + briefId: AgentBriefId; + plannedAgentId: PlanNodeId; + status: "active" | "retired"; + createdAt: string; + retiredAt: string | null; + transitions: readonly Readonly<{ + status: "active" | "retired"; + at: string; + planVersion: BuildPlanVersion; + }>[]; +} + +export interface BuildPlanIdempotencyReceipt { + sessionId: string; + requestId: string; + requestDigest: string; + resultRecordDigest: RecordDigest; + createdAt: string; +} + +export interface BuildPlanningAggregateV1 { + schemaVersion: typeof BUILD_PLANNING_AGGREGATE_SCHEMA_VERSION; + planId: BuildPlanId | null; + currentPlanVersion: BuildPlanVersion | null; + planVersions: readonly ProjectBuildPlanVersion[]; + currentBriefByAgentId: Readonly>; + briefVersionsById: Readonly< + Record + >; + assignmentByAgentId: Readonly>; + submissionsByAssignmentId: Readonly< + Record + >; + idempotencyReceipts: readonly BuildPlanIdempotencyReceipt[]; +} + +export interface BuildPlanImpactEvaluator { + evaluate(input: { + previousSource: ArchitectureSourceRef; + nextSource: ArchitectureSourceRef; + briefs: readonly AgentBriefVersionRecord[]; + }): Promise>>; +} + +export const emptyBuildPlanningAggregate = (): BuildPlanningAggregateV1 => ({ + schemaVersion: BUILD_PLANNING_AGGREGATE_SCHEMA_VERSION, + planId: null, + currentPlanVersion: null, + planVersions: [], + currentBriefByAgentId: {}, + briefVersionsById: {}, + assignmentByAgentId: {}, + submissionsByAssignmentId: {}, + idempotencyReceipts: [], +}); diff --git a/packages/harness/tsconfig.build.json b/packages/harness/tsconfig.build.json index 44958ab7a..cb7de7495 100644 --- a/packages/harness/tsconfig.build.json +++ b/packages/harness/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "dist", "web", "src/**/*.test.ts", "src/test-setup.ts", "src/**/__fixtures__/**"] + "exclude": ["node_modules", "dist", "web", "src/**/*.test.ts", "src/**/*.test-support.ts", "src/test-setup.ts", "src/**/__fixtures__/**"] } From 61a4a1c30e7ccfaf138ce9e8d24737b70fadc20f Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 3 Sep 2026 10:04:00 +0000 Subject: [PATCH 2/4] fix(harness): harden build planning persistence Closes: SAP-3067 --- .../durable-build-planning-contracts.md | 7 + .../core/agent-map-workspace-store.test.ts | 62 +++- .../src/core/agent-map-workspace-store.ts | 51 +++- .../core/architecture-source-resolver.test.ts | 99 ++++++ .../src/core/architecture-source-resolver.ts | 8 +- .../src/core/build-plan-canonicalization.ts | 18 ++ .../build-plan-contract-validator.test.ts | 235 ++++++++++++++- .../src/core/build-plan-contract-validator.ts | 192 ++++++++---- .../harness/src/core/build-plan-store.test.ts | 284 +++++++++++++++++- packages/harness/src/core/build-plan-store.ts | 194 ++++++++++-- packages/harness/src/index.ts | 12 +- .../harness/src/shared/build-plan-codec.ts | 62 ++-- packages/harness/src/shared/build-plan.ts | 32 ++ 13 files changed, 1114 insertions(+), 142 deletions(-) create mode 100644 .changeset/durable-build-planning-contracts.md diff --git a/.changeset/durable-build-planning-contracts.md b/.changeset/durable-build-planning-contracts.md new file mode 100644 index 000000000..82426ba7c --- /dev/null +++ b/.changeset/durable-build-planning-contracts.md @@ -0,0 +1,7 @@ +--- +"@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. + +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. diff --git a/packages/harness/src/core/agent-map-workspace-store.test.ts b/packages/harness/src/core/agent-map-workspace-store.test.ts index 1958e95a8..09c006ca7 100644 --- a/packages/harness/src/core/agent-map-workspace-store.test.ts +++ b/packages/harness/src/core/agent-map-workspace-store.test.ts @@ -3,8 +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"; @@ -147,21 +149,49 @@ describe("AgentMapWorkspaceStore", () => { projectId, "workspace.json", ); - const workspace = { + 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, - recordVersion: 3, - confirmedRevisionId: null, - activeProposalId: null, - projectBuildPlanId: null, - createdAt: "2026-09-01T12:00:00.000Z", - updatedAt: "2026-09-01T12:01:00.000Z", + 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[]; }; - await fs.mkdir(path.dirname(workspacePath), { recursive: true }); - await fs.writeFile( - workspacePath, - `${JSON.stringify({ storageSchemaVersion: 1, workspace, proposal: null, receipts: [] })}\n`, - ); + 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, @@ -169,11 +199,13 @@ describe("AgentMapWorkspaceStore", () => { expect(aggregate).toEqual({ storageSchemaVersion: 2, - workspace, - proposal: null, - receipts: [], + 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, ); diff --git a/packages/harness/src/core/agent-map-workspace-store.ts b/packages/harness/src/core/agent-map-workspace-store.ts index 6fc15e907..36205533a 100644 --- a/packages/harness/src/core/agent-map-workspace-store.ts +++ b/packages/harness/src/core/agent-map-workspace-store.ts @@ -25,6 +25,8 @@ import { computeAgentBriefSemanticDigest, computeBuildPlanRecordDigest, computeBuildPlanSemanticDigest, + computePlanningAssignmentRecordDigest, + computePlanningSubmissionRecordDigest, computePlanningSubmissionSemanticDigest, } from "./build-plan-canonicalization.js"; import { DurableFileLock } from "./durable-file-lock.js"; @@ -257,6 +259,19 @@ function parseAggregate( } catch { throw new AgentMapWorkspaceStoreError("malformed_state"); } + if (architecture.workspace.projectBuildPlanId !== buildPlanning.planId) + throw new AgentMapWorkspaceStoreError("malformed_state"); + return structuredClone({ + storageSchemaVersion: AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, + ...architecture, + buildPlanning, + }); +} + +/** Full immutable-record verification, run on initial/change load and writes. */ +function assertBuildPlanningIntegrity( + buildPlanning: BuildPlanningAggregateV1, +): void { if ( buildPlanning.planVersions.some( (plan) => @@ -270,22 +285,22 @@ function parseAggregate( computeAgentBriefSemanticDigest(brief) !== brief.semanticDigest || computeAgentBriefRecordDigest(brief) !== brief.recordDigest, ) || + Object.values(buildPlanning.assignmentByAgentId).some( + (assignment) => + computePlanningAssignmentRecordDigest(assignment) !== + assignment.recordDigest, + ) || Object.values(buildPlanning.submissionsByAssignmentId) .flat() .some( (submission) => computePlanningSubmissionSemanticDigest(submission) !== - submission.semanticDigest, + submission.semanticDigest || + computePlanningSubmissionRecordDigest(submission) !== + submission.recordDigest, ) ) throw new AgentMapWorkspaceStoreError("malformed_state"); - if (architecture.workspace.projectBuildPlanId !== buildPlanning.planId) - throw new AgentMapWorkspaceStoreError("malformed_state"); - return structuredClone({ - storageSchemaVersion: AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, - ...architecture, - buildPlanning, - }); } function migrateAggregateV1( @@ -316,6 +331,7 @@ function migrateAggregateV1( /** Crash-atomic owner of workspace, active proposal, history, and private receipts. */ export class AgentMapWorkspaceStore { private readonly queues = new Map>(); + private readonly verifiedFileIdentity = new Map(); constructor( private readonly agentMapRoot: string, @@ -373,8 +389,12 @@ export class AgentMapWorkspaceStore { }> { const file = this.workspacePath(projectId); let decoded: unknown; + let fileIdentity: string; try { - decoded = JSON.parse(await fs.readFile(file, "utf8")) as unknown; + const raw = await fs.readFile(file, "utf8"); + const stat = await fs.stat(file, { bigint: true }); + fileIdentity = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}`; + decoded = JSON.parse(raw) as unknown; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { @@ -408,8 +428,13 @@ export class AgentMapWorkspaceStore { needsWrite: true, created: false, }; + const aggregate = parseAggregate(decoded, projectId); + if (this.verifiedFileIdentity.get(projectId) !== fileIdentity) { + assertBuildPlanningIntegrity(aggregate.buildPlanning); + this.verifiedFileIdentity.set(projectId, fileIdentity); + } return { - aggregate: parseAggregate(decoded, projectId), + aggregate, needsWrite: false, created: false, }; @@ -444,6 +469,11 @@ export class AgentMapWorkspaceStore { } finally { await directoryHandle.close(); } + const stat = await fs.stat(file, { bigint: true }); + this.verifiedFileIdentity.set( + projectId, + `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}`, + ); } catch { throw storageError(); } finally { @@ -488,6 +518,7 @@ export class AgentMapWorkspaceStore { const next = outcome.next ? parseAggregate(outcome.next, projectId) : loaded.aggregate; + if (outcome.next) assertBuildPlanningIntegrity(next.buildPlanning); await this.persist(projectId, next); } if (loaded.created) diff --git a/packages/harness/src/core/architecture-source-resolver.test.ts b/packages/harness/src/core/architecture-source-resolver.test.ts index a447cb977..52a85486b 100644 --- a/packages/harness/src/core/architecture-source-resolver.test.ts +++ b/packages/harness/src/core/architecture-source-resolver.test.ts @@ -112,4 +112,103 @@ describe("ArchitectureSourceResolver", () => { }), ).rejects.toMatchObject({ code: "cross_project" }); }); + + it("rejects a revision reader that returns a different revision identity", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "source-resolver-")); + roots.push(root); + const requestedId = + "revision_00000000-0000-7000-8000-000000000006" as AgentMapRevisionId; + const returnedId = + "revision_00000000-0000-7000-8000-000000000007" as AgentMapRevisionId; + const graph = { nodes: [], relationships: [] }; + const resolver = new ArchitectureSourceResolver( + new AgentMapWorkspaceStore(root), + async () => ({ + projectId: PROJECT_ID, + revisionId: returnedId, + revisionNumber: 2, + graph, + }), + ); + + await expect( + resolver.resolve(PROJECT_ID, { + kind: "revision", + revisionId: requestedId, + revisionNumber: 2, + graphDigest: computeArchitectureGraphDigest(graph), + }), + ).rejects.toMatchObject({ code: "source_not_found" }); + }); + + it("verifies the proposal base revision identity before materializing", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "source-resolver-")); + roots.push(root); + const store = new AgentMapWorkspaceStore(root); + const baseRevisionId = + "revision_00000000-0000-7000-8000-000000000006" as AgentMapRevisionId; + const wrongRevisionId = + "revision_00000000-0000-7000-8000-000000000007" as AgentMapRevisionId; + await store.readOrCreate(PROJECT_ID); + await store.transact(PROJECT_ID, async (aggregate) => ({ + value: undefined, + next: { + ...aggregate, + workspace: { + ...aggregate.workspace, + confirmedRevisionId: baseRevisionId, + recordVersion: aggregate.workspace.recordVersion + 1, + updatedAt: "2026-09-03T09:00:00.000Z", + }, + }, + })); + const service = new AgentMapProposalService(store, { + readBaseRevision: async () => ({ nodes: [], relationships: [] }), + }); + const identity: PlanningSessionIdentity = { + projectId: PROJECT_ID, + userId: "user-1", + sessionId: "session-1", + role: "map-planner", + }; + const created = await service.propose(identity, { + schemaVersion: 1, + proposalId: null, + expectedVersion: 0, + requestId: "request-base", + operations: [ + { + kind: "add-node", + draftRef: "draft-base" as DraftRef, + node: { + kind: "agent", + name: "Builder", + purpose: "Build", + ownerAgent: null, + contractRefs: [], + }, + }, + ], + }); + const proposal = (await store.readAggregate(PROJECT_ID)).proposal!; + const source = { + kind: "proposal" as const, + proposalId: created.proposalId, + version: 1, + graphDigest: computeArchitectureGraphDigest({ + nodes: proposal.nodes, + relationships: proposal.relationships, + }), + }; + const resolver = new ArchitectureSourceResolver(store, async () => ({ + projectId: PROJECT_ID, + revisionId: wrongRevisionId, + revisionNumber: 1, + graph: { nodes: [], relationships: [] }, + })); + + await expect(resolver.resolve(PROJECT_ID, source)).rejects.toMatchObject({ + code: "source_not_found", + }); + }); }); diff --git a/packages/harness/src/core/architecture-source-resolver.ts b/packages/harness/src/core/architecture-source-resolver.ts index 86114de6c..df95b735f 100644 --- a/packages/harness/src/core/architecture-source-resolver.ts +++ b/packages/harness/src/core/architecture-source-resolver.ts @@ -57,7 +57,11 @@ export class ArchitectureSourceResolver { let graph: AgentMapGraph; if (source.kind === "revision") { const revision = await this.readRevision(source.revisionId); - if (!revision || revision.revisionNumber !== source.revisionNumber) + if ( + !revision || + revision.revisionId !== source.revisionId || + revision.revisionNumber !== source.revisionNumber + ) throw new ArchitectureSourceResolutionError("source_not_found"); if (revision.projectId !== projectId) throw new ArchitectureSourceResolutionError("cross_project"); @@ -76,7 +80,7 @@ export class ArchitectureSourceResolver { const revision = await this.readRevision( proposal.baseRevisionId as AgentMapRevisionId, ); - if (!revision) + if (!revision || revision.revisionId !== proposal.baseRevisionId) throw new ArchitectureSourceResolutionError("source_not_found"); if (revision.projectId !== projectId) throw new ArchitectureSourceResolutionError("cross_project"); diff --git a/packages/harness/src/core/build-plan-canonicalization.ts b/packages/harness/src/core/build-plan-canonicalization.ts index 0b0960f08..9e4ca2e67 100644 --- a/packages/harness/src/core/build-plan-canonicalization.ts +++ b/packages/harness/src/core/build-plan-canonicalization.ts @@ -8,6 +8,7 @@ import type { BuilderPlanningSubmission, GraphDigest, PlanningSubmissionDigest, + PlanningAssignmentRecord, ProjectBuildPlanVersion, RecordDigest, } from "../shared/build-plan.js"; @@ -196,6 +197,7 @@ export const computePlanningSubmissionSemanticDigest = ( "submittedAt", "supersedesSubmissionId", "semanticDigest", + "recordDigest", "source", ]); return hash("sapiom.planning-submission.semantic.v1", { @@ -217,6 +219,22 @@ export const computePlanningSubmissionSemanticDigest = ( }) as PlanningSubmissionDigest; }; +export const computePlanningSubmissionRecordDigest = ( + submission: BuilderPlanningSubmission, +): RecordDigest => + hash( + "sapiom.planning-submission.record.v1", + omit(submission, ["recordDigest"]), + ) as RecordDigest; + +export const computePlanningAssignmentRecordDigest = ( + assignment: PlanningAssignmentRecord, +): RecordDigest => + hash( + "sapiom.planning-assignment.record.v1", + omit(assignment, ["recordDigest"]), + ) as RecordDigest; + export const computeArchitectureGraphDigest = ( graph: AgentMapGraph, ): GraphDigest => diff --git a/packages/harness/src/core/build-plan-contract-validator.test.ts b/packages/harness/src/core/build-plan-contract-validator.test.ts index d46f6e0c6..3a8a1bce2 100644 --- a/packages/harness/src/core/build-plan-contract-validator.test.ts +++ b/packages/harness/src/core/build-plan-contract-validator.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it } from "vitest"; -import type { PlanDecisionId } from "../shared/build-plan.js"; +import type { + AgentMapGraph, + PlanNodeId, + PlanRelationshipId, +} from "../shared/agent-map.js"; +import type { + AgentBriefId, + ArchitectureSourceRef, + BriefDependencyId, + PlanContractId, + PlanDecisionId, + PlanningAssignmentId, +} from "../shared/build-plan.js"; import { AGENT_ID, graph, @@ -8,7 +20,10 @@ import { makePlan, PROJECT_ID, } from "./build-plan.test-support.js"; -import { BuildPlanContractValidator } from "./build-plan-contract-validator.js"; +import { + BuildPlanContractValidator, + computeBriefFreshness, +} from "./build-plan-contract-validator.js"; const validator = new BuildPlanContractValidator({ resolve: async (_projectId, source) => ({ @@ -68,4 +83,220 @@ describe("BuildPlanContractValidator", () => { ]); expect(result.eligibility.planningEligible).toBe(false); }); + + it("compares caller-built exact sources independently of property order", () => { + const plan = makePlan(); + const reordered: ArchitectureSourceRef = { + graphDigest: plan.source.graphDigest, + version: plan.source.kind === "proposal" ? plan.source.version : 1, + proposalId: + plan.source.kind === "proposal" + ? plan.source.proposalId + : ("proposal_00000000-0000-7000-8000-000000000005" as never), + kind: "proposal", + }; + + expect(computeBriefFreshness(makeBrief(plan), reordered).status).toBe( + "current", + ); + }); + + it("accepts subagent boundary evidence and shared-resource paths", async () => { + const fixture = dependencyFixture(); + const result = await fixture.validator.validate(fixture.plan, [ + fixture.primaryBrief, + fixture.counterpartBrief, + ]); + + expect(result.completeness.issues).toEqual([]); + expect(result.completeness.status).toBe("complete"); + }); + + it("rejects empty evidence and ports without endpoint-contract linkage", async () => { + const fixture = dependencyFixture(); + const invalid = makeBrief(fixture.plan, { + ...fixture.primaryBrief, + inputs: [ + { + ...fixture.primaryBrief.inputs[0]!, + contractId: fixture.otherContractId, + }, + ], + dependencies: [ + { + ...fixture.primaryBrief.dependencies[0]!, + kind: "coordination", + relationshipIds: [], + contractIds: [], + }, + ], + }); + const result = await fixture.validator.validate(fixture.plan, [ + invalid, + fixture.counterpartBrief, + ]); + + expect(result.completeness.issues.map(({ code }) => code)).toEqual([ + "invalid-dependency", + "incompatible-contract-direction", + ]); + }); }); + +function dependencyFixture() { + const counterpartAgentId = + "node_00000000-0000-7000-8000-000000000011" as PlanNodeId; + const primarySubagentId = + "node_00000000-0000-7000-8000-000000000012" as PlanNodeId; + const counterpartSubagentId = + "node_00000000-0000-7000-8000-000000000013" as PlanNodeId; + const resourceId = "node_00000000-0000-7000-8000-000000000014" as PlanNodeId; + const inboundId = + "rel_00000000-0000-7000-8000-000000000011" as PlanRelationshipId; + const primaryResourceId = + "rel_00000000-0000-7000-8000-000000000012" as PlanRelationshipId; + const counterpartResourceId = + "rel_00000000-0000-7000-8000-000000000013" as PlanRelationshipId; + const contractId = + "contract_00000000-0000-7000-8000-000000000011" as PlanContractId; + const otherContractId = + "contract_00000000-0000-7000-8000-000000000012" as PlanContractId; + const dependencyGraph: AgentMapGraph = { + nodes: [ + graph.nodes[0]!, + { + id: counterpartAgentId, + kind: "agent", + name: "Counterpart", + purpose: "Provide data", + ownerAgentId: null, + contractRefs: [], + }, + { + id: primarySubagentId, + kind: "subagent", + name: "Primary worker", + purpose: "Consume data", + ownerAgentId: AGENT_ID, + contractRefs: [contractId], + }, + { + id: counterpartSubagentId, + kind: "subagent", + name: "Counterpart worker", + purpose: "Produce data", + ownerAgentId: counterpartAgentId, + contractRefs: [contractId], + }, + { + id: resourceId, + kind: "resource", + name: "Shared queue", + purpose: "Coordinate work", + ownerAgentId: null, + contractRefs: [], + }, + ], + relationships: [ + { + id: inboundId, + fromNodeId: counterpartSubagentId, + toNodeId: primarySubagentId, + kind: "feeds", + executionMode: "asynchronous", + contractRef: contractId, + description: "Provides input", + }, + { + id: primaryResourceId, + fromNodeId: primarySubagentId, + toNodeId: resourceId, + kind: "uses", + executionMode: null, + contractRef: null, + description: "Uses shared queue", + }, + { + id: counterpartResourceId, + fromNodeId: counterpartSubagentId, + toNodeId: resourceId, + kind: "uses", + executionMode: null, + contractRef: null, + description: "Uses shared queue", + }, + ], + }; + const baseAssignment = makePlan().assignments[0]!; + const plan = makePlan({ + assignments: [ + baseAssignment, + { ...baseAssignment, plannedAgentId: counterpartAgentId }, + ], + }); + const primaryBrief = makeBrief(plan, { + ownedNodeIds: [AGENT_ID, primarySubagentId], + relevantNodeIds: [ + AGENT_ID, + primarySubagentId, + counterpartAgentId, + counterpartSubagentId, + resourceId, + ], + inputs: [ + { + contractId, + nodeId: primarySubagentId, + relationshipIds: [inboundId], + description: "Counterpart input", + }, + ], + dependencies: [ + { + dependencyId: + "dependency_00000000-0000-7000-8000-000000000011" as BriefDependencyId, + kind: "consumes-output", + direction: "upstream", + counterpartAgentId, + relationshipIds: [inboundId], + contractIds: [contractId], + requiredByMilestoneIds: [], + blocking: true, + description: "Consumes counterpart data", + }, + { + dependencyId: + "dependency_00000000-0000-7000-8000-000000000012" as BriefDependencyId, + kind: "shared-resource", + direction: "bidirectional", + counterpartAgentId, + relationshipIds: [primaryResourceId, counterpartResourceId], + contractIds: [], + requiredByMilestoneIds: [], + blocking: false, + description: "Shares a queue", + }, + ], + }); + const counterpartBrief = makeBrief(plan, { + briefId: "brief_00000000-0000-7000-8000-000000000011" as AgentBriefId, + assignmentId: + "assignment_00000000-0000-7000-8000-000000000011" as PlanningAssignmentId, + plannedAgentId: counterpartAgentId, + ownedNodeIds: [counterpartAgentId, counterpartSubagentId], + relevantNodeIds: [counterpartAgentId, counterpartSubagentId], + }); + return { + validator: new BuildPlanContractValidator({ + resolve: async (_projectId, source) => ({ + projectId: PROJECT_ID, + source, + graph: dependencyGraph, + }), + }), + plan, + primaryBrief, + counterpartBrief, + otherContractId, + }; +} diff --git a/packages/harness/src/core/build-plan-contract-validator.ts b/packages/harness/src/core/build-plan-contract-validator.ts index e9904d7c8..951e88581 100644 --- a/packages/harness/src/core/build-plan-contract-validator.ts +++ b/packages/harness/src/core/build-plan-contract-validator.ts @@ -1,12 +1,13 @@ import type { AgentMapGraph, PlanNodeId } from "../shared/agent-map.js"; -import type { - AgentBriefVersionRecord, - ArchitectureSourceRef, - BriefFreshness, - BuildPlanCompleteness, - BuildPlanDiagnostic, - BuildPlanEligibility, - ProjectBuildPlanVersion, +import { + architectureSourceRefsEqual, + type AgentBriefVersionRecord, + type ArchitectureSourceRef, + type BriefFreshness, + type BuildPlanCompleteness, + type BuildPlanDiagnostic, + type BuildPlanEligibility, + type ProjectBuildPlanVersion, } from "../shared/build-plan.js"; import { ArchitectureSourceResolutionError, @@ -24,10 +25,6 @@ export interface ExactArchitectureSourceResolver { const compare = (left: string, right: string) => left < right ? -1 : left > right ? 1 : 0; -const sameSource = ( - left: ArchitectureSourceRef, - right: ArchitectureSourceRef, -) => JSON.stringify(left) === JSON.stringify(right); function diagnostic( code: BuildPlanDiagnostic["code"], @@ -97,7 +94,7 @@ function validateBrief( issues.push( diagnostic("invalid-dependency", `${prefix}.plan`, [brief.plan.planId]), ); - if (!sameSource(brief.source, plan.source)) + if (!architectureSourceRefsEqual(brief.source, plan.source)) issues.push( diagnostic("source-digest-mismatch", `${prefix}.source`, [brief.briefId]), ); @@ -105,23 +102,20 @@ function validateBrief( const relationships = new Map( graph.relationships.map((entry) => [entry.id, entry]), ); - const knownContracts = new Set([ - ...graph.nodes.flatMap((node) => node.contractRefs), - ...graph.relationships.flatMap((entry) => - entry.contractRef === null ? [] : [entry.contractRef], - ), - ]); - const owned = new Set(brief.ownedNodeIds); - const belongsToPlannedAgent = (nodeId: PlanNodeId): boolean => { + const ownershipRoot = (nodeId: PlanNodeId): PlanNodeId | null => { const visited = new Set(); let current = nodes.get(nodeId); - while (current && current.ownerAgentId !== null) { - if (visited.has(current.id)) return false; + while (current) { + if (visited.has(current.id)) return null; visited.add(current.id); + if (current.ownerAgentId === null) + return current.kind === "agent" ? current.id : null; current = nodes.get(current.ownerAgentId); } - return current?.id === brief.plannedAgentId; + return null; }; + const belongsToPlannedAgent = (nodeId: PlanNodeId): boolean => + ownershipRoot(nodeId) === brief.plannedAgentId; const plannedNode = nodes.get(brief.plannedAgentId); if ( !plannedNode || @@ -154,6 +148,8 @@ function validateBrief( ); }); [...brief.inputs, ...brief.outputs].forEach((port, portIndex) => { + const isInput = portIndex < brief.inputs.length; + const evidence = port.relationshipIds.map((id) => relationships.get(id)); if (!nodes.has(port.nodeId)) issues.push( diagnostic( @@ -162,52 +158,130 @@ function validateBrief( [port.nodeId], ), ); - if (!knownContracts.has(port.contractId)) + const validEvidence = + port.relationshipIds.length > 0 && + evidence.every( + (relation) => + relation !== undefined && + relation.contractRef === port.contractId && + (isInput + ? relation.toNodeId === port.nodeId && + belongsToPlannedAgent(relation.toNodeId) && + ownershipRoot(relation.fromNodeId) !== brief.plannedAgentId + : relation.fromNodeId === port.nodeId && + belongsToPlannedAgent(relation.fromNodeId) && + ownershipRoot(relation.toNodeId) !== brief.plannedAgentId), + ); + if (!validEvidence) issues.push( diagnostic( - "invalid-dependency", - `${prefix}.ports[${portIndex}].contractId`, - [port.contractId], + "incompatible-contract-direction", + `${prefix}.ports[${portIndex}].relationshipIds`, + [port.contractId, ...port.relationshipIds], ), ); - port.relationshipIds.forEach((id) => { - const relation = relationships.get(id); - const isInput = portIndex < brief.inputs.length; - if ( - !relation || - (isInput - ? !owned.has(relation.toNodeId) - : !owned.has(relation.fromNodeId)) - ) - issues.push( - diagnostic( - "incompatible-contract-direction", - `${prefix}.ports[${portIndex}].relationshipIds`, - [id], - ), - ); - }); }); brief.dependencies.forEach((dependency, dependencyIndex) => { const counterpart = nodes.get(dependency.counterpartAgentId); - const supported = dependency.relationshipIds.every((id) => { - const relation = relationships.get(id); - return ( - relation && - ((owned.has(relation.fromNodeId) && - relation.toNodeId === dependency.counterpartAgentId) || - (owned.has(relation.toNodeId) && - relation.fromNodeId === dependency.counterpartAgentId)) - ); - }); - const contractsExist = dependency.contractIds.every((id) => - knownContracts.has(id), + const evidence = dependency.relationshipIds + .map((id) => relationships.get(id)) + .filter((entry) => entry !== undefined); + const ownToCounterpart = evidence.filter( + (relation) => + ownershipRoot(relation.fromNodeId) === brief.plannedAgentId && + ownershipRoot(relation.toNodeId) === dependency.counterpartAgentId, + ); + const counterpartToOwn = evidence.filter( + (relation) => + ownershipRoot(relation.fromNodeId) === dependency.counterpartAgentId && + ownershipRoot(relation.toNodeId) === brief.plannedAgentId, + ); + const supportsDirection = + dependency.direction === "upstream" + ? counterpartToOwn.length > 0 + : dependency.direction === "downstream" + ? ownToCounterpart.length > 0 + : ownToCounterpart.length > 0 && counterpartToOwn.length > 0; + const evidencedContracts = new Set( + evidence.flatMap((relation) => + relation.contractRef === null ? [] : [relation.contractRef], + ), + ); + const contractsLinked = dependency.contractIds.every((id) => + evidencedContracts.has(id), + ); + const milestoneIds = new Set( + plan.milestones.map(({ milestoneId }) => milestoneId), + ); + const milestonesLinked = dependency.requiredByMilestoneIds.every( + (id) => milestoneIds.has(id) && brief.milestones.includes(id), + ); + const sharedResourceIds = graph.nodes + .filter( + (node) => + node.kind === "resource" || + node.kind === "connector" || + node.kind === "artifact", + ) + .map((node) => node.id) + .filter((resourceId) => { + const adjacentRoots = new Set(); + for (const relation of evidence) { + if (relation.fromNodeId === resourceId) { + const root = ownershipRoot(relation.toNodeId); + if (root !== null) adjacentRoots.add(root); + } + if (relation.toNodeId === resourceId) { + const root = ownershipRoot(relation.fromNodeId); + if (root !== null) adjacentRoots.add(root); + } + } + return ( + adjacentRoots.has(brief.plannedAgentId) && + adjacentRoots.has(dependency.counterpartAgentId) + ); + }); + const allEvidenceResolved = + evidence.length === dependency.relationshipIds.length && + evidence.length > 0; + const allEvidenceCrossesBoundary = + ownToCounterpart.length + counterpartToOwn.length === evidence.length; + const allEvidenceUsesSharedResource = evidence.every((relation) => + sharedResourceIds.some( + (resourceId) => + relation.fromNodeId === resourceId || + relation.toNodeId === resourceId, + ), ); + const supported = + allEvidenceResolved && + contractsLinked && + milestonesLinked && + (dependency.kind === "consumes-output" + ? dependency.direction === "upstream" && + dependency.contractIds.length > 0 && + counterpartToOwn.length === evidence.length + : dependency.kind === "provides-input" + ? dependency.direction === "downstream" && + dependency.contractIds.length > 0 && + ownToCounterpart.length === evidence.length + : dependency.kind === "shared-resource" + ? dependency.direction === "bidirectional" && + sharedResourceIds.length > 0 && + allEvidenceUsesSharedResource + : dependency.kind === "sequence-gate" + ? dependency.requiredByMilestoneIds.length > 0 && + dependency.blocking && + allEvidenceCrossesBoundary && + supportsDirection + : allEvidenceCrossesBoundary && supportsDirection); if ( !counterpart || counterpart.kind !== "agent" || + counterpart.ownerAgentId !== null || + dependency.counterpartAgentId === brief.plannedAgentId || !supported || - !contractsExist + !contractsLinked ) issues.push( diagnostic( @@ -358,7 +432,7 @@ export function computeBriefFreshness( brief: AgentBriefVersionRecord, evaluatedAgainst: ArchitectureSourceRef, ): BriefFreshness { - if (sameSource(brief.source, evaluatedAgainst)) + if (architectureSourceRefsEqual(brief.source, evaluatedAgainst)) return { status: "current", evaluatedAgainst, reasons: [] }; return { status: "stale", diff --git a/packages/harness/src/core/build-plan-store.test.ts b/packages/harness/src/core/build-plan-store.test.ts index 5def5cbf9..69bef9311 100644 --- a/packages/harness/src/core/build-plan-store.test.ts +++ b/packages/harness/src/core/build-plan-store.test.ts @@ -5,6 +5,9 @@ import { afterEach, describe, expect, it } from "vitest"; import type { AgentBriefId, + AgentBriefVersion, + AgentBriefVersionRecord, + BuildPlanRef, BuilderPlanningSubmission, BuilderPlanningSubmissionId, PlanningAssignmentId, @@ -24,6 +27,7 @@ import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; import { BuildPlanStore } from "./build-plan-store.js"; import { computeArchitectureGraphDigest, + computePlanningSubmissionRecordDigest, computePlanningSubmissionSemanticDigest, } from "./build-plan-canonicalization.js"; @@ -44,21 +48,65 @@ describe("BuildPlanStore", () => { ); async function fixture( options: ConstructorParameters[1] = {}, + buildPlanOptions: ConstructorParameters[1] = {}, ) { const root = await fs.mkdtemp(path.join(os.tmpdir(), "build-plan-store-")); roots.push(root); const workspaceStore = new AgentMapWorkspaceStore(root, options); const buildPlanStore = new BuildPlanStore(workspaceStore, { - allocator: { + ...buildPlanOptions, + allocator: buildPlanOptions.allocator ?? { allocateBuildPlanId: () => makePlan().planId, allocateBriefId: () => BRIEF_ID as AgentBriefId, allocateAssignmentId: () => ASSIGNMENT_ID as PlanningAssignmentId, }, - now: () => new Date("2026-09-03T09:05:00.000Z"), + now: buildPlanOptions.now ?? (() => new Date("2026-09-03T09:05:00.000Z")), }); return { root, workspaceStore, buildPlanStore }; } + function submissionFor( + plan: BuildPlanRef, + brief: AgentBriefVersionRecord, + overrides: Partial = {}, + ): BuilderPlanningSubmission { + const draft = { + schemaVersion: 1, + submissionId: + "submission_00000000-0000-7000-8000-000000000008" as BuilderPlanningSubmissionId, + projectId: PROJECT_ID, + assignmentId: brief.assignmentId, + sessionId: "builder-session-1", + source: brief.source, + plan, + brief: { + briefId: brief.briefId, + version: brief.version, + semanticDigest: brief.semanticDigest, + }, + status: "ready", + implementationPlan: [ + { + stepId: "step-1", + ordinal: 1, + description: "Implement", + verification: "Run tests", + }, + ], + risks: [], + questions: [], + proposedMapOperationIds: [], + supersedesSubmissionId: null, + semanticDigest: `sha256:${"0".repeat(64)}`, + recordDigest: `sha256:${"0".repeat(64)}`, + submittedAt: "2026-09-03T09:10:00.000Z", + ...overrides, + } as unknown as BuilderPlanningSubmission; + draft.semanticDigest = computePlanningSubmissionSemanticDigest(draft); + draft.recordDigest = computePlanningSubmissionRecordDigest(draft); + return draft; + } + it("persists stable assignments, immutable brief history, replay, and restart", async () => { const { root, buildPlanStore } = await fixture(); const plan = makePlan(); @@ -68,7 +116,7 @@ describe("BuildPlanStore", () => { briefId: first.assignments[0]!.briefId, assignmentId: first.assignments[0]!.assignmentId, }); - await buildPlanStore.commitBriefVersions(PROJECT_ID, [brief]); + await buildPlanStore.commitBriefVersions(PROJECT_ID, first.plan, [brief]); const submission = { schemaVersion: 1, submissionId: @@ -97,10 +145,12 @@ describe("BuildPlanStore", () => { proposedMapOperationIds: [], supersedesSubmissionId: null, semanticDigest: `sha256:${"0".repeat(64)}`, + recordDigest: `sha256:${"0".repeat(64)}`, submittedAt: "2026-09-03T09:10:00.000Z", } as unknown as BuilderPlanningSubmission; submission.semanticDigest = computePlanningSubmissionSemanticDigest(submission); + submission.recordDigest = computePlanningSubmissionRecordDigest(submission); await buildPlanStore.commitSubmission(submission); const restarted = new BuildPlanStore(new AgentMapWorkspaceStore(root)); @@ -124,8 +174,8 @@ describe("BuildPlanStore", () => { ).toEqual(brief); }); - it("rejects record corruption after restart", async () => { - const { root, buildPlanStore } = await fixture(); + it("revalidates record integrity when the on-disk file changes", async () => { + const { root, workspaceStore, buildPlanStore } = await fixture(); await buildPlanStore.commitPlanVersion(makePlan(), graph, request); const file = path.join(root, "projects", PROJECT_ID, "workspace.json"); const persisted = JSON.parse(await fs.readFile(file, "utf8")) as { @@ -135,7 +185,7 @@ describe("BuildPlanStore", () => { await fs.writeFile(file, `${JSON.stringify(persisted)}\n`); await expect( - new AgentMapWorkspaceStore(root).readAggregate(PROJECT_ID), + workspaceStore.readAggregate(PROJECT_ID), ).rejects.toMatchObject({ code: "malformed_state", }); @@ -192,6 +242,228 @@ describe("BuildPlanStore", () => { }); }); + it("rejects a delayed brief compiler after the current plan advances", async () => { + const { buildPlanStore } = await fixture(); + const firstPlan = makePlan(); + const first = await buildPlanStore.commitPlanVersion( + firstPlan, + graph, + request, + ); + const delayedBrief = makeBrief(firstPlan, { + briefId: first.assignments[0]!.briefId, + assignmentId: first.assignments[0]!.assignmentId, + }); + const secondPlan = makePlan({ + version: 2 as BuildPlanVersion, + parentVersion: 1 as BuildPlanVersion, + changeKind: "edited", + source: { ...proposalSource(), version: 2 }, + }); + await buildPlanStore.commitPlanVersion(secondPlan, graph, { + ...request, + requestId: "request-2", + requestDigest: `sha256:${"b".repeat(64)}`, + }); + + await expect( + buildPlanStore.commitBriefVersions(PROJECT_ID, first.plan, [ + delayedBrief, + ]), + ).rejects.toMatchObject({ code: "version_conflict" }); + expect( + (await buildPlanStore.read(PROJECT_ID)).currentBriefByAgentId, + ).toEqual({}); + }); + + it("fails closed when an exact idempotency receipt ages into a tombstone", async () => { + const { buildPlanStore } = await fixture({}, { receiptRetentionLimit: 1 }); + const firstPlan = makePlan(); + await buildPlanStore.commitPlanVersion(firstPlan, graph, request); + const secondPlan = makePlan({ + version: 2 as BuildPlanVersion, + parentVersion: 1 as BuildPlanVersion, + changeKind: "edited", + source: { ...proposalSource(), version: 2 }, + }); + await buildPlanStore.commitPlanVersion(secondPlan, graph, { + ...request, + requestId: "request-2", + requestDigest: `sha256:${"b".repeat(64)}`, + }); + + await expect( + buildPlanStore.commitPlanVersion(firstPlan, graph, request), + ).rejects.toMatchObject({ code: "request_id_expired" }); + await expect( + buildPlanStore.commitPlanVersion(firstPlan, graph, { + ...request, + requestDigest: `sha256:${"f".repeat(64)}`, + }), + ).rejects.toMatchObject({ code: "request_id_expired" }); + expect( + (await buildPlanStore.read(PROJECT_ID)).idempotencyTombstones, + ).toEqual([{ sessionId: request.sessionId, requestId: request.requestId }]); + }); + + it("reports explicit limits without allocating another durable version", async () => { + const { buildPlanStore } = await fixture( + {}, + { historyLimits: { planVersions: 1 } }, + ); + await buildPlanStore.commitPlanVersion(makePlan(), graph, request); + const secondPlan = makePlan({ + version: 2 as BuildPlanVersion, + parentVersion: 1 as BuildPlanVersion, + changeKind: "edited", + source: { ...proposalSource(), version: 2 }, + }); + + await expect( + buildPlanStore.commitPlanVersion(secondPlan, graph, { + ...request, + requestId: "request-2", + }), + ).rejects.toMatchObject({ + code: "history_limit_exceeded", + historyKind: "plan-versions", + limit: 1, + }); + expect((await buildPlanStore.read(PROJECT_ID)).planVersions).toHaveLength( + 1, + ); + }); + + it("reports explicit brief and submission history limits", async () => { + const briefFixture = await fixture( + {}, + { historyLimits: { briefVersions: 1 } }, + ); + const briefPlan = makePlan(); + const briefCommit = await briefFixture.buildPlanStore.commitPlanVersion( + briefPlan, + graph, + request, + ); + const firstBrief = makeBrief(briefPlan, { + briefId: briefCommit.assignments[0]!.briefId, + assignmentId: briefCommit.assignments[0]!.assignmentId, + }); + await briefFixture.buildPlanStore.commitBriefVersions( + PROJECT_ID, + briefCommit.plan, + [firstBrief], + ); + const secondBrief = makeBrief(briefPlan, { + ...firstBrief, + version: 2 as AgentBriefVersion, + parentVersion: 1 as AgentBriefVersion, + }); + await expect( + briefFixture.buildPlanStore.commitBriefVersions( + PROJECT_ID, + briefCommit.plan, + [secondBrief], + ), + ).rejects.toMatchObject({ + code: "history_limit_exceeded", + historyKind: "brief-versions", + limit: 1, + }); + + const submissionFixture = await fixture( + {}, + { historyLimits: { planningSubmissions: 1 } }, + ); + const submissionPlan = makePlan(); + const submissionCommit = + await submissionFixture.buildPlanStore.commitPlanVersion( + submissionPlan, + graph, + request, + ); + const submissionBrief = makeBrief(submissionPlan, { + briefId: submissionCommit.assignments[0]!.briefId, + assignmentId: submissionCommit.assignments[0]!.assignmentId, + }); + await submissionFixture.buildPlanStore.commitBriefVersions( + PROJECT_ID, + submissionCommit.plan, + [submissionBrief], + ); + const firstSubmission = submissionFor( + submissionCommit.plan, + submissionBrief, + ); + await submissionFixture.buildPlanStore.commitSubmission(firstSubmission); + const secondSubmission = submissionFor( + submissionCommit.plan, + submissionBrief, + { + submissionId: + "submission_00000000-0000-7000-8000-000000000009" as BuilderPlanningSubmissionId, + supersedesSubmissionId: firstSubmission.submissionId, + submittedAt: "2026-09-03T09:11:00.000Z", + }, + ); + await expect( + submissionFixture.buildPlanStore.commitSubmission(secondSubmission), + ).rejects.toMatchObject({ + code: "history_limit_exceeded", + historyKind: "planning-submissions", + limit: 1, + }); + }); + + it.each(["assignment transition", "submission provenance"] as const)( + "detects %s tampering after restart", + async (target) => { + const { root, buildPlanStore } = await fixture(); + const plan = makePlan(); + const committed = await buildPlanStore.commitPlanVersion( + plan, + graph, + request, + ); + const brief = makeBrief(plan, { + briefId: committed.assignments[0]!.briefId, + assignmentId: committed.assignments[0]!.assignmentId, + }); + await buildPlanStore.commitBriefVersions(PROJECT_ID, committed.plan, [ + brief, + ]); + await buildPlanStore.commitSubmission( + submissionFor(committed.plan, brief), + ); + const file = path.join(root, "projects", PROJECT_ID, "workspace.json"); + const persisted = JSON.parse(await fs.readFile(file, "utf8")) as { + buildPlanning: { + assignmentByAgentId: Record< + string, + { transitions: Array<{ at: string }> } + >; + submissionsByAssignmentId: Record< + string, + Array<{ sessionId: string }> + >; + }; + }; + if (target === "assignment transition") + persisted.buildPlanning.assignmentByAgentId[ + AGENT_ID + ]!.transitions[0]!.at = "2026-09-03T09:06:00.000Z"; + else + persisted.buildPlanning.submissionsByAssignmentId[ + ASSIGNMENT_ID + ]![0]!.sessionId = "tampered-session"; + await fs.writeFile(file, `${JSON.stringify(persisted)}\n`); + + await expect( + new AgentMapWorkspaceStore(root).readAggregate(PROJECT_ID), + ).rejects.toMatchObject({ code: "malformed_state" }); + }, + ); + it("does not publish IDs or versions when the atomic replace fails", async () => { let fail = false; const { root, workspaceStore, buildPlanStore } = await fixture({ diff --git a/packages/harness/src/core/build-plan-store.ts b/packages/harness/src/core/build-plan-store.ts index ebae60f18..ba34bda48 100644 --- a/packages/harness/src/core/build-plan-store.ts +++ b/packages/harness/src/core/build-plan-store.ts @@ -5,18 +5,23 @@ import type { PlanNodeId, StudioProjectId, } from "../shared/agent-map.js"; -import type { - AgentBriefId, - AgentBriefRef, - AgentBriefVersionRecord, - BuildPlanIdempotencyReceipt, - BuildPlanId, - BuildPlanRef, - BuilderPlanningSubmission, - PlanningAssignmentId, - PlanningAssignmentRef, - PlanningAssignmentRecord, - ProjectBuildPlanVersion, +import { + AGENT_BRIEF_VERSION_HISTORY_LIMIT, + architectureSourceRefsEqual, + BUILD_PLAN_VERSION_HISTORY_LIMIT, + PLANNING_SUBMISSION_HISTORY_LIMIT, + type AgentBriefId, + type AgentBriefRef, + type AgentBriefVersionRecord, + type BuildPlanIdempotencyReceipt, + type BuildPlanId, + type BuildPlanRef, + type BuilderPlanningSubmission, + type PlanningAssignmentId, + type PlanningAssignmentRef, + type PlanningAssignmentRecord, + type ProjectBuildPlanVersion, + type RecordDigest, } from "../shared/build-plan.js"; import { parseAgentBriefVersionRecord, @@ -30,20 +35,46 @@ import { computeBuildPlanRecordDigest, computeBuildPlanSemanticDigest, computeArchitectureGraphDigest, + computePlanningAssignmentRecordDigest, + computePlanningSubmissionRecordDigest, computePlanningSubmissionSemanticDigest, } from "./build-plan-canonicalization.js"; export class BuildPlanStoreConflictError extends Error { - constructor(readonly code: "version_conflict" | "request_id_reused") { + constructor( + readonly code: + | "version_conflict" + | "request_id_reused" + | "request_id_expired", + ) { super( code === "version_conflict" ? "Build planning version changed" - : "Build planning request ID was reused", + : code === "request_id_reused" + ? "Build planning request ID was reused" + : "Build planning request replay has expired", ); this.name = "BuildPlanStoreConflictError"; } } +export type BuildPlanHistoryKind = + | "plan-versions" + | "brief-versions" + | "planning-submissions"; + +export class BuildPlanStoreLimitError extends Error { + readonly code = "history_limit_exceeded" as const; + + constructor( + readonly historyKind: BuildPlanHistoryKind, + readonly limit: number, + ) { + super(`Build planning ${historyKind} history limit was reached`); + this.name = "BuildPlanStoreLimitError"; + } +} + export interface BuildPlanIdentityAllocator { allocateBuildPlanId(): BuildPlanId; allocateBriefId(): AgentBriefId; @@ -62,21 +93,85 @@ export interface BuildPlanCommitIdentity { requestDigest: string; } -const sameSource = (left: unknown, right: unknown) => - JSON.stringify(left) === JSON.stringify(right); +export interface BuildPlanStoreOptions { + allocator?: BuildPlanIdentityAllocator; + now?: () => Date; + receiptRetentionLimit?: number; + historyLimits?: Partial< + Readonly<{ + planVersions: number; + briefVersions: number; + planningSubmissions: number; + }> + >; +} + +const ZERO_RECORD_DIGEST = `sha256:${"0".repeat(64)}` as RecordDigest; +const sealAssignment = ( + assignment: PlanningAssignmentRecord, +): PlanningAssignmentRecord => ({ + ...assignment, + recordDigest: computePlanningAssignmentRecordDigest(assignment), +}); +const samePlanRef = (left: BuildPlanRef, right: BuildPlanRef) => + left.planId === right.planId && + left.version === right.version && + left.semanticDigest === right.semanticDigest; /** Persistence primitives over the same crash-atomic E2 project aggregate. */ export class BuildPlanStore { private readonly allocator: BuildPlanIdentityAllocator; private readonly now: () => Date; + private readonly receiptRetentionLimit: number; + private readonly historyLimits: { + planVersions: number; + briefVersions: number; + planningSubmissions: number; + }; constructor( private readonly store: AgentMapWorkspaceStore, - options: { allocator?: BuildPlanIdentityAllocator; now?: () => Date } = {}, + options: BuildPlanStoreOptions = {}, ) { this.allocator = options.allocator ?? new UuidV7BuildPlanIdentityAllocator(); this.now = options.now ?? (() => new Date()); + const requestedReceiptLimit = options.receiptRetentionLimit ?? 256; + if ( + !Number.isSafeInteger(requestedReceiptLimit) || + requestedReceiptLimit < 1 + ) + throw new RangeError("receiptRetentionLimit must be a positive integer"); + this.receiptRetentionLimit = Math.min(requestedReceiptLimit, 256); + this.historyLimits = { + planVersions: + options.historyLimits?.planVersions ?? BUILD_PLAN_VERSION_HISTORY_LIMIT, + briefVersions: + options.historyLimits?.briefVersions ?? + AGENT_BRIEF_VERSION_HISTORY_LIMIT, + planningSubmissions: + options.historyLimits?.planningSubmissions ?? + PLANNING_SUBMISSION_HISTORY_LIMIT, + }; + for (const [name, limit, maximum] of [ + [ + "planVersions", + this.historyLimits.planVersions, + BUILD_PLAN_VERSION_HISTORY_LIMIT, + ], + [ + "briefVersions", + this.historyLimits.briefVersions, + AGENT_BRIEF_VERSION_HISTORY_LIMIT, + ], + [ + "planningSubmissions", + this.historyLimits.planningSubmissions, + PLANNING_SUBMISSION_HISTORY_LIMIT, + ], + ] as const) + if (!Number.isSafeInteger(limit) || limit < 1 || limit > maximum) + throw new RangeError(`${name} history limit is invalid`); } async read(projectId: StudioProjectId) { @@ -168,6 +263,19 @@ export class BuildPlanStore { value: { plan: this.planRef(plan), assignments, replayed: true }, }; } + if ( + planning.idempotencyTombstones.some( + (entry) => + entry.sessionId === request.sessionId && + entry.requestId === request.requestId, + ) + ) + throw new BuildPlanStoreConflictError("request_id_expired"); + if (planning.planVersions.length >= this.historyLimits.planVersions) + throw new BuildPlanStoreLimitError( + "plan-versions", + this.historyLimits.planVersions, + ); if ( (planning.planId !== null && planning.planId !== plan.planId) || plan.version !== planning.planVersions.length + 1 || @@ -183,7 +291,7 @@ export class BuildPlanStore { !active.has(agentId as PlanNodeId) && existing.status === "active" ) { - assignmentByAgentId[agentId] = { + assignmentByAgentId[agentId] = sealAssignment({ ...existing, status: "retired", retiredAt: timestamp, @@ -195,7 +303,7 @@ export class BuildPlanStore { planVersion: plan.version, }, ], - }; + }); delete currentBriefByAgentId[agentId]; } } @@ -203,7 +311,7 @@ export class BuildPlanStore { const existing = assignmentByAgentId[agentId]; assignmentByAgentId[agentId] = existing ? existing.status === "retired" - ? { + ? sealAssignment({ ...existing, status: "active", retiredAt: null, @@ -215,9 +323,9 @@ export class BuildPlanStore { planVersion: plan.version, }, ], - } + }) : existing - : { + : sealAssignment({ schemaVersion: 1, projectId: plan.projectId, assignmentId: this.allocator.allocateAssignmentId(), @@ -229,13 +337,19 @@ export class BuildPlanStore { transitions: [ { status: "active", at: timestamp, planVersion: plan.version }, ], - }; + recordDigest: ZERO_RECORD_DIGEST, + }); } const receipt: BuildPlanIdempotencyReceipt = { ...request, resultRecordDigest: plan.recordDigest, createdAt: timestamp, }; + const retainedReceipts = [...planning.idempotencyReceipts, receipt]; + const expiredReceipts = retainedReceipts.slice( + 0, + -this.receiptRetentionLimit, + ); const nextPlanning = { ...planning, planId: plan.planId, @@ -243,9 +357,16 @@ export class BuildPlanStore { planVersions: [...planning.planVersions, plan], currentBriefByAgentId, assignmentByAgentId, - idempotencyReceipts: [...planning.idempotencyReceipts, receipt].slice( - -256, + idempotencyReceipts: retainedReceipts.slice( + -this.receiptRetentionLimit, ), + idempotencyTombstones: [ + ...planning.idempotencyTombstones, + ...expiredReceipts.map(({ sessionId, requestId }) => ({ + sessionId, + requestId, + })), + ], }; const next = { ...aggregate, @@ -272,6 +393,7 @@ export class BuildPlanStore { async commitBriefVersions( projectId: StudioProjectId, + expectedPlan: BuildPlanRef, input: readonly AgentBriefVersionRecord[], ): Promise { const briefs = input.map(parseAgentBriefVersionRecord); @@ -285,11 +407,19 @@ export class BuildPlanStore { } return this.store.transact(projectId, async (aggregate) => { const planning = aggregate.buildPlanning; + const currentPlan = planning.planVersions.at(-1); + if (!currentPlan || !samePlanRef(this.planRef(currentPlan), expectedPlan)) + throw new BuildPlanStoreConflictError("version_conflict"); const histories = { ...planning.briefVersionsById }; const current = { ...planning.currentBriefByAgentId }; for (const brief of briefs) { const assignment = planning.assignmentByAgentId[brief.plannedAgentId]; const history = histories[brief.briefId] ?? []; + if (history.length >= this.historyLimits.briefVersions) + throw new BuildPlanStoreLimitError( + "brief-versions", + this.historyLimits.briefVersions, + ); const plan = planning.planVersions.find( (entry) => entry.planId === brief.plan.planId && @@ -304,7 +434,8 @@ export class BuildPlanStore { brief.parentVersion !== (history.at(-1)?.version ?? null) || !plan || plan.semanticDigest !== brief.plan.semanticDigest || - !sameSource(plan.source, brief.source) + !samePlanRef(brief.plan, expectedPlan) || + !architectureSourceRefsEqual(currentPlan.source, brief.source) ) throw new BuildPlanStoreConflictError("version_conflict"); histories[brief.briefId] = [...history, brief]; @@ -334,7 +465,9 @@ export class BuildPlanStore { const submission = parseBuilderPlanningSubmission(input); if ( computePlanningSubmissionSemanticDigest(submission) !== - submission.semanticDigest + submission.semanticDigest || + computePlanningSubmissionRecordDigest(submission) !== + submission.recordDigest ) throw new Error("invalid planning submission digest"); await this.store.transact(submission.projectId, async (aggregate) => { @@ -349,6 +482,11 @@ export class BuildPlanStore { ]; const history = planning.submissionsByAssignmentId[submission.assignmentId] ?? []; + if (history.length >= this.historyLimits.planningSubmissions) + throw new BuildPlanStoreLimitError( + "planning-submissions", + this.historyLimits.planningSubmissions, + ); const plan = planning.planVersions.find( (entry) => entry.planId === submission.plan.planId && @@ -361,7 +499,7 @@ export class BuildPlanStore { !assignment || !plan || plan.semanticDigest !== submission.plan.semanticDigest || - !sameSource(plan.source, submission.source) || + !architectureSourceRefsEqual(plan.source, submission.source) || !brief || brief.semanticDigest !== submission.brief.semanticDigest || brief.assignmentId !== submission.assignmentId || diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 720ef3fac..9c2059a77 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -29,10 +29,16 @@ export type { } from "./shared/agent-map.js"; export { BUILD_PLAN_SCHEMA_VERSION, - BUILD_PLANNING_AGGREGATE_SCHEMA_VERSION, - emptyBuildPlanningAggregate, + architectureSourceRefsEqual, +} from "./shared/build-plan.js"; +export type { + AgentBriefRef, + ArchitectureSourceRef, + BuilderPlanningContextRef, + BuilderPlanningSubmission, + BuildPlanRef, + PlanningAssignmentRef, } from "./shared/build-plan.js"; -export type * from "./shared/build-plan.js"; export type { WorkspaceScopeSummary } from "./shared/system-graph.js"; export { AGENT_STUDIO_PRODUCT_NAME } from "./shared/branding.js"; export { diff --git a/packages/harness/src/shared/build-plan-codec.ts b/packages/harness/src/shared/build-plan-codec.ts index a14376b4d..f32f29ff7 100644 --- a/packages/harness/src/shared/build-plan-codec.ts +++ b/packages/harness/src/shared/build-plan-codec.ts @@ -1,12 +1,16 @@ import { z } from "zod"; -import type { - AgentBriefVersionRecord, - ArchitectureSourceRef, - BuilderPlanningSubmission, - BuildPlanningAggregateV1, - PlanningAssignmentRecord, - ProjectBuildPlanVersion, +import { + AGENT_BRIEF_VERSION_HISTORY_LIMIT, + architectureSourceRefsEqual, + BUILD_PLAN_VERSION_HISTORY_LIMIT, + PLANNING_SUBMISSION_HISTORY_LIMIT, + type AgentBriefVersionRecord, + type ArchitectureSourceRef, + type BuilderPlanningSubmission, + type BuildPlanningAggregateV1, + type PlanningAssignmentRecord, + type ProjectBuildPlanVersion, } from "./build-plan.js"; const UUID_V7 = @@ -438,6 +442,7 @@ export const planningAssignmentRecordSchema = z ) .min(1) .max(1_024), + recordDigest: digest, }) .strict() .superRefine((assignment, context) => { @@ -502,6 +507,7 @@ export const builderPlanningSubmissionSchema = z proposedMapOperationIds: unique(operationId, (entry) => entry), supersedesSubmissionId: generatedId("submission").nullable(), semanticDigest: digest, + recordDigest: digest, submittedAt: timestamp, }) .strict() @@ -523,32 +529,39 @@ const receiptSchema = z createdAt: timestamp, }) .strict(); +const tombstoneSchema = z + .object({ sessionId: opaqueId, requestId: opaqueId }) + .strict(); const buildPlanningAggregateSchema = z .object({ schemaVersion: z.literal(1), planId: generatedId("build-plan").nullable(), currentPlanVersion: version.nullable(), - planVersions: z.array(projectBuildPlanVersionSchema).max(1_024), + planVersions: z + .array(projectBuildPlanVersionSchema) + .max(BUILD_PLAN_VERSION_HISTORY_LIMIT), currentBriefByAgentId: z.record(nodeId, briefRefSchema), briefVersionsById: z.record( generatedId("brief"), - z.array(agentBriefVersionRecordSchema).max(1_024), + z + .array(agentBriefVersionRecordSchema) + .max(AGENT_BRIEF_VERSION_HISTORY_LIMIT), ), assignmentByAgentId: z.record(nodeId, planningAssignmentRecordSchema), submissionsByAssignmentId: z.record( generatedId("assignment"), - z.array(builderPlanningSubmissionSchema).max(1_024), + z + .array(builderPlanningSubmissionSchema) + .max(PLANNING_SUBMISSION_HISTORY_LIMIT), ), idempotencyReceipts: z.array(receiptSchema).max(256), + idempotencyTombstones: z + .array(tombstoneSchema) + .max(BUILD_PLAN_VERSION_HISTORY_LIMIT), }) .strict(); -const sameSource = ( - left: ArchitectureSourceRef, - right: ArchitectureSourceRef, -) => JSON.stringify(left) === JSON.stringify(right); - export function parseArchitectureSourceRef( value: unknown, ): ArchitectureSourceRef { @@ -678,7 +691,7 @@ export function parseBuildPlanningAggregate( brief.version !== index + 1 || !plan || plan.semanticDigest !== brief.plan.semanticDigest || - !sameSource(plan.source, brief.source) || + !architectureSourceRefsEqual(plan.source, brief.source) || !assignment || assignment.briefId !== brief.briefId || assignment.plannedAgentId !== brief.plannedAgentId @@ -721,7 +734,7 @@ export function parseBuildPlanningAggregate( submission.assignmentId !== assignmentId || !plan || plan.semanticDigest !== submission.plan.semanticDigest || - !sameSource(plan.source, submission.source) || + !architectureSourceRefsEqual(plan.source, submission.source) || !brief || brief.semanticDigest !== submission.brief.semanticDigest || brief.assignmentId !== assignmentId || @@ -742,6 +755,21 @@ export function parseBuildPlanningAggregate( ).size !== aggregate.idempotencyReceipts.length ) fail(); + const receiptKeys = new Set( + aggregate.idempotencyReceipts.map( + (entry) => `${entry.sessionId}\0${entry.requestId}`, + ), + ); + const tombstoneKeys = aggregate.idempotencyTombstones.map( + (entry) => `${entry.sessionId}\0${entry.requestId}`, + ); + if ( + new Set(tombstoneKeys).size !== tombstoneKeys.length || + tombstoneKeys.some((key) => receiptKeys.has(key)) || + aggregate.idempotencyReceipts.length + tombstoneKeys.length > + aggregate.planVersions.length + ) + fail(); if ( aggregate.idempotencyReceipts.some( (receipt) => diff --git a/packages/harness/src/shared/build-plan.ts b/packages/harness/src/shared/build-plan.ts index 77051515b..fa4412af4 100644 --- a/packages/harness/src/shared/build-plan.ts +++ b/packages/harness/src/shared/build-plan.ts @@ -8,6 +8,9 @@ import type { export const BUILD_PLAN_SCHEMA_VERSION = 1 as const; export const BUILD_PLANNING_AGGREGATE_SCHEMA_VERSION = 1 as const; +export const BUILD_PLAN_VERSION_HISTORY_LIMIT = 1_024; +export const AGENT_BRIEF_VERSION_HISTORY_LIMIT = 1_024; +export const PLANNING_SUBMISSION_HISTORY_LIMIT = 1_024; type BuildPlanBrand = T & { readonly __brand: TBrand; @@ -64,6 +67,25 @@ export type ArchitectureSourceRef = graphDigest: GraphDigest; }>; +/** Compare exact source identities without depending on object property order. */ +export function architectureSourceRefsEqual( + left: ArchitectureSourceRef, + right: ArchitectureSourceRef, +): boolean { + if (left.kind !== right.kind || left.graphDigest !== right.graphDigest) + return false; + if (left.kind === "proposal" && right.kind === "proposal") + return ( + left.proposalId === right.proposalId && left.version === right.version + ); + if (left.kind === "revision" && right.kind === "revision") + return ( + left.revisionId === right.revisionId && + left.revisionNumber === right.revisionNumber + ); + return false; +} + export interface BuildPlanRef { planId: BuildPlanId; version: BuildPlanVersion; @@ -340,6 +362,7 @@ export interface BuilderPlanningSubmission { proposedMapOperationIds: readonly ProposalOperationId[]; supersedesSubmissionId: BuilderPlanningSubmissionId | null; semanticDigest: PlanningSubmissionDigest; + recordDigest: RecordDigest; submittedAt: string; } @@ -357,6 +380,7 @@ export interface PlanningAssignmentRecord { at: string; planVersion: BuildPlanVersion; }>[]; + recordDigest: RecordDigest; } export interface BuildPlanIdempotencyReceipt { @@ -367,6 +391,12 @@ export interface BuildPlanIdempotencyReceipt { createdAt: string; } +/** Permanent compact provenance for requests whose exact result aged out. */ +export interface BuildPlanIdempotencyTombstone { + sessionId: string; + requestId: string; +} + export interface BuildPlanningAggregateV1 { schemaVersion: typeof BUILD_PLANNING_AGGREGATE_SCHEMA_VERSION; planId: BuildPlanId | null; @@ -381,6 +411,7 @@ export interface BuildPlanningAggregateV1 { Record >; idempotencyReceipts: readonly BuildPlanIdempotencyReceipt[]; + idempotencyTombstones: readonly BuildPlanIdempotencyTombstone[]; } export interface BuildPlanImpactEvaluator { @@ -401,4 +432,5 @@ export const emptyBuildPlanningAggregate = (): BuildPlanningAggregateV1 => ({ assignmentByAgentId: {}, submissionsByAssignmentId: {}, idempotencyReceipts: [], + idempotencyTombstones: [], }); From 3a0d505828afb90fee58fcf0d951719c3a9025e9 Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 3 Sep 2026 10:17:34 +0000 Subject: [PATCH 3/4] fix(harness): validate artifact data-flow paths Closes: SAP-3067 --- .../build-plan-contract-validator.test.ts | 186 ++++++++++++++++++ .../src/core/build-plan-contract-validator.ts | 161 +++++++++++++-- 2 files changed, 330 insertions(+), 17 deletions(-) diff --git a/packages/harness/src/core/build-plan-contract-validator.test.ts b/packages/harness/src/core/build-plan-contract-validator.test.ts index 3a8a1bce2..fb1ad9b86 100644 --- a/packages/harness/src/core/build-plan-contract-validator.test.ts +++ b/packages/harness/src/core/build-plan-contract-validator.test.ts @@ -141,6 +141,60 @@ describe("BuildPlanContractValidator", () => { "incompatible-contract-direction", ]); }); + + it("accepts producer and consumer evidence through a written and read artifact", async () => { + const fixture = reportFlowFixture(); + const result = await fixture.validator.validate(fixture.plan, [ + fixture.researchBrief, + fixture.marketingBrief, + ]); + + expect(result.completeness.issues).toEqual([]); + expect(result.completeness.status).toBe("complete"); + }); + + it("rejects report-flow evidence with the wrong dependency direction", async () => { + const fixture = reportFlowFixture(); + const marketingBrief = makeBrief(fixture.plan, { + ...fixture.marketingBrief, + dependencies: fixture.marketingBrief.dependencies.map((dependency) => ({ + ...dependency, + direction: "downstream" as const, + })), + }); + const result = await fixture.validator.validate(fixture.plan, [ + fixture.researchBrief, + marketingBrief, + ]); + + expect(result.completeness.issues.map(({ code }) => code)).toEqual([ + "invalid-dependency", + ]); + }); + + it("rejects report-flow ports and dependencies with the wrong contract", async () => { + const fixture = reportFlowFixture(); + const marketingBrief = makeBrief(fixture.plan, { + ...fixture.marketingBrief, + inputs: fixture.marketingBrief.inputs.map((port) => ({ + ...port, + contractId: fixture.otherContractId, + })), + dependencies: fixture.marketingBrief.dependencies.map((dependency) => ({ + ...dependency, + contractIds: [fixture.otherContractId], + })), + }); + const result = await fixture.validator.validate(fixture.plan, [ + fixture.researchBrief, + marketingBrief, + ]); + + expect(result.completeness.issues.map(({ code }) => code)).toEqual([ + "invalid-dependency", + "incompatible-contract-direction", + ]); + }); }); function dependencyFixture() { @@ -300,3 +354,135 @@ function dependencyFixture() { otherContractId, }; } + +function reportFlowFixture() { + const marketingAgentId = + "node_00000000-0000-7000-8000-000000000021" as PlanNodeId; + const reportArtifactId = + "node_00000000-0000-7000-8000-000000000022" as PlanNodeId; + const writeRelationshipId = + "rel_00000000-0000-7000-8000-000000000021" as PlanRelationshipId; + const readRelationshipId = + "rel_00000000-0000-7000-8000-000000000022" as PlanRelationshipId; + const contractId = + "contract_00000000-0000-7000-8000-000000000021" as PlanContractId; + const otherContractId = + "contract_00000000-0000-7000-8000-000000000022" as PlanContractId; + const reportGraph: AgentMapGraph = { + nodes: [ + { ...graph.nodes[0]!, name: "Research", purpose: "Research the market" }, + { + id: marketingAgentId, + kind: "agent", + name: "Marketing", + purpose: "Use research in campaigns", + ownerAgentId: null, + contractRefs: [contractId], + }, + { + id: reportArtifactId, + kind: "artifact", + name: "ResearchReport", + purpose: "Carry research findings", + ownerAgentId: AGENT_ID, + contractRefs: [contractId], + }, + ], + relationships: [ + { + id: writeRelationshipId, + fromNodeId: AGENT_ID, + toNodeId: reportArtifactId, + kind: "writes", + executionMode: "asynchronous", + contractRef: contractId, + description: "Research produces the report", + }, + { + id: readRelationshipId, + fromNodeId: marketingAgentId, + toNodeId: reportArtifactId, + kind: "reads", + executionMode: "asynchronous", + contractRef: contractId, + description: "Marketing consumes the report", + }, + ], + }; + const baseAssignment = makePlan().assignments[0]!; + const plan = makePlan({ + assignments: [ + baseAssignment, + { ...baseAssignment, plannedAgentId: marketingAgentId }, + ], + }); + const researchBrief = makeBrief(plan, { + ownedNodeIds: [AGENT_ID, reportArtifactId], + relevantNodeIds: [AGENT_ID, marketingAgentId, reportArtifactId], + outputs: [ + { + contractId, + nodeId: AGENT_ID, + relationshipIds: [writeRelationshipId], + description: "Published research report", + }, + ], + dependencies: [ + { + dependencyId: + "dependency_00000000-0000-7000-8000-000000000021" as BriefDependencyId, + kind: "provides-input", + direction: "downstream", + counterpartAgentId: marketingAgentId, + relationshipIds: [writeRelationshipId, readRelationshipId], + contractIds: [contractId], + requiredByMilestoneIds: [], + blocking: false, + description: "Provides the report to Marketing", + }, + ], + }); + const marketingBrief = makeBrief(plan, { + briefId: "brief_00000000-0000-7000-8000-000000000021" as AgentBriefId, + assignmentId: + "assignment_00000000-0000-7000-8000-000000000021" as PlanningAssignmentId, + plannedAgentId: marketingAgentId, + ownedNodeIds: [marketingAgentId], + relevantNodeIds: [AGENT_ID, marketingAgentId, reportArtifactId], + inputs: [ + { + contractId, + nodeId: marketingAgentId, + relationshipIds: [readRelationshipId], + description: "Research report input", + }, + ], + dependencies: [ + { + dependencyId: + "dependency_00000000-0000-7000-8000-000000000022" as BriefDependencyId, + kind: "consumes-output", + direction: "upstream", + counterpartAgentId: AGENT_ID, + relationshipIds: [writeRelationshipId, readRelationshipId], + contractIds: [contractId], + requiredByMilestoneIds: [], + blocking: true, + description: "Consumes Research's report", + }, + ], + }); + return { + validator: new BuildPlanContractValidator({ + resolve: async (_projectId, source) => ({ + projectId: PROJECT_ID, + source, + graph: reportGraph, + }), + }), + plan, + researchBrief, + marketingBrief, + otherContractId, + }; +} diff --git a/packages/harness/src/core/build-plan-contract-validator.ts b/packages/harness/src/core/build-plan-contract-validator.ts index 951e88581..b0e94e8a5 100644 --- a/packages/harness/src/core/build-plan-contract-validator.ts +++ b/packages/harness/src/core/build-plan-contract-validator.ts @@ -1,4 +1,8 @@ -import type { AgentMapGraph, PlanNodeId } from "../shared/agent-map.js"; +import type { + AgentMapGraph, + PlanNodeId, + PlanRelationship, +} from "../shared/agent-map.js"; import { architectureSourceRefsEqual, type AgentBriefVersionRecord, @@ -72,6 +76,33 @@ function finalize(issues: BuildPlanDiagnostic[]): BuildPlanDiagnostic[] { .slice(0, BUILD_PLAN_DIAGNOSTIC_LIMIT); } +type EffectiveDataFlow = Readonly<{ + fromNodeId: PlanNodeId; + toNodeId: PlanNodeId; +}>; + +/** + * E2 records actor-oriented resource access: both reads and writes point from + * the actor to the resource/artifact. Delivery dependencies need the semantic + * direction of the transferred data, so reads flow in the opposite direction. + * `uses` is deliberately excluded because resource access alone does not prove + * that one agent produces an input consumed by another. + */ +function effectiveDataFlow( + relationship: PlanRelationship, +): EffectiveDataFlow | null { + if (relationship.kind === "uses") return null; + if (relationship.kind === "reads") + return { + fromNodeId: relationship.toNodeId, + toNodeId: relationship.fromNodeId, + }; + return { + fromNodeId: relationship.fromNodeId, + toNodeId: relationship.toNodeId, + }; +} + function validateBrief( brief: AgentBriefVersionRecord, plan: ProjectBuildPlanVersion, @@ -116,6 +147,79 @@ function validateBrief( }; const belongsToPlannedAgent = (nodeId: PlanNodeId): boolean => ownershipRoot(nodeId) === brief.plannedAgentId; + const isCarrierNode = (nodeId: PlanNodeId): boolean => { + const kind = nodes.get(nodeId)?.kind; + return kind === "artifact" || kind === "resource" || kind === "connector"; + }; + const evidenceFormsDataPath = ( + evidence: readonly EffectiveDataFlow[], + producerAgentId: PlanNodeId, + consumerAgentId: PlanNodeId, + ): boolean => { + if (evidence.length === 0) return false; + const isAllowedNode = (nodeId: PlanNodeId): boolean => { + const root = ownershipRoot(nodeId); + return ( + root === producerAgentId || + root === consumerAgentId || + (root === null && isCarrierNode(nodeId)) + ); + }; + if ( + evidence.some( + ({ fromNodeId, toNodeId }) => + !isAllowedNode(fromNodeId) || !isAllowedNode(toNodeId), + ) + ) + return false; + + const isActorFor = (nodeId: PlanNodeId, agentId: PlanNodeId): boolean => { + const kind = nodes.get(nodeId)?.kind; + return ( + ownershipRoot(nodeId) === agentId && + (kind === "agent" || kind === "subagent") + ); + }; + const startIds = new Set( + evidence + .flatMap(({ fromNodeId, toNodeId }) => [fromNodeId, toNodeId]) + .filter((nodeId) => isActorFor(nodeId, producerAgentId)), + ); + const targetIds = new Set( + evidence + .flatMap(({ fromNodeId, toNodeId }) => [fromNodeId, toNodeId]) + .filter((nodeId) => isActorFor(nodeId, consumerAgentId)), + ); + if (startIds.size === 0 || targetIds.size === 0) return false; + + const reachableFrom = ( + initial: ReadonlySet, + reverse: boolean, + ): Set => { + const reached = new Set(initial); + const queue = [...initial]; + for (let index = 0; index < queue.length; index += 1) { + const current = queue[index]!; + for (const edge of evidence) { + const fromNodeId = reverse ? edge.toNodeId : edge.fromNodeId; + const toNodeId = reverse ? edge.fromNodeId : edge.toNodeId; + if (fromNodeId !== current || reached.has(toNodeId)) continue; + reached.add(toNodeId); + queue.push(toNodeId); + } + } + return reached; + }; + const forward = reachableFrom(startIds, false); + const backward = reachableFrom(targetIds, true); + return ( + [...targetIds].some((targetId) => forward.has(targetId)) && + evidence.every( + ({ fromNodeId, toNodeId }) => + forward.has(fromNodeId) && backward.has(toNodeId), + ) + ); + }; const plannedNode = nodes.get(brief.plannedAgentId); if ( !plannedNode || @@ -160,18 +264,20 @@ function validateBrief( ); const validEvidence = port.relationshipIds.length > 0 && - evidence.every( - (relation) => - relation !== undefined && - relation.contractRef === port.contractId && - (isInput - ? relation.toNodeId === port.nodeId && - belongsToPlannedAgent(relation.toNodeId) && - ownershipRoot(relation.fromNodeId) !== brief.plannedAgentId - : relation.fromNodeId === port.nodeId && - belongsToPlannedAgent(relation.fromNodeId) && - ownershipRoot(relation.toNodeId) !== brief.plannedAgentId), - ); + evidence.every((relation) => { + if (!relation || relation.contractRef !== port.contractId) return false; + const flow = effectiveDataFlow(relation); + if (!flow) return false; + return isInput + ? flow.toNodeId === port.nodeId && + belongsToPlannedAgent(flow.toNodeId) && + (ownershipRoot(flow.fromNodeId) !== brief.plannedAgentId || + isCarrierNode(flow.fromNodeId)) + : flow.fromNodeId === port.nodeId && + belongsToPlannedAgent(flow.fromNodeId) && + (ownershipRoot(flow.toNodeId) !== brief.plannedAgentId || + isCarrierNode(flow.toNodeId)); + }); if (!validEvidence) issues.push( diagnostic( @@ -186,6 +292,10 @@ function validateBrief( const evidence = dependency.relationshipIds .map((id) => relationships.get(id)) .filter((entry) => entry !== undefined); + const dataFlowEvidence = evidence.flatMap((relation) => { + const flow = effectiveDataFlow(relation); + return flow === null ? [] : [flow]; + }); const ownToCounterpart = evidence.filter( (relation) => ownershipRoot(relation.fromNodeId) === brief.plannedAgentId && @@ -210,6 +320,15 @@ function validateBrief( const contractsLinked = dependency.contractIds.every((id) => evidencedContracts.has(id), ); + const dataFlowContractsLinked = + dependency.contractIds.length > 0 && + dataFlowEvidence.length === evidence.length && + contractsLinked && + evidence.every( + (relation) => + relation.contractRef !== null && + dependency.contractIds.some((id) => id === relation.contractRef), + ); const milestoneIds = new Set( plan.milestones.map(({ milestoneId }) => milestoneId), ); @@ -259,12 +378,20 @@ function validateBrief( milestonesLinked && (dependency.kind === "consumes-output" ? dependency.direction === "upstream" && - dependency.contractIds.length > 0 && - counterpartToOwn.length === evidence.length + dataFlowContractsLinked && + evidenceFormsDataPath( + dataFlowEvidence, + dependency.counterpartAgentId, + brief.plannedAgentId, + ) : dependency.kind === "provides-input" ? dependency.direction === "downstream" && - dependency.contractIds.length > 0 && - ownToCounterpart.length === evidence.length + dataFlowContractsLinked && + evidenceFormsDataPath( + dataFlowEvidence, + brief.plannedAgentId, + dependency.counterpartAgentId, + ) : dependency.kind === "shared-resource" ? dependency.direction === "bidirectional" && sharedResourceIds.length > 0 && From 48dc1c5cdf5507e14f74e5b9bccdb0a863c2c16d Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 3 Sep 2026 10:25:30 +0000 Subject: [PATCH 4/4] fix(harness): harden planning handoff boundaries Expose the complete explicit v1 handoff type closure and key planning integrity verification by the raw bytes read from disk. Closes: SAP-3067 --- .../durable-build-planning-contracts.md | 2 + .../src/core/agent-map-workspace-store.ts | 25 ++-- .../build-plan-contract-validator.test.ts | 4 +- .../src/core/build-plan-contract-validator.ts | 3 +- .../harness/src/core/build-plan-store.test.ts | 29 ++++ packages/harness/src/index.ts | 17 +++ .../src/public-build-plan-entrypoint.test.ts | 128 ++++++++++++++++++ packages/harness/tsconfig.json | 6 +- packages/harness/vitest.config.ts | 5 + 9 files changed, 201 insertions(+), 18 deletions(-) create mode 100644 packages/harness/src/public-build-plan-entrypoint.test.ts diff --git a/.changeset/durable-build-planning-contracts.md b/.changeset/durable-build-planning-contracts.md index 82426ba7c..b9ddc9448 100644 --- a/.changeset/durable-build-planning-contracts.md +++ b/.changeset/durable-build-planning-contracts.md @@ -4,4 +4,6 @@ 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. diff --git a/packages/harness/src/core/agent-map-workspace-store.ts b/packages/harness/src/core/agent-map-workspace-store.ts index 36205533a..39140c3ed 100644 --- a/packages/harness/src/core/agent-map-workspace-store.ts +++ b/packages/harness/src/core/agent-map-workspace-store.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -157,6 +157,9 @@ export function parseAgentMapWorkspaceState( const storageError = () => new AgentMapWorkspaceStoreError("storage_unavailable"); +const contentDigest = (raw: string): string => + createHash("sha256").update(raw).digest("hex"); + function parseArchitectureFields( value: unknown, projectId: StudioProjectId, @@ -331,7 +334,7 @@ function migrateAggregateV1( /** Crash-atomic owner of workspace, active proposal, history, and private receipts. */ export class AgentMapWorkspaceStore { private readonly queues = new Map>(); - private readonly verifiedFileIdentity = new Map(); + private readonly verifiedContentDigest = new Map(); constructor( private readonly agentMapRoot: string, @@ -389,11 +392,10 @@ export class AgentMapWorkspaceStore { }> { const file = this.workspacePath(projectId); let decoded: unknown; - let fileIdentity: string; + let rawDigest: string; try { const raw = await fs.readFile(file, "utf8"); - const stat = await fs.stat(file, { bigint: true }); - fileIdentity = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}`; + rawDigest = contentDigest(raw); decoded = JSON.parse(raw) as unknown; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") @@ -429,9 +431,9 @@ export class AgentMapWorkspaceStore { created: false, }; const aggregate = parseAggregate(decoded, projectId); - if (this.verifiedFileIdentity.get(projectId) !== fileIdentity) { + if (this.verifiedContentDigest.get(projectId) !== rawDigest) { assertBuildPlanningIntegrity(aggregate.buildPlanning); - this.verifiedFileIdentity.set(projectId, fileIdentity); + this.verifiedContentDigest.set(projectId, rawDigest); } return { aggregate, @@ -450,12 +452,13 @@ export class AgentMapWorkspaceStore { const file = this.workspacePath(projectId); const directory = path.dirname(file); const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; + const serialized = `${JSON.stringify(aggregate, null, 2)}\n`; let handle: fs.FileHandle | undefined; try { await fs.mkdir(directory, { recursive: true }); handle = await fs.open(temporary, "wx", 0o600); await this.options.beforePersistStep?.("write"); - await handle.writeFile(`${JSON.stringify(aggregate, null, 2)}\n`, "utf8"); + await handle.writeFile(serialized, "utf8"); await this.options.beforePersistStep?.("file-sync"); await handle.sync(); await handle.close(); @@ -469,11 +472,7 @@ export class AgentMapWorkspaceStore { } finally { await directoryHandle.close(); } - const stat = await fs.stat(file, { bigint: true }); - this.verifiedFileIdentity.set( - projectId, - `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}`, - ); + this.verifiedContentDigest.set(projectId, contentDigest(serialized)); } catch { throw storageError(); } finally { diff --git a/packages/harness/src/core/build-plan-contract-validator.test.ts b/packages/harness/src/core/build-plan-contract-validator.test.ts index fb1ad9b86..7c7e16249 100644 --- a/packages/harness/src/core/build-plan-contract-validator.test.ts +++ b/packages/harness/src/core/build-plan-contract-validator.test.ts @@ -384,7 +384,7 @@ function reportFlowFixture() { kind: "artifact", name: "ResearchReport", purpose: "Carry research findings", - ownerAgentId: AGENT_ID, + ownerAgentId: null, contractRefs: [contractId], }, ], @@ -417,7 +417,7 @@ function reportFlowFixture() { ], }); const researchBrief = makeBrief(plan, { - ownedNodeIds: [AGENT_ID, reportArtifactId], + ownedNodeIds: [AGENT_ID], relevantNodeIds: [AGENT_ID, marketingAgentId, reportArtifactId], outputs: [ { diff --git a/packages/harness/src/core/build-plan-contract-validator.ts b/packages/harness/src/core/build-plan-contract-validator.ts index b0e94e8a5..e4670a590 100644 --- a/packages/harness/src/core/build-plan-contract-validator.ts +++ b/packages/harness/src/core/build-plan-contract-validator.ts @@ -407,8 +407,7 @@ function validateBrief( counterpart.kind !== "agent" || counterpart.ownerAgentId !== null || dependency.counterpartAgentId === brief.plannedAgentId || - !supported || - !contractsLinked + !supported ) issues.push( diagnostic( diff --git a/packages/harness/src/core/build-plan-store.test.ts b/packages/harness/src/core/build-plan-store.test.ts index 69bef9311..d2dd60a42 100644 --- a/packages/harness/src/core/build-plan-store.test.ts +++ b/packages/harness/src/core/build-plan-store.test.ts @@ -191,6 +191,35 @@ describe("BuildPlanStore", () => { }); }); + it("detects same-size tampering when file identity metadata is restored", async () => { + const { root, workspaceStore, buildPlanStore } = await fixture(); + await buildPlanStore.commitPlanVersion(makePlan(), graph, request); + const file = path.join(root, "projects", PROJECT_ID, "workspace.json"); + const fixedTime = new Date("2026-09-03T10:00:00.000Z"); + await fs.utimes(file, fixedTime, fixedTime); + await workspaceStore.readAggregate(PROJECT_ID); + + const before = await fs.stat(file, { bigint: true }); + const raw = await fs.readFile(file, "utf8"); + const tampered = raw.replace( + "Ship a durable product", + "Hack a durable product", + ); + expect(tampered).not.toBe(raw); + expect(Buffer.byteLength(tampered)).toBe(Buffer.byteLength(raw)); + await fs.writeFile(file, tampered); + await fs.utimes(file, fixedTime, fixedTime); + const after = await fs.stat(file, { bigint: true }); + expect(after.dev).toBe(before.dev); + expect(after.ino).toBe(before.ino); + expect(after.size).toBe(before.size); + expect(after.mtimeNs).toBe(before.mtimeNs); + + await expect( + workspaceStore.readAggregate(PROJECT_ID), + ).rejects.toMatchObject({ code: "malformed_state" }); + }); + it("retires and restores the same assignment identity without erasing history", async () => { const { buildPlanStore } = await fixture(); const first = makePlan(); diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 9c2059a77..3c598fee3 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -31,13 +31,30 @@ export { BUILD_PLAN_SCHEMA_VERSION, architectureSourceRefsEqual, } from "./shared/build-plan.js"; +// Deliberate v1 planning handoff surface. Keep this list explicit, but include +// every branded/member type needed to construct and consume the six records. export type { + AgentBriefId, AgentBriefRef, + AgentBriefSemanticDigest, + AgentBriefVersion, + AgentMapRevisionId, ArchitectureSourceRef, BuilderPlanningContextRef, BuilderPlanningSubmission, + BuilderPlanningSubmissionId, + BuildPlanId, BuildPlanRef, + BuildPlanSemanticDigest, + BuildPlanVersion, + GraphDigest, + ImplementationPlanStep, + PlanningAssignmentId, PlanningAssignmentRef, + PlanningQuestion, + PlanningRisk, + PlanningSubmissionDigest, + RecordDigest, } from "./shared/build-plan.js"; export type { WorkspaceScopeSummary } from "./shared/system-graph.js"; export { AGENT_STUDIO_PRODUCT_NAME } from "./shared/branding.js"; diff --git a/packages/harness/src/public-build-plan-entrypoint.test.ts b/packages/harness/src/public-build-plan-entrypoint.test.ts new file mode 100644 index 000000000..079e6aaab --- /dev/null +++ b/packages/harness/src/public-build-plan-entrypoint.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { + BUILD_PLAN_SCHEMA_VERSION, + architectureSourceRefsEqual, + type AgentBriefId, + type AgentBriefRef, + type AgentBriefSemanticDigest, + type AgentBriefVersion, + type AgentMapRevisionId, + type ArchitectureSourceRef, + type BuilderPlanningContextRef, + type BuilderPlanningSubmission, + type BuilderPlanningSubmissionId, + type BuildPlanId, + type BuildPlanRef, + type BuildPlanSemanticDigest, + type BuildPlanVersion, + type GraphDigest, + type ImplementationPlanStep, + type MapProposalId, + type PlanningAssignmentId, + type PlanningAssignmentRef, + type PlanningQuestion, + type PlanningRisk, + type PlanningSubmissionDigest, + type PlanNodeId, + type ProposalOperationId, + type RecordDigest, + type StudioProjectId, +} from "@sapiom/harness"; + +describe("@sapiom/harness build-planning entrypoint", () => { + it("constructs and consumes the complete v1 handoff surface", () => { + const graphDigest = `sha256:${"a".repeat(64)}` as GraphDigest; + const source: ArchitectureSourceRef = { + kind: "proposal", + proposalId: + "proposal_00000000-0000-7000-8000-000000000001" as MapProposalId, + version: 1, + graphDigest, + }; + const revisionSource: ArchitectureSourceRef = { + kind: "revision", + revisionId: + "revision_00000000-0000-7000-8000-000000000001" as AgentMapRevisionId, + revisionNumber: 1, + graphDigest, + }; + const plan: BuildPlanRef = { + planId: "build-plan_00000000-0000-7000-8000-000000000001" as BuildPlanId, + version: 1 as BuildPlanVersion, + semanticDigest: `sha256:${"b".repeat(64)}` as BuildPlanSemanticDigest, + }; + const brief: AgentBriefRef = { + briefId: "brief_00000000-0000-7000-8000-000000000001" as AgentBriefId, + version: 1 as AgentBriefVersion, + semanticDigest: `sha256:${"c".repeat(64)}` as AgentBriefSemanticDigest, + }; + const assignment: PlanningAssignmentRef = { + assignmentId: + "assignment_00000000-0000-7000-8000-000000000001" as PlanningAssignmentId, + briefId: brief.briefId, + plannedAgentId: "node_00000000-0000-7000-8000-000000000001" as PlanNodeId, + }; + const context: BuilderPlanningContextRef = { + projectId: + "project_00000000-0000-4000-8000-000000000001" as StudioProjectId, + source, + plan, + brief, + assignment, + }; + const implementationPlan: ImplementationPlanStep = { + stepId: "step-1", + ordinal: 1, + description: "Implement the handoff", + verification: "Run the public contract test", + }; + const risk: PlanningRisk = { + riskId: "risk-1", + description: "A dependency changes", + mitigation: "Revalidate the exact source", + }; + const question: PlanningQuestion = { + questionId: "question-1", + question: "Is the source still current?", + }; + const submission: BuilderPlanningSubmission = { + schemaVersion: BUILD_PLAN_SCHEMA_VERSION, + submissionId: + "submission_00000000-0000-7000-8000-000000000001" as BuilderPlanningSubmissionId, + projectId: context.projectId, + assignmentId: assignment.assignmentId, + sessionId: "session-1", + source, + plan, + brief, + status: "ready", + implementationPlan: [implementationPlan], + risks: [risk], + questions: [question], + proposedMapOperationIds: [ + "operation_00000000-0000-7000-8000-000000000001" as ProposalOperationId, + ], + supersedesSubmissionId: null, + semanticDigest: `sha256:${"d".repeat(64)}` as PlanningSubmissionDigest, + recordDigest: `sha256:${"e".repeat(64)}` as RecordDigest, + submittedAt: "2026-09-03T10:00:00.000Z", + }; + + expect( + architectureSourceRefsEqual(source, { + graphDigest, + version: 1, + proposalId: source.proposalId, + kind: "proposal", + }), + ).toBe(true); + expect(architectureSourceRefsEqual(source, revisionSource)).toBe(false); + expect(submission).toMatchObject({ + projectId: context.projectId, + assignmentId: assignment.assignmentId, + plan, + brief, + }); + }); +}); diff --git a/packages/harness/tsconfig.json b/packages/harness/tsconfig.json index ea3a3e465..c53f74364 100644 --- a/packages/harness/tsconfig.json +++ b/packages/harness/tsconfig.json @@ -5,7 +5,11 @@ "moduleResolution": "Node16", "outDir": "./dist", "rootDir": "./src", - "types": ["node"] + "types": ["node"], + "baseUrl": ".", + "paths": { + "@sapiom/harness": ["./src/index.ts"] + } }, "include": ["src/**/*"], // __fixtures__ trees are bundled independently by esbuild inside diff --git a/packages/harness/vitest.config.ts b/packages/harness/vitest.config.ts index 51547410f..073845a02 100644 --- a/packages/harness/vitest.config.ts +++ b/packages/harness/vitest.config.ts @@ -13,6 +13,11 @@ import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { + // Public-contract tests must exercise the package root without relying + // on a previously generated dist/ declaration or implementation. + "@sapiom/harness": fileURLToPath( + new URL("src/index.ts", import.meta.url), + ), // Resolve "@shared/types" to the package's canonical contract so web // unit tests and server tests always build against the same source of // truth. Mirrors the alias in web/vite.config.ts.