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

Filter by extension

Filter by extension

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

Add shared build-plan read, validate, apply and rebase tools for trusted project sessions, with deterministic assignment IDs, conflict handling and idempotent write receipts. Keep validation errors visible within bounded diagnostics and timestamp semantic no-op receipts at the time they are accepted.
57 changes: 57 additions & 0 deletions packages/harness/docs/shared-build-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Shared build-plan versions

Agent Studio stores one project Agent Map and one project build plan as
immutable version histories. Every ordinary project session receives the same
map and build-plan MCP tools. Trusted `{ projectId, userId, sessionId }` scope
comes only from the private session capability; tool input cannot select or
override it.

## Digests and exact references

`GraphContentDigest` identifies canonical graph semantics without project,
version, author, or timestamp metadata. An `AgentMapVersionRef` adds the exact
project and immutable version identity. Build-plan semantic digests cover only
normalized plan content, while version-record digests also cover exact map
binding, ancestry, authorship, origin, and creation time.

Current reads and historical reads are distinct. Historical reads require the
logical plan ID, immutable version ID, and semantic digest. No omitted version
is interpreted as “latest.” Restoring an old map or plan appends a new
`changeKind: "restored"` version; it never rewinds a current pointer or mutates
history.

## Authoring and concurrency

`build_plan_validate` executes the apply parser, deterministic ID mapping,
source checks, reducer, and contract validation without writing a receipt or
moving a pointer. `build_plan_apply` persists a semantic change, current
pointer, and complete replay receipt in one locked atomic replacement. An exact
semantic no-op stores only its receipt.

Request identity is scoped by the trusted project, user, session, and request
ID. Retrying identical content returns the original result; changing content
under the same request ID fails. Concurrent same-source edits merge only when
their stable touch sets are disjoint. Overlaps return stable conflict IDs and
paths. A map-version change always requires `build_plan_rebase`, including
explicit resolutions for every invalidated assignment, repository intent, or
dependency; intent is never silently dropped.

Immutable map and plan histories are each bounded at 1,024 versions and are
never silently trimmed. Exhaustion returns terminal `quota_exceeded` with
`manual_intervention` recovery so callers do not retry forever; an operator
must preserve/archive the project history before a future storage migration can
raise or replace the bound.

Validation warnings such as missing assignments, missing briefs, or unresolved
decisions are diagnostic. They do not restrict coding, tool discovery, or
session creation.

## Reserved focused-brief seam

SAP-3149 established append-only brief histories for focused-context work. A
brief has a stable logical ID and a neutral focus
scope: either a canonical workstream or an ad-hoc delegation whose parent scope
may identify nested delegation. Each scope has an explicit active or retired
pointer. Retirement preserves history, and reactivation appends the next
version against that retained history. New and migrated aggregates start with
empty brief histories.
127 changes: 127 additions & 0 deletions packages/harness/src/core/build-plan-contract-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { AgentMapGraph, PlanNodeId, PlanRelationship } from "../shared/agent-map.js";
import type {
BuildPlanDiagnostic,
BuildPlanDependencyIntent,
ProjectBuildPlanContent,
} from "../shared/build-plan.js";

export const BUILD_PLAN_DIAGNOSTIC_LIMIT = 64;

const compare = (left: string, right: string) => left < right ? -1 : left > right ? 1 : 0;
const issue = (
code: BuildPlanDiagnostic["code"],
severity: BuildPlanDiagnostic["severity"],
path: string,
relatedIds: readonly string[] = [],
): BuildPlanDiagnostic => ({ code, severity, path: path.slice(0, 512), relatedIds: [...relatedIds].sort(compare).slice(0, 16) });

const effectiveFlow = (relationship: PlanRelationship) =>
relationship.kind === "reads"
? { from: relationship.toNodeId, to: relationship.fromNodeId }
: relationship.kind === "uses"
? null
: { from: relationship.fromNodeId, to: relationship.toNodeId };

export function validateProjectBuildPlanContent(
content: ProjectBuildPlanContent,
graph: AgentMapGraph,
activeBriefIds: ReadonlySet<string> = new Set(),
): BuildPlanDiagnostic[] {
const diagnostics: BuildPlanDiagnostic[] = [];
const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
const relationships = new Map<string, PlanRelationship>(graph.relationships.map((relationship) => [relationship.id, relationship]));
const ownershipRoot = (nodeId: PlanNodeId): PlanNodeId | null => {
const seen = new Set<PlanNodeId>();
let current = nodes.get(nodeId);
while (current) {
if (seen.has(current.id)) return null;
seen.add(current.id);
if (current.ownerAgentId === null) return current.kind === "agent" ? current.id : null;
current = nodes.get(current.ownerAgentId);
}
return null;
};
const topAgents = graph.nodes.filter(({ kind, ownerAgentId }) => kind === "agent" && ownerAgentId === null);
const assigned = new Set(content.assignments.map(({ plannedAgentId }) => plannedAgentId));
for (const agent of topAgents) {
if (!assigned.has(agent.id)) diagnostics.push(issue("missing-assignment", "warning", "assignments", [agent.id]));
}

const milestoneIds = new Set(content.milestones.map(({ id }) => id));
const milestoneOrdinals = new Set<number>();
content.milestones.forEach((milestone, index) => {
if (milestoneOrdinals.has(milestone.ordinal))
diagnostics.push(issue("duplicate-ordinal", "error", `milestones[${index}].ordinal`, [milestone.id]));
milestoneOrdinals.add(milestone.ordinal);
milestone.dependsOn.forEach((dependency, dependencyIndex) => {
if (!milestoneIds.has(dependency) || dependency === milestone.id)
diagnostics.push(issue("invalid-milestone-dependency", "error", `milestones[${index}].dependsOn[${dependencyIndex}]`, [milestone.id, dependency]));
});
});
const gateOrdinals = new Set<number>();
content.sequenceGates.forEach((gate, index) => {
if (gateOrdinals.has(gate.ordinal))
diagnostics.push(issue("duplicate-ordinal", "error", `sequenceGates[${index}].ordinal`, [gate.id]));
gateOrdinals.add(gate.ordinal);
gate.milestoneIds.forEach((milestoneId, item) => {
if (!milestoneIds.has(milestoneId))
diagnostics.push(issue("invalid-milestone-dependency", "error", `sequenceGates[${index}].milestoneIds[${item}]`, [gate.id, milestoneId]));
});
});

const validatePlannedAgent = (nodeId: PlanNodeId, path: string, code: BuildPlanDiagnostic["code"]) => {
const node = nodes.get(nodeId);
if (!node || node.kind !== "agent" || node.ownerAgentId !== null)
diagnostics.push(issue(code, "error", path, [nodeId]));
};
content.repositoryIntents.forEach((intent, index) =>
validatePlannedAgent(intent.plannedAgentId, `repositoryIntents[${index}].plannedAgentId`, "invalid-repository-owner"));

const dependencyEvidenceValid = (
dependency: BuildPlanDependencyIntent,
plannedAgentId: PlanNodeId,
): boolean => {
const target = nodes.get(dependency.nodeId);
if (!target || dependency.relationshipIds.length === 0) return false;
const evidence = dependency.relationshipIds.map((id) => relationships.get(id));
if (evidence.some((relationship) => !relationship ||
(dependency.contractRef !== null && relationship.contractRef !== dependency.contractRef))) return false;
if (dependency.kind === "shared-resource") {
if (!["resource", "artifact", "connector"].includes(target.kind)) return false;
return evidence.every((relationship) => relationship !== undefined &&
["reads", "writes", "uses"].includes(relationship.kind) &&
relationship.toNodeId === dependency.nodeId && ownershipRoot(relationship.fromNodeId) === plannedAgentId);
}
if (dependency.kind === "depends-on" &&
(target.kind !== "agent" || target.ownerAgentId !== null || target.id === plannedAgentId)) return false;
const flows = evidence.map((relationship) => relationship ? effectiveFlow(relationship) : null);
if (flows.some((flow) => flow === null)) return false;
const owned = (nodeId: PlanNodeId) => ownershipRoot(nodeId) === plannedAgentId;
const targetSide = (nodeId: PlanNodeId) => nodeId === dependency.nodeId || ownershipRoot(nodeId) === dependency.nodeId;
if (dependency.kind === "input" || dependency.kind === "depends-on")
return flows.some((flow) => flow !== null && targetSide(flow.from) && owned(flow.to));
return flows.some((flow) => flow !== null && owned(flow.from) && targetSide(flow.to));
};

content.assignments.forEach((assignment, index) => {
validatePlannedAgent(assignment.plannedAgentId, `assignments[${index}].plannedAgentId`, "unknown-node-reference");
if (assignment.briefId === null || !activeBriefIds.has(assignment.briefId))
diagnostics.push(issue("missing-brief", "warning", `assignments[${index}].briefId`, [assignment.id]));
assignment.dependencies.forEach((dependency, dependencyIndex) => {
if (!dependencyEvidenceValid(dependency, assignment.plannedAgentId))
diagnostics.push(issue("invalid-dependency", "error", `assignments[${index}].dependencies[${dependencyIndex}]`, [assignment.id, dependency.id, dependency.nodeId]));
});
});
[...content.decisions, ...content.unresolvedDecisions].forEach((decision, index) => {
if (decision.status === "open") diagnostics.push(issue("unresolved-decision", "warning", `decisions[${index}]`, [decision.id]));
});

const unique = new Map<string, BuildPlanDiagnostic>();
for (const diagnostic of diagnostics)
unique.set(JSON.stringify([diagnostic.path, diagnostic.code, diagnostic.relatedIds]), diagnostic);
// Keep a blocking error visible even when warnings exceed the display budget.
return [...unique.values()].sort((left, right) =>
Number(left.severity !== "error") - Number(right.severity !== "error") ||
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);
}
78 changes: 78 additions & 0 deletions packages/harness/src/core/build-plan-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";

import {
parseAgentBriefRefreshRequest,
parseBuildPlanApplyRequest,
parseBuildPlanReadRequest,
parseBuildPlanRebaseRequest,
} from "./build-plan-schema.js";

const map = {
versionId: "mapv_018f0000-0000-7000-8000-000000000001",
contentDigest: `sha256:${"1".repeat(64)}`,
};
const plan = {
planId: "plan_018f0000-0000-7000-8000-000000000002",
versionId: "planv_018f0000-0000-7000-8000-000000000003",
semanticDigest: `sha256:${"2".repeat(64)}`,
};
const content = {
outcome: "",
nonGoals: [],
milestones: [],
sequenceGates: [],
sharedConstraints: [],
repositoryIntents: [],
integrationCriteria: [],
acceptanceCriteria: [],
decisions: [],
assignments: [],
unresolvedDecisions: [],
risks: [],
};

describe("build plan tool schemas", () => {
it("accepts only explicit current or exact historical reads", () => {
expect(parseBuildPlanReadRequest({ kind: "current" })).toEqual({ kind: "current" });
expect(parseBuildPlanReadRequest({ kind: "exact", ...plan })).toEqual({ kind: "exact", ...plan });
expect(() => parseBuildPlanReadRequest({})).toThrow();
expect(() => parseBuildPlanReadRequest({ kind: "exact", planId: plan.planId })).toThrow();
});

it("keeps trusted project, user, session, role, and capability selectors out of apply", () => {
const request = { schemaVersion: 1, requestId: "request", expectedMap: map, expectedPlan: null,
operations: [{ op: "replace-content", content }] };
expect(parseBuildPlanApplyRequest(request)).toEqual(request);
for (const field of ["projectId", "userId", "sessionId", "role", "capability", "assignment"])
expect(() => parseBuildPlanApplyRequest({ ...request, [field]: "forged" })).toThrow();
});

it("requires exact from/to map and plan references for explicit rebase", () => {
const request = { schemaVersion: 1, requestId: "rebase", expectedPlan: plan,
fromMap: map, toMap: { ...map, versionId: "mapv_018f0000-0000-7000-8000-000000000004" }, resolutions: [] };
expect(parseBuildPlanRebaseRequest(request)).toEqual(request);
expect(() => parseBuildPlanRebaseRequest({ ...request, fromMap: { versionId: map.versionId } })).toThrow();
expect(() => parseBuildPlanRebaseRequest({ ...request, projectId: "project-forged" })).toThrow();
});

it("bounds content arrays and rejects unknown operation fields", () => {
const request = { schemaVersion: 1, requestId: "request", expectedMap: map, expectedPlan: null,
operations: [{ op: "replace-content", content: { ...content,
nonGoals: Array.from({ length: 129 }, (_, index) => `non-goal-${index}`) } }] };
expect(() => parseBuildPlanApplyRequest(request)).toThrow();
expect(() => parseBuildPlanApplyRequest({ ...request,
operations: [{ op: "replace-content", content, privatePath: "/secret" }] })).toThrow();
});

it("accepts exact canonical refresh and assignment-only nested focus", () => {
const canonical = { schemaVersion: 1, requestId: "refresh", expectedMap: map, expectedPlan: plan,
focus: { mode: "canonical" } };
expect(parseAgentBriefRefreshRequest(canonical)).toEqual(canonical);
const focused = { ...canonical, requestId: "focused", focus: { mode: "focused", selections: [{
focusScope: { family: "ad-hoc-delegation", delegationKey: "review", parentScopeKey: null },
assignmentId: "work_018f0000-0000-7000-8000-000000000004",
mission: "Review the contract",
}] } };
expect(parseAgentBriefRefreshRequest(focused)).toEqual(focused);
});
});
Loading
Loading