From 300b7f4b7e8d9ac70d8cc074f30076e9f665ec48 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:54:30 -0500 Subject: [PATCH] Add deterministic capability control plane (#12355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Semantic operations need a control plane that owns authorization and state changes > - Conformance tests need this authority without a live server or database > - The existing in-memory port covers transport facts but not capability-governed domains > - This pull request adds a deterministic capability-aware control plane for tests > - It keeps the implementation behind the package testing entry point > - The benefit is repeatable authorization and mutation tests before production wiring lands ## Linked Issues or Issue Description **Subsystem affected** packages/paperclip-runner testing and semantic capability contracts **Problem or motivation** Later semantic action slices need deterministic company, actor, task, governance, workspace, secret, budget, routine, and audit state. A live Paperclip server would make those tests slow and environment-dependent. **Proposed solution** Add serializable capability fixture types and an in-memory control-plane adapter. Enforce company scope, claims, roles, idempotency, redaction, faults, and audit records. Export the adapter only from the testing entry point. **Alternatives considered** The tests could mock each action separately. That would duplicate authorization rules and would not verify state transitions across multiple actions. **Roadmap alignment** This supports the existing experimental Paperclip Runner rollout. It does not enable production semantic operations or change app execution. ## What Changed - Add deterministic capability fixture types and seed state. - Add an in-memory capability control-plane adapter. - Enforce run, company, actor, claim, role, and idempotency boundaries. - Model governed task, document, interaction, workspace, secret, routine, and audit changes. - Add deterministic tests and testing-entry-point exports. ## Verification - `pnpm --filter @paperclipai/paperclip-runner test:typescript` - `pnpm --filter @paperclipai/paperclip-runner typecheck:typescript` - `pnpm -r typecheck` - `pnpm build` - The branch changes 5 files relative to its declared base. ## Risks The main risk is a permissive mock that hides a production authorization error. The adapter fails closed for missing claims, wrong roles, cross-company access, duplicate mutations, invalid state, and restricted secret access. Tests cover each fixture domain and serialized restore behavior. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, `gpt-5`, with agentic reasoning, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked an existing public issue or described the issue in-PR - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../capability-control-plane-types.ts | 558 ++++++ ...ability-mock-control-plane-adapter.test.ts | 1703 ++++++++++++++++ .../capability-mock-control-plane-adapter.ts | 1783 +++++++++++++++++ packages/paperclip-runner/src/testing.ts | 2 + .../tools/capability-semantic-tool-types.ts | 210 ++ 5 files changed, 4256 insertions(+) create mode 100644 packages/paperclip-runner/src/mock-core/capability-control-plane-types.ts create mode 100644 packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.test.ts create mode 100644 packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts create mode 100644 packages/paperclip-runner/src/tools/capability-semantic-tool-types.ts diff --git a/packages/paperclip-runner/src/mock-core/capability-control-plane-types.ts b/packages/paperclip-runner/src/mock-core/capability-control-plane-types.ts new file mode 100644 index 0000000000..e461697109 --- /dev/null +++ b/packages/paperclip-runner/src/mock-core/capability-control-plane-types.ts @@ -0,0 +1,558 @@ +import type { + CompleteControlPlaneRunInput, + ControlPlanePort, + OpenControlPlaneRunInput, +} from "../contracts/control-plane-port.js"; +import type { NativeRunEvent, NativeRunResult } from "../contracts/types.js"; +import type { PersistedNativeSession } from "../contracts/native-session-backend.js"; +import type { PrpEvent } from "../protocol/replay-contract.js"; + +export type CapabilityJsonPrimitive = string | number | boolean | null; +export type CapabilityJsonValue = + | CapabilityJsonPrimitive + | CapabilityJsonValue[] + | { [key: string]: CapabilityJsonValue }; + +export type CapabilityTaskStatus = + | "backlog" + | "todo" + | "in_progress" + | "in_review" + | "done" + | "blocked" + | "cancelled"; + +export interface CapabilityFixtureCompany { + id: string; + name: string; + issuePrefix: string; + status: "active" | "paused"; + budgetId: string; +} +export interface CapabilityFixtureActor { + id: string; + companyId: string; + name: string; + role: string; + status: "active" | "paused"; + budgetId: string; + capabilityGrants: string[]; +} + +export interface CapabilityFixtureTask { + id: string; + companyId: string; + identifier: string; + title: string; + description: string | null; + status: CapabilityTaskStatus; + priority: "critical" | "high" | "medium" | "low"; + workMode: "standard" | "ask" | "planning" | "skill_test"; + parentId: string | null; + assigneeActorId: string | null; + checkoutRunId: string | null; + executionRunId: string | null; + startedAt: string | null; + completedAt: string | null; +} + +export interface CapabilityFixtureComment { + id: string; + taskId: string; + authorActorId: string | null; + body: string; + createdAt: string; +} + +export interface CapabilityFixtureDocumentRevision { + id: string; + documentId: string; + revision: number; + body: string; + changeSummary: string | null; + createdAt: string; +} + +export interface CapabilityFixtureDocument { + id: string; + taskId: string; + key: string; + title: string; + format: "markdown"; + latestRevisionId: string; + revisions: CapabilityFixtureDocumentRevision[]; +} + +export type CapabilityInteractionKind = + | "confirmation" + | "checkbox" + | "questions" + | "suggest_tasks" + | "item_verdicts"; + +export interface CapabilityFixtureInteraction { + id: string; + taskId: string; + kind: CapabilityInteractionKind; + status: "pending" | "answered" | "accepted" | "rejected" | "expired"; + title: string; + prompt: string; + payload: CapabilityJsonValue; + targetRevisionId: string | null; + continuationPolicy: "none" | "wake_assignee" | "wake_assignee_on_accept"; + result: CapabilityJsonValue | null; + createdAt: string; + resolvedAt: string | null; +} + +export interface CapabilityFixtureApprovalComment { + id: string; + authorActorId: string | null; + body: string; + createdAt: string; +} + +export interface CapabilityFixtureApproval { + id: string; + companyId: string; + taskIds: string[]; + type: string; + status: "pending" | "approved" | "rejected" | "cancelled"; + requestedByActorId: string | null; + payload: CapabilityJsonValue; + decisionNote: string | null; + comments: CapabilityFixtureApprovalComment[]; + createdAt: string; + decidedAt: string | null; +} + +export interface CapabilityFixtureArtifact { + id: string; + taskId: string; + filename: string; + contentType: string; + byteSize: number; + sha256: string; + contentRef: string; + createdAt: string; +} + +export interface CapabilityFixtureWorkProduct { + id: string; + taskId: string; + type: "artifact" | "document" | "pull_request" | "preview_url" | "runtime_service" | "commit" | "branch"; + title: string; + metadata: CapabilityJsonValue; + artifactId: string | null; + createdAt: string; +} + +export interface CapabilityFixtureBlocker { + id: string; + taskId: string; + blockedByTaskId: string; + createdAt: string; +} + +export interface CapabilityFixtureWorkspaceService { + id: string; + taskId: string; + name: string; + status: "stopped" | "starting" | "running" | "failed"; + url: string | null; + lastTransitionAt: string; +} + +export interface CapabilityFixtureBudget { + id: string; + scope: "company" | "actor"; + scopeId: string; + limitCents: number; + spentCents: number; + hardStop: boolean; +} + +export type CapabilityWakeReason = + | "manual" + | "issue_commented" + | "interaction_resolved" + | "approval_resolved" + | "blockers_resolved" + | "scheduled_retry" + | "resume"; + +export interface CapabilityWakeContext { + reason: CapabilityWakeReason; + payload: CapabilityJsonValue; +} + +export interface CapabilityScheduledWake { + id: string; + actorId: string; + taskId: string; + reason: CapabilityWakeReason; + payload: CapabilityJsonValue; + dueAt: string; + status: "scheduled" | "delivered" | "cancelled"; + createdAt: string; +} + +export interface CapabilityFixtureRun { + id: string; + companyId: string; + actorId: string; + taskId: string; + sessionId: string; + backendKind: OpenControlPlaneRunInput["backendKind"]; + sourceInstanceId: string; + status: "running" | "succeeded" | "failed" | "cancelled" | "budget_stopped"; + attempt: number; + openedAt: string; + finishedAt: string | null; + wake: CapabilityWakeContext; + capabilities: string[]; + events: Array; + result: NativeRunResult | CompleteControlPlaneRunInput | null; + sessionCheckpoint: PersistedNativeSession | null; +} + +export interface CapabilityAuditRecord { + id: string; + at: string; + runId: string | null; + actorId: string | null; + action: string; + entityType: string; + entityId: string; + details: CapabilityJsonValue; +} + +export interface CapabilityDecisionRecord { + id: string; + at: string; + runId: string | null; + operation: string; + outcome: "allowed" | "denied" | "duplicate" | "reconciled" | "faulted"; + reason: string; + stateRevision: number; + entityRefs: string[]; +} + +export interface CapabilityFaultRule { + id: string; + operation: "open_run" | "append_event" | "apply_command" | "complete_run"; + commandKind?: CapabilitySemanticCommand["kind"]; + effect: "retryable_error" | "lost_ack"; + remaining: number; + code: string; +} + +export interface ScenarioChatdempotencyRecord { + scope: string; + key: string; + inputCanonical: string; + result: CapabilityCommandResult; +} + +export interface CapabilityFixtureState { + schema: "paperclip.capability.mock-state.v1"; + revision: number; + clock: { epochMs: number; tick: number }; + counters: Record; + lifecycle: "stopped" | "running"; + activeRunId: string | null; + company: CapabilityFixtureCompany; + actors: CapabilityFixtureActor[]; + tasks: CapabilityFixtureTask[]; + /** Known task ids outside the fixture company, without leaking foreign data. */ + outOfScopeTaskIds: string[]; + comments: CapabilityFixtureComment[]; + documents: CapabilityFixtureDocument[]; + interactions: CapabilityFixtureInteraction[]; + approvals: CapabilityFixtureApproval[]; + artifacts: CapabilityFixtureArtifact[]; + workProducts: CapabilityFixtureWorkProduct[]; + blockers: CapabilityFixtureBlocker[]; + workspaceServices: CapabilityFixtureWorkspaceService[]; + budgets: CapabilityFixtureBudget[]; + runs: CapabilityFixtureRun[]; + wakes: CapabilityScheduledWake[]; + audit: CapabilityAuditRecord[]; + decisions: CapabilityDecisionRecord[]; + idempotency: ScenarioChatdempotencyRecord[]; + faults: CapabilityFaultRule[]; +} + +export interface CapabilityRunContext { + schema: "paperclip.capability.run-context.v1"; + company: Pick; + actor: Pick; + activeTask: CapabilityFixtureTask; + ancestors: CapabilityFixtureTask[]; + wake: CapabilityWakeContext; + capabilities: string[]; + budget: { limitCents: number; spentCents: number; remainingCents: number }; + interactionResults: Array>; +} + +export interface CapabilityOpenFixtureRunInput extends OpenControlPlaneRunInput { + wake?: CapabilityWakeContext; + capabilities?: string[]; + resume?: boolean; +} + +interface CapabilityBaseCommand { + taskId?: string; +} + +export type CapabilitySemanticCommand = + | (CapabilityBaseCommand & { kind: "report_progress"; body: string }) + | (CapabilityBaseCommand & { + kind: "write_document"; + key: string; + title: string; + body: string; + baseRevisionId: string | null; + changeSummary?: string | null; + }) + | (CapabilityBaseCommand & { + kind: "request_human_input"; + interactionKind: CapabilityInteractionKind; + title: string; + prompt: string; + payload?: CapabilityJsonValue; + targetRevisionId?: string | null; + continuationPolicy: CapabilityFixtureInteraction["continuationPolicy"]; + }) + | (CapabilityBaseCommand & { + kind: "resolve_human_input"; + interactionId: string; + outcome: "answered" | "accepted" | "rejected" | "expired"; + result: CapabilityJsonValue; + }) + | (CapabilityBaseCommand & { + kind: "register_deliverable"; + filename: string; + contentType: string; + byteSize: number; + sha256: string; + contentRef: string; + title: string; + }) + | (CapabilityBaseCommand & { kind: "set_dependencies"; blockedByTaskIds: string[] }) + | (CapabilityBaseCommand & { + kind: "create_task"; + title: string; + description?: string | null; + assigneeActorId?: string | null; + priority?: CapabilityFixtureTask["priority"]; + blockedByTaskIds?: string[]; + }) + | (CapabilityBaseCommand & { + kind: "request_approval"; + approvalType: string; + payload: CapabilityJsonValue; + }) + | (CapabilityBaseCommand & { + kind: "decide_approval"; + approvalId: string; + decision: "approved" | "rejected" | "cancelled"; + note: string; + }) + | (CapabilityBaseCommand & { + kind: "comment_on_approval"; + approvalId: string; + body: string; + }) + | (CapabilityBaseCommand & { + kind: "control_workspace_service"; + serviceId: string; + action: "start" | "stop" | "fail"; + url?: string | null; + }) + | (CapabilityBaseCommand & { kind: "record_budget_usage"; cents: number }) + | (CapabilityBaseCommand & { kind: "finish_task"; summary: string }) + | (CapabilityBaseCommand & { + kind: "block_task"; + reason: string; + blockedByTaskIds?: string[]; + }) + | (CapabilityBaseCommand & { kind: "request_review"; summary: string }) + | (CapabilityBaseCommand & { kind: "release_task"; reason: string }) + | (CapabilityBaseCommand & { + kind: "schedule_wake"; + reason: CapabilityWakeReason; + payload?: CapabilityJsonValue; + delayTicks: number; + }); + +export interface CapabilityCommandEnvelope { + runId: string; + idempotencyKey: string; + expectedRevision?: number; + command: CapabilitySemanticCommand; +} + +export interface CapabilityCommandResult { + commandId: string; + commandKind: CapabilitySemanticCommand["kind"]; + disposition: "applied" | "duplicate"; + stateRevision: number; + entityRefs: string[]; + scheduledWakeIds: string[]; +} + +export interface CapabilityCommandErrorResult { + code: string; + message: string; + retryable: boolean; +} + +export type CapabilityCommandOutcome = + | { ok: true; result: CapabilityCommandResult } + | { + ok: false; + commandKind: CapabilitySemanticCommand["kind"]; + stateRevision: number; + error: CapabilityCommandErrorResult; + }; + +/** + * Claims enforced by the mock command boundary itself. The semantic catalog + * may hide ungranted operations earlier, but direct adapter consumers cannot + * bypass these checks. + */ +export const CAPABILITY_COMMAND_REQUIRED_CLAIMS = { + report_progress: [], + write_document: [], + request_human_input: [], + resolve_human_input: ["control_plane:interactions"], + register_deliverable: [], + set_dependencies: ["dependencies:write"], + create_task: ["delegation:tasks:create"], + request_approval: ["governance:approvals:request"], + decide_approval: ["governance:approvals:decide"], + comment_on_approval: ["governance:approvals:comment"], + control_workspace_service: ["workspace:control"], + record_budget_usage: ["control_plane:budget"], + finish_task: [], + block_task: [], + request_review: [], + release_task: ["control_plane:release"], + schedule_wake: ["control_plane:wakes"], +} as const satisfies Record; + +export interface CapabilityMockControlPlanePort extends ControlPlanePort { + start(): Promise; + stop(): Promise; + openFixtureRun(input: CapabilityOpenFixtureRunInput): Promise; + context(runId: string): CapabilityRunContext; + applyCommand(envelope: CapabilityCommandEnvelope): Promise; + tryApplyCommand(envelope: CapabilityCommandEnvelope): Promise; + snapshot(): Readonly; + decisionRecords(): readonly CapabilityDecisionRecord[]; + serialize(): string; +} + +export interface CapabilityFixtureSeed { + epochMs?: number; + company?: Partial; + actors?: CapabilityFixtureActor[]; + tasks?: CapabilityFixtureTask[]; + outOfScopeTaskIds?: string[]; + comments?: CapabilityFixtureComment[]; + documents?: CapabilityFixtureDocument[]; + interactions?: CapabilityFixtureInteraction[]; + approvals?: CapabilityFixtureApproval[]; + artifacts?: CapabilityFixtureArtifact[]; + workProducts?: CapabilityFixtureWorkProduct[]; + blockers?: CapabilityFixtureBlocker[]; + workspaceServices?: CapabilityFixtureWorkspaceService[]; + budgets?: CapabilityFixtureBudget[]; + faults?: CapabilityFaultRule[]; +} + +export function createCapabilityFixtureState(seed: CapabilityFixtureSeed = {}): CapabilityFixtureState { + const company: CapabilityFixtureCompany = { + id: "company-1", + name: "Mock Paperclip Company", + issuePrefix: "MCK", + status: "active", + budgetId: "budget-company-1", + ...seed.company, + }; + const actors = seed.actors ?? [ + { + id: "actor-1", + companyId: company.id, + name: "Mock Engineer", + role: "engineer", + status: "active", + budgetId: "budget-actor-1", + capabilityGrants: [], + }, + ]; + const tasks = seed.tasks ?? [ + { + id: "task-1", + companyId: company.id, + identifier: `${company.issuePrefix}-1`, + title: "Deterministic fixture task", + description: null, + status: "todo", + priority: "medium", + workMode: "standard", + parentId: null, + assigneeActorId: actors[0]?.id ?? null, + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + ]; + const budgets = seed.budgets ?? [ + { + id: company.budgetId, + scope: "company", + scopeId: company.id, + limitCents: 10_000, + spentCents: 0, + hardStop: true, + }, + ...actors.map((actor) => ({ + id: actor.budgetId, + scope: "actor" as const, + scopeId: actor.id, + limitCents: 1_000, + spentCents: 0, + hardStop: true, + })), + ]; + return { + schema: "paperclip.capability.mock-state.v1", + revision: 0, + clock: { epochMs: seed.epochMs ?? Date.UTC(2026, 7, 9), tick: 0 }, + counters: {}, + lifecycle: "stopped", + activeRunId: null, + company, + actors: structuredClone(actors), + tasks: structuredClone(tasks), + outOfScopeTaskIds: structuredClone(seed.outOfScopeTaskIds ?? []), + comments: structuredClone(seed.comments ?? []), + documents: structuredClone(seed.documents ?? []), + interactions: structuredClone(seed.interactions ?? []), + approvals: structuredClone(seed.approvals ?? []), + artifacts: structuredClone(seed.artifacts ?? []), + workProducts: structuredClone(seed.workProducts ?? []), + blockers: structuredClone(seed.blockers ?? []), + workspaceServices: structuredClone(seed.workspaceServices ?? []), + budgets: structuredClone(budgets), + runs: [], + wakes: [], + audit: [], + decisions: [], + idempotency: [], + faults: structuredClone(seed.faults ?? []), + }; +} diff --git a/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.test.ts b/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.test.ts new file mode 100644 index 0000000000..7a7ce2cfc9 --- /dev/null +++ b/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.test.ts @@ -0,0 +1,1703 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + CONTROL_PLANE_CONFORMANCE_OPEN, + CONTROL_PLANE_CONFORMANCE_RESULT, + CONTROL_PLANE_CONFORMANCE_TERMINAL, + runControlPlanePortConformance, +} from "../conformance/control-plane-port.js"; +import type { CompleteControlPlaneRunInput } from "../contracts/control-plane-port.js"; +import type { CapabilityFixtureSeed } from "./capability-control-plane-types.js"; +import { + CapabilityMockControlPlaneAdapter, + CapabilityMockControlPlaneError, +} from "./capability-mock-control-plane-adapter.js"; + +const OPEN = { + identity: { + runId: "run-1", + sessionId: "session-1", + companyId: "company-1", + issueId: "task-1", + agentId: "actor-1", + }, + backendKind: "mock" as const, + sourceInstanceId: "fixture-source", +}; + +function seeded(seed: CapabilityFixtureSeed = {}) { + return new CapabilityMockControlPlaneAdapter({ + workspaceServices: [ + { + id: "service-1", + taskId: "task-1", + name: "preview", + status: "stopped", + url: null, + lastTransitionAt: "2026-08-09T00:00:00.000Z", + }, + ], + ...seed, + }); +} + +describe("CapabilityMockControlPlaneAdapter", () => { + it("produces deterministic immutable context and state across every fixture domain", async () => { + const runScenario = async () => { + const adapter = seeded(); + await adapter.start(); + const context = await adapter.openFixtureRun({ + ...OPEN, + wake: { + reason: "issue_commented", + payload: { + commentId: "comment-wake", + apiKey: "must-not-be-visible", + nested: { authorization: "Bearer must-not-be-visible" }, + }, + }, + capabilities: [ + "control_plane:wakes", + "governance:approvals:comment", + "governance:approvals:request", + "workspace:control", + "task:write", + "task:write", + ], + }); + expect(context.capabilities).toEqual([ + "control_plane:wakes", + "governance:approvals:comment", + "governance:approvals:request", + "task:write", + "workspace:control", + ]); + expect(context.wake.payload).toEqual({ + commentId: "comment-wake", + apiKey: "[REDACTED]", + nested: { authorization: "[REDACTED]" }, + }); + + await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "progress-1", + command: { kind: "report_progress", body: "Fixture work started." }, + }); + const documentResult = await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "document-1", + command: { + kind: "write_document", + key: "plan", + title: "Plan", + body: "# Deterministic plan", + baseRevisionId: null, + }, + }); + const revisionId = documentResult.entityRefs + .find((ref) => ref.startsWith("revision:"))! + .slice("revision:".length); + await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "interaction-1", + command: { + kind: "request_human_input", + interactionKind: "confirmation", + title: "Confirm plan", + prompt: "Accept the deterministic plan?", + targetRevisionId: revisionId, + continuationPolicy: "wake_assignee", + }, + }); + await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "artifact-1", + command: { + kind: "register_deliverable", + filename: "report.json", + contentType: "application/json", + byteSize: 42, + sha256: "sha256-fixture", + contentRef: "fixture://artifacts/report.json", + title: "Fixture report", + }, + }); + const approvalResult = await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "approval-1", + command: { + kind: "request_approval", + approvalType: "request_board_approval", + payload: { summary: "Approve fixture action" }, + }, + }); + const approvalId = approvalResult.entityRefs + .find((ref) => ref.startsWith("approval:"))! + .slice("approval:".length); + await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "approval-comment-1", + command: { + kind: "comment_on_approval", + approvalId, + body: "Deterministic approval context.", + }, + }); + await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "workspace-1", + command: { + kind: "control_workspace_service", + serviceId: "service-1", + action: "start", + url: "http://fixture.invalid", + }, + }); + await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "wake-1", + command: { + kind: "schedule_wake", + reason: "scheduled_retry", + payload: { attempt: 2 }, + delayTicks: 3, + }, + }); + return { adapter, snapshot: adapter.snapshot() }; + }; + + const left = await runScenario(); + const right = await runScenario(); + expect(left.adapter.serialize()).toBe(right.adapter.serialize()); + expect(left.snapshot).toMatchObject({ + schema: "paperclip.capability.mock-state.v1", + comments: [{ body: "Fixture work started." }], + documents: [{ key: "plan", revisions: [{ revision: 1 }] }], + interactions: [{ kind: "confirmation", status: "pending" }], + approvals: [{ + type: "request_board_approval", + status: "pending", + comments: [{ body: "Deterministic approval context." }], + }], + artifacts: [{ filename: "report.json" }], + workProducts: [{ type: "artifact" }], + workspaceServices: [{ id: "service-1", status: "running" }], + wakes: [{ reason: "scheduled_retry", status: "scheduled" }], + }); + expect(Object.isFrozen(left.snapshot)).toBe(true); + expect(Object.isFrozen(left.snapshot.tasks)).toBe(true); + expect(left.snapshot.audit.length).toBeGreaterThanOrEqual(7); + expect(left.adapter.decisionRecords().every((decision) => Object.isFrozen(decision))).toBe(true); + }); + + it("commits a lost-ack command once and returns its stored result on retry", async () => { + const adapter = seeded({ + faults: [ + { + id: "lost-progress-ack", + operation: "apply_command", + commandKind: "report_progress", + effect: "lost_ack", + remaining: 1, + code: "fixture_ack_lost", + }, + ], + }); + await adapter.start(); + await adapter.openFixtureRun(OPEN); + const envelope = { + runId: "run-1", + idempotencyKey: "progress-retry", + command: { kind: "report_progress" as const, body: "Exactly once." }, + }; + + await expect(adapter.applyCommand(envelope)).rejects.toMatchObject({ + code: "fixture_ack_lost", + retryable: true, + }); + const replayed = await adapter.applyCommand(envelope); + expect(replayed.disposition).toBe("duplicate"); + expect(replayed.stateRevision).toBe(adapter.snapshot().revision); + expect(adapter.snapshot().comments.filter((comment) => comment.body === "Exactly once.")).toHaveLength(1); + expect(adapter.decisionRecords().map((decision) => decision.outcome)).toContain("faulted"); + expect(adapter.decisionRecords().map((decision) => decision.outcome)).toContain("duplicate"); + + await expect( + adapter.applyCommand({ + ...envelope, + command: { kind: "report_progress", body: "Mutated retry." }, + }), + ).rejects.toMatchObject({ code: "idempotency_conflict" }); + }); + + it("rolls back every state change when command execution fails", async () => { + const adapter = seeded({ + faults: [{ + id: "lost-create-ack", + operation: "apply_command", + commandKind: "create_task", + effect: "lost_ack", + remaining: 1, + code: "fixture_create_ack_lost", + }], + }); + await adapter.start(); + await adapter.openFixtureRun({ + ...OPEN, + capabilities: ["delegation:tasks:create"], + }); + const before = adapter.serialize(); + + await expect(adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "invalid-child", + command: { kind: "create_task", title: "" }, + })).rejects.toMatchObject({ code: "semantic_command_invalid" }); + + expect(adapter.serialize()).toBe(before); + const corrected = { + runId: "run-1", + idempotencyKey: "invalid-child", + command: { kind: "create_task", title: "Valid child" }, + } as const; + await expect(adapter.applyCommand(corrected)).rejects.toMatchObject({ + code: "fixture_create_ack_lost", + retryable: true, + }); + await expect(adapter.applyCommand(corrected)).resolves.toMatchObject({ + disposition: "duplicate", + }); + expect(adapter.snapshot().tasks.map((task) => task.title)).toContain("Valid child"); + }); + + it("preserves retryable command faults until semantic validation succeeds", async () => { + const adapter = seeded({ + faults: [{ + id: "retry-create", + operation: "apply_command", + commandKind: "create_task", + effect: "retryable_error", + remaining: 1, + code: "fixture_create_temporarily_unavailable", + }], + }); + await adapter.start(); + await adapter.openFixtureRun({ + ...OPEN, + capabilities: ["delegation:tasks:create"], + }); + + await expect(adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "create-after-validation", + command: { kind: "create_task", title: "" }, + })).rejects.toMatchObject({ code: "semantic_command_invalid", retryable: false }); + expect(adapter.snapshot().faults[0]?.remaining).toBe(1); + + const corrected = { + runId: "run-1", + idempotencyKey: "create-after-validation", + command: { kind: "create_task", title: "Valid child" }, + } as const; + await expect(adapter.applyCommand(corrected)).rejects.toMatchObject({ + code: "fixture_create_temporarily_unavailable", + retryable: true, + }); + expect(adapter.snapshot().faults[0]?.remaining).toBe(0); + await expect(adapter.applyCommand(corrected)).resolves.toMatchObject({ + disposition: "applied", + }); + }); + + it("serializes, restores, and replays gapped events without a live service", async () => { + const adapter = seeded(); + await adapter.start(); + await adapter.openFixtureRun(OPEN); + const event = (sourceSeq: number) => ({ + schema: "paperclip.prp.event.v1" as const, + sourceEventId: `fixture-source:event:${sourceSeq}`, + sourceSeq, + sourceInstanceId: "fixture-source", + sourceKind: "runner" as const, + runId: "run-1", + normalizedSessionId: "session-1", + turnId: "turn-1", + eventType: "session.started" as const, + schemaVersion: 1 as const, + priority: 1 as const, + emittedAt: `2026-08-09T00:00:0${sourceSeq}.000Z`, + payload: {}, + }); + expect((await adapter.appendEvent(event(2))).highestContiguousSourceSeq).toBe(0); + expect((await adapter.appendEvent(event(1))).highestContiguousSourceSeq).toBe(2); + + const restored = CapabilityMockControlPlaneAdapter.restore(adapter.serialize()); + const replay = await restored.replayEvents({ + runId: "run-1", + sourceInstanceId: "fixture-source", + afterSourceSeq: 0, + limit: 10, + }); + expect(replay.events.map((entry) => entry.sourceSeq)).toEqual([1, 2]); + expect(replay.highestContiguousSourceSeq).toBe(2); + expect(restored.context("run-1")).toEqual(adapter.context("run-1")); + }); + + it("reconciles terminal state, releases locks, and schedules dependent wakes", async () => { + const adapter = new CapabilityMockControlPlaneAdapter({ + tasks: [ + { + id: "task-1", + companyId: "company-1", + identifier: "MCK-1", + title: "Blocking task", + description: null, + status: "todo", + priority: "high", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-1", + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + { + id: "task-2", + companyId: "company-1", + identifier: "MCK-2", + title: "Dependent task", + description: null, + status: "blocked", + priority: "high", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-1", + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + ], + blockers: [ + { + id: "blocker-1", + taskId: "task-2", + blockedByTaskId: "task-1", + createdAt: "2026-08-09T00:00:00.000Z", + }, + ], + }); + await adapter.start(); + await adapter.openFixtureRun(OPEN); + await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "finish-1", + command: { kind: "finish_task", summary: "Blocking work completed." }, + }); + + const terminal = { + schema: "paperclip.prp.terminal.v1" as const, + turnTerminalState: "completed" as const, + runTerminalState: "succeeded" as const, + reportedWorkDisposition: "done" as const, + }; + const result = { + schema: "paperclip.run_result.v1" as const, + reportedWorkDisposition: "done" as const, + summary: "Blocking work completed.", + completionClaim: { + contractRevision: "capability-v1", + objectiveSatisfied: true, + criteria: [{ criterionId: "objective", status: "satisfied" as const, evidenceRefs: [] }], + remainingWork: [], + }, + evidence: [], + verification: [], + attentionRequests: [], + artifacts: [], + }; + await adapter.appendEvent({ + schema: "paperclip.prp.event.v1", + sourceEventId: "fixture-source:terminal:1", + sourceSeq: 1, + sourceInstanceId: "fixture-source", + sourceKind: "runner", + runId: "run-1", + normalizedSessionId: "session-1", + turnId: "turn-1", + eventType: "run.terminal", + schemaVersion: 1, + priority: 0, + emittedAt: "2026-08-09T00:00:10.000Z", + payload: terminal, + }); + await adapter.completeRun({ result, terminal }); + + const snapshot = adapter.snapshot(); + expect(snapshot.tasks.find((task) => task.id === "task-1")).toMatchObject({ + status: "done", + checkoutRunId: null, + executionRunId: null, + }); + expect(snapshot.tasks.find((task) => task.id === "task-2")?.status).toBe("todo"); + expect(snapshot.blockers).toEqual([]); + expect(snapshot.wakes).toMatchObject([ + { taskId: "task-2", reason: "blockers_resolved", status: "scheduled" }, + ]); + expect(snapshot.runs[0]?.status).toBe("succeeded"); + }); + + it("preserves completion faults until a completion passes semantic validation", async () => { + const adapter = seeded({ + faults: [{ + id: "retry-completion", + operation: "complete_run", + effect: "retryable_error", + remaining: 1, + code: "completion_temporarily_unavailable", + }], + }); + await adapter.start(); + await adapter.openFixtureRun(OPEN); + + await expect( + adapter.completeRun({ + result: { + ...CONTROL_PLANE_CONFORMANCE_RESULT, + completionClaim: null, + } as unknown as CompleteControlPlaneRunInput["result"], + terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, + }), + ).rejects.toMatchObject({ code: "native_result_invalid" }); + await expect( + adapter.completeRun({ + result: CONTROL_PLANE_CONFORMANCE_RESULT, + terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, + }), + ).rejects.toMatchObject({ code: "terminal_event_missing" }); + expect(adapter.snapshot().faults[0]?.remaining).toBe(1); + + await adapter.appendEvent({ + schema: "paperclip.prp.event.v1", + sourceEventId: "fixture-source:terminal:completion-retry", + sourceSeq: 1, + sourceInstanceId: "fixture-source", + sourceKind: "runner", + runId: "run-1", + normalizedSessionId: "session-1", + turnId: "turn-1", + eventType: "run.terminal", + schemaVersion: 1, + priority: 0, + emittedAt: "2026-08-09T00:00:10.000Z", + payload: { ...CONTROL_PLANE_CONFORMANCE_TERMINAL }, + }); + await expect( + adapter.completeRun({ + result: CONTROL_PLANE_CONFORMANCE_RESULT, + terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, + }), + ).rejects.toMatchObject({ code: "completion_temporarily_unavailable", retryable: true }); + expect(adapter.snapshot().faults[0]?.remaining).toBe(0); + + await adapter.completeRun({ + result: CONTROL_PLANE_CONFORMANCE_RESULT, + terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, + }); + expect(adapter.snapshot().runs[0]).toMatchObject({ status: "succeeded" }); + }); + + it("enforces budget hard stops and releases run authority", async () => { + const adapter = seeded({ + budgets: [ + { + id: "budget-company-1", + scope: "company", + scopeId: "company-1", + limitCents: 100, + spentCents: 90, + hardStop: true, + }, + { + id: "budget-actor-1", + scope: "actor", + scopeId: "actor-1", + limitCents: 100, + spentCents: 90, + hardStop: true, + }, + ], + }); + await adapter.start(); + await adapter.openFixtureRun({ ...OPEN, capabilities: ["control_plane:budget"] }); + await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "usage-1", + command: { kind: "record_budget_usage", cents: 10 }, + }); + const snapshot = adapter.snapshot(); + expect(snapshot.actors[0]?.status).toBe("paused"); + expect(snapshot.runs[0]?.status).toBe("budget_stopped"); + expect(snapshot.tasks[0]).toMatchObject({ + status: "blocked", + checkoutRunId: null, + executionRunId: null, + }); + await expect( + adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "after-budget-stop", + command: { kind: "report_progress", body: "Should not be accepted." }, + }), + ).rejects.toMatchObject({ code: "budget_hard_stop" }); + }); + + it("replays a lost acknowledgement after the committed command stops its run", async () => { + const adapter = seeded({ + budgets: [ + { + id: "budget-company-1", + scope: "company", + scopeId: "company-1", + limitCents: 100, + spentCents: 90, + hardStop: true, + }, + { + id: "budget-actor-1", + scope: "actor", + scopeId: "actor-1", + limitCents: 100, + spentCents: 90, + hardStop: true, + }, + ], + faults: [{ + id: "lost-budget-ack", + operation: "apply_command", + commandKind: "record_budget_usage", + effect: "lost_ack", + remaining: 1, + }], + }); + await adapter.start(); + await adapter.openFixtureRun({ ...OPEN, capabilities: ["control_plane:budget"] }); + const envelope = { + runId: "run-1", + idempotencyKey: "budget-stop-retry", + command: { kind: "record_budget_usage" as const, cents: 10 }, + }; + + await expect(adapter.applyCommand(envelope)).rejects.toMatchObject({ retryable: true }); + await expect(adapter.applyCommand(envelope)).resolves.toMatchObject({ + disposition: "duplicate", + }); + expect(adapter.snapshot().budgets.map((budget) => budget.spentCents)).toEqual([100, 100]); + expect(adapter.snapshot().runs[0]?.status).toBe("budget_stopped"); + }); + + it("returns typed denials when a command claim is missing", async () => { + const adapter = seeded(); + await adapter.start(); + await adapter.openFixtureRun(OPEN); + + const outcome = await adapter.tryApplyCommand({ + runId: "run-1", + idempotencyKey: "workspace-without-claim", + command: { + kind: "control_workspace_service", + serviceId: "service-1", + action: "start", + }, + }); + + expect(outcome).toEqual({ + ok: false, + commandKind: "control_workspace_service", + stateRevision: 2, + error: { + code: "operation_not_authorized", + message: "the fixture run does not hold the required command claim", + retryable: false, + }, + }); + expect(adapter.snapshot().workspaceServices[0]?.status).toBe("stopped"); + expect(adapter.decisionRecords().at(-1)).toMatchObject({ + operation: "control_workspace_service", + outcome: "denied", + reason: "required_claim_missing", + }); + }); + + it("enforces active-task ownership and legal transitions", async () => { + const adapter = seeded({ + tasks: [ + { + id: "task-1", + companyId: "company-1", + identifier: "MCK-1", + title: "Active task", + description: null, + status: "todo", + priority: "high", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-1", + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + { + id: "task-2", + companyId: "company-1", + identifier: "MCK-2", + title: "Other task", + description: null, + status: "todo", + priority: "low", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-1", + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + ], + }); + await adapter.start(); + await adapter.openFixtureRun(OPEN); + + await expect(adapter.tryApplyCommand({ + runId: "run-1", + idempotencyKey: "cross-task-write", + command: { kind: "report_progress", taskId: "task-2", body: "Not allowed." }, + })).resolves.toMatchObject({ + ok: false, + error: { code: "task_scope_violation" }, + }); + + await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "review-once", + command: { kind: "request_review", summary: "Ready for review." }, + }); + await expect(adapter.tryApplyCommand({ + runId: "run-1", + idempotencyKey: "review-twice", + command: { kind: "request_review", summary: "Still ready." }, + })).resolves.toMatchObject({ + ok: false, + error: { code: "task_transition_illegal" }, + }); + expect(adapter.snapshot().comments.map((comment) => comment.body)).toEqual([ + "Ready for review.", + ]); + }); + + it("resolves linked approvals only with an executable requester continuation", async () => { + const adapter = seeded({ + actors: [ + { + id: "actor-1", + companyId: "company-1", + name: "Mock Approver", + role: "approver", + status: "active", + budgetId: "budget-actor-1", + capabilityGrants: [], + }, + { + id: "actor-2", + companyId: "company-1", + name: "Other Requester", + role: "engineer", + status: "active", + budgetId: "budget-actor-2", + capabilityGrants: [], + }, + ], + tasks: [ + { + id: "task-1", + companyId: "company-1", + identifier: "MCK-1", + title: "Active task", + description: null, + status: "todo", + priority: "high", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-1", + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + { + id: "task-2", + companyId: "company-1", + identifier: "MCK-2", + title: "Other task", + description: null, + status: "in_review", + priority: "medium", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-2", + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + { + id: "task-3", + companyId: "company-1", + identifier: "MCK-3", + title: "Terminal requester task", + description: null, + status: "done", + priority: "medium", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-2", + checkoutRunId: null, + executionRunId: null, + startedAt: "2026-08-09T00:00:00.000Z", + completedAt: "2026-08-09T00:01:00.000Z", + }, + { + id: "task-4", + companyId: "company-1", + identifier: "MCK-4", + title: "Owned requester task", + description: null, + status: "in_review", + priority: "medium", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-2", + checkoutRunId: "run-conflict", + executionRunId: "run-conflict", + startedAt: "2026-08-09T00:00:00.000Z", + completedAt: null, + }, + { + id: "task-5", + companyId: "company-1", + identifier: "MCK-5", + title: "Parked requester task", + description: null, + status: "backlog", + priority: "medium", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-2", + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + ], + approvals: [ + { + id: "approval-active-task", + companyId: "company-1", + taskIds: ["task-1"], + type: "request_board_approval", + status: "pending", + requestedByActorId: "actor-2", + payload: {}, + decisionNote: null, + comments: [], + createdAt: "2026-08-09T00:00:00.000Z", + decidedAt: null, + }, + { + id: "approval-other-task", + companyId: "company-1", + taskIds: ["task-1", "task-2"], + type: "request_board_approval", + status: "pending", + requestedByActorId: "actor-2", + payload: {}, + decisionNote: null, + comments: [], + createdAt: "2026-08-09T00:00:00.000Z", + decidedAt: null, + }, + { + id: "approval-unlinked", + companyId: "company-1", + taskIds: ["task-2"], + type: "request_board_approval", + status: "pending", + requestedByActorId: "actor-2", + payload: {}, + decisionNote: null, + comments: [], + createdAt: "2026-08-09T00:00:00.000Z", + decidedAt: null, + }, + { + id: "approval-terminal-requester", + companyId: "company-1", + taskIds: ["task-1", "task-3"], + type: "request_board_approval", + status: "pending", + requestedByActorId: "actor-2", + payload: {}, + decisionNote: null, + comments: [], + createdAt: "2026-08-09T00:00:00.000Z", + decidedAt: null, + }, + { + id: "approval-owned-requester", + companyId: "company-1", + taskIds: ["task-1", "task-4"], + type: "request_board_approval", + status: "pending", + requestedByActorId: "actor-2", + payload: {}, + decisionNote: null, + comments: [], + createdAt: "2026-08-09T00:00:00.000Z", + decidedAt: null, + }, + { + id: "approval-backlog-requester", + companyId: "company-1", + taskIds: ["task-1", "task-5"], + type: "request_board_approval", + status: "pending", + requestedByActorId: "actor-2", + payload: {}, + decisionNote: null, + comments: [], + createdAt: "2026-08-09T00:00:00.000Z", + decidedAt: null, + }, + ], + }); + await adapter.start(); + await adapter.openFixtureRun({ + ...OPEN, + capabilities: [ + "governance:approvals:comment", + "governance:approvals:decide", + ], + }); + + await expect(adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "shared-approval-comment", + command: { + kind: "comment_on_approval", + approvalId: "approval-other-task", + body: "Scoped from the active task.", + }, + })).resolves.toMatchObject({ disposition: "applied" }); + await expect(adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "shared-approval-decision", + command: { + kind: "decide_approval", + approvalId: "approval-other-task", + decision: "approved", + note: "Shared approval is in scope.", + }, + })).resolves.toMatchObject({ disposition: "applied" }); + await expect(adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "active-approval-decision", + command: { + kind: "decide_approval", + approvalId: "approval-active-task", + decision: "approved", + note: "Active-task approval is in scope.", + }, + })).resolves.toMatchObject({ disposition: "applied" }); + await expect(adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "terminal-requester-approval-decision", + command: { + kind: "decide_approval", + approvalId: "approval-terminal-requester", + decision: "approved", + note: "A terminal task cannot receive the requester continuation.", + }, + })).resolves.toMatchObject({ disposition: "applied" }); + await expect(adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "owned-requester-approval-decision", + command: { + kind: "decide_approval", + approvalId: "approval-owned-requester", + decision: "approved", + note: "A task owned by another run cannot receive the continuation.", + }, + })).resolves.toMatchObject({ disposition: "applied" }); + await expect(adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "backlog-requester-approval-decision", + command: { + kind: "decide_approval", + approvalId: "approval-backlog-requester", + decision: "approved", + note: "A parked task cannot receive an executable continuation.", + }, + })).resolves.toMatchObject({ disposition: "applied" }); + await expect(adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "unlinked-approval-decision", + command: { + kind: "decide_approval", + approvalId: "approval-unlinked", + decision: "approved", + note: "Must remain scoped.", + }, + })).rejects.toMatchObject({ code: "approval_scope_violation" }); + expect(adapter.snapshot()).toMatchObject({ + approvals: [ + { + id: "approval-active-task", + status: "approved", + }, + { + id: "approval-other-task", + status: "approved", + comments: [{ body: "Scoped from the active task." }], + }, + { id: "approval-unlinked", status: "pending" }, + { id: "approval-terminal-requester", status: "approved" }, + { id: "approval-owned-requester", status: "approved" }, + { id: "approval-backlog-requester", status: "approved" }, + ], + }); + expect(adapter.snapshot().wakes).toHaveLength(5); + expect(adapter.snapshot().wakes).toEqual(expect.arrayContaining([ + expect.objectContaining({ + actorId: "actor-2", + taskId: "task-2", + reason: "approval_resolved", + payload: { approvalId: "approval-other-task", decision: "approved" }, + }), + ...[ + "approval-active-task", + "approval-terminal-requester", + "approval-owned-requester", + "approval-backlog-requester", + ].map((approvalId) => expect.objectContaining({ + actorId: "actor-2", + reason: "approval_resolved", + payload: { approvalId, decision: "approved" }, + })), + ])); + const recoveryTaskIds = adapter.snapshot().wakes + .filter((wake) => wake.taskId !== "task-2") + .map((wake) => wake.taskId); + expect(recoveryTaskIds).toHaveLength(4); + for (const taskId of recoveryTaskIds) { + expect(adapter.snapshot().tasks.find((candidate) => candidate.id === taskId)).toMatchObject({ + status: "todo", + assigneeActorId: "actor-2", + checkoutRunId: null, + executionRunId: null, + }); + } + expect(adapter.snapshot().tasks.find((candidate) => candidate.id === "task-5")).toMatchObject({ + status: "backlog", + assigneeActorId: "actor-2", + checkoutRunId: null, + executionRunId: null, + }); + expect(adapter.snapshot().wakes).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ taskId: "task-5" }), + ])); + const requesterWake = adapter.snapshot().wakes.find((wake) => wake.actorId === "actor-2"); + expect(requesterWake).toMatchObject({ taskId: "task-2", status: "scheduled" }); + await expect(adapter.openFixtureRun({ + ...OPEN, + identity: { + ...OPEN.identity, + runId: "run-requester-wake", + sessionId: "session-requester-wake", + agentId: "actor-2", + issueId: requesterWake!.taskId, + }, + wake: { + reason: requesterWake!.reason, + payload: requesterWake!.payload, + }, + })).resolves.toMatchObject({ + actor: { id: "actor-2" }, + activeTask: { id: "task-2", checkoutRunId: "run-requester-wake" }, + }); + }); + + it("routes approval continuation according to unresolved blocker state", async () => { + const seed = { + actors: [ + { + id: "actor-1", + companyId: "company-1", + name: "Mock Approver", + role: "approver", + status: "active", + budgetId: "budget-actor-1", + capabilityGrants: [], + }, + { + id: "actor-2", + companyId: "company-1", + name: "Blocked Requester", + role: "engineer", + status: "active", + budgetId: "budget-actor-2", + capabilityGrants: [], + }, + ], + tasks: [ + { + id: "task-1", + companyId: "company-1", + identifier: "MCK-1", + title: "Approval task", + description: null, + status: "todo", + priority: "high", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-1", + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + { + id: "task-2", + companyId: "company-1", + identifier: "MCK-2", + title: "Blocked requester task", + description: null, + status: "blocked", + priority: "medium", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-2", + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + { + id: "task-3", + companyId: "company-1", + identifier: "MCK-3", + title: "Unfinished dependency", + description: null, + status: "todo", + priority: "medium", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-2", + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + ], + approvals: [{ + id: "approval-blocked-requester", + companyId: "company-1", + taskIds: ["task-1", "task-2"], + type: "request_board_approval", + status: "pending", + requestedByActorId: "actor-2", + payload: {}, + decisionNote: null, + comments: [], + createdAt: "2026-08-09T00:00:00.000Z", + decidedAt: null, + }], + blockers: [{ + id: "blocker-requester", + taskId: "task-2", + blockedByTaskId: "task-3", + createdAt: "2026-08-09T00:00:00.000Z", + }], + } satisfies CapabilityFixtureSeed; + const adapter = seeded(seed); + await adapter.start(); + await adapter.openFixtureRun({ + ...OPEN, + capabilities: ["governance:approvals:decide"], + }); + + await expect(adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "blocked-requester-decision", + command: { + kind: "decide_approval", + approvalId: "approval-blocked-requester", + decision: "approved", + note: "Record approval without bypassing dependencies.", + }, + })).resolves.toMatchObject({ disposition: "applied" }); + + const snapshot = adapter.snapshot(); + expect(snapshot.tasks.find((task) => task.id === "task-2")).toMatchObject({ + status: "blocked", + checkoutRunId: null, + executionRunId: null, + }); + expect(snapshot.blockers).toEqual([ + expect.objectContaining({ + taskId: "task-2", + blockedByTaskId: "task-3", + }), + ]); + expect(snapshot.wakes).toHaveLength(1); + expect(snapshot.wakes[0]?.taskId).not.toBe("task-2"); + expect(snapshot.tasks.find((task) => task.id === snapshot.wakes[0]?.taskId)).toMatchObject({ + status: "todo", + assigneeActorId: "actor-2", + checkoutRunId: null, + executionRunId: null, + }); + + const completedBlockerAdapter = seeded({ + ...seed, + tasks: seed.tasks.map((task) => + task.id === "task-3" + ? { + ...task, + status: "done" as const, + completedAt: "2026-08-09T00:01:00.000Z", + } + : { ...task }, + ), + }); + await completedBlockerAdapter.start(); + await completedBlockerAdapter.openFixtureRun({ + ...OPEN, + capabilities: ["governance:approvals:decide"], + }); + await expect(completedBlockerAdapter.applyCommand({ + runId: "run-1", + idempotencyKey: "completed-blocker-requester-decision", + command: { + kind: "decide_approval", + approvalId: "approval-blocked-requester", + decision: "approved", + note: "Continue the linked task after its dependency completed.", + }, + })).resolves.toMatchObject({ disposition: "applied" }); + + const completedBlockerSnapshot = completedBlockerAdapter.snapshot(); + expect(completedBlockerSnapshot.blockers).toEqual([ + expect.objectContaining({ + taskId: "task-2", + blockedByTaskId: "task-3", + }), + ]); + expect(completedBlockerSnapshot.tasks).toHaveLength(seed.tasks.length); + expect(completedBlockerSnapshot.wakes).toEqual([ + expect.objectContaining({ + actorId: "actor-2", + taskId: "task-2", + reason: "approval_resolved", + payload: { + approvalId: "approval-blocked-requester", + decision: "approved", + }, + }), + ]); + + await completedBlockerAdapter.openFixtureRun({ + ...OPEN, + identity: { + ...OPEN.identity, + runId: "run-completed-blocker-requester", + sessionId: "session-completed-blocker-requester", + agentId: "actor-2", + issueId: "task-2", + }, + }); + await completedBlockerAdapter.appendEvent({ + schema: "paperclip.prp.event.v1", + sourceEventId: "fixture-source:terminal:completed-blocker-requester", + sourceSeq: 1, + sourceInstanceId: "fixture-source", + sourceKind: "runner", + runId: "run-completed-blocker-requester", + normalizedSessionId: "session-completed-blocker-requester", + turnId: "turn-completed-blocker-requester", + eventType: "run.terminal", + schemaVersion: 1, + priority: 0, + emittedAt: "2026-08-09T00:02:00.000Z", + payload: { ...CONTROL_PLANE_CONFORMANCE_TERMINAL }, + }); + await expect(completedBlockerAdapter.completeRun({ + result: CONTROL_PLANE_CONFORMANCE_RESULT, + terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, + })).resolves.toBeUndefined(); + const terminalSnapshot = completedBlockerAdapter.snapshot(); + expect(terminalSnapshot.tasks.find((task) => task.id === "task-2")) + .toMatchObject({ status: "done", checkoutRunId: null, executionRunId: null }); + expect(terminalSnapshot.blockers).toEqual([]); + }); + + it("does not traverse resolved blocker rows during cycle detection", async () => { + const adapter = seeded({ + tasks: [ + { + id: "task-1", + companyId: "company-1", + identifier: "MCK-1", + title: "Active task", + description: null, + status: "todo", + priority: "high", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-1", + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + { + id: "task-2", + companyId: "company-1", + identifier: "MCK-2", + title: "Candidate dependency", + description: null, + status: "todo", + priority: "medium", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-1", + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + { + id: "task-3", + companyId: "company-1", + identifier: "MCK-3", + title: "Completed dependency", + description: null, + status: "done", + priority: "medium", + workMode: "standard", + parentId: null, + assigneeActorId: "actor-1", + checkoutRunId: null, + executionRunId: null, + startedAt: "2026-08-09T00:00:00.000Z", + completedAt: "2026-08-09T00:01:00.000Z", + }, + ], + blockers: [ + { + id: "resolved-blocker", + taskId: "task-2", + blockedByTaskId: "task-3", + createdAt: "2026-08-09T00:00:00.000Z", + }, + { + id: "historical-return-edge", + taskId: "task-3", + blockedByTaskId: "task-1", + createdAt: "2026-08-09T00:00:00.000Z", + }, + ], + }); + await adapter.start(); + await adapter.openFixtureRun({ + ...OPEN, + capabilities: ["dependencies:write"], + }); + + await expect(adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "resolved-blocker-cycle-check", + command: { kind: "set_dependencies", blockedByTaskIds: ["task-2"] }, + })).resolves.toMatchObject({ disposition: "applied" }); + expect(adapter.snapshot().blockers).toEqual(expect.arrayContaining([ + expect.objectContaining({ taskId: "task-1", blockedByTaskId: "task-2" }), + ])); + }); + + it("does not schedule approval recovery for an inactive requester", async () => { + const adapter = seeded({ + actors: [ + { + id: "actor-1", + companyId: "company-1", + name: "Mock Approver", + role: "approver", + status: "active", + budgetId: "budget-actor-1", + capabilityGrants: [], + }, + { + id: "actor-2", + companyId: "company-1", + name: "Paused Requester", + role: "engineer", + status: "paused", + budgetId: "budget-actor-2", + capabilityGrants: [], + }, + ], + approvals: [{ + id: "approval-paused-requester", + companyId: "company-1", + taskIds: ["task-1"], + type: "request_board_approval", + status: "pending", + requestedByActorId: "actor-2", + payload: {}, + decisionNote: null, + comments: [], + createdAt: "2026-08-09T00:00:00.000Z", + decidedAt: null, + }], + }); + await adapter.start(); + await adapter.openFixtureRun({ + ...OPEN, + capabilities: ["governance:approvals:decide"], + }); + + await expect(adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "paused-requester-decision", + command: { + kind: "decide_approval", + approvalId: "approval-paused-requester", + decision: "approved", + note: "Record the decision without an unusable continuation.", + }, + })).resolves.toMatchObject({ disposition: "applied", scheduledWakeIds: [] }); + expect(adapter.snapshot().tasks).toHaveLength(1); + expect(adapter.snapshot().wakes).toEqual([]); + }); + + it("records approval decisions when the requester has no applicable budget", async () => { + const adapter = seeded({ + actors: [ + { + id: "actor-1", + companyId: "company-1", + name: "Mock Approver", + role: "approver", + status: "active", + budgetId: "budget-actor-1", + capabilityGrants: [], + }, + { + id: "actor-2", + companyId: "company-1", + name: "Budgetless Requester", + role: "engineer", + status: "active", + budgetId: "missing-budget-actor-2", + capabilityGrants: [], + }, + ], + budgets: [{ + id: "budget-actor-1", + scope: "actor", + scopeId: "actor-1", + limitCents: 1_000, + spentCents: 0, + hardStop: true, + }], + approvals: [{ + id: "approval-budgetless-requester", + companyId: "company-1", + taskIds: ["task-1"], + type: "request_board_approval", + status: "pending", + requestedByActorId: "actor-2", + payload: {}, + decisionNote: null, + comments: [], + createdAt: "2026-08-09T00:00:00.000Z", + decidedAt: null, + }], + }); + await adapter.start(); + await adapter.openFixtureRun({ + ...OPEN, + capabilities: ["governance:approvals:decide"], + }); + + await expect(adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "budgetless-requester-decision", + command: { + kind: "decide_approval", + approvalId: "approval-budgetless-requester", + decision: "approved", + note: "Record governance independently from continuation capacity.", + }, + })).resolves.toMatchObject({ disposition: "applied", scheduledWakeIds: [] }); + + expect(adapter.snapshot()).toMatchObject({ + approvals: [{ + id: "approval-budgetless-requester", + status: "approved", + decisionNote: "Record governance independently from continuation capacity.", + }], + wakes: [], + }); + expect(adapter.snapshot().tasks).toHaveLength(1); + }); + + it("rejects fixture wake references outside the company actor scope", () => { + expect(() => seeded({ + tasks: [ + { + id: "task-1", + companyId: "company-1", + identifier: "MCK-1", + title: "Invalid assignee", + description: null, + status: "todo", + priority: "high", + workMode: "standard", + parentId: null, + assigneeActorId: "missing-actor", + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + ], + })).toThrow(/invalid assignee actor/); + + expect(() => seeded({ + approvals: [ + { + id: "approval-invalid-requester", + companyId: "company-1", + taskIds: ["task-1"], + type: "request_board_approval", + status: "pending", + requestedByActorId: "missing-actor", + payload: {}, + decisionNote: null, + comments: [], + createdAt: "2026-08-09T00:00:00.000Z", + decidedAt: null, + }, + ], + })).toThrow(/invalid requester actor/); + + expect(() => seeded({ + blockers: [{ + id: "blocker-invalid-task", + taskId: "task-1", + blockedByTaskId: "missing-task", + createdAt: "2026-08-09T00:00:00.000Z", + }], + })).toThrow(/invalid task reference/); + }); + + it("rejects stale interaction targets and invalid interaction outcomes", async () => { + const adapter = seeded(); + await adapter.start(); + await adapter.openFixtureRun({ + ...OPEN, + capabilities: ["control_plane:interactions"], + }); + const first = await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "plan-v1", + command: { + kind: "write_document", + key: "plan", + title: "Plan", + body: "Version one", + baseRevisionId: null, + }, + }); + const firstRevisionId = first.entityRefs.find((ref) => ref.startsWith("revision:"))!.slice(9); + const second = await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "plan-v2", + command: { + kind: "write_document", + key: "plan", + title: "Plan", + body: "Version two", + baseRevisionId: firstRevisionId, + }, + }); + const secondRevisionId = second.entityRefs.find((ref) => ref.startsWith("revision:"))!.slice(9); + + await expect(adapter.tryApplyCommand({ + runId: "run-1", + idempotencyKey: "stale-confirmation", + command: { + kind: "request_human_input", + interactionKind: "confirmation", + title: "Confirm", + prompt: "Accept?", + targetRevisionId: firstRevisionId, + continuationPolicy: "wake_assignee", + }, + })).resolves.toMatchObject({ + ok: false, + error: { code: "interaction_target_stale" }, + }); + + const interaction = await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "current-confirmation", + command: { + kind: "request_human_input", + interactionKind: "confirmation", + title: "Confirm", + prompt: "Accept?", + targetRevisionId: secondRevisionId, + continuationPolicy: "wake_assignee", + }, + }); + const interactionId = interaction.entityRefs.find((ref) => ref.startsWith("interaction:"))!.slice(12); + await expect(adapter.tryApplyCommand({ + runId: "run-1", + idempotencyKey: "invalid-confirmation-answer", + command: { + kind: "resolve_human_input", + interactionId, + outcome: "answered", + result: { answer: "yes" }, + }, + })).resolves.toMatchObject({ + ok: false, + error: { code: "interaction_outcome_invalid" }, + }); + }); + + it("performs complete mock operations without any Paperclip network request", async () => { + const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue( + new Error("network access is forbidden in the mock control plane"), + ); + try { + const adapter = seeded(); + await adapter.start(); + await adapter.openFixtureRun(OPEN); + await adapter.applyCommand({ + runId: "run-1", + idempotencyKey: "offline-progress", + command: { kind: "report_progress", body: "Committed offline." }, + }); + const restored = CapabilityMockControlPlaneAdapter.restore(adapter.serialize()); + expect(restored.snapshot().comments).toMatchObject([{ body: "Committed offline." }]); + expect(fetch).not.toHaveBeenCalled(); + } finally { + fetch.mockRestore(); + } + }); + + it("implements the existing ControlPlanePort conformance contract", async () => { + const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity; + const adapter = new CapabilityMockControlPlaneAdapter({ + company: { + id: identity.companyId, + name: "Conformance Company", + issuePrefix: "CNF", + status: "active", + budgetId: "budget-company-conformance", + }, + actors: [ + { + id: identity.agentId, + companyId: identity.companyId, + name: "Conformance Actor", + role: "engineer", + status: "active", + budgetId: "budget-actor-conformance", + capabilityGrants: [], + }, + ], + tasks: [ + { + id: identity.issueId, + companyId: identity.companyId, + identifier: "CNF-1", + title: "Conformance task", + description: null, + status: "todo", + priority: "medium", + workMode: "standard", + parentId: null, + assigneeActorId: identity.agentId, + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }, + ], + budgets: [ + { + id: "budget-company-conformance", + scope: "company", + scopeId: identity.companyId, + limitCents: 100, + spentCents: 0, + hardStop: true, + }, + { + id: "budget-actor-conformance", + scope: "actor", + scopeId: identity.agentId, + limitCents: 100, + spentCents: 0, + hardStop: true, + }, + ], + }); + await expect(runControlPlanePortConformance({ port: adapter, start: () => adapter.start() })) + .resolves.toMatchObject({ + eventCount: 3, + terminalReplayIdempotent: true, + openBindingRejected: true, + eventIdMutationRejected: true, + eventSequenceMutationRejected: true, + replayBindingRejected: true, + resultMutationRejected: true, + }); + }); + + it("uses stable typed errors for retryable scripted failures", async () => { + const adapter = seeded({ + faults: [ + { + id: "retry-open", + operation: "open_run", + effect: "retryable_error", + remaining: 1, + code: "fixture_transient", + }, + ], + }); + await adapter.start(); + const error = await adapter.openFixtureRun(OPEN).catch((caught) => caught); + expect(error).toBeInstanceOf(CapabilityMockControlPlaneError); + expect(error).toMatchObject({ code: "fixture_transient", retryable: true }); + await expect(adapter.openFixtureRun(OPEN)).resolves.toMatchObject({ + activeTask: { id: "task-1", status: "in_progress" }, + }); + }); +}); diff --git a/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts b/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts new file mode 100644 index 0000000000..59974bed97 --- /dev/null +++ b/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts @@ -0,0 +1,1783 @@ +import type { + AppendedEventReceipt, + CompleteControlPlaneRunInput, + OpenControlPlaneRunInput, + ReplayControlPlaneEventsInput, + ReplayedControlPlaneEvents, +} from "../contracts/control-plane-port.js"; +import type { PersistedNativeSession } from "../contracts/native-session-backend.js"; +import type { NativeRunEvent, NativeRunResult } from "../contracts/types.js"; +import { + validatePrpEvent, + validatePrpStructuredRunResult, + type PrpEvent, +} from "../protocol/replay-contract.js"; +import { + CAPABILITY_COMMAND_REQUIRED_CLAIMS, + createCapabilityFixtureState, + type CapabilityAuditRecord, + type CapabilityCommandEnvelope, + type CapabilityCommandOutcome, + type CapabilityCommandResult, + type CapabilityDecisionRecord, + type CapabilityFaultRule, + type CapabilityFixtureActor, + type CapabilityFixtureApproval, + type CapabilityFixtureDocument, + type CapabilityFixtureRun, + type CapabilityFixtureSeed, + type CapabilityFixtureState, + type CapabilityFixtureTask, + type CapabilityJsonValue, + type CapabilityMockControlPlanePort, + type CapabilityOpenFixtureRunInput, + type CapabilityRunContext, + type CapabilitySemanticCommand, + type CapabilityWakeContext, +} from "./capability-control-plane-types.js"; + +const DEFAULT_WAKE: CapabilityWakeContext = { reason: "manual", payload: {} }; + +export class CapabilityMockControlPlaneError extends Error { + constructor( + readonly code: string, + message: string, + readonly retryable = false, + ) { + super(message); + this.name = "CapabilityMockControlPlaneError"; + } +} +/** + * Deterministic, serializable control-plane authority for Capability scenarios. + * + * The adapter deliberately models Paperclip semantics instead of Paperclip + * transport. It imports no server route, service, database, or provider binding. + */ +export class CapabilityMockControlPlaneAdapter implements CapabilityMockControlPlanePort { + #state: CapabilityFixtureState; + + constructor(seed: CapabilityFixtureSeed | CapabilityFixtureState = {}) { + this.#state = isFixtureState(seed) + ? clone(seed) + : createCapabilityFixtureState(seed); + // Serialized v1 fixtures from before company-scope sentinels remain valid. + this.#state.outOfScopeTaskIds ??= []; + this.#validateState(); + } + + static restore(serialized: string): CapabilityMockControlPlaneAdapter { + let parsed: unknown; + try { + parsed = JSON.parse(serialized); + } catch (error) { + throw new CapabilityMockControlPlaneError( + "fixture_state_invalid", + `fixture state is not valid JSON: ${String(error)}`, + ); + } + if (!isFixtureState(parsed)) { + throw new CapabilityMockControlPlaneError( + "fixture_state_invalid", + "fixture state schema must be paperclip.capability.mock-state.v1", + ); + } + return new CapabilityMockControlPlaneAdapter(parsed); + } + + async start(): Promise { + if (this.#state.lifecycle === "running") { + throw new CapabilityMockControlPlaneError( + "mock_already_running", + "Capability mock control plane is already running", + ); + } + this.#state.lifecycle = "running"; + } + + async stop(): Promise { + this.#state.lifecycle = "stopped"; + this.#state.activeRunId = null; + } + + async openRun(input: OpenControlPlaneRunInput): Promise { + await this.openFixtureRun(input); + } + + async openFixtureRun(input: CapabilityOpenFixtureRunInput): Promise { + this.#assertRunning(); + const existing = this.#state.runs.find((run) => run.id === input.identity.runId); + if (existing !== undefined) { + this.#assertRunBinding(existing, input); + this.#state.activeRunId = existing.id; + this.#recordDecisionOnly(existing.id, "open_run", "duplicate", "run_binding_resumed", [ + `run:${existing.id}`, + ]); + return this.context(existing.id); + } + + const actor = this.#actor(input.identity.agentId); + const task = this.#task(input.identity.issueId); + this.#assertCompany(input.identity.companyId, actor.companyId, task.companyId); + if (actor.status !== "active") { + this.#deny(input.identity.runId, "open_run", "actor_not_active", [`actor:${actor.id}`]); + throw new CapabilityMockControlPlaneError("actor_not_active", "fixture actor is not active"); + } + this.#assertBudgetAvailable(actor, input.identity.runId); + if (task.assigneeActorId !== null && task.assigneeActorId !== actor.id) { + this.#deny(input.identity.runId, "open_run", "checkout_assignee_conflict", [ + `task:${task.id}`, + ]); + throw new CapabilityMockControlPlaneError( + "checkout_conflict", + "fixture task is assigned to another actor", + ); + } + if (task.checkoutRunId !== null && task.checkoutRunId !== input.identity.runId) { + this.#deny(input.identity.runId, "open_run", "checkout_lock_conflict", [`task:${task.id}`]); + throw new CapabilityMockControlPlaneError( + "checkout_conflict", + `fixture task is checked out by ${task.checkoutRunId}`, + ); + } + if (["done", "cancelled"].includes(task.status) && input.resume !== true) { + this.#deny(input.identity.runId, "open_run", "terminal_task_requires_resume", [ + `task:${task.id}`, + ]); + throw new CapabilityMockControlPlaneError( + "terminal_task_requires_resume", + "terminal fixture tasks require an explicit resume", + ); + } + if ( + !["backlog", "todo", "blocked", "in_review"].includes(task.status) && + !(input.resume === true && ["done", "cancelled"].includes(task.status)) + ) { + this.#deny(input.identity.runId, "open_run", "checkout_status_illegal", [ + `task:${task.id}`, + ]); + throw new CapabilityMockControlPlaneError( + "task_transition_illegal", + `fixture task cannot be checked out from ${task.status}`, + ); + } + + const fault = this.#consumeFault("open_run"); + this.#throwBeforeFault(fault, input.identity.runId, "open_run"); + const openedAt = this.#now(); + task.assigneeActorId = actor.id; + task.checkoutRunId = input.identity.runId; + task.executionRunId = input.identity.runId; + task.status = "in_progress"; + task.startedAt ??= openedAt; + const run: CapabilityFixtureRun = { + id: input.identity.runId, + companyId: input.identity.companyId, + actorId: actor.id, + taskId: task.id, + sessionId: input.identity.sessionId, + backendKind: input.backendKind, + sourceInstanceId: input.sourceInstanceId ?? "capability-mock", + status: "running", + attempt: input.resume === true ? 2 : 1, + openedAt, + finishedAt: null, + wake: sanitizeWake(input.wake ?? DEFAULT_WAKE), + capabilities: sortedUnique(input.capabilities ?? actor.capabilityGrants), + events: [], + result: null, + sessionCheckpoint: null, + }; + this.#state.runs.push(run); + this.#state.activeRunId = run.id; + this.#recordMutation(run.id, actor.id, "run.opened", "run", run.id, { + taskId: task.id, + wakeReason: run.wake.reason, + capabilities: run.capabilities, + }, "open_run", "allowed", "atomic_checkout_acquired", [ + `run:${run.id}`, + `task:${task.id}`, + ]); + this.#throwAfterFault(fault, run.id, "open_run"); + return this.context(run.id); + } + + context(runId: string): CapabilityRunContext { + const run = this.#run(runId); + const actor = this.#actor(run.actorId); + const task = this.#task(run.taskId); + const ancestors: CapabilityFixtureTask[] = []; + const visited = new Set(); + let parentId = task.parentId; + while (parentId !== null) { + if (visited.has(parentId)) { + throw new CapabilityMockControlPlaneError( + "fixture_state_invalid", + "fixture task ancestry contains a cycle", + ); + } + visited.add(parentId); + const parent = this.#task(parentId); + ancestors.push(clone(parent)); + parentId = parent.parentId; + } + const budgets = this.#effectiveBudgets(actor); + const limitCents = Math.min(...budgets.map((budget) => budget.limitCents)); + const remainingCents = Math.min( + ...budgets.map((budget) => Math.max(0, budget.limitCents - budget.spentCents)), + ); + return deepFreeze({ + schema: "paperclip.capability.run-context.v1", + company: { + id: this.#state.company.id, + name: this.#state.company.name, + issuePrefix: this.#state.company.issuePrefix, + status: this.#state.company.status, + }, + actor: { + id: actor.id, + name: actor.name, + role: actor.role, + status: actor.status, + capabilityGrants: clone(actor.capabilityGrants), + }, + activeTask: clone(task), + ancestors, + wake: sanitizeWake(run.wake), + capabilities: clone(run.capabilities), + budget: { + limitCents, + spentCents: limitCents - remainingCents, + remainingCents, + }, + interactionResults: this.#state.interactions + .filter((interaction) => interaction.taskId === task.id && interaction.result !== null) + .map(({ id, kind, status, result }) => ({ id, kind, status, result: clone(result) })), + }); + } + + async appendEvent(event: NativeRunEvent | PrpEvent): Promise { + this.#assertRunning(); + const run = this.#run(event.runId); + this.#assertRunWritable(run); + const fault = this.#consumeFault("append_event"); + this.#throwBeforeFault(fault, run.id, "append_event"); + if ("sourceEventId" in event) { + const validation = validatePrpEvent(event); + if (!validation.ok) { + throw new CapabilityMockControlPlaneError( + "native_event_invalid", + validation.issues[0]?.message ?? "event failed validation", + ); + } + const sameId = run.events.find( + (candidate): candidate is PrpEvent => + "sourceEventId" in candidate && candidate.sourceEventId === event.sourceEventId, + ); + const sameSequence = run.events.find( + (candidate): candidate is PrpEvent => + "sourceSeq" in candidate && + candidate.sourceInstanceId === event.sourceInstanceId && + candidate.sourceSeq === event.sourceSeq, + ); + for (const candidate of [sameId, sameSequence]) { + if (candidate !== undefined) { + if (canonicalJson(candidate) !== canonicalJson(event)) { + throw new CapabilityMockControlPlaneError( + "native_event_replay_conflict", + "event id or source sequence was replayed with different content", + ); + } + this.#recordDecisionOnly(run.id, "append_event", "duplicate", "event_already_committed", [ + `event:${event.sourceEventId}`, + ]); + return { + cursor: event.sourceSeq, + highestContiguousSourceSeq: this.#highestContiguous(run, event.sourceInstanceId), + disposition: "duplicate", + }; + } + } + run.events.push(clone(event)); + this.#recordMutation(run.id, run.actorId, "run.event_appended", "run", run.id, { + sourceEventId: event.sourceEventId, + sourceSeq: event.sourceSeq, + }, "append_event", "allowed", "event_committed", [`event:${event.sourceEventId}`]); + const receipt: AppendedEventReceipt = { + cursor: event.sourceSeq, + highestContiguousSourceSeq: this.#highestContiguous(run, event.sourceInstanceId), + disposition: "committed", + }; + this.#throwAfterFault(fault, run.id, "append_event"); + return receipt; + } + + const nativeEvents = run.events.filter( + (candidate): candidate is NativeRunEvent => "sequence" in candidate, + ); + const sameSequence = nativeEvents.find((candidate) => candidate.sequence === event.sequence); + if (sameSequence !== undefined) { + if (canonicalJson(sameSequence) !== canonicalJson(event)) { + throw new CapabilityMockControlPlaneError( + "native_event_replay_conflict", + "native event sequence was replayed with different content", + ); + } + this.#recordDecisionOnly(run.id, "append_event", "duplicate", "event_already_committed", [ + `event:${event.eventId}`, + ]); + return { + cursor: event.sequence, + highestContiguousSourceSeq: nativeEvents.length, + disposition: "duplicate", + }; + } + if (event.sequence !== nativeEvents.length + 1) { + throw new CapabilityMockControlPlaneError( + "native_event_sequence_gap", + `native event sequence must be ${nativeEvents.length + 1}`, + ); + } + run.events.push(clone(event)); + this.#recordMutation(run.id, run.actorId, "run.event_appended", "run", run.id, { + eventId: event.eventId, + sequence: event.sequence, + }, "append_event", "allowed", "event_committed", [`event:${event.eventId}`]); + const receipt: AppendedEventReceipt = { + cursor: event.sequence, + highestContiguousSourceSeq: event.sequence, + disposition: "committed", + }; + this.#throwAfterFault(fault, run.id, "append_event"); + return receipt; + } + + async replayEvents(input: ReplayControlPlaneEventsInput): Promise { + const run = this.#run(input.runId); + if (input.sourceInstanceId !== run.sourceInstanceId) { + throw new CapabilityMockControlPlaneError( + "replay_binding_mismatch", + "replay source does not match the fixture run binding", + ); + } + return { + events: run.events + .filter( + (event): event is PrpEvent => + "sourceSeq" in event && + event.sourceInstanceId === input.sourceInstanceId && + event.sourceSeq > input.afterSourceSeq, + ) + .sort((left, right) => left.sourceSeq - right.sourceSeq) + .slice(0, input.limit) + .map(clone), + highestContiguousSourceSeq: this.#highestContiguous(run, input.sourceInstanceId), + }; + } + + async loadSessionCheckpoint(): Promise { + const run = this.#activeRun(); + return run.sessionCheckpoint === null ? null : clone(run.sessionCheckpoint); + } + + async checkpointSession(snapshot: PersistedNativeSession): Promise { + const run = this.#activeRun(); + this.#assertRunWritable(run); + run.sessionCheckpoint = clone(snapshot); + this.#recordMutation(run.id, run.actorId, "run.session_checkpointed", "run", run.id, {}, + "apply_command", "allowed", "session_checkpoint_persisted", [`run:${run.id}`]); + } + + async applyCommand(envelope: CapabilityCommandEnvelope): Promise { + this.#assertRunning(); + if (envelope.idempotencyKey.trim().length === 0) { + throw new CapabilityMockControlPlaneError( + "idempotency_key_required", + "semantic commands require a non-empty idempotency key", + ); + } + const run = this.#run(envelope.runId); + const inputCanonical = canonicalJson(envelope.command); + const scope = `${run.id}:semantic-command`; + const existing = this.#state.idempotency.find( + (record) => record.scope === scope && record.key === envelope.idempotencyKey, + ); + if (existing !== undefined) { + if (existing.inputCanonical !== inputCanonical) { + throw new CapabilityMockControlPlaneError( + "idempotency_conflict", + "idempotency key was reused with a different semantic command", + ); + } + this.#recordDecisionOnly(run.id, envelope.command.kind, "duplicate", "command_already_applied", [ + ...existing.result.entityRefs, + ]); + return deepFreeze({ + ...clone(existing.result), + disposition: "duplicate", + stateRevision: this.#state.revision, + }); + } + // A committed command may itself make the run non-writable (for example, + // by reaching a hard budget limit). Resolve exact idempotent replays before + // enforcing current run writability so a lost acknowledgement remains + // recoverable without granting authority for a new command. + this.#assertRunWritable(run); + if ( + envelope.expectedRevision !== undefined && + envelope.expectedRevision !== this.#state.revision + ) { + throw new CapabilityMockControlPlaneError( + "state_revision_conflict", + `expected state revision ${envelope.expectedRevision}, found ${this.#state.revision}`, + ); + } + this.#authorizeCommand(run, envelope.command); + + // Exercise the complete command against an isolated state snapshot before + // consuming a scripted fault. This keeps validation failures from spending + // a retryable fault that belongs to the first semantically valid attempt. + // Re-resolve the run afterwards because restoring the snapshot invalidates + // object references into the previous state. + const stateBeforeValidation = clone(this.#state); + try { + this.#executeCommand(run, envelope.command); + } finally { + this.#state = stateBeforeValidation; + } + + const validatedRun = this.#run(envelope.runId); + const stateBeforeCommand = clone(this.#state); + const fault = this.#consumeFault("apply_command", envelope.command.kind); + this.#throwBeforeFault(fault, validatedRun.id, envelope.command.kind); + let result: CapabilityCommandResult; + try { + const commandId = this.#id("command"); + const { entityRefs, scheduledWakeIds } = this.#executeCommand(validatedRun, envelope.command); + this.#recordMutation(validatedRun.id, validatedRun.actorId, "semantic_command.applied", "command", commandId, { + kind: envelope.command.kind, + idempotencyKey: envelope.idempotencyKey, + entityRefs, + }, envelope.command.kind, "allowed", "semantic_command_applied", entityRefs); + result = { + commandId, + commandKind: envelope.command.kind, + disposition: "applied", + stateRevision: this.#state.revision, + entityRefs, + scheduledWakeIds, + }; + this.#state.idempotency.push({ + scope, + key: envelope.idempotencyKey, + inputCanonical, + result: clone(result), + }); + } catch (error) { + this.#state = stateBeforeCommand; + throw error; + } + // A post-commit lost-ack fault deliberately retains the transaction and its + // idempotency record. Only validation and execution failures roll back. + this.#throwAfterFault(fault, validatedRun.id, envelope.command.kind); + return deepFreeze(clone(result)); + } + + async tryApplyCommand(envelope: CapabilityCommandEnvelope): Promise { + try { + return { ok: true, result: await this.applyCommand(envelope) }; + } catch (error) { + if (!(error instanceof CapabilityMockControlPlaneError)) throw error; + return deepFreeze({ + ok: false, + commandKind: envelope.command.kind, + stateRevision: this.#state.revision, + error: { + code: error.code, + message: error.message, + retryable: error.retryable, + }, + }); + } + } + + async completeRun(result: NativeRunResult | CompleteControlPlaneRunInput): Promise { + this.#assertRunning(); + const run = "runId" in result ? this.#run(result.runId) : this.#activeRun(); + if (run.result !== null) { + if (canonicalJson(run.result) !== canonicalJson(result)) { + throw new CapabilityMockControlPlaneError( + "native_result_replay_conflict", + "run result was replayed with different content", + ); + } + this.#recordDecisionOnly(run.id, "complete_run", "duplicate", "terminal_result_already_committed", [ + `run:${run.id}`, + ]); + return; + } + this.#assertRunWritable(run); + const task = this.#task(run.taskId); + const entityRefs = [`run:${run.id}`, `task:${task.id}`]; + if ("terminal" in result) { + const validation = validatePrpStructuredRunResult(result.result); + if (!validation.ok) { + throw new CapabilityMockControlPlaneError( + "native_result_invalid", + validation.issues[0]?.message ?? "structured result failed validation", + ); + } + if (!run.events.some((event) => "eventType" in event && event.eventType === "run.terminal")) { + throw new CapabilityMockControlPlaneError( + "terminal_event_missing", + "run.terminal must be committed before terminal reconciliation", + ); + } + this.#assertDispositionReconcilable(task, result); + } else if (!run.events.some((event) => "type" in event && event.type === "run.completed")) { + throw new CapabilityMockControlPlaneError( + "terminal_event_missing", + "run.completed must be committed before terminal reconciliation", + ); + } + + // A rejected completion is not an attempt against the scripted transport: + // validate the complete semantic input and every blocker/wake reference + // before consuming its fault budget or changing terminal state. + const reconciledTaskStatus = "terminal" in result + ? result.result.reportedWorkDisposition === "done" + ? "done" + : result.result.reportedWorkDisposition === "blocked" + ? "blocked" + : result.result.reportedWorkDisposition === "needs_review" + ? "in_review" + : "todo" + : task.status; + const blockerReconciliation = this.#planBlockerReconciliation( + task, + reconciledTaskStatus, + ); + const fault = this.#consumeFault("complete_run"); + this.#throwBeforeFault(fault, run.id, "complete_run"); + if ("terminal" in result) { + this.#reconcileDisposition(run, task, result); + run.status = result.terminal.runTerminalState; + } else { + run.status = result.status; + } + run.result = clone(result); + run.finishedAt = this.#now(); + if (task.checkoutRunId === run.id) task.checkoutRunId = null; + if (task.executionRunId === run.id) task.executionRunId = null; + const wakeIds = this.#reconcileBlockerWakes(task.id, blockerReconciliation); + entityRefs.push(...wakeIds.map((id) => `wake:${id}`)); + this.#recordMutation(run.id, run.actorId, "run.completed", "run", run.id, { + status: run.status, + reconciledTaskStatus: task.status, + wakeIds, + }, "complete_run", "reconciled", "terminal_state_reconciled", entityRefs); + this.#throwAfterFault(fault, run.id, "complete_run"); + } + + snapshot(): Readonly { + return deepFreeze(clone(this.#state)); + } + + decisionRecords(): readonly CapabilityDecisionRecord[] { + return deepFreeze(clone(this.#state.decisions)); + } + + serialize(): string { + return canonicalJson(this.#state); + } + + #executeCommand( + run: CapabilityFixtureRun, + command: CapabilitySemanticCommand, + ): { entityRefs: string[]; scheduledWakeIds: string[] } { + const task = this.#commandTask(run, command); + const entityRefs = [`task:${task.id}`]; + const scheduledWakeIds: string[] = []; + switch (command.kind) { + case "report_progress": { + requireText(command.body, "progress body"); + const comment = this.#appendComment(task.id, run.actorId, command.body); + entityRefs.push(`comment:${comment.id}`); + break; + } + case "write_document": { + requireText(command.key, "document key"); + requireText(command.title, "document title"); + const existing = this.#state.documents.find( + (document) => document.taskId === task.id && document.key === command.key, + ); + if (existing === undefined && command.baseRevisionId !== null) { + throw new CapabilityMockControlPlaneError( + "document_revision_conflict", + "a new document must use a null base revision", + ); + } + if (existing !== undefined && existing.latestRevisionId !== command.baseRevisionId) { + throw new CapabilityMockControlPlaneError( + "document_revision_conflict", + `document latest revision is ${existing.latestRevisionId}`, + ); + } + const document = existing ?? this.#newDocument(task.id, command.key, command.title); + const revision = { + id: this.#id("revision"), + documentId: document.id, + revision: document.revisions.length + 1, + body: command.body, + changeSummary: command.changeSummary ?? null, + createdAt: this.#now(), + }; + document.title = command.title; + document.latestRevisionId = revision.id; + document.revisions.push(revision); + entityRefs.push(`document:${document.id}`, `revision:${revision.id}`); + break; + } + case "request_human_input": { + const interaction = { + id: this.#id("interaction"), + taskId: task.id, + kind: command.interactionKind, + status: "pending" as const, + title: requireText(command.title, "interaction title"), + prompt: requireText(command.prompt, "interaction prompt"), + payload: clone(command.payload ?? {}), + targetRevisionId: command.targetRevisionId ?? null, + continuationPolicy: command.continuationPolicy, + result: null, + createdAt: this.#now(), + resolvedAt: null, + }; + if ( + interaction.targetRevisionId !== null && + !this.#state.documents.some( + (document) => + document.taskId === task.id && + document.revisions.some((revision) => revision.id === interaction.targetRevisionId), + ) + ) { + throw new CapabilityMockControlPlaneError( + "interaction_target_missing", + "interaction target revision does not exist", + ); + } + if ( + interaction.targetRevisionId !== null && + !this.#state.documents.some( + (document) => + document.taskId === task.id && + document.latestRevisionId === interaction.targetRevisionId, + ) + ) { + throw new CapabilityMockControlPlaneError( + "interaction_target_stale", + "interaction target revision is not current", + ); + } + this.#state.interactions.push(interaction); + this.#transitionTask(run, task, "in_review", command.kind); + entityRefs.push(`interaction:${interaction.id}`); + break; + } + case "resolve_human_input": { + const interaction = this.#state.interactions.find( + (candidate) => candidate.id === command.interactionId && candidate.taskId === task.id, + ); + if (interaction === undefined) { + throw new CapabilityMockControlPlaneError("interaction_missing", "fixture interaction not found"); + } + if (interaction.status !== "pending") { + throw new CapabilityMockControlPlaneError( + "interaction_already_resolved", + "fixture interaction has already been resolved", + ); + } + const allowedOutcomes = interaction.kind === "questions" || interaction.kind === "item_verdicts" + ? ["answered", "rejected", "expired"] + : ["accepted", "rejected", "expired"]; + if (!allowedOutcomes.includes(command.outcome)) { + throw new CapabilityMockControlPlaneError( + "interaction_outcome_invalid", + `${command.outcome} is not valid for ${interaction.kind}`, + ); + } + interaction.status = command.outcome; + interaction.result = clone(command.result); + interaction.resolvedAt = this.#now(); + const shouldWake = + interaction.continuationPolicy === "wake_assignee" || + (interaction.continuationPolicy === "wake_assignee_on_accept" && + command.outcome === "accepted"); + if (shouldWake && task.assigneeActorId !== null) { + const wakeId = this.#scheduleWake( + task.assigneeActorId, + task.id, + "interaction_resolved", + { interactionId: interaction.id, outcome: command.outcome }, + 0, + ); + scheduledWakeIds.push(wakeId); + } + entityRefs.push(`interaction:${interaction.id}`); + break; + } + case "register_deliverable": { + if (!Number.isSafeInteger(command.byteSize) || command.byteSize < 0) { + throw new CapabilityMockControlPlaneError( + "artifact_invalid", + "artifact byteSize must be a non-negative safe integer", + ); + } + const artifact = { + id: this.#id("artifact"), + taskId: task.id, + filename: requireText(command.filename, "artifact filename"), + contentType: requireText(command.contentType, "artifact content type"), + byteSize: command.byteSize, + sha256: requireText(command.sha256, "artifact sha256"), + contentRef: requireText(command.contentRef, "artifact content ref"), + createdAt: this.#now(), + }; + const workProduct = { + id: this.#id("work-product"), + taskId: task.id, + type: "artifact" as const, + title: requireText(command.title, "work product title"), + metadata: { + filename: artifact.filename, + contentType: artifact.contentType, + byteSize: artifact.byteSize, + contentRef: artifact.contentRef, + }, + artifactId: artifact.id, + createdAt: this.#now(), + }; + this.#state.artifacts.push(artifact); + this.#state.workProducts.push(workProduct); + entityRefs.push(`artifact:${artifact.id}`, `work-product:${workProduct.id}`); + break; + } + case "set_dependencies": { + this.#replaceDependencies(task, command.blockedByTaskIds); + if (command.blockedByTaskIds.length > 0) { + this.#transitionTask(run, task, "blocked", command.kind); + } + entityRefs.push(...command.blockedByTaskIds.map((id) => `task:${id}`)); + break; + } + case "create_task": { + if (command.assigneeActorId !== undefined && command.assigneeActorId !== null) { + const assignee = this.#actor(command.assigneeActorId); + this.#assertCompany(this.#state.company.id, assignee.companyId); + } + const childId = this.#id("task"); + const child: CapabilityFixtureTask = { + id: childId, + companyId: task.companyId, + identifier: `${this.#state.company.issuePrefix}-${this.#nextCounter("issue-number")}`, + title: requireText(command.title, "task title"), + description: command.description ?? null, + status: command.blockedByTaskIds?.length ? "blocked" : "todo", + priority: command.priority ?? "medium", + workMode: "standard", + parentId: task.id, + assigneeActorId: command.assigneeActorId ?? null, + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }; + this.#state.tasks.push(child); + if (command.blockedByTaskIds?.length) { + this.#replaceDependencies(child, command.blockedByTaskIds); + } + entityRefs.push(`task:${child.id}`); + break; + } + case "request_approval": { + const approval = { + id: this.#id("approval"), + companyId: task.companyId, + taskIds: [task.id], + type: requireText(command.approvalType, "approval type"), + status: "pending" as const, + requestedByActorId: run.actorId, + payload: clone(command.payload), + decisionNote: null, + comments: [], + createdAt: this.#now(), + decidedAt: null, + }; + this.#state.approvals.push(approval); + this.#transitionTask(run, task, "in_review", command.kind); + entityRefs.push(`approval:${approval.id}`); + break; + } + case "decide_approval": { + const approval = this.#approvalForTask(task, command.approvalId); + if (approval.status !== "pending") { + throw new CapabilityMockControlPlaneError( + "approval_already_decided", + "fixture approval has already been decided", + ); + } + const requester = approval.requestedByActorId === null + ? null + : this.#actor(approval.requestedByActorId); + const requesterBudgets = requester === null + ? [] + : this.#applicableBudgets(requester); + const requesterBudgetAvailable = requester === null + || (requesterBudgets.length > 0 && !requesterBudgets.some( + (budget) => budget.hardStop && budget.spentCents >= budget.limitCents, + )); + let requesterTarget: { actorId: string; taskId: string } | null = null; + for (const linkedTaskId of approval.taskIds) { + const linkedTask = this.#task(linkedTaskId); + this.#assertCompany(task.companyId, linkedTask.companyId); + if ( + requester !== null && + requester.status === "active" && + requesterBudgetAvailable && + linkedTask.assigneeActorId === requester.id && + linkedTask.checkoutRunId === null && + linkedTask.executionRunId === null && + ["todo", "blocked", "in_review"].includes(linkedTask.status) && + !this.#hasUnresolvedBlockers(linkedTask.id) + ) { + requesterTarget ??= { + actorId: requester.id, + taskId: linkedTask.id, + }; + } + } + if ( + requester !== null && + requester.status === "active" && + requesterBudgetAvailable && + requesterTarget === null + ) { + // Persist an unowned continuation task rather than dropping the + // requester notification or making the governance decision depend + // on today's checkout/budget state. The actor can consume this wake + // as soon as it becomes runnable without stealing a linked task from + // another run or reopening terminal work. + const recoveryTask: CapabilityFixtureTask = { + id: this.#id("task"), + companyId: task.companyId, + identifier: `${this.#state.company.issuePrefix}-APPROVAL-${approval.id}`, + title: `Continue after approval ${approval.id}`, + description: "Review the recorded approval decision and continue any remaining work.", + status: "todo", + priority: "medium", + workMode: "standard", + parentId: null, + assigneeActorId: requester.id, + checkoutRunId: null, + executionRunId: null, + startedAt: null, + completedAt: null, + }; + this.#state.tasks.push(recoveryTask); + requesterTarget = { + actorId: requester.id, + taskId: recoveryTask.id, + }; + entityRefs.push(`task:${recoveryTask.id}`); + } + approval.status = command.decision; + approval.decisionNote = requireText(command.note, "approval decision note"); + approval.decidedAt = this.#now(); + // The decision is authoritative even when its requester cannot be + // resumed immediately. A continuation is best-effort and belongs only + // to that requester on an unowned executable task; never redirect it + // to another assignee or make wake availability block governance. + if (requesterTarget !== null) { + const wakeId = this.#scheduleWake( + requesterTarget.actorId, + requesterTarget.taskId, + "approval_resolved", + { approvalId: approval.id, decision: command.decision }, + 0, + ); + scheduledWakeIds.push(wakeId); + } + entityRefs.push(`approval:${approval.id}`); + break; + } + case "comment_on_approval": { + const approval = this.#approvalForTask(task, command.approvalId); + const comment = { + id: this.#id("approval-comment"), + authorActorId: run.actorId, + body: requireText(command.body, "approval comment body"), + createdAt: this.#now(), + }; + approval.comments.push(comment); + entityRefs.push(`approval:${approval.id}`, `approval-comment:${comment.id}`); + break; + } + case "control_workspace_service": { + const service = this.#state.workspaceServices.find( + (candidate) => candidate.id === command.serviceId && candidate.taskId === task.id, + ); + if (service === undefined) { + throw new CapabilityMockControlPlaneError( + "workspace_service_missing", + "fixture workspace service not found", + ); + } + service.status = command.action === "start" ? "running" : command.action === "stop" ? "stopped" : "failed"; + service.url = command.action === "start" ? command.url ?? service.url : null; + service.lastTransitionAt = this.#now(); + entityRefs.push(`workspace-service:${service.id}`); + break; + } + case "record_budget_usage": { + if (!Number.isSafeInteger(command.cents) || command.cents < 0) { + throw new CapabilityMockControlPlaneError( + "budget_usage_invalid", + "budget usage must be a non-negative safe integer", + ); + } + const actor = this.#actor(run.actorId); + const budgets = this.#effectiveBudgets(actor); + for (const budget of budgets) budget.spentCents += command.cents; + const stopped = budgets.some( + (budget) => budget.hardStop && budget.spentCents >= budget.limitCents, + ); + entityRefs.push(...budgets.map((budget) => `budget:${budget.id}`)); + if (stopped) { + actor.status = "paused"; + run.status = "budget_stopped"; + this.#transitionTask(run, task, "blocked", command.kind); + if (task.checkoutRunId === run.id) task.checkoutRunId = null; + if (task.executionRunId === run.id) task.executionRunId = null; + const comment = this.#appendComment( + task.id, + null, + "Budget hard stop reached; the fixture actor was paused and the run lock was released.", + ); + entityRefs.push(`comment:${comment.id}`, `actor:${actor.id}`); + } + break; + } + case "finish_task": { + requireText(command.summary, "completion summary"); + this.#transitionTask(run, task, "done", command.kind); + task.completedAt = this.#now(); + const comment = this.#appendComment(task.id, run.actorId, command.summary); + entityRefs.push(`comment:${comment.id}`); + break; + } + case "block_task": { + requireText(command.reason, "block reason"); + if (command.blockedByTaskIds !== undefined) { + this.#replaceDependencies(task, command.blockedByTaskIds); + entityRefs.push(...command.blockedByTaskIds.map((id) => `task:${id}`)); + } + this.#transitionTask(run, task, "blocked", command.kind); + const comment = this.#appendComment(task.id, run.actorId, command.reason); + entityRefs.push(`comment:${comment.id}`); + break; + } + case "request_review": { + this.#transitionTask(run, task, "in_review", command.kind); + const comment = this.#appendComment( + task.id, + run.actorId, + requireText(command.summary, "review summary"), + ); + entityRefs.push(`comment:${comment.id}`); + break; + } + case "release_task": { + requireText(command.reason, "release reason"); + if (task.checkoutRunId !== run.id) { + throw new CapabilityMockControlPlaneError( + "release_lock_mismatch", + "the fixture run does not own the task checkout lock", + ); + } + this.#transitionTask(run, task, "todo", command.kind); + task.checkoutRunId = null; + task.executionRunId = null; + const comment = this.#appendComment(task.id, run.actorId, command.reason); + entityRefs.push(`comment:${comment.id}`); + break; + } + case "schedule_wake": { + if (!Number.isSafeInteger(command.delayTicks) || command.delayTicks < 0) { + throw new CapabilityMockControlPlaneError( + "wake_delay_invalid", + "wake delayTicks must be a non-negative safe integer", + ); + } + if (task.assigneeActorId === null) { + throw new CapabilityMockControlPlaneError( + "wake_assignee_missing", + "cannot schedule a fixture wake for an unassigned task", + ); + } + const wakeId = this.#scheduleWake( + task.assigneeActorId, + task.id, + command.reason, + command.payload ?? {}, + command.delayTicks, + ); + scheduledWakeIds.push(wakeId); + entityRefs.push(`wake:${wakeId}`); + break; + } + default: + return assertNever(command); + } + return { entityRefs: sortedUnique(entityRefs), scheduledWakeIds }; + } + + #reconcileDisposition( + run: CapabilityFixtureRun, + task: CapabilityFixtureTask, + completion: CompleteControlPlaneRunInput, + ): void { + switch (completion.result.reportedWorkDisposition) { + case "done": + task.status = "done"; + task.completedAt ??= this.#now(); + this.#state.blockers = this.#state.blockers.filter( + (blocker) => blocker.taskId !== task.id, + ); + break; + case "blocked": + task.status = "blocked"; + break; + case "needs_review": + task.status = "in_review"; + break; + case "yielded": { + task.status = "todo"; + if (task.assigneeActorId !== null) { + this.#scheduleWake( + task.assigneeActorId, + task.id, + "scheduled_retry", + { + runId: run.id, + continuation: toJsonValue(completion.result.continuation ?? {}), + }, + 1, + ); + } + break; + } + } + } + + #assertDispositionReconcilable( + task: CapabilityFixtureTask, + completion: CompleteControlPlaneRunInput, + ): void { + if ( + completion.result.reportedWorkDisposition === "done" && + this.#hasUnresolvedBlockers(task.id) + ) { + throw new CapabilityMockControlPlaneError( + "terminal_reconciliation_failed", + "a done result cannot reconcile a task with unresolved blockers", + ); + } + } + + #planBlockerReconciliation( + completedTask: CapabilityFixtureTask, + resultingStatus: CapabilityFixtureTask["status"], + ): Array<{ dependentTask: CapabilityFixtureTask; wakeActorId: string | null }> { + if (resultingStatus !== "done") return []; + const dependentIds = sortedUnique( + this.#state.blockers + .filter((blocker) => blocker.blockedByTaskId === completedTask.id) + .map((blocker) => blocker.taskId), + ); + const plan: Array<{ dependentTask: CapabilityFixtureTask; wakeActorId: string | null }> = []; + for (const dependentId of dependentIds) { + const dependentTask = this.#task(dependentId); + this.#assertCompany(completedTask.companyId, dependentTask.companyId); + const unresolved = this.#state.blockers.filter( + (blocker) => { + if (blocker.taskId !== dependentId) return false; + const blockingTask = this.#task(blocker.blockedByTaskId); + this.#assertCompany(completedTask.companyId, blockingTask.companyId); + return blockingTask.id !== completedTask.id && blockingTask.status !== "done"; + }, + ); + if (unresolved.length > 0) continue; + if (dependentTask.assigneeActorId !== null) { + const actor = this.#actor(dependentTask.assigneeActorId); + this.#assertCompany(completedTask.companyId, actor.companyId); + } + plan.push({ + dependentTask, + wakeActorId: dependentTask.assigneeActorId, + }); + } + return plan; + } + + #reconcileBlockerWakes( + completedTaskId: string, + plan: Array<{ dependentTask: CapabilityFixtureTask; wakeActorId: string | null }>, + ): string[] { + const wakeIds: string[] = []; + for (const { dependentTask: dependent, wakeActorId } of plan) { + this.#state.blockers = this.#state.blockers.filter( + (blocker) => blocker.taskId !== dependent.id, + ); + if (dependent.status === "blocked") dependent.status = "todo"; + if (wakeActorId !== null) { + wakeIds.push( + this.#scheduleWake( + wakeActorId, + dependent.id, + "blockers_resolved", + { completedTaskId }, + 0, + ), + ); + } + } + return wakeIds; + } + + #replaceDependencies(task: CapabilityFixtureTask, blockedByTaskIds: string[]): void { + const unique = sortedUnique(blockedByTaskIds); + if (unique.includes(task.id)) { + throw new CapabilityMockControlPlaneError("dependency_cycle", "a task cannot block itself"); + } + for (const blockerId of unique) { + const blocker = this.#task(blockerId); + this.#assertCompany(task.companyId, blocker.companyId); + if (this.#dependsOn(blocker.id, task.id)) { + throw new CapabilityMockControlPlaneError( + "dependency_cycle", + `dependency ${blocker.id} would create a cycle`, + ); + } + } + this.#state.blockers = this.#state.blockers.filter((blocker) => blocker.taskId !== task.id); + for (const blockerId of unique) { + this.#state.blockers.push({ + id: this.#id("blocker"), + taskId: task.id, + blockedByTaskId: blockerId, + createdAt: this.#now(), + }); + } + } + + #hasUnresolvedBlockers(taskId: string): boolean { + return this.#state.blockers.some( + (blocker) => + blocker.taskId === taskId && + this.#task(blocker.blockedByTaskId).status !== "done", + ); + } + + #dependsOn(taskId: string, candidateAncestorId: string, visited = new Set()): boolean { + if (taskId === candidateAncestorId) return true; + if (visited.has(taskId)) return false; + visited.add(taskId); + return this.#state.blockers + .filter( + (blocker) => + blocker.taskId === taskId && + this.#task(blocker.blockedByTaskId).status !== "done", + ) + .some((blocker) => this.#dependsOn(blocker.blockedByTaskId, candidateAncestorId, visited)); + } + + #newDocument(taskId: string, key: string, title: string): CapabilityFixtureDocument { + const document: CapabilityFixtureDocument = { + id: this.#id("document"), + taskId, + key, + title, + format: "markdown", + latestRevisionId: "", + revisions: [], + }; + this.#state.documents.push(document); + return document; + } + + #appendComment(taskId: string, authorActorId: string | null, body: string) { + const comment = { + id: this.#id("comment"), + taskId, + authorActorId, + body, + createdAt: this.#now(), + }; + this.#state.comments.push(comment); + return comment; + } + + #scheduleWake( + actorId: string, + taskId: string, + reason: CapabilityWakeContext["reason"], + payload: CapabilityJsonValue, + delayTicks: number, + ): string { + const actor = this.#actor(actorId); + const task = this.#task(taskId); + this.#assertCompany(this.#state.company.id, actor.companyId, task.companyId); + const createdAt = this.#now(); + const dueAt = new Date( + Date.parse(createdAt) + delayTicks * 1_000, + ).toISOString(); + const id = this.#id("wake"); + this.#state.wakes.push({ + id, + actorId, + taskId, + reason, + payload: sanitizeJson(payload), + dueAt, + status: "scheduled", + createdAt, + }); + return id; + } + + #assertBudgetAvailable(actor: CapabilityFixtureActor, runId: string): void { + const exceeded = this.#effectiveBudgets(actor).find( + (budget) => budget.hardStop && budget.spentCents >= budget.limitCents, + ); + if (exceeded === undefined) return; + actor.status = "paused"; + this.#recordMutation(runId, actor.id, "budget.hard_stop", "budget", exceeded.id, { + spentCents: exceeded.spentCents, + limitCents: exceeded.limitCents, + }, "open_run", "denied", "budget_hard_stop", [`budget:${exceeded.id}`, `actor:${actor.id}`]); + throw new CapabilityMockControlPlaneError( + "budget_hard_stop", + `fixture budget ${exceeded.id} has reached its hard limit`, + ); + } + + #effectiveBudgets(actor: CapabilityFixtureActor) { + const budgets = this.#applicableBudgets(actor); + if (budgets.length === 0) { + throw new CapabilityMockControlPlaneError( + "fixture_state_invalid", + `actor ${actor.id} has no fixture budget`, + ); + } + return budgets; + } + + #applicableBudgets(actor: CapabilityFixtureActor) { + return this.#state.budgets.filter( + (budget) => + (budget.scope === "company" && budget.scopeId === actor.companyId) || + (budget.scope === "actor" && budget.scopeId === actor.id), + ); + } + + #consumeFault( + operation: CapabilityFaultRule["operation"], + commandKind?: CapabilitySemanticCommand["kind"], + ): CapabilityFaultRule | null { + const fault = this.#state.faults.find( + (candidate) => + candidate.operation === operation && + candidate.remaining > 0 && + (candidate.commandKind === undefined || candidate.commandKind === commandKind), + ); + if (fault === undefined) return null; + fault.remaining -= 1; + return clone(fault); + } + + #throwBeforeFault(fault: CapabilityFaultRule | null, runId: string, operation: string): void { + if (fault?.effect !== "retryable_error") return; + this.#recordDecisionOnly(runId, operation, "faulted", fault.code, [`fault:${fault.id}`]); + throw new CapabilityMockControlPlaneError(fault.code, `scripted fault ${fault.id}`, true); + } + + #throwAfterFault(fault: CapabilityFaultRule | null, runId: string, operation: string): void { + if (fault?.effect !== "lost_ack") return; + this.#recordDecisionOnly(runId, operation, "faulted", fault.code, [`fault:${fault.id}`]); + throw new CapabilityMockControlPlaneError(fault.code, `scripted lost acknowledgement ${fault.id}`, true); + } + + #assertRunBinding(run: CapabilityFixtureRun, input: CapabilityOpenFixtureRunInput): void { + const expected = { + companyId: run.companyId, + actorId: run.actorId, + taskId: run.taskId, + sessionId: run.sessionId, + backendKind: run.backendKind, + sourceInstanceId: run.sourceInstanceId, + }; + const received = { + companyId: input.identity.companyId, + actorId: input.identity.agentId, + taskId: input.identity.issueId, + sessionId: input.identity.sessionId, + backendKind: input.backendKind, + sourceInstanceId: input.sourceInstanceId ?? "capability-mock", + }; + if (canonicalJson(expected) !== canonicalJson(received)) { + throw new CapabilityMockControlPlaneError( + "run_binding_conflict", + "fixture run id is already bound to different context", + ); + } + } + + #authorizeCommand(run: CapabilityFixtureRun, command: CapabilitySemanticCommand): void { + const requiredClaims = CAPABILITY_COMMAND_REQUIRED_CLAIMS[command.kind]; + const missingClaims = requiredClaims.filter((claim) => !run.capabilities.includes(claim)); + if (missingClaims.length > 0) { + this.#deny(run.id, command.kind, "required_claim_missing", [`task:${run.taskId}`]); + throw new CapabilityMockControlPlaneError( + "operation_not_authorized", + "the fixture run does not hold the required command claim", + ); + } + + const task = this.#commandTask(run, command); + if ( + task.assigneeActorId !== run.actorId || + task.checkoutRunId !== run.id || + task.executionRunId !== run.id + ) { + this.#deny(run.id, command.kind, "run_does_not_own_task", [`task:${task.id}`]); + throw new CapabilityMockControlPlaneError( + "task_ownership_violation", + "the fixture run does not own the active task", + ); + } + + if (command.kind === "decide_approval") { + const actor = this.#actor(run.actorId); + if (!["board", "approver", "security"].includes(actor.role.trim().toLowerCase())) { + this.#deny(run.id, command.kind, "actor_role_not_authorized", [ + `actor:${actor.id}`, + ]); + throw new CapabilityMockControlPlaneError( + "operation_not_authorized", + "the fixture actor role cannot decide approvals", + ); + } + const approval = this.#state.approvals.find( + (candidate) => candidate.id === command.approvalId && candidate.companyId === run.companyId, + ); + if ( + approval === undefined || + !approval.taskIds.includes(run.taskId) + ) { + this.#deny(run.id, command.kind, "active_task_scope_required", [ + `task:${run.taskId}`, + ]); + throw new CapabilityMockControlPlaneError( + "approval_scope_violation", + "the approval is not linked to the active fixture task", + ); + } + if (approval?.requestedByActorId === actor.id) { + this.#deny(run.id, command.kind, "self_approval_forbidden", [ + `approval:${approval.id}`, + ]); + throw new CapabilityMockControlPlaneError( + "self_approval_forbidden", + "the fixture actor cannot decide its own approval request", + ); + } + } + } + + #commandTask(run: CapabilityFixtureRun, command: CapabilitySemanticCommand): CapabilityFixtureTask { + const task = this.#task(command.taskId ?? run.taskId); + this.#assertCompany(run.companyId, task.companyId); + if (task.id !== run.taskId) { + this.#deny(run.id, command.kind, "active_task_scope_required", [`task:${task.id}`]); + throw new CapabilityMockControlPlaneError( + "task_scope_violation", + "semantic commands are scoped to the active fixture task", + ); + } + return task; + } + + #approvalForTask( + task: CapabilityFixtureTask, + approvalId: string, + ): CapabilityFixtureApproval { + const approval = this.#state.approvals.find( + (candidate) => candidate.id === approvalId && candidate.companyId === task.companyId, + ); + if (approval === undefined) { + throw new CapabilityMockControlPlaneError("approval_missing", "fixture approval not found"); + } + if (!approval.taskIds.includes(task.id)) { + throw new CapabilityMockControlPlaneError( + "approval_scope_violation", + "the approval is not linked to the active fixture task", + ); + } + return approval; + } + + #transitionTask( + run: CapabilityFixtureRun, + task: CapabilityFixtureTask, + next: CapabilityFixtureTask["status"], + operation: CapabilitySemanticCommand["kind"], + ): void { + const allowed: Record = { + backlog: ["todo", "cancelled"], + todo: ["in_progress", "blocked", "cancelled"], + in_progress: ["in_review", "blocked", "done", "cancelled"], + in_review: ["in_progress", "done", "cancelled"], + blocked: ["todo", "in_progress", "cancelled"], + done: [], + cancelled: [], + }; + const releaseToTodo = operation === "release_task" && task.status === "in_progress" && next === "todo"; + const preserveReview = + (operation === "request_human_input" || operation === "request_approval") && + task.status === "in_review" && + next === "in_review"; + if (!releaseToTodo && !preserveReview && !allowed[task.status].includes(next)) { + this.#deny(run.id, operation, "task_transition_illegal", [`task:${task.id}`]); + throw new CapabilityMockControlPlaneError( + "task_transition_illegal", + `fixture task cannot transition from ${task.status} to ${next}`, + ); + } + task.status = next; + } + + #assertCompany(expected: string, ...actual: string[]): void { + if (actual.some((companyId) => companyId !== expected) || this.#state.company.id !== expected) { + throw new CapabilityMockControlPlaneError( + "company_scope_violation", + "fixture entity is outside the run company", + ); + } + } + + #assertRunWritable(run: CapabilityFixtureRun): void { + if (run.status === "budget_stopped") { + throw new CapabilityMockControlPlaneError( + "budget_hard_stop", + "fixture run was stopped by the budget hard limit", + ); + } + if (run.status !== "running") { + throw new CapabilityMockControlPlaneError( + "run_not_writable", + `fixture run is ${run.status}`, + ); + } + } + + #highestContiguous(run: CapabilityFixtureRun, sourceInstanceId: string): number { + const sequences = new Set( + run.events + .filter( + (event): event is PrpEvent => + "sourceSeq" in event && event.sourceInstanceId === sourceInstanceId, + ) + .map((event) => event.sourceSeq), + ); + let cursor = 0; + while (sequences.has(cursor + 1)) cursor += 1; + return cursor; + } + + #recordMutation( + runId: string | null, + actorId: string | null, + action: string, + entityType: string, + entityId: string, + details: CapabilityJsonValue, + operation: string, + outcome: CapabilityDecisionRecord["outcome"], + reason: string, + entityRefs: string[], + ): void { + this.#state.revision += 1; + const at = this.#now(); + const audit: CapabilityAuditRecord = { + id: this.#id("audit"), + at, + runId, + actorId, + action, + entityType, + entityId, + details: clone(details), + }; + this.#state.audit.push(audit); + this.#state.decisions.push({ + id: this.#id("decision"), + at, + runId, + operation, + outcome, + reason, + stateRevision: this.#state.revision, + entityRefs: sortedUnique(entityRefs), + }); + } + + #recordDecisionOnly( + runId: string | null, + operation: string, + outcome: CapabilityDecisionRecord["outcome"], + reason: string, + entityRefs: string[], + ): void { + this.#state.revision += 1; + this.#state.decisions.push({ + id: this.#id("decision"), + at: this.#now(), + runId, + operation, + outcome, + reason, + stateRevision: this.#state.revision, + entityRefs: sortedUnique(entityRefs), + }); + } + + #deny(runId: string | null, operation: string, reason: string, entityRefs: string[]): void { + this.#recordDecisionOnly(runId, operation, "denied", reason, entityRefs); + } + + #actor(id: string): CapabilityFixtureActor { + const actor = this.#state.actors.find((candidate) => candidate.id === id); + if (actor === undefined) { + throw new CapabilityMockControlPlaneError("actor_missing", `fixture actor ${id} not found`); + } + return actor; + } + + #task(id: string): CapabilityFixtureTask { + const task = this.#state.tasks.find((candidate) => candidate.id === id); + if (task === undefined) { + if (this.#state.outOfScopeTaskIds.includes(id)) { + throw new CapabilityMockControlPlaneError( + "company_scope_violation", + "fixture entity is outside the run company", + ); + } + throw new CapabilityMockControlPlaneError("task_missing", `fixture task ${id} not found`); + } + return task; + } + + #run(id: string): CapabilityFixtureRun { + const run = this.#state.runs.find((candidate) => candidate.id === id); + if (run === undefined) { + throw new CapabilityMockControlPlaneError("run_missing", `fixture run ${id} not found`); + } + return run; + } + + #activeRun(): CapabilityFixtureRun { + if (this.#state.activeRunId === null) { + throw new CapabilityMockControlPlaneError("run_missing", "no active fixture run"); + } + return this.#run(this.#state.activeRunId); + } + + #assertRunning(): void { + if (this.#state.lifecycle !== "running") { + throw new CapabilityMockControlPlaneError( + "mock_not_running", + "Capability mock control plane must be started first", + ); + } + } + + #nextCounter(kind: string): number { + const next = (this.#state.counters[kind] ?? 0) + 1; + this.#state.counters[kind] = next; + return next; + } + + #id(kind: string): string { + return `fixture-${kind}-${String(this.#nextCounter(kind)).padStart(4, "0")}`; + } + + #now(): string { + const value = new Date(this.#state.clock.epochMs + this.#state.clock.tick * 1_000).toISOString(); + this.#state.clock.tick += 1; + return value; + } + + #validateState(): void { + if (this.#state.company.id.trim().length === 0) { + throw new CapabilityMockControlPlaneError("fixture_state_invalid", "fixture company id is required"); + } + const ids = new Set(); + for (const [kind, values] of [ + ["actor", this.#state.actors], + ["task", this.#state.tasks], + ["run", this.#state.runs], + ] as const) { + for (const value of values) { + const scoped = `${kind}:${value.id}`; + if (ids.has(scoped)) { + throw new CapabilityMockControlPlaneError( + "fixture_state_invalid", + `duplicate fixture ${kind} id ${value.id}`, + ); + } + ids.add(scoped); + } + } + const actorsById = new Map( + this.#state.actors.map((actor) => [actor.id, actor]), + ); + const tasksById = new Map( + this.#state.tasks.map((task) => [task.id, task]), + ); + for (const actor of this.#state.actors) { + this.#assertCompany(this.#state.company.id, actor.companyId); + } + for (const task of this.#state.tasks) { + this.#assertCompany(this.#state.company.id, task.companyId); + if ( + task.assigneeActorId !== null && + actorsById.get(task.assigneeActorId)?.companyId !== task.companyId + ) { + throw new CapabilityMockControlPlaneError( + "fixture_state_invalid", + `fixture task ${task.id} has an invalid assignee actor`, + ); + } + } + for (const approval of this.#state.approvals) { + this.#assertCompany(this.#state.company.id, approval.companyId); + if ( + approval.taskIds.length === 0 || + approval.taskIds.some((taskId) => + tasksById.get(taskId)?.companyId !== approval.companyId + ) + ) { + throw new CapabilityMockControlPlaneError( + "fixture_state_invalid", + `fixture approval ${approval.id} has an invalid linked task`, + ); + } + if ( + approval.requestedByActorId !== null && + actorsById.get(approval.requestedByActorId)?.companyId !== approval.companyId + ) { + throw new CapabilityMockControlPlaneError( + "fixture_state_invalid", + `fixture approval ${approval.id} has an invalid requester actor`, + ); + } + } + for (const blocker of this.#state.blockers) { + const task = tasksById.get(blocker.taskId); + const blockingTask = tasksById.get(blocker.blockedByTaskId); + if ( + task === undefined || + blockingTask === undefined || + task.companyId !== this.#state.company.id || + blockingTask.companyId !== this.#state.company.id || + task.id === blockingTask.id + ) { + throw new CapabilityMockControlPlaneError( + "fixture_state_invalid", + `fixture blocker ${blocker.id} has an invalid task reference`, + ); + } + } + } +} + +function isFixtureState(value: unknown): value is CapabilityFixtureState { + return ( + typeof value === "object" && + value !== null && + "schema" in value && + value.schema === "paperclip.capability.mock-state.v1" + ); +} + +function clone(value: T): T { + return structuredClone(value); +} + +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +} + +function sortedUnique(values: string[]): string[] { + return [...new Set(values)].sort((left, right) => left.localeCompare(right)); +} + +function requireText(value: string, name: string): string { + if (value.trim().length === 0) { + throw new CapabilityMockControlPlaneError("semantic_command_invalid", `${name} is required`); + } + return value; +} + +function sanitizeWake(wake: CapabilityWakeContext): CapabilityWakeContext { + return { reason: wake.reason, payload: sanitizeJson(wake.payload) }; +} + +function sanitizeJson(value: CapabilityJsonValue): CapabilityJsonValue { + if (Array.isArray(value)) return value.map(sanitizeJson); + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key, + /authorization|credential|api.?key|secret|token/i.test(key) + ? "[REDACTED]" + : sanitizeJson(child), + ]), + ); +} + +function toJsonValue(value: unknown): CapabilityJsonValue { + return JSON.parse(JSON.stringify(value)) as CapabilityJsonValue; +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value === "object" && value !== null) { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "undefined"; +} + +function assertNever(value: never): never { + throw new CapabilityMockControlPlaneError( + "semantic_command_invalid", + `unsupported semantic command ${canonicalJson(value)}`, + ); +} diff --git a/packages/paperclip-runner/src/testing.ts b/packages/paperclip-runner/src/testing.ts index 14a11890a1..0e32d1b92f 100644 --- a/packages/paperclip-runner/src/testing.ts +++ b/packages/paperclip-runner/src/testing.ts @@ -11,6 +11,8 @@ export * from "./conformance/harness-driver.js"; export * from "./conformance/semantic-conformance.js"; export * from "./mock-core/deterministic-harness-driver.js"; export * from "./mock-core/mock-control-plane-adapter.js"; +export * from "./mock-core/capability-control-plane-types.js"; +export * from "./mock-core/capability-mock-control-plane-adapter.js"; export * from "./protocol/conformance-fixture.js"; export * from "./protocol/replay-loader.js"; export * from "./tracer/conformance-runner.js"; diff --git a/packages/paperclip-runner/src/tools/capability-semantic-tool-types.ts b/packages/paperclip-runner/src/tools/capability-semantic-tool-types.ts new file mode 100644 index 0000000000..7ccb522fda --- /dev/null +++ b/packages/paperclip-runner/src/tools/capability-semantic-tool-types.ts @@ -0,0 +1,210 @@ +import type { + CapabilityCommandResult, + CapabilityInteractionKind, + CapabilityJsonValue, + CapabilitySemanticCommand, +} from "../mock-core/capability-control-plane-types.js"; + +export type { CapabilityJsonValue } from "../mock-core/capability-control-plane-types.js"; + +export type CapabilityToolDisposition = "always_agent_tool" | "optional_agent_tool"; + +export type CapabilityOptionalCatalogGroup = + | "discovery" + | "delegation_dependencies" + | "governance" + | "cases" + | "workspace_runtime" + | "routines" + | "company_skills" + | "secrets" + | "portability_admin" + | "test_escape_hatch"; + +export type CapabilitySideEffectClass = + | "read" + | "task_write" + | "company_write" + | "governance" + | "workspace_control" + | "secret_read" + | "admin" + | "test_escape_hatch"; + +export type ScenarioChatdempotencyBehavior = "none" | "required" | "recommended"; + +export type CapabilityToolTaskMode = "standard" | "ask" | "planning" | "skill_test"; + +export interface CapabilityJsonSchema { + $schema?: string; + type?: "object" | "array" | "string" | "number" | "integer" | "boolean" | "null"; + title?: string; + description?: string; + properties?: Record; + required?: string[]; + additionalProperties?: boolean | CapabilityJsonSchema; + items?: CapabilityJsonSchema; + enum?: CapabilityJsonValue[]; + minLength?: number; + minimum?: number; + oneOf?: CapabilityJsonSchema[]; +} + +export interface CapabilityRedactionRule { + path: string; + replacement: "[REDACTED]" | "[SECRET_VALUE]"; + appliesTo: ReadonlyArray<"input" | "output" | "error" | "authorization_record">; +} + +export type CapabilityMockCommandMapping = + | { kind: "context_read"; projection: string } + | { kind: "snapshot_read"; projection: string } + | { kind: "semantic_command"; commandKind: CapabilitySemanticCommand["kind"] } + | { kind: "operation_result" } + | { kind: "mock_extension"; extension: string }; + +export interface CapabilitySemanticToolDescriptor { + operationId: string; + version: 1; + title: string; + description: string; + inputSchema: CapabilityJsonSchema; + outputSchema: CapabilityJsonSchema; + disposition: CapabilityToolDisposition; + optionalGroup: CapabilityOptionalCatalogGroup | null; + requiredClaims: string[]; + allowedRoles?: string[]; + taskModes: CapabilityToolTaskMode[]; + sideEffectClass: CapabilitySideEffectClass; + idempotency: ScenarioChatdempotencyBehavior; + redaction: CapabilityRedactionRule[]; + mockCommandMapping: CapabilityMockCommandMapping; +} + +export interface CapabilityScenarioToolPolicy { + deniedOperationIds?: string[]; + deniedClaims?: string[]; + allowedInteractionKinds?: CapabilityInteractionKind[]; + allowSecretValueAccess?: boolean; + allowGenericEscapeHatch?: boolean; + genericEscapeHatchAllowlist?: Array<{ method: string; path: string }>; +} + +export interface CapabilityToolAuthorizationContext { + runId: string; + actor: { + id: string; + role: string; + capabilityGrants: string[]; + }; + task: { + id: string; + assigneeActorId: string | null; + workMode: CapabilityToolTaskMode; + }; + scenarioGrants: string[]; + policy?: CapabilityScenarioToolPolicy; +} + +export type CapabilityAuthorizationOutcome = "exposed" | "allowed" | "denied" | "absent"; + +export interface CapabilityAuthorizationRecord { + schema: "paperclip.capability.authorization-record.v1"; + sequence: number; + runId: string; + actorId: string; + taskId: string; + operationId: string; + phase: "exposure" | "invocation"; + outcome: CapabilityAuthorizationOutcome; + consideredClaims: string[]; + grantedClaims: string[]; + missingClaims: string[]; + reason: string; + redactions: Array<{ path: string; replacement: string }>; + resultingStateChange: { + beforeRevision: number | null; + afterRevision: number | null; + entityRefs: string[]; + } | null; +} + +export interface CapabilityVisibleToolSet { + schema: "paperclip.capability.visible-tools.v1"; + tools: CapabilitySemanticToolDescriptor[]; + authorizationRecords: CapabilityAuthorizationRecord[]; +} + +export interface CapabilityPolicyDenial { + schema: "paperclip.capability.tool-result.v1"; + ok: false; + error: { + code: "policy_denied" | "operation_absent" | "input_invalid" | "operation_unsupported"; + message: string; + operationId: string; + reason: string; + }; + authorization: CapabilityAuthorizationRecord; +} + +export interface CapabilityToolSuccess { + schema: "paperclip.capability.tool-result.v1"; + ok: true; + operationId: string; + operationResultId: string; + value: CapabilityJsonValue; + commandResult: CapabilityCommandResult | null; + authorization: CapabilityAuthorizationRecord; +} + +export type CapabilityToolInvocationResult = CapabilityToolSuccess | CapabilityPolicyDenial; + +/** + * A result intended only for the provider/model tool-response channel. + * + * The distinct schema prevents this value from being assigned to an observable + * tool-result sink. Callers receive it only by explicitly opening a + * {@link CapabilityModelToolDelivery}; the delivery capsule itself serializes to + * its redacted observable result. + */ +export interface CapabilityModelToolSuccess { + schema: "paperclip.capability.model-tool-result.v1"; + ok: true; + operationId: string; + operationResultId: string; + value: CapabilityJsonValue; + commandResult: CapabilityCommandResult | null; + authorization: CapabilityAuthorizationRecord; +} + +export type CapabilityModelToolInvocationResult = CapabilityModelToolSuccess | CapabilityPolicyDenial; + +export interface CapabilityModelToolDelivery { + /** Safe for traces, snapshots, persistence, errors, and browser payloads. */ + readonly observableResult: CapabilityToolInvocationResult; + + /** Explicitly opens the narrowly scoped provider/model delivery value. */ + readModelResult(): CapabilityModelToolInvocationResult; + + /** Accidental JSON serialization is always the observable redacted form. */ + toJSON(): CapabilityToolInvocationResult; +} + +export interface CapabilityToolInvocation { + operationId: string; + input: CapabilityJsonValue; + idempotencyKey?: string; +} + +export const CAPABILITY_CONTROL_PLANE_OWNED_OPERATION_IDS = [ + "checkout_task", + "release_task", + "select_work", + "route_wake", + "enforce_budget", + "append_audit_record", + "persist_run", + "replay_run", + "schedule_blocker_wake", + "reconcile_run", +] as const;