feat: add durable phase transition controls (#1038)

This commit is contained in:
Brad Groux 2026-07-25 01:36:06 -05:00 committed by GitHub
parent dcd4e61f66
commit 1c5d44a2af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2772 additions and 24 deletions

View file

@ -189,9 +189,13 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise,
profile, sandbox, tool-catalog, and launch-policy authority only through
`phase-capability-service.ts`; never union scopes or infer missing
dimensions. The plan artifact exception is one harness-owned exact path and
never implies general filesystem write authority. Until #1035, #1036, and
#1033 land, this compiler is a contract foundation rather than active
transition or runtime enforcement.
never implies general filesystem write authority. Active phase changes go
only through `phase-transition-service.ts` with exact attempt, sequence,
evidence-digest, and launch-manifest compare-and-set guards. Authority
expansion requires an exact-action approval; an emergency override requires
`admin:manage`, expires within 24 hours, and is durably reverted. Until #1036
and #1033 land, launch propagation and provider/tool enforcement remain
separate delivery boundaries.
- Credential-bound tool servers persist only exact definition/scope digests and
safe target names in `run-tool-catalog/v1`. Discovery strips their source
environment/header values, native provider injection omits them, and

View file

@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the append-only `phase-transition-record/v1` journal for active runs.
Every transition is bound to the exact attempt, prior sequence, compiled
phase evidence, and launch-manifest digest. Narrowing transitions apply
immediately, while authority expansion uses the existing exact-action
approval broker. Administrators can apply an auditable emergency expansion
for at most 24 hours; expiry durably restores the prior evidence. File and
SQLite repositories preserve restart recovery and idempotency, and new REST
and CLI controls expose current state, history, transitions, and approval
decisions (#1035).
- Added the provider-neutral `phase-capability-profile/v1` foundation with
strict schemas and built-in explore, plan, implement, verify, and publish
profiles. A pure compiler now intersects phase requests with parent, agent,

View file

@ -97,6 +97,7 @@ When the board is working, use [Setup Paths](docs/SETUP-PATHS.md) to choose the
- [Agent Providers](docs/AGENT-PROVIDERS.md) — evidence-backed Buzz, Grok Build, Codex, Claude Code, Copilot CLI, Hermes, OpenClaw, and optional model profiles.
- [v6 Agent Runtime Control Plane](docs/architecture/V6-AGENT-RUNTIME-CONTROL-PLANE.md) — authority, adapter, lifecycle, approval, tool, credential, Buzz, and certification boundaries.
- [Phase Capability Profiles](docs/architecture/PHASE-CAPABILITY-PROFILES.md) — versioned execution-phase authority contracts, deterministic intersections, exact-path plan artifacts, and current delivery boundaries.
- [Phase Transition Journal](docs/architecture/PHASE-TRANSITION-JOURNAL.md) — durable compare-and-set transitions, approval and override controls, restart recovery, REST, and CLI operations.
- [OpenAI Codex Integration Roadmap](docs/CODEX-INTEGRATION.md) — optional local execution, SDK sessions, cloud delegation, MCP setup, workflows, telemetry, and release QA.
- [Veritas Cutover Operating Guide](docs/VERITAS-CUTOVER.md) — authority model, HermesAgent roster, QA evidence gate, and GitHub-backed task templates.
- [Codex Integration SOP](docs/SOP-codex-integration.md) & [Codex Workflow Examples](docs/EXAMPLES-codex-workflows.md) — operational playbooks for using Codex as a first-class Veritas agent.

View file

@ -1,4 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Command } from 'commander';
const { mockApi, mockFindTask } = vi.hoisted(() => ({
@ -11,6 +14,14 @@ vi.mock('../utils/find.js', () => ({ findTask: mockFindTask }));
import { registerAgentCommands } from '../commands/agents.js';
const temporaryRoots: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))
);
});
describe('vk agent runtime capability controls', () => {
beforeEach(() => {
vi.clearAllMocks();
@ -135,6 +146,165 @@ describe('vk agent runtime capability controls', () => {
});
});
it('reads durable phase state for one exact attempt', async () => {
mockApi.mockResolvedValueOnce({ current: null, history: [] });
const program = new Command();
program.exitOverride();
registerAgentCommands(program);
await program.parseAsync(
['agent:phase', 'task_1', '--attempt', 'attempt_1', '--limit', '25', '--json'],
{ from: 'user' }
);
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/phase?attemptId=attempt_1&limit=25');
});
it('binds the first phase transition to exact evidence and manifest provenance', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'vk-phase-cli-'));
temporaryRoots.push(root);
const fromPath = path.join(root, 'from.json');
const targetPath = path.join(root, 'target.json');
const fromEvidence = { digest: `sha256:${'1'.repeat(64)}` };
const targetEvidence = { digest: `sha256:${'2'.repeat(64)}` };
await fs.writeFile(fromPath, JSON.stringify(fromEvidence));
await fs.writeFile(targetPath, JSON.stringify(targetEvidence));
mockApi.mockResolvedValueOnce({ current: null, history: [] }).mockResolvedValueOnce({
status: 'applied',
current: null,
targetEvidenceDigest: targetEvidence.digest,
});
const program = new Command();
program.exitOverride();
registerAgentCommands(program);
await program.parseAsync(
[
'agent:transition-phase',
'task_1',
'--attempt',
'attempt_1',
'--operation',
'phase-op-1',
'--from-evidence',
fromPath,
'--target-evidence',
targetPath,
'--manifest',
`sha256:${'3'.repeat(64)}`,
'--reason',
'Approved plan is ready.',
'--json',
],
{ from: 'user' }
);
expect(mockApi).toHaveBeenNthCalledWith(2, '/api/agents/task_1/phase/transitions', {
method: 'POST',
body: JSON.stringify({
attemptId: 'attempt_1',
operationId: 'phase-op-1',
expectedSequence: 0,
expectedPhaseEvidenceDigest: fromEvidence.digest,
expectedManifestDigest: `sha256:${'3'.repeat(64)}`,
reason: 'Approved plan is ready.',
fromEvidence,
targetEvidence,
}),
});
});
it('rejects partially numeric phase approval lifetimes before transition', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'vk-phase-cli-'));
temporaryRoots.push(root);
const fromPath = path.join(root, 'from.json');
const targetPath = path.join(root, 'target.json');
await fs.writeFile(fromPath, JSON.stringify({ digest: `sha256:${'1'.repeat(64)}` }));
await fs.writeFile(targetPath, JSON.stringify({ digest: `sha256:${'2'.repeat(64)}` }));
mockApi.mockResolvedValueOnce({ current: null, history: [] });
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called');
}) as typeof process.exit);
const program = new Command();
program.exitOverride();
registerAgentCommands(program);
try {
await expect(
program.parseAsync(
[
'agent:transition-phase',
'task_1',
'--attempt',
'attempt_1',
'--operation',
'phase-op-1',
'--from-evidence',
fromPath,
'--target-evidence',
targetPath,
'--manifest',
`sha256:${'3'.repeat(64)}`,
'--reason',
'Approved plan is ready.',
'--approval-ttl-ms',
'1000x',
],
{ from: 'user' }
)
).rejects.toThrow('process.exit called');
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('--approval-ttl-ms must be an integer')
);
expect(mockApi).toHaveBeenCalledTimes(1);
expect(exitSpy).toHaveBeenCalledWith(1);
} finally {
errorSpy.mockRestore();
exitSpy.mockRestore();
}
});
it('decides an exact phase approval with revision and action-hash guards', async () => {
const approval = {
id: 'runapproval_000000000001',
revision: 4,
actionHash: 'a'.repeat(64),
status: 'pending',
};
mockApi.mockResolvedValueOnce(approval).mockResolvedValueOnce({
...approval,
revision: 5,
status: 'approved',
});
const program = new Command();
program.exitOverride();
registerAgentCommands(program);
await program.parseAsync(
[
'agent:decide-phase-approval',
approval.id,
'--decision',
'approve',
'--note',
'Expansion reviewed.',
'--json',
],
{ from: 'user' }
);
expect(mockApi).toHaveBeenNthCalledWith(2, `/api/run-approvals/${approval.id}/decision`, {
method: 'POST',
body: JSON.stringify({
decision: 'approved',
expectedRevision: 4,
expectedActionHash: approval.actionHash,
note: 'Expansion reviewed.',
}),
});
});
it('starts a native history fork from an explicit source attempt and turn', async () => {
const program = new Command();
program.exitOverride();

View file

@ -11,6 +11,10 @@ import type {
AgentProfileValidationResult,
ConversationLifecycleRecord,
ConversationLifecycleResult,
PhaseCapabilityEvidence,
PhaseTransitionRecord,
PhaseTransitionResult,
RunApprovalRequest,
RunRecoveryRecord,
RunLaunchManifestPreview,
WorkspaceExecutionTrustDecision,
@ -36,6 +40,20 @@ interface ConversationControlOptions {
json?: boolean;
}
interface PhaseTransitionOptions {
attempt: string;
operation: string;
targetEvidence: string;
fromEvidence?: string;
manifest?: string;
reason: string;
approvalId?: string;
approvalTtlMs?: string;
overrideUntil?: string;
overrideReason?: string;
json?: boolean;
}
function inferProfileFormat(filePath: string): AgentProfilePackageFormat {
const extension = path.extname(filePath).toLowerCase();
return extension === '.json' ? 'json' : 'yaml';
@ -47,6 +65,10 @@ async function resolveTaskId(id: string): Promise<string> {
return task.id;
}
function readPhaseEvidence(filePath: string): PhaseCapabilityEvidence {
return JSON.parse(readFileSync(path.resolve(filePath), 'utf8')) as PhaseCapabilityEvidence;
}
function printConversationResult(
action: string,
result: {
@ -69,6 +91,13 @@ function printConversationResult(
if (result.note) console.log(chalk.dim(result.note));
}
function phaseIdentityLabel(record: PhaseTransitionRecord): string {
const identity = record.effectiveEvidence.identity;
return identity.mode === 'legacy'
? 'legacy'
: `${identity.phase} (${identity.profileId}@${identity.profileVersion})`;
}
function registerConversationTurnCommand(
program: Command,
action: ConversationTurnAction,
@ -565,6 +594,179 @@ export function registerAgentCommands(program: Command): void {
}
});
program
.command('agent:phase <id>')
.description('Show the active durable phase and transition history for an exact run')
.requiredOption('--attempt <attemptId>', 'Exact attempt ID')
.option('--limit <count>', 'Maximum transition records', '100')
.option('--json', 'Output as JSON')
.action(
async (
id: string,
options: { attempt: string; limit: string; json?: boolean }
): Promise<void> => {
try {
const taskId = await resolveTaskId(id);
const result = await api<{
current: PhaseTransitionRecord | null;
history: PhaseTransitionRecord[];
}>(
`/api/agents/${taskId}/phase?attemptId=${encodeURIComponent(options.attempt)}&limit=${encodeURIComponent(options.limit)}`
);
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else if (!result.current) {
console.log(chalk.dim('No durable phase transition is recorded for this run'));
} else {
console.log(chalk.cyan(`Phase: ${phaseIdentityLabel(result.current)}`));
console.log(chalk.dim(`Sequence: ${result.current.sequence}`));
console.log(chalk.dim(`Evidence: ${result.current.effectiveEvidence.digest}`));
console.log(chalk.dim(`Manifest: ${result.current.manifestDigest}`));
if (result.current.emergencyOverride) {
console.log(
chalk.yellow(
`Emergency override expires: ${result.current.emergencyOverride.expiresAt}`
)
);
}
}
} catch (err) {
console.error(chalk.red(`Error: ${(err as Error).message}`));
process.exit(1);
}
}
);
program
.command('agent:transition-phase <id>')
.description('Apply or request approval for one compare-and-set phase transition')
.requiredOption('--attempt <attemptId>', 'Exact active attempt ID')
.requiredOption('--operation <id>', 'Stable idempotency key for this transition')
.requiredOption('--target-evidence <file>', 'Compiled target phase evidence JSON')
.requiredOption('--reason <text>', 'Operator reason')
.option('--from-evidence <file>', 'Initial phase evidence JSON for the first transition')
.option('--manifest <digest>', 'Launch manifest digest for the first transition')
.option('--approval-id <id>', 'Exact approval returned by the prior request')
.option('--approval-ttl-ms <milliseconds>', 'Approval request lifetime')
.option('--override-until <timestamp>', 'Emergency override expiry, at most 24 hours')
.option('--override-reason <text>', 'Emergency override justification')
.option('--json', 'Output as JSON')
.action(async (id: string, options: PhaseTransitionOptions): Promise<void> => {
try {
const taskId = await resolveTaskId(id);
const state = await api<{
current: PhaseTransitionRecord | null;
history: PhaseTransitionRecord[];
}>(`/api/agents/${taskId}/phase?attemptId=${encodeURIComponent(options.attempt)}`);
const fromEvidence = options.fromEvidence
? readPhaseEvidence(options.fromEvidence)
: undefined;
const priorEvidence = state.current?.effectiveEvidence ?? fromEvidence;
if (!priorEvidence) {
throw new Error('The first transition requires --from-evidence');
}
const manifestDigest = state.current?.manifestDigest ?? options.manifest;
if (!manifestDigest) {
throw new Error('The first transition requires --manifest');
}
if (
(options.overrideUntil && !options.overrideReason) ||
(!options.overrideUntil && options.overrideReason)
) {
throw new Error('--override-until and --override-reason must be used together');
}
const approvalTtlMs =
options.approvalTtlMs && /^\d+$/.test(options.approvalTtlMs)
? Number(options.approvalTtlMs)
: undefined;
if (options.approvalTtlMs && !Number.isSafeInteger(approvalTtlMs)) {
throw new Error('--approval-ttl-ms must be an integer');
}
const result = await api<PhaseTransitionResult>(`/api/agents/${taskId}/phase/transitions`, {
method: 'POST',
body: JSON.stringify({
attemptId: options.attempt,
operationId: options.operation,
expectedSequence: state.current?.sequence ?? 0,
expectedPhaseEvidenceDigest: priorEvidence.digest,
expectedManifestDigest: manifestDigest,
reason: options.reason,
...(state.current ? {} : { fromEvidence: priorEvidence }),
targetEvidence: readPhaseEvidence(options.targetEvidence),
approvalId: options.approvalId,
approvalTtlMs,
...(options.overrideUntil && options.overrideReason
? {
emergencyOverride: {
expiresAt: options.overrideUntil,
justification: options.overrideReason,
},
}
: {}),
}),
});
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else if (result.status === 'approval-required' && result.approval) {
console.log(chalk.yellow('Phase expansion requires approval'));
console.log(` Approval: ${result.approval.id}`);
console.log(` Revision: ${result.approval.revision}`);
console.log(` Action hash: ${result.approval.actionHash}`);
console.log(
chalk.dim('Approve it, then retry this command with the same --operation value.')
);
} else if (result.record) {
console.log(chalk.green(`✓ Phase transitioned to ${phaseIdentityLabel(result.record)}`));
console.log(chalk.dim(`Sequence: ${result.record.sequence}`));
}
} catch (err) {
console.error(chalk.red(`Error: ${(err as Error).message}`));
process.exit(1);
}
});
program
.command('agent:decide-phase-approval <approvalId>')
.description('Approve or reject an exact pending phase transition')
.requiredOption('--decision <decision>', 'approve or reject')
.option('--note <text>', 'Decision note')
.option('--json', 'Output as JSON')
.action(
async (
approvalId: string,
options: { decision: string; note?: string; json?: boolean }
): Promise<void> => {
try {
if (!['approve', 'reject'].includes(options.decision)) {
throw new Error('--decision must be approve or reject');
}
const approval = await api<RunApprovalRequest>(
`/api/run-approvals/${encodeURIComponent(approvalId)}`
);
const decided = await api<RunApprovalRequest>(
`/api/run-approvals/${encodeURIComponent(approval.id)}/decision`,
{
method: 'POST',
body: JSON.stringify({
decision: options.decision === 'approve' ? 'approved' : 'rejected',
expectedRevision: approval.revision,
expectedActionHash: approval.actionHash,
note: options.note,
}),
}
);
if (options.json) {
console.log(JSON.stringify(decided, null, 2));
} else {
console.log(chalk.green(`✓ Phase transition approval ${decided.status}`));
}
} catch (err) {
console.error(chalk.red(`Error: ${(err as Error).message}`));
process.exit(1);
}
}
);
program
.command('agent:cancel-recovery <id>')
.description('Cancel the exact pending retry or fallback for a task')

View file

@ -2402,6 +2402,49 @@ Approval-required tools are omitted from native provider configuration and use
the mediated tool-call API below. Prompt text is never accepted as equivalent
enforcement.
### Durable Phase Transitions
```
GET /api/agents/:taskId/phase?attemptId=attempt_123&limit=100
POST /api/agents/:taskId/phase/transitions
```
The GET endpoint returns `current` plus bounded append-only `history` for one
exact run. The POST body is a compare-and-set request:
```json
{
"attemptId": "attempt_123",
"operationId": "move-to-implement",
"expectedSequence": 1,
"expectedPhaseEvidenceDigest": "sha256:...",
"expectedManifestDigest": "sha256:...",
"reason": "The approved plan is ready to implement.",
"targetEvidence": {
"schemaVersion": "phase-capability-evidence/v1"
}
}
```
`targetEvidence` must be the complete compiled evidence document with a valid
content digest. The first transition also includes `fromEvidence`; subsequent
requests must match the journal's current sequence and evidence. The active
attempt and persisted launch-manifest digest must still match.
Same-authority and narrowing transitions return `201` with `status: "applied"`.
Expansion creates an exact-action run approval and returns `202` with
`status: "approval-required"`. After approval, retry the identical operation
with its `approvalId`. Emergency expansion accepts
`emergencyOverride: { justification, expiresAt }`, requires `admin:manage`, and
cannot last more than 24 hours. Expiry appends a system-attributed restoration
of the prior evidence.
Reads require `agent:read`; transition requests require `task:write`. Approval
decisions use `/api/run-approvals/:approvalId/decision` and require an
administrator. See
[Phase Transition Journal](architecture/PHASE-TRANSITION-JOURNAL.md) for
storage, idempotency, and delivery boundaries.
### Automatic Run Recovery
```

View file

@ -455,6 +455,9 @@ Manage AI agents on code tasks.
| `vk stop <id>` | Stop a run only when its persisted manifest supports stop |
| `vk agent:recovery <id>` | Inspect the latest retry or fallback decision |
| `vk agent:cancel-recovery <id> --attempt <id>` | Cancel the exact pending recovery parent |
| `vk agent:phase <id> --attempt <id>` | Read durable phase state and transition history |
| `vk agent:transition-phase <id> ...` | Apply or request approval for one exact phase transition |
| `vk agent:decide-phase-approval <approvalId> ...` | Approve or reject an exact pending phase expansion |
| `vk agent:resume <id> --source-attempt <id> -m <text>` | Resume the exact persisted provider conversation |
| `vk agent:follow-up <id> --source-attempt <id> -m <text>` | Start a provider-native follow-up turn |
| `vk agent:fork <id> --source-attempt <id> -m <text>` | Fork provider history without mutating its source |
@ -543,6 +546,27 @@ vk agent:cancel-recovery TASK-001 --attempt attempt_parent --json
The server rejects stale parent IDs, recoveries that already launched, and
recoveries that are already terminal.
Inspect and transition the exact active phase:
```bash
vk agent:phase TASK-001 --attempt attempt_123 --json
vk agent:transition-phase TASK-001 \
--attempt attempt_123 \
--operation move-to-implement \
--target-evidence ./implement-evidence.json \
--reason "The approved plan is ready to implement." \
--json
```
The first transition also requires `--from-evidence <file>` and
`--manifest <sha256:...>`. Later requests read the current journal record and
automatically bind its sequence, evidence digest, and manifest. Narrowing
applies immediately. Expansion returns an exact approval; decide it with
`vk agent:decide-phase-approval <id> --decision approve`, then retry the same
transition with the same `--operation` and `--approval-id`. Emergency expansion
requires `--override-until` and `--override-reason`; the authenticated caller
must be an administrator and the expiry cannot exceed 24 hours.
---
### Prompt Commands

View file

@ -338,14 +338,18 @@ First-class support for autonomous coding agents.
- **Team roster routing manifests** — Workspace coordinators can define enabled members, capabilities, routing rules, fallbacks, reviewers, and escalation posture before `/api/agents/route` selects an agent
- **Workspace capability discovery** — Trusted workspace catalogs expose supported task types, SLA/queue posture, intake requirements, and delegated-work packaging so cross-workspace handoffs are explicit
- **Agent profile packages** — Reusable YAML/JSON packages bundle role, runtime, model, prompt instructions, tools, permissions, sandbox, budget, workflow, and health metadata for portable task launches
- **Phase capability contract** — Versioned explore, plan, implement, verify,
and publish profiles compile a monotonic intersection across parent, agent,
sandbox, tool-catalog, and launch policy authority. Unsupported required
dimensions fail closed, legacy mode remains explicit, and plan artifacts
stay bound to one harness-owned exact path. This is the model/compiler
foundation; transition persistence, launch propagation, tool enforcement,
and UI remain tracked in #1035, #1036, and #1033. See
[Phase Capability Profiles](architecture/PHASE-CAPABILITY-PROFILES.md).
- **Phase capability contract and transition journal** — Versioned explore,
plan, implement, verify, and publish profiles compile a monotonic
intersection across parent, agent, sandbox, tool-catalog, and launch policy
authority. Unsupported required dimensions fail closed, legacy mode remains
explicit, and plan artifacts stay bound to one harness-owned exact path.
Active runs persist append-only compare-and-set transitions with actor,
authority delta, policy, approval or override, manifest, and event evidence.
Expansion requires exact-action approval; administrator overrides expire and
durably restore the prior phase. Launch propagation, tool enforcement, and UI
remain tracked in #1036 and #1033. See
[Phase Capability Profiles](architecture/PHASE-CAPABILITY-PROFILES.md) and
[Phase Transition Journal](architecture/PHASE-TRANSITION-JOURNAL.md).
- **Provider runtime manifests** — Every executable adapter records a versioned, evidence-backed capability snapshot and digest on the attempt, history, trace, and log; provider version skew reruns conformance and unsupported configured providers fail closed instead of falling back to OpenClaw
- **Cross-harness compatibility matrix** — Buzz, Grok Build, OpenAI Codex app-server, Claude Code, and GitHub Copilot CLI publish exact reviewed builds, source-availability caveats, deterministic fixture identity, capability evidence, limitations, and live support tiers through one API record consumed by Settings, `vk doctor`, telemetry, and [operator guidance](HARNESS-COMPATIBILITY.md)
- **Harness conformance suites** — Versioned seeded scenarios compare

View file

@ -2,12 +2,14 @@
Issue #1034 establishes the provider-neutral authority contract for execution
phases. It defines what a phase may request and how Veritas computes the
effective result before runtime transition, propagation, and tool enforcement
are added in the remaining #875 slices.
effective result. Issue #1035 adds durable active-run transitions and operator
controls; propagation and tool enforcement remain in later #875 slices.
This foundation is intentionally pure. It does not change an active attempt,
The compiler remains intentionally pure. It does not mutate an active attempt,
persist a transition, filter a tool catalog, or claim that a provider enforced
the result.
the result. The separate
[Phase Transition Journal](PHASE-TRANSITION-JOURNAL.md) owns active state
changes and their evidence.
## Contract
@ -109,12 +111,15 @@ grant.
## Delivery boundary
This issue supplies the shared types, strict schemas, built-in profiles, pure
compiler, and focused matrix coverage. The remaining tracking-epic slices add:
The delivered phase control plane now includes:
- Durable transition state, approvals, and operator controls in #1035
- Launch, descendant, retry, resume, and handoff propagation in #1036
- Tool enforcement, evidence surfaces, and UI in #1033
- Shared types, strict schemas, built-in profiles, and the pure compiler from
#1034
- Durable transition state, approvals, emergency override expiry, restart
recovery, REST, and CLI controls from #1035
Until those slices land, compiled phase evidence is an architecture contract,
not a claim that active runs are phase-restricted.
The remaining tracking-epic slices add launch, descendant, retry, resume, and
handoff propagation in #1036, then tool enforcement, evidence surfaces, and UI
in #1033. Until those slices land, a durable transition is authoritative
Veritas state, not a claim that every provider process or tool has enforced the
new phase.

View file

@ -0,0 +1,128 @@
# Phase Transition Journal
Issue #1035 adds the durable state machine that moves one active run between
compiled phase capability profiles. It builds on the
[Phase Capability Profiles](PHASE-CAPABILITY-PROFILES.md) contract without
claiming launch or tool enforcement that belongs to #1036 and #1033.
## Durable record
Each applied change appends one immutable `phase-transition-record/v1` record.
The record binds:
- Workspace, task, active attempt, sequence, and idempotent operation ID
- Prior and effective `phase-capability-evidence/v1` documents
- Added and removed scopes for every changed authority dimension
- Verified actor, reason, and policy decision
- Exact approval or emergency-override evidence when required
- Active `run-launch-manifest/v1` digest
- Deterministic run-event projection reference and timestamp
File storage uses a locked, bounded JSONL journal. SQLite uses an append-only
table with unique run sequence and operation constraints. Both repositories
implement the same compare-and-set contract and recover the current phase as
the highest sequence for the exact workspace, task, and attempt.
## Compare-and-set rules
A request supplies the expected sequence, prior phase-evidence digest, and
launch-manifest digest. The server also verifies that the task still has the
same running attempt, executable provider, and manifest before appending.
The first transition additionally supplies the exact initial compiled
evidence. Later transitions use the journal's current evidence. Stale attempt,
sequence, evidence, manifest, or changed reuse of an operation ID fails closed.
An exact duplicate operation returns the original record without replaying the
transition.
Evidence is validated against its content digest. A blocked result or legacy
identity cannot become the target of an operator transition.
## Policy and approvals
The authority delta is calculated independently for filesystem read and write,
command execution, network egress, credentials, external actions, and the plan
artifact capability.
- Same-authority and narrowing transitions apply immediately.
- Any added scope creates or reuses an exact-action request in the existing run
approval broker.
- Approval binds the operation, prior and target evidence digests, manifest,
and complete authority delta.
- Pending approval returns `approval-required`; rejected or expired approval
fails closed.
- Credential or external-action expansion is classified as critical risk.
An emergency expansion requires verified `admin:manage` authority, a reason,
and an expiry no more than 24 hours in the future. The first read after expiry
appends one system-attributed `override-expired` transition that restores the
prior evidence. Concurrent readers use compare-and-set behavior, so only one
expiry record wins.
## REST controls
Read the active phase and bounded history:
```http
GET /api/agents/:taskId/phase?attemptId=attempt_123&limit=100
```
Request or apply a transition:
```http
POST /api/agents/:taskId/phase/transitions
Content-Type: application/json
{
"attemptId": "attempt_123",
"operationId": "move-to-implement",
"expectedSequence": 1,
"expectedPhaseEvidenceDigest": "sha256:...",
"expectedManifestDigest": "sha256:...",
"reason": "The approved plan is ready to implement.",
"targetEvidence": {
"schemaVersion": "phase-capability-evidence/v1"
}
}
```
The abbreviated `targetEvidence` above represents the complete compiled
evidence document. Reads require `agent:read`; transition requests require
`task:write`. Expansion is not applied until an administrator resolves its
approval. Emergency override authority is checked independently by the service.
## CLI controls
```bash
vk agent:phase TASK-001 --attempt attempt_123 --json
vk agent:transition-phase TASK-001 \
--attempt attempt_123 \
--operation move-to-implement \
--target-evidence ./implement-evidence.json \
--reason "The approved plan is ready to implement." \
--json
vk agent:decide-phase-approval runapproval_123 \
--decision approve \
--note "Reviewed the exact authority delta." \
--json
```
For the first transition, add `--from-evidence` and `--manifest`. The CLI reads
the current durable record for later transitions and supplies its sequence,
evidence digest, and manifest automatically. Retry an approved expansion with
the same `--operation` value and the returned `--approval-id`.
Emergency override uses `--override-until` and `--override-reason` together.
The server remains authoritative for administrator permission and maximum
expiry.
## Delivery boundary
The journal makes phase state, approval, expiry, restart recovery, and operator
control durable. Issue #1036 will bind compiled evidence into launch,
descendant, retry, resume, and handoff behavior. Issue #1033 will enforce the
active evidence at tool invocation and expose it through evidence and UI
surfaces. Until those slices land, clients must not describe the journal alone
as complete provider-side phase enforcement.

View file

@ -699,6 +699,30 @@
"source": "cli/src/commands/agents.ts",
"denialReason": "Cancelling an exact pending recovery requires task read and task write access."
},
{
"id": "cli:agents:agent:phase",
"kind": "cli",
"classification": "authenticated-read",
"permissions": ["task:read", "agent:read"],
"source": "cli/src/commands/agents.ts",
"denialReason": "Reading active phase state requires task and agent read access."
},
{
"id": "cli:agents:agent:transition-phase",
"kind": "cli",
"classification": "agent-scoped",
"permissions": ["task:read", "task:write"],
"source": "cli/src/commands/agents.ts",
"denialReason": "Requesting a compare-and-set phase transition requires task read and write access; expansions still require exact approval."
},
{
"id": "cli:agents:agent:decide-phase-approval",
"kind": "cli",
"classification": "owner-admin",
"permissions": ["task:read", "admin:manage"],
"source": "cli/src/commands/agents.ts",
"denialReason": "Approving or rejecting an authority expansion requires administrator authority."
},
{
"id": "cli:agents:agent:resume",
"kind": "cli",

View file

@ -0,0 +1,167 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
PHASE_AUTHORITY_DIMENSIONS,
PHASE_TRANSITION_RECORD_SCHEMA_VERSION,
type PhaseAuthorityDimension,
type PhaseAuthoritySource,
type PhaseTransitionRecord,
} from '@veritas-kanban/shared';
import {
compilePhaseCapabilityAuthority,
getBuiltInPhaseCapabilityProfile,
} from '../services/phase-capability-service.js';
import { calculatePhaseAuthorityDelta } from '../services/phase-transition-service.js';
import type { PhaseTransitionRepository } from '../storage/interfaces.js';
import { FilePhaseTransitionRepository } from '../storage/phase-transition-repository.js';
import { SqliteDatabase } from '../storage/sqlite/database.js';
import { SqlitePhaseTransitionRepository } from '../storage/sqlite/phase-transition-repository.js';
const roots: string[] = [];
const MANIFEST_DIGEST = `sha256:${'1'.repeat(64)}`;
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
describe('phase transition repository parity', () => {
it('persists append-only compare-and-set state in the file backend', async () => {
const root = await temporaryRoot();
const filePath = path.join(root, 'phase-transitions.jsonl');
await exerciseRepository(
new FilePhaseTransitionRepository(filePath),
async () => new FilePhaseTransitionRepository(filePath)
);
});
it('persists append-only compare-and-set state through SQLite restart', async () => {
const root = await temporaryRoot();
const databasePath = path.join(root, 'veritas.db');
let database = new SqliteDatabase({ databasePath });
database.open();
const repository = new SqlitePhaseTransitionRepository(database);
await exerciseRepository(repository, async () => {
database.close();
database = new SqliteDatabase({ databasePath });
database.open();
return new SqlitePhaseTransitionRepository(database);
});
database.close();
});
});
async function exerciseRepository(
repository: PhaseTransitionRepository,
reopen: () => Promise<PhaseTransitionRepository>
): Promise<void> {
const record = transitionRecord();
const input = {
record,
expectedSequence: 0,
expectedPhaseEvidenceDigest: record.priorEvidence.digest,
expectedManifestDigest: MANIFEST_DIGEST,
};
const first = await repository.append(input);
const duplicate = await repository.append(input);
const staleRecord = {
...record,
id: 'phasetransition_000000000000000002',
operationId: 'stale-operation',
};
const stale = await repository.append({ ...input, record: staleRecord });
expect(first).toMatchObject({ appended: true, record: { sequence: 1 } });
expect(duplicate).toMatchObject({ appended: false, record: { id: record.id } });
expect(stale).toMatchObject({ appended: false, reason: 'stale-sequence' });
expect(
await repository.getByOperationId('local', 'task-1', 'attempt-1', record.operationId)
).toEqual(record);
expect(await repository.list(query())).toEqual([record]);
const restarted = await reopen();
expect(await restarted.getCurrent('local', 'task-1', 'attempt-1')).toEqual(record);
expect(await restarted.list(query())).toEqual([record]);
}
function transitionRecord(): PhaseTransitionRecord {
const priorEvidence = phaseEvidence('implement');
const effectiveEvidence = phaseEvidence('plan');
return {
schemaVersion: PHASE_TRANSITION_RECORD_SCHEMA_VERSION,
id: 'phasetransition_000000000000000001',
workspaceId: 'local',
taskId: 'task-1',
attemptId: 'attempt-1',
sequence: 1,
operationId: 'transition-1',
priorEvidence,
effectiveEvidence,
authorityDelta: calculatePhaseAuthorityDelta(
priorEvidence.effectiveAuthority,
effectiveEvidence.effectiveAuthority
),
actor: {
id: 'operator',
type: 'user',
authMethod: 'session',
workspaceId: 'local',
},
reason: 'Narrow to planning.',
policyDecision: 'allow',
manifestDigest: MANIFEST_DIGEST,
eventReference: 'phase:phasetransition_000000000000000001',
createdAt: '2026-07-25T01:00:00.000Z',
};
}
function phaseEvidence(phase: 'plan' | 'implement') {
return compilePhaseCapabilityAuthority({
profile: getBuiltInPhaseCapabilityProfile(phase),
sources: {
parent: source('parent', 'parent'),
agentProfile: source('agent-profile', 'agent-profile'),
sandbox: source('sandbox', 'sandbox'),
toolCatalog: source('tool-catalog', 'tool-catalog'),
launchPolicy: source('launch-policy', 'launch-policy'),
},
});
}
function source<K extends PhaseAuthoritySource['kind']>(
id: string,
kind: K
): PhaseAuthoritySource & { kind: K } {
return {
id,
kind,
authority: dimensions(() => ['*']),
enforcement: dimensions(() => 'enforced' as const),
};
}
function dimensions<T>(
value: (dimension: PhaseAuthorityDimension) => T
): Record<PhaseAuthorityDimension, T> {
return Object.fromEntries(
PHASE_AUTHORITY_DIMENSIONS.map((dimension) => [dimension, value(dimension)])
) as Record<PhaseAuthorityDimension, T>;
}
function query() {
return {
workspaceId: 'local',
taskId: 'task-1',
attemptId: 'attempt-1',
};
}
async function temporaryRoot(): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-phase-transitions-'));
roots.push(root);
return root;
}

View file

@ -0,0 +1,484 @@
import { describe, expect, it } from 'vitest';
import type {
PhaseAuthorityDimension,
PhaseAuthoritySource,
PhaseCapabilityEvidence,
RunLaunchManifest,
RunApprovalRequest,
RunApprovalStatus,
RunEventAppendInput,
RunEventAppendResult,
Task,
} from '@veritas-kanban/shared';
import { PHASE_AUTHORITY_DIMENSIONS } from '@veritas-kanban/shared';
import { InMemoryPhaseTransitionRepository } from '../storage/phase-transition-repository.js';
import {
calculatePhaseCapabilityEvidenceDigest,
compilePhaseCapabilityAuthority,
getBuiltInPhaseCapabilityProfile,
} from '../services/phase-capability-service.js';
import {
PhaseTransitionService,
type PhaseTransitionActorContext,
} from '../services/phase-transition-service.js';
import type { CreateRunApprovalRequestInput } from '../services/run-approval-broker-service.js';
const MANIFEST_DIGEST = `sha256:${'1'.repeat(64)}`;
const WORKSPACE_ID = 'local';
const TASK_ID = 'task-1';
const ATTEMPT_ID = 'attempt-1';
describe('PhaseTransitionService', () => {
it('applies a narrowing transition immediately with durable evidence', async () => {
const fixture = createFixture();
const from = evidence('implement');
const target = evidence('plan');
const result = await fixture.service.transition(
WORKSPACE_ID,
TASK_ID,
request(from, target, 'narrow-1'),
operator()
);
expect(result.status).toBe('applied');
expect(result.record).toMatchObject({
sequence: 1,
operationId: 'narrow-1',
policyDecision: 'allow',
manifestDigest: MANIFEST_DIGEST,
});
expect(result.record?.authorityDelta.classification).toBe('narrowing');
expect(result.record?.approvalId).toBeUndefined();
expect(fixture.journal.inputs).toHaveLength(1);
});
it('requires exact-action approval before applying an expansion', async () => {
const fixture = createFixture();
const from = evidence('plan');
const target = evidence('implement');
const input = request(from, target, 'expand-1');
const pending = await fixture.service.transition(WORKSPACE_ID, TASK_ID, input, operator());
expect(pending.status).toBe('approval-required');
expect(pending.approval).toMatchObject({
status: 'pending',
actionClass: 'workflow',
evidenceRevision: from.digest,
});
expect(await fixture.repository.getCurrent(WORKSPACE_ID, TASK_ID, ATTEMPT_ID)).toBeNull();
fixture.approvals.resolve('approved');
const applied = await fixture.service.transition(
WORKSPACE_ID,
TASK_ID,
{ ...input, approvalId: pending.approval?.id },
operator()
);
expect(applied.status).toBe('applied');
expect(applied.record).toMatchObject({
policyDecision: 'approved-expansion',
approvalId: pending.approval?.id,
});
expect(applied.record?.authorityDelta.classification).toBe('expanding');
});
it.each(['rejected', 'expired'] as const)(
'does not apply an expansion after approval is %s',
async (status) => {
const fixture = createFixture();
const from = evidence('plan');
const target = evidence('implement');
const input = request(from, target, `expand-${status}`);
await fixture.service.transition(WORKSPACE_ID, TASK_ID, input, operator());
fixture.approvals.resolve(status);
await expect(
fixture.service.transition(WORKSPACE_ID, TASK_ID, input, operator())
).rejects.toThrow('Phase transition approval is not approved');
expect(await fixture.repository.getCurrent(WORKSPACE_ID, TASK_ID, ATTEMPT_ID)).toBeNull();
}
);
it('returns the original record for an exact duplicate operation', async () => {
const fixture = createFixture();
const from = evidence('implement');
const target = evidence('plan');
const input = request(from, target, 'duplicate-1');
const first = await fixture.service.transition(WORKSPACE_ID, TASK_ID, input, operator());
const duplicate = await fixture.service.transition(WORKSPACE_ID, TASK_ID, input, operator());
const history = await fixture.repository.list({
workspaceId: WORKSPACE_ID,
taskId: TASK_ID,
attemptId: ATTEMPT_ID,
});
expect(duplicate.record?.id).toBe(first.record?.id);
expect(history).toHaveLength(1);
});
it('rejects reuse of an operation identity for changed evidence', async () => {
const fixture = createFixture();
const from = evidence('implement');
await fixture.service.transition(
WORKSPACE_ID,
TASK_ID,
request(from, evidence('plan'), 'operation-1'),
operator()
);
await expect(
fixture.service.transition(
WORKSPACE_ID,
TASK_ID,
request(from, evidence('verify'), 'operation-1'),
operator()
)
).rejects.toThrow('Phase transition compare-and-set failed');
});
it('fails closed on stale sequence, phase evidence, and manifest provenance', async () => {
const fixture = createFixture();
const from = evidence('implement');
const target = evidence('plan');
await expect(
fixture.service.transition(
WORKSPACE_ID,
TASK_ID,
{ ...request(from, target, 'stale-sequence'), expectedSequence: 1 },
operator()
)
).rejects.toThrow('Phase transition sequence is stale');
await expect(
fixture.service.transition(
WORKSPACE_ID,
TASK_ID,
{
...request(from, target, 'stale-evidence'),
expectedPhaseEvidenceDigest: evidence('explore').digest,
},
operator()
)
).rejects.toThrow('Phase transition evidence is stale');
await expect(
fixture.service.transition(
WORKSPACE_ID,
TASK_ID,
{
...request(from, target, 'stale-manifest'),
expectedManifestDigest: `sha256:${'2'.repeat(64)}`,
},
operator()
)
).rejects.toThrow('launch-manifest evidence is stale');
});
it('requires administrator authority for an expiring emergency override', async () => {
const fixture = createFixture();
const from = evidence('plan');
const target = evidence('publish');
const input = {
...request(from, target, 'override-1'),
emergencyOverride: {
justification: 'Restore publication while the approval service is unavailable.',
expiresAt: '2026-07-25T01:30:00.000Z',
},
};
await expect(
fixture.service.transition(WORKSPACE_ID, TASK_ID, input, operator(false))
).rejects.toThrow('admin:manage');
const applied = await fixture.service.transition(WORKSPACE_ID, TASK_ID, input, operator(true));
expect(applied.record).toMatchObject({
policyDecision: 'emergency-override',
emergencyOverride: {
permission: 'admin:manage',
expiresAt: '2026-07-25T01:30:00.000Z',
},
});
});
it('recovers after restart and durably narrows an expired override exactly once', async () => {
const fixture = createFixture();
const from = evidence('plan');
const target = evidence('publish');
await fixture.service.transition(
WORKSPACE_ID,
TASK_ID,
{
...request(from, target, 'override-restart'),
emergencyOverride: {
justification: 'Time-bounded publication recovery.',
expiresAt: '2026-07-25T01:30:00.000Z',
},
},
operator(true)
);
fixture.clock.value = new Date('2026-07-25T01:31:00.000Z');
const restarted = fixture.restart();
const [left, right] = await Promise.all([
restarted.getCurrent(WORKSPACE_ID, TASK_ID, ATTEMPT_ID),
restarted.getCurrent(WORKSPACE_ID, TASK_ID, ATTEMPT_ID),
]);
const history = await fixture.repository.list({
workspaceId: WORKSPACE_ID,
taskId: TASK_ID,
attemptId: ATTEMPT_ID,
});
expect(left?.effectiveEvidence.digest).toBe(from.digest);
expect(right?.effectiveEvidence.digest).toBe(from.digest);
expect(left?.policyDecision).toBe('override-expired');
expect(history).toHaveLength(2);
expect(history.map((record) => record.sequence)).toEqual([1, 2]);
});
it('allows only one winner for concurrent compare-and-set transitions', async () => {
const fixture = createFixture();
const from = evidence('implement');
const results = await Promise.allSettled([
fixture.service.transition(
WORKSPACE_ID,
TASK_ID,
request(from, evidence('plan'), 'race-plan'),
operator()
),
fixture.service.transition(
WORKSPACE_ID,
TASK_ID,
request(from, evidence('verify'), 'race-verify'),
operator()
),
]);
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
expect(
await fixture.repository.list({
workspaceId: WORKSPACE_ID,
taskId: TASK_ID,
attemptId: ATTEMPT_ID,
})
).toHaveLength(1);
});
it('rejects blocked or content-tampered target evidence', async () => {
const fixture = createFixture();
const from = evidence('implement');
const blocked = structuredClone(evidence('plan'));
blocked.status = 'blocked';
blocked.digest = recalculate(blocked);
await expect(
fixture.service.transition(
WORKSPACE_ID,
TASK_ID,
request(from, blocked, 'blocked-target'),
operator()
)
).rejects.toThrow('Blocked phase evidence');
const tampered = structuredClone(evidence('plan'));
tampered.effectiveAuthority['filesystem.write'] = ['<workspace>'];
await expect(
fixture.service.transition(
WORKSPACE_ID,
TASK_ID,
request(from, tampered, 'tampered-target'),
operator()
)
).rejects.toThrow('digest does not match');
});
});
function createFixture() {
const repository = new InMemoryPhaseTransitionRepository();
const approvals = new FakeApprovals();
const journal = new FakeJournal();
const clock = { value: new Date('2026-07-25T01:00:00.000Z') };
let nextId = 1;
const options = {
repository,
tasks: {
findById: async (id: string) => (id === TASK_ID ? activeTask() : null),
},
approvals,
journal,
now: () => clock.value,
id: () => `phasetransition_${String(nextId++).padStart(18, '0')}`,
};
return {
repository,
approvals,
journal,
clock,
service: new PhaseTransitionService(options),
restart: () => new PhaseTransitionService(options),
};
}
function activeTask(): Task {
return {
id: TASK_ID,
title: 'Phase transition test',
description: 'Fixture',
type: 'task',
status: 'in-progress',
priority: 'high',
created: '2026-07-25T00:00:00.000Z',
updated: '2026-07-25T00:00:00.000Z',
attempt: {
id: ATTEMPT_ID,
agent: 'codex',
provider: 'codex-cli',
status: 'running',
runLaunchManifest: { digest: MANIFEST_DIGEST } as RunLaunchManifest,
},
};
}
function request(
fromEvidence: PhaseCapabilityEvidence,
targetEvidence: PhaseCapabilityEvidence,
operationId: string
) {
return {
attemptId: ATTEMPT_ID,
operationId,
expectedSequence: 0,
expectedPhaseEvidenceDigest: fromEvidence.digest,
expectedManifestDigest: MANIFEST_DIGEST,
reason: `Transition for ${operationId}.`,
fromEvidence,
targetEvidence,
};
}
function evidence(phase: 'explore' | 'plan' | 'implement' | 'verify' | 'publish') {
return compilePhaseCapabilityAuthority({
profile: getBuiltInPhaseCapabilityProfile(phase),
sources: {
parent: source('parent', 'parent'),
agentProfile: source('agent-profile', 'agent-profile'),
sandbox: source('sandbox', 'sandbox'),
toolCatalog: source('tool-catalog', 'tool-catalog'),
launchPolicy: source('launch-policy', 'launch-policy'),
},
});
}
function source<K extends PhaseAuthoritySource['kind']>(
id: string,
kind: K
): PhaseAuthoritySource & { kind: K } {
return {
id,
kind,
authority: recordForDimensions<string[]>(() => ['*']),
enforcement: recordForDimensions(() => 'enforced' as const),
};
}
function recordForDimensions<T>(
value: (dimension: PhaseAuthorityDimension) => T
): Record<PhaseAuthorityDimension, T> {
return Object.fromEntries(
PHASE_AUTHORITY_DIMENSIONS.map((dimension) => [dimension, value(dimension)])
) as Record<PhaseAuthorityDimension, T>;
}
function operator(administrator = false): PhaseTransitionActorContext {
return {
actor: {
id: 'operator-brad',
type: 'user',
authMethod: 'session',
authenticatedAt: '2026-07-25T01:00:00.000Z',
workspaceId: WORKSPACE_ID,
},
administrator,
};
}
class FakeApprovals {
private requestValue?: RunApprovalRequest;
async request(input: CreateRunApprovalRequestInput): Promise<RunApprovalRequest> {
if (this.requestValue) return this.requestValue;
this.requestValue = {
schemaVersion: 'run-approval/v1',
id: 'runapproval_000000000001',
workspaceId: input.workspaceId ?? WORKSPACE_ID,
taskId: input.taskId,
attemptId: input.attemptId,
provider: input.provider,
agentId: input.agentId,
requestKind: input.requestKind,
actionClass: input.actionClass,
action: input.action,
actionHash: 'a'.repeat(64),
details: input.details,
resourceScope: input.resourceScope ?? [],
riskClass: input.riskClass,
policyReason: input.policyReason,
evidenceRevision: input.evidenceRevision,
providerRequestId: input.providerRequestId,
mobileSafe: input.mobileSafe ?? false,
status: 'pending',
revision: 1,
createdAt: '2026-07-25T01:00:00.000Z',
updatedAt: '2026-07-25T01:00:00.000Z',
expiresAt: '2026-07-25T01:05:00.000Z',
};
return this.requestValue;
}
resolve(status: Exclude<RunApprovalStatus, 'pending' | 'cancelled'>): void {
if (!this.requestValue) throw new Error('No approval exists.');
this.requestValue = {
...this.requestValue,
status,
revision: 2,
updatedAt: '2026-07-25T01:01:00.000Z',
resolution: {
decision: status,
actor: {
id: 'approver',
type: 'user',
authMethod: 'session',
authenticatedAt: '2026-07-25T01:01:00.000Z',
workspaceId: WORKSPACE_ID,
},
decidedAt: '2026-07-25T01:01:00.000Z',
},
};
}
}
class FakeJournal {
readonly inputs: RunEventAppendInput[] = [];
async append(input: RunEventAppendInput): Promise<RunEventAppendResult> {
this.inputs.push(input);
return {
appended: true,
event: {
eventId: `runevt_${this.inputs.length}`,
} as RunEventAppendResult['event'],
};
}
}
function recalculate(evidenceValue: PhaseCapabilityEvidence): string {
return calculatePhaseCapabilityEvidenceDigest(evidenceValue);
}

View file

@ -0,0 +1,158 @@
import express from 'express';
import request from 'supertest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
PHASE_AUTHORITY_DIMENSIONS,
type PhaseAuthorityDimension,
type PhaseAuthoritySource,
} from '@veritas-kanban/shared';
import type { AuthenticatedRequest } from '../../middleware/auth.js';
import { errorHandler } from '../../middleware/error-handler.js';
import {
compilePhaseCapabilityAuthority,
getBuiltInPhaseCapabilityProfile,
} from '../../services/phase-capability-service.js';
const { mockGetCurrent, mockList, mockTransition } = vi.hoisted(() => ({
mockGetCurrent: vi.fn(),
mockList: vi.fn(),
mockTransition: vi.fn(),
}));
vi.mock('../../services/phase-transition-service.js', () => ({
getPhaseTransitionService: () => ({
getCurrent: mockGetCurrent,
list: mockList,
transition: mockTransition,
}),
}));
vi.mock('../../services/clawdbot-agent-service.js', () => ({
AgentReadinessError: class AgentReadinessError extends Error {},
clawdbotAgentService: {},
}));
vi.mock('../../services/task-service.js', () => ({
getTaskService: () => ({ getTask: vi.fn() }),
}));
vi.mock('../../services/telemetry-service.js', () => ({
getTelemetryService: () => ({ emit: vi.fn() }),
}));
vi.mock('../../services/workspace-execution-trust-service.js', () => ({
getWorkspaceExecutionTrustService: () => ({}),
}));
import { agentRoutes } from '../../routes/agents.js';
describe('phase transition routes', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetCurrent.mockResolvedValue(null);
mockList.mockResolvedValue([]);
});
it('reads current state and bounded history for one exact run', async () => {
const response = await request(app())
.get('/api/agents/task-1/phase')
.query({ attemptId: 'attempt-1', limit: 25 });
expect(response.status).toBe(200);
expect(response.body).toEqual({ current: null, history: [] });
expect(mockGetCurrent).toHaveBeenCalledWith('local', 'task-1', 'attempt-1');
expect(mockList).toHaveBeenCalledWith('local', 'task-1', 'attempt-1', 25);
});
it('forwards a validated compare-and-set transition and actor authority', async () => {
const fromEvidence = evidence('plan');
const targetEvidence = evidence('implement');
mockTransition.mockResolvedValue({
status: 'approval-required',
current: null,
targetEvidenceDigest: targetEvidence.digest,
approval: { id: 'runapproval_000000000001' },
});
const body = {
attemptId: 'attempt-1',
operationId: 'transition-1',
expectedSequence: 0,
expectedPhaseEvidenceDigest: fromEvidence.digest,
expectedManifestDigest: `sha256:${'1'.repeat(64)}`,
reason: 'Move from planning into implementation.',
fromEvidence,
targetEvidence,
};
const response = await request(app()).post('/api/agents/task-1/phase/transitions').send(body);
expect(response.status).toBe(202);
expect(mockTransition).toHaveBeenCalledWith(
'local',
'task-1',
body,
expect.objectContaining({
administrator: true,
actor: expect.objectContaining({
id: 'owner',
workspaceId: 'local',
type: 'user',
}),
})
);
});
});
function app() {
const application = express();
application.use(express.json());
application.use((req, _res, next) => {
(req as AuthenticatedRequest).auth = {
role: 'admin',
isLocalhost: true,
userId: 'owner',
workspaceId: 'local',
actorType: 'user',
authMethod: 'session',
authenticatedAt: '2026-07-25T01:00:00.000Z',
permissions: ['*'],
};
next();
});
application.use('/api/agents', agentRoutes);
application.use(errorHandler);
return application;
}
function evidence(phase: 'plan' | 'implement') {
return compilePhaseCapabilityAuthority({
profile: getBuiltInPhaseCapabilityProfile(phase),
sources: {
parent: source('parent', 'parent'),
agentProfile: source('agent-profile', 'agent-profile'),
sandbox: source('sandbox', 'sandbox'),
toolCatalog: source('tool-catalog', 'tool-catalog'),
launchPolicy: source('launch-policy', 'launch-policy'),
},
});
}
function source<K extends PhaseAuthoritySource['kind']>(
id: string,
kind: K
): PhaseAuthoritySource & { kind: K } {
return {
id,
kind,
authority: dimensions(() => ['*']),
enforcement: dimensions(() => 'enforced' as const),
};
}
function dimensions<T>(
value: (dimension: PhaseAuthorityDimension) => T
): Record<PhaseAuthorityDimension, T> {
return Object.fromEntries(
PHASE_AUTHORITY_DIMENSIONS.map((dimension) => [dimension, value(dimension)])
) as Record<PhaseAuthorityDimension, T>;
}

View file

@ -34,6 +34,17 @@ describe('shared API permission metadata', () => {
}
});
it('keeps phase reads agent-scoped and transition requests task-write scoped', () => {
expect(
getApiPermissionRequirement('/api/agents/task_1/phase', { method: 'GET' }).permissions
).toEqual(['agent:read']);
expect(
getApiPermissionRequirement('/api/agents/task_1/phase/transitions', {
method: 'POST',
}).permissions
).toEqual(['task:write']);
});
it('separates conversation steering from lifecycle mutation authority', () => {
expect(
getApiPermissionRequirement('/api/agents/task_1/conversation/steer', {

View file

@ -24,7 +24,7 @@ import { asyncHandler } from '../middleware/async-handler.js';
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
import { requireLocalAgentCapability } from '../middleware/local-agent-capability.js';
import { AgentBudgetPolicySchema } from '../schemas/agent-budget-schemas.js';
import type { AuthenticatedRequest } from '../middleware/auth.js';
import { hasPermission, type AuthenticatedRequest } from '../middleware/auth.js';
import { ProviderRuntimeCapabilityIdSchema } from '../schemas/provider-runtime-manifest-schemas.js';
import { TaskCommitPolicySchema } from '../schemas/task-envelope-schemas.js';
import { RunEventQuerySchema } from '../schemas/run-event-schemas.js';
@ -33,6 +33,11 @@ import {
workspaceExecutionTrustRevokeInputSchema,
} from '../schemas/workspace-execution-trust-schemas.js';
import { getWorkspaceExecutionTrustService } from '../services/workspace-execution-trust-service.js';
import { phaseTransitionRequestInputSchema } from '../schemas/phase-capability-schemas.js';
import {
getPhaseTransitionService,
type PhaseTransitionActorContext,
} from '../services/phase-transition-service.js';
const router: RouterType = Router();
const workspaceExecutionTrust = getWorkspaceExecutionTrustService();
@ -155,6 +160,13 @@ const runControlSchema = z.object({
attemptId: z.string().trim().min(1).max(120),
});
const phaseReadQuerySchema = z
.object({
attemptId: z.string().trim().min(1).max(240),
limit: z.coerce.number().int().min(1).max(1_000).default(100),
})
.strict();
const conversationTurnSchema = z
.object({
sourceAttemptId: z.string().trim().min(1).max(120),
@ -402,6 +414,42 @@ router.post(
})
);
// GET /api/agents/:taskId/phase - Read durable phase authority and transition history.
router.get(
'/:taskId/phase',
asyncHandler(async (req: AuthenticatedRequest, res) => {
const query = phaseReadQuerySchema.parse(req.query);
const workspaceId = req.auth?.workspaceId || 'local';
const service = getPhaseTransitionService();
const current = await service.getCurrent(
workspaceId,
req.params.taskId as string,
query.attemptId
);
const history = await service.list(
workspaceId,
req.params.taskId as string,
query.attemptId,
query.limit
);
res.json({ current, history });
})
);
router.post(
'/:taskId/phase/transitions',
asyncHandler(async (req: AuthenticatedRequest, res) => {
const input = phaseTransitionRequestInputSchema.parse(req.body);
const result = await getPhaseTransitionService().transition(
req.auth?.workspaceId || 'local',
req.params.taskId as string,
input,
phaseActorContext(req)
);
res.status(result.status === 'approval-required' ? 202 : 201).json(result);
})
);
// GET /api/agents/:taskId/recovery - Read the latest durable recovery decision.
router.get(
'/:taskId/recovery',
@ -798,3 +846,20 @@ function requestActor(req: AuthenticatedRequest): string {
auth?.userId || auth?.tokenName || auth?.keyName || auth?.clientId || auth?.role || 'operator'
);
}
function phaseActorContext(req: AuthenticatedRequest): PhaseTransitionActorContext {
const auth = req.auth;
const id = requestActor(req);
return {
actor: {
id,
label: auth?.tokenName || auth?.keyName || auth?.clientId || auth?.userId || id,
type: auth?.actorType,
authMethod: auth?.authMethod,
authenticatedAt: auth?.authenticatedAt,
clientMode: auth?.clientMode,
workspaceId: auth?.workspaceId || 'local',
},
administrator: hasPermission(auth, 'admin:manage'),
};
}

View file

@ -6,7 +6,11 @@ import {
PHASE_CAPABILITY_PROFILE_SCHEMA_VERSION,
PHASE_NAMES,
PHASE_TRANSITION_INTENT_SCHEMA_VERSION,
PHASE_TRANSITION_RECORD_SCHEMA_VERSION,
type PhaseTransitionRecord,
type PhaseTransitionRequestInput,
} from '@veritas-kanban/shared';
import { RunApprovalActorSchema, RunApprovalRequestSchema } from './run-approval-schemas.js';
const identifierSchema = z
.string()
@ -253,6 +257,122 @@ export const phaseCapabilityEvidenceSchema = z
})
.strict();
const phaseAuthorityDeltaEntrySchema = z
.object({
dimension: phaseAuthorityDimensionSchema,
addedScopes: authorityScopesSchema,
removedScopes: authorityScopesSchema,
})
.strict();
export const phaseAuthorityDeltaSchema = z
.object({
classification: z.enum(['same', 'narrowing', 'expanding', 'mixed']),
entries: z.array(phaseAuthorityDeltaEntrySchema).max(PHASE_AUTHORITY_DIMENSIONS.length),
})
.strict();
export const phaseTransitionRecordSchema: z.ZodType<PhaseTransitionRecord> = z
.object({
schemaVersion: z.literal(PHASE_TRANSITION_RECORD_SCHEMA_VERSION),
id: z.string().regex(/^phasetransition_[A-Za-z0-9_-]{12,32}$/),
workspaceId: identifierSchema,
taskId: identifierSchema,
attemptId: identifierSchema,
sequence: z.number().int().positive(),
operationId: identifierSchema,
priorEvidence: phaseCapabilityEvidenceSchema,
effectiveEvidence: phaseCapabilityEvidenceSchema,
authorityDelta: phaseAuthorityDeltaSchema,
actor: RunApprovalActorSchema,
reason: safeTextSchema,
policyDecision: z.enum([
'allow',
'approved-expansion',
'emergency-override',
'override-expired',
]),
approvalId: z
.string()
.regex(/^runapproval_[A-Za-z0-9_-]{12,32}$/)
.optional(),
emergencyOverride: z
.object({
permission: z.literal('admin:manage'),
justification: safeTextSchema,
expiresAt: z.string().datetime({ offset: true }),
})
.strict()
.optional(),
manifestDigest: digestSchema,
eventReference: identifierSchema,
createdAt: z.string().datetime({ offset: true }),
})
.strict()
.superRefine((record, context) => {
if (record.policyDecision === 'approved-expansion' && !record.approvalId) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['approvalId'],
message: 'Approved expansions require the exact approval reference',
});
}
if (record.policyDecision === 'emergency-override' && !record.emergencyOverride) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['emergencyOverride'],
message: 'Emergency override transitions require expiring override evidence',
});
}
if (record.policyDecision !== 'emergency-override' && record.emergencyOverride) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['emergencyOverride'],
message: 'Only emergency override transitions may carry override evidence',
});
}
});
export const phaseTransitionRequestInputSchema: z.ZodType<PhaseTransitionRequestInput> = z
.object({
attemptId: identifierSchema,
operationId: identifierSchema,
expectedSequence: z.number().int().nonnegative(),
expectedPhaseEvidenceDigest: digestSchema,
expectedManifestDigest: digestSchema,
reason: safeTextSchema,
fromEvidence: phaseCapabilityEvidenceSchema.optional(),
targetEvidence: phaseCapabilityEvidenceSchema,
approvalId: z
.string()
.regex(/^runapproval_[A-Za-z0-9_-]{12,32}$/)
.optional(),
approvalTtlMs: z
.number()
.int()
.min(1_000)
.max(24 * 60 * 60 * 1_000)
.optional(),
emergencyOverride: z
.object({
justification: safeTextSchema,
expiresAt: z.string().datetime({ offset: true }),
})
.strict()
.optional(),
})
.strict();
export const phaseTransitionResultSchema = z
.object({
status: z.enum(['applied', 'approval-required']),
current: phaseTransitionRecordSchema.nullable(),
record: phaseTransitionRecordSchema.optional(),
approval: RunApprovalRequestSchema.optional(),
targetEvidenceDigest: digestSchema,
})
.strict();
function hasControlCharacters(value: string): boolean {
return [...value].some((character) => {
const code = character.charCodeAt(0);

View file

@ -320,6 +320,15 @@ export function compilePhaseCapabilityAuthority(
}) as PhaseCapabilityEvidence;
}
export function calculatePhaseCapabilityEvidenceDigest(evidence: PhaseCapabilityEvidence): string {
const { digest: _digest, ...payload } = evidence;
return digestRunLaunchValue(payload);
}
export function verifyPhaseCapabilityEvidenceDigest(evidence: PhaseCapabilityEvidence): boolean {
return evidence.digest === calculatePhaseCapabilityEvidenceDigest(evidence);
}
function profile(
input: Pick<PhaseCapabilityProfile, 'id' | 'phase' | 'name' | 'description' | 'authority'> & {
planArtifactPolicy?: PhaseCapabilityProfile['planArtifactPolicy'];

View file

@ -0,0 +1,506 @@
import { nanoid } from 'nanoid';
import {
EXECUTABLE_AGENT_PROVIDERS,
PHASE_AUTHORITY_DIMENSIONS,
PHASE_TRANSITION_RECORD_SCHEMA_VERSION,
type ExecutableAgentProvider,
type PhaseAuthority,
type PhaseAuthorityDelta,
type PhaseAuthorityDeltaEntry,
type PhaseCapabilityEvidence,
type PhaseTransitionRecord,
type PhaseTransitionRequestInput,
type PhaseTransitionResult,
type RunApprovalActor,
type RunApprovalRequest,
type Task,
} from '@veritas-kanban/shared';
import {
ConflictError,
ForbiddenError,
NotFoundError,
ValidationError,
} from '../middleware/error-handler.js';
import {
phaseCapabilityEvidenceSchema,
phaseTransitionRecordSchema,
phaseTransitionRequestInputSchema,
} from '../schemas/phase-capability-schemas.js';
import type { PhaseTransitionRepository, TaskRepository } from '../storage/interfaces.js';
import { FilePhaseTransitionRepository } from '../storage/phase-transition-repository.js';
import { getStorage, getStorageTypeFromEnv } from '../storage/index.js';
import { verifyPhaseCapabilityEvidenceDigest } from './phase-capability-service.js';
import {
getRunApprovalBrokerService,
type RunApprovalBrokerService,
} from './run-approval-broker-service.js';
import { RunEventJournalService } from './run-event-journal-service.js';
const MAX_OVERRIDE_TTL_MS = 24 * 60 * 60 * 1_000;
export interface PhaseTransitionActorContext {
actor: RunApprovalActor;
administrator: boolean;
}
export interface PhaseTransitionServiceOptions {
repository?: PhaseTransitionRepository;
tasks?: Pick<TaskRepository, 'findById'>;
approvals?: Pick<RunApprovalBrokerService, 'request'>;
journal?: Pick<RunEventJournalService, 'append'>;
now?: () => Date;
id?: () => string;
}
let fileRepository: FilePhaseTransitionRepository | undefined;
let singleton: PhaseTransitionService | undefined;
function defaultRepository(): PhaseTransitionRepository {
if (getStorageTypeFromEnv() === 'sqlite') return getStorage().phaseTransitions;
fileRepository ??= new FilePhaseTransitionRepository();
return fileRepository;
}
export class PhaseTransitionService {
private readonly repository: PhaseTransitionRepository;
private readonly tasks: Pick<TaskRepository, 'findById'>;
private readonly approvals: Pick<RunApprovalBrokerService, 'request'>;
private readonly journal: Pick<RunEventJournalService, 'append'>;
private readonly now: () => Date;
private readonly id: () => string;
constructor(options: PhaseTransitionServiceOptions = {}) {
this.repository = options.repository ?? defaultRepository();
this.tasks = options.tasks ?? getStorage().tasks;
this.approvals = options.approvals ?? getRunApprovalBrokerService();
this.journal = options.journal ?? new RunEventJournalService();
this.now = options.now ?? (() => new Date());
this.id = options.id ?? (() => `phasetransition_${nanoid(18)}`);
}
async getCurrent(
workspaceId: string,
taskId: string,
attemptId: string
): Promise<PhaseTransitionRecord | null> {
const current = await this.repository.getCurrent(workspaceId, taskId, attemptId);
if (!current?.emergencyOverride) return current;
if (Date.parse(current.emergencyOverride.expiresAt) > this.now().getTime()) return current;
return this.expireOverride(current);
}
async list(
workspaceId: string,
taskId: string,
attemptId: string,
limit = 100
): Promise<PhaseTransitionRecord[]> {
await this.getCurrent(workspaceId, taskId, attemptId);
return this.repository.list({ workspaceId, taskId, attemptId, limit });
}
async transition(
workspaceId: string,
taskId: string,
request: PhaseTransitionRequestInput,
actorContext: PhaseTransitionActorContext
): Promise<PhaseTransitionResult> {
const input = phaseTransitionRequestInputSchema.parse(request);
if (actorContext.actor.workspaceId !== workspaceId) {
throw new ForbiddenError('Phase transition actor does not belong to this workspace.');
}
const targetEvidence = verifiedEvidence(input.targetEvidence, 'Target phase evidence');
if (targetEvidence.status === 'blocked') {
throw new ForbiddenError('Blocked phase evidence cannot become active.', {
evidenceDigest: targetEvidence.digest,
blockers: targetEvidence.blockers.map((blocker) => blocker.code),
});
}
if (targetEvidence.identity.mode !== 'profile') {
throw new ValidationError('Operator transitions must target a defined phase profile.');
}
const existing = await this.repository.getByOperationId(
workspaceId,
taskId,
input.attemptId,
input.operationId
);
if (existing) {
if (
existing.priorEvidence.digest !== input.expectedPhaseEvidenceDigest ||
existing.effectiveEvidence.digest !== targetEvidence.digest ||
existing.manifestDigest !== input.expectedManifestDigest ||
existing.sequence !== input.expectedSequence + 1
) {
throw transitionConflict('operation-reused', existing);
}
await this.projectEvent(existing);
return {
status: 'applied',
current: existing,
record: existing,
targetEvidenceDigest: targetEvidence.digest,
};
}
const task = await this.activeTask(taskId, input);
const current = await this.getCurrent(workspaceId, taskId, input.attemptId);
const priorEvidence = current
? current.effectiveEvidence
: input.fromEvidence
? verifiedEvidence(input.fromEvidence, 'Initial phase evidence')
: undefined;
if (!priorEvidence) {
throw new ValidationError(
'The first transition for a run requires the exact initial phase evidence.'
);
}
assertExpectedState(current, priorEvidence, input);
const authorityDelta = calculatePhaseAuthorityDelta(
priorEvidence.effectiveAuthority,
targetEvidence.effectiveAuthority
);
const expansion = hasExpansion(authorityDelta);
let policyDecision: PhaseTransitionRecord['policyDecision'] = 'allow';
let approval: RunApprovalRequest | undefined;
let emergencyOverride: PhaseTransitionRecord['emergencyOverride'];
if (input.emergencyOverride) {
if (!expansion) {
throw new ValidationError('Emergency override is reserved for authority expansion.');
}
if (!actorContext.administrator) {
throw new ForbiddenError('Emergency phase override requires admin:manage permission.');
}
const expiresAt = Date.parse(input.emergencyOverride.expiresAt);
const now = this.now().getTime();
if (
!Number.isFinite(expiresAt) ||
expiresAt <= now ||
expiresAt - now > MAX_OVERRIDE_TTL_MS
) {
throw new ValidationError('Emergency phase override must expire within 24 hours.');
}
policyDecision = 'emergency-override';
emergencyOverride = {
permission: 'admin:manage',
justification: input.emergencyOverride.justification,
expiresAt: input.emergencyOverride.expiresAt,
};
} else if (expansion) {
approval = await this.expansionApproval(
task,
workspaceId,
input,
priorEvidence,
authorityDelta
);
if (input.approvalId && input.approvalId !== approval.id) {
throw new ConflictError(
'Phase transition approval does not match the requested expansion.',
{
expectedApprovalId: approval.id,
receivedApprovalId: input.approvalId,
}
);
}
if (approval.status === 'pending') {
return {
status: 'approval-required',
current,
approval,
targetEvidenceDigest: targetEvidence.digest,
};
}
if (approval.status !== 'approved') {
throw new ConflictError('Phase transition approval is not approved.', {
approvalId: approval.id,
status: approval.status,
});
}
policyDecision = 'approved-expansion';
}
const transitionId = this.id();
const record = phaseTransitionRecordSchema.parse({
schemaVersion: PHASE_TRANSITION_RECORD_SCHEMA_VERSION,
id: transitionId,
workspaceId,
taskId,
attemptId: input.attemptId,
sequence: input.expectedSequence + 1,
operationId: input.operationId,
priorEvidence,
effectiveEvidence: targetEvidence,
authorityDelta,
actor: actorContext.actor,
reason: input.reason,
policyDecision,
...(approval ? { approvalId: approval.id } : {}),
...(emergencyOverride ? { emergencyOverride } : {}),
manifestDigest: input.expectedManifestDigest,
eventReference: phaseEventReference(transitionId),
createdAt: this.now().toISOString(),
});
const result = await this.repository.append({
record,
expectedSequence: input.expectedSequence,
expectedPhaseEvidenceDigest: input.expectedPhaseEvidenceDigest,
expectedManifestDigest: input.expectedManifestDigest,
});
if (!result.record || result.reason) {
throw transitionConflict(result.reason, result.record);
}
await this.projectEvent(result.record);
return {
status: 'applied',
current: result.record,
record: result.record,
targetEvidenceDigest: targetEvidence.digest,
};
}
private async activeTask(taskId: string, input: PhaseTransitionRequestInput): Promise<Task> {
const task = await this.tasks.findById(taskId);
if (!task) throw new NotFoundError('Task not found.');
if (task.attempt?.id !== input.attemptId || task.attempt.status !== 'running') {
throw new ConflictError('Phase transition does not match the active running attempt.', {
expectedAttemptId: input.attemptId,
activeAttemptId: task.attempt?.id,
activeStatus: task.attempt?.status,
});
}
if (task.attempt.runLaunchManifest?.digest !== input.expectedManifestDigest) {
throw new ConflictError('Phase transition launch-manifest evidence is stale.', {
expectedManifestDigest: input.expectedManifestDigest,
activeManifestDigest: task.attempt.runLaunchManifest?.digest,
});
}
if (
!task.attempt.provider ||
!EXECUTABLE_AGENT_PROVIDERS.includes(task.attempt.provider as ExecutableAgentProvider)
) {
throw new ConflictError('Active attempt does not identify an executable provider.');
}
return task;
}
private async expansionApproval(
task: Task,
workspaceId: string,
input: PhaseTransitionRequestInput,
priorEvidence: PhaseCapabilityEvidence,
authorityDelta: PhaseAuthorityDelta
): Promise<RunApprovalRequest> {
const added = authorityDelta.entries.flatMap((entry) =>
entry.addedScopes.map((scope) => `${entry.dimension}:${scope}`)
);
const critical = authorityDelta.entries.some(
(entry) =>
entry.addedScopes.length > 0 &&
(entry.dimension === 'credential.access' || entry.dimension === 'external.action')
);
return this.approvals.request({
workspaceId,
taskId: task.id,
attemptId: input.attemptId,
provider: task.attempt?.provider as ExecutableAgentProvider,
agentId: task.attempt?.agent ?? 'agent',
requestKind: 'approval',
actionClass: 'workflow',
action: 'Expand active run phase authority',
exactAction: {
operationId: input.operationId,
fromEvidenceDigest: priorEvidence.digest,
toEvidenceDigest: input.targetEvidence.digest,
manifestDigest: input.expectedManifestDigest,
authorityDelta,
},
details: `Transition from ${identityLabel(priorEvidence)} to ${identityLabel(input.targetEvidence)}.`,
resourceScope: added,
riskClass: critical ? 'critical' : 'high',
policyReason: 'Authority-expanding phase transitions require exact-action approval.',
evidenceRevision: input.expectedPhaseEvidenceDigest,
providerRequestId: `phase:${input.operationId}`,
mobileSafe: false,
ttlMs: input.approvalTtlMs,
});
}
private async expireOverride(current: PhaseTransitionRecord): Promise<PhaseTransitionRecord> {
const operationId = `override-expiry:${current.id}`;
const transitionId = this.id();
const record = phaseTransitionRecordSchema.parse({
schemaVersion: PHASE_TRANSITION_RECORD_SCHEMA_VERSION,
id: transitionId,
workspaceId: current.workspaceId,
taskId: current.taskId,
attemptId: current.attemptId,
sequence: current.sequence + 1,
operationId,
priorEvidence: current.effectiveEvidence,
effectiveEvidence: current.priorEvidence,
authorityDelta: calculatePhaseAuthorityDelta(
current.effectiveEvidence.effectiveAuthority,
current.priorEvidence.effectiveAuthority
),
actor: {
id: 'phase-override-expiry',
label: 'Phase override expiry',
type: 'service',
authMethod: 'system',
workspaceId: current.workspaceId,
},
reason: `Emergency phase override ${current.id} expired.`,
policyDecision: 'override-expired',
manifestDigest: current.manifestDigest,
eventReference: phaseEventReference(transitionId),
createdAt: this.now().toISOString(),
});
const result = await this.repository.append({
record,
expectedSequence: current.sequence,
expectedPhaseEvidenceDigest: current.effectiveEvidence.digest,
expectedManifestDigest: current.manifestDigest,
});
if (!result.record || result.reason) {
const latest = await this.repository.getCurrent(
current.workspaceId,
current.taskId,
current.attemptId
);
if (latest && latest.sequence > current.sequence) return latest;
throw transitionConflict(result.reason, result.record);
}
await this.projectEvent(result.record);
return result.record;
}
private async projectEvent(record: PhaseTransitionRecord): Promise<void> {
await this.journal.append({
taskId: record.taskId,
attemptId: record.attemptId,
kind:
record.policyDecision === 'override-expired'
? 'phase.override-expired'
: 'phase.transitioned',
source: {
provider: record.policyDecision === 'override-expired' ? 'system' : 'operator',
adapter: 'phase-transition-service',
},
payload: {
transitionId: record.id,
sequence: record.sequence,
operationId: record.operationId,
fromEvidenceDigest: record.priorEvidence.digest,
toEvidenceDigest: record.effectiveEvidence.digest,
policyDecision: record.policyDecision,
approvalId: record.approvalId,
overrideExpiresAt: record.emergencyOverride?.expiresAt,
manifestDigest: record.manifestDigest,
},
dedupeKey: record.eventReference,
});
}
}
export function calculatePhaseAuthorityDelta(
from: PhaseAuthority,
to: PhaseAuthority
): PhaseAuthorityDelta {
const entries: PhaseAuthorityDeltaEntry[] = [];
for (const dimension of PHASE_AUTHORITY_DIMENSIONS) {
const delta = scopeDelta(from[dimension], to[dimension]);
if (delta.addedScopes.length || delta.removedScopes.length) {
entries.push({ dimension, ...delta });
}
}
const expanded = entries.some((entry) => entry.addedScopes.length > 0);
const narrowed = entries.some((entry) => entry.removedScopes.length > 0);
return {
classification:
expanded && narrowed ? 'mixed' : expanded ? 'expanding' : narrowed ? 'narrowing' : 'same',
entries,
};
}
function scopeDelta(
from: string[],
to: string[]
): Pick<PhaseAuthorityDeltaEntry, 'addedScopes' | 'removedScopes'> {
if (from.includes('*') && to.includes('*')) return { addedScopes: [], removedScopes: [] };
if (from.includes('*')) return { addedScopes: [], removedScopes: ['*'] };
if (to.includes('*')) return { addedScopes: ['*'], removedScopes: [] };
const fromSet = new Set(from);
const toSet = new Set(to);
return {
addedScopes: to.filter((scope) => !fromSet.has(scope)),
removedScopes: from.filter((scope) => !toSet.has(scope)),
};
}
function hasExpansion(delta: PhaseAuthorityDelta): boolean {
return delta.entries.some((entry) => entry.addedScopes.length > 0);
}
function verifiedEvidence(input: PhaseCapabilityEvidence, label: string): PhaseCapabilityEvidence {
const evidence = phaseCapabilityEvidenceSchema.parse(input);
if (!verifyPhaseCapabilityEvidenceDigest(evidence)) {
throw new ConflictError(`${label} digest does not match its content.`);
}
return evidence;
}
function assertExpectedState(
current: PhaseTransitionRecord | null,
priorEvidence: PhaseCapabilityEvidence,
input: PhaseTransitionRequestInput
): void {
if ((current?.sequence ?? 0) !== input.expectedSequence) {
throw new ConflictError('Phase transition sequence is stale.', {
expectedSequence: input.expectedSequence,
activeSequence: current?.sequence ?? 0,
});
}
if (priorEvidence.digest !== input.expectedPhaseEvidenceDigest) {
throw new ConflictError('Phase transition evidence is stale.', {
expectedPhaseEvidenceDigest: input.expectedPhaseEvidenceDigest,
activePhaseEvidenceDigest: priorEvidence.digest,
});
}
if (current && current.manifestDigest !== input.expectedManifestDigest) {
throw new ConflictError('Phase transition manifest reference is stale.', {
expectedManifestDigest: input.expectedManifestDigest,
activeManifestDigest: current.manifestDigest,
});
}
}
function phaseEventReference(transitionId: string): string {
return `phase:${transitionId}`;
}
function identityLabel(evidence: PhaseCapabilityEvidence): string {
return evidence.identity.mode === 'legacy'
? 'legacy'
: `${evidence.identity.profileId}@${evidence.identity.profileVersion}`;
}
function transitionConflict(
reason?: 'stale-sequence' | 'stale-phase-evidence' | 'stale-manifest' | 'operation-reused',
record?: PhaseTransitionRecord
): ConflictError {
return new ConflictError('Phase transition compare-and-set failed.', {
reason: reason ?? 'unknown',
activeSequence: record?.sequence,
activeEvidenceDigest: record?.effectiveEvidence.digest,
activeManifestDigest: record?.manifestDigest,
});
}
export function getPhaseTransitionService(): PhaseTransitionService {
singleton ??= new PhaseTransitionService();
return singleton;
}

View file

@ -41,6 +41,7 @@ import type {
TelemetryRepository,
RunEventRepository,
RunApprovalRepository,
PhaseTransitionRepository,
RunSupervisorRepository,
ToolControlPlaneRepository,
} from './interfaces.js';
@ -68,6 +69,7 @@ import { ManagedListService } from '../services/managed-list-service.js';
import { TelemetryService, type TelemetryServiceOptions } from '../services/telemetry-service.js';
import { FileRunEventRepository } from './run-event-repository.js';
import { FileRunApprovalRepository } from './run-approval-repository.js';
import { FilePhaseTransitionRepository } from './phase-transition-repository.js';
import { FileRunSupervisorRepository } from './run-supervisor-repository.js';
import { FileToolControlPlaneRepository } from './tool-control-plane-repository.js';
@ -488,6 +490,7 @@ export interface FileStorageOptions {
telemetryServiceOptions?: TelemetryServiceOptions;
runEventsDir?: string;
runApprovalsPath?: string;
phaseTransitionsPath?: string;
runSupervisorsPath?: string;
toolControlPlanePath?: string;
}
@ -503,6 +506,7 @@ export class FileStorageProvider implements StorageProvider {
readonly telemetry: FileTelemetryRepository;
readonly runEvents: RunEventRepository;
readonly runApprovals: RunApprovalRepository;
readonly phaseTransitions: PhaseTransitionRepository;
readonly runSupervisors: RunSupervisorRepository;
readonly toolControlPlane: ToolControlPlaneRepository;
@ -556,6 +560,7 @@ export class FileStorageProvider implements StorageProvider {
this.telemetry = new FileTelemetryRepository(this.telemetryService);
this.runEvents = new FileRunEventRepository(options.runEventsDir);
this.runApprovals = new FileRunApprovalRepository(options.runApprovalsPath);
this.phaseTransitions = new FilePhaseTransitionRepository(options.phaseTransitionsPath);
this.runSupervisors = new FileRunSupervisorRepository(options.runSupervisorsPath);
this.toolControlPlane = new FileToolControlPlaneRepository(options.toolControlPlanePath);
}

View file

@ -26,6 +26,7 @@ export type {
RunEventRepository,
RunEventRepositoryAppendInput,
RunApprovalRepository,
PhaseTransitionRepository,
ToolControlPlaneRepository,
SetupContextRepository,
WorkspaceFileRepository,
@ -56,6 +57,11 @@ export {
} from './file-storage.js';
export { FileRunEventRepository, getRunEventsDir } from './run-event-repository.js';
export { FileRunApprovalRepository, getRunApprovalsPath } from './run-approval-repository.js';
export {
FilePhaseTransitionRepository,
InMemoryPhaseTransitionRepository,
getPhaseTransitionsPath,
} from './phase-transition-repository.js';
export type { FileStorageOptions } from './file-storage.js';
export {
DEFAULT_SQLITE_FILENAME,
@ -83,6 +89,7 @@ export { SqliteStatusHistoryRepository } from './sqlite/status-history-repositor
export { SqliteTelemetryRepository } from './sqlite/telemetry-repository.js';
export { SqliteRunEventRepository } from './sqlite/run-event-repository.js';
export { SqliteRunApprovalRepository } from './sqlite/run-approval-repository.js';
export { SqlitePhaseTransitionRepository } from './sqlite/phase-transition-repository.js';
export {
FileToolControlPlaneRepository,
InMemoryToolControlPlaneRepository,

View file

@ -36,6 +36,10 @@ import type {
RunApprovalRequest,
RunApprovalTransitionInput,
RunApprovalTransitionResult,
PhaseTransitionAppendInput,
PhaseTransitionAppendResult,
PhaseTransitionQuery,
PhaseTransitionRecord,
RunSupervisorCompareAndSetInput,
RunSupervisorCompareAndSetResult,
RunSupervisorListQuery,
@ -372,6 +376,33 @@ export interface RunApprovalRepository {
transition(input: RunApprovalTransitionInput): Promise<RunApprovalTransitionResult>;
}
// ---------------------------------------------------------------------------
// Phase Transition Repository
// ---------------------------------------------------------------------------
export interface PhaseTransitionRepository {
/** Return the one materialized active phase for an exact run. */
getCurrent(
workspaceId: string,
taskId: string,
attemptId: string
): Promise<PhaseTransitionRecord | null>;
/** Resolve one run-scoped idempotency key without scanning bounded history. */
getByOperationId(
workspaceId: string,
taskId: string,
attemptId: string,
operationId: string
): Promise<PhaseTransitionRecord | null>;
/** Return append-only transition history in ascending sequence order. */
list(query: PhaseTransitionQuery): Promise<PhaseTransitionRecord[]>;
/** Append one transition behind exact sequence, phase, and manifest CAS guards. */
append(input: PhaseTransitionAppendInput): Promise<PhaseTransitionAppendResult>;
}
// ---------------------------------------------------------------------------
// Durable Run Supervisor Repository
// ---------------------------------------------------------------------------
@ -420,6 +451,7 @@ export interface StorageProvider {
readonly telemetry: TelemetryRepository;
readonly runEvents: RunEventRepository;
readonly runApprovals: RunApprovalRepository;
readonly phaseTransitions: PhaseTransitionRepository;
readonly runSupervisors: RunSupervisorRepository;
readonly toolControlPlane: ToolControlPlaneRepository;
readonly setupContext?: SetupContextRepository;

View file

@ -0,0 +1,255 @@
import { constants } from 'node:fs';
import { lstat, mkdir, open } from 'node:fs/promises';
import path from 'node:path';
import type {
PhaseTransitionAppendInput,
PhaseTransitionAppendResult,
PhaseTransitionQuery,
PhaseTransitionRecord,
} from '@veritas-kanban/shared';
import { phaseTransitionRecordSchema } from '../schemas/phase-capability-schemas.js';
import { withFileLock } from '../services/file-lock.js';
import { getRuntimeDir } from '../utils/paths.js';
import { ensureWithinBase } from '../utils/sanitize.js';
import type { PhaseTransitionRepository } from './interfaces.js';
const MAX_TRANSITION_LOG_BYTES = 64 * 1024 * 1024;
const MAX_TRANSITION_RECORDS = 100_000;
export function getPhaseTransitionsPath(): string {
return path.join(getRuntimeDir(), 'phase-transitions.jsonl');
}
export class FilePhaseTransitionRepository implements PhaseTransitionRepository {
constructor(private readonly filePath = getPhaseTransitionsPath()) {
ensureWithinBase(path.dirname(filePath), filePath);
}
async getCurrent(
workspaceId: string,
taskId: string,
attemptId: string
): Promise<PhaseTransitionRecord | null> {
return currentFor(await this.readRecords(), workspaceId, taskId, attemptId);
}
async getByOperationId(
workspaceId: string,
taskId: string,
attemptId: string,
operationId: string
): Promise<PhaseTransitionRecord | null> {
const record = (await this.readRecords()).find(
(candidate) =>
sameRun(candidate, workspaceId, taskId, attemptId) && candidate.operationId === operationId
);
return record ? structuredClone(record) : null;
}
async list(query: PhaseTransitionQuery): Promise<PhaseTransitionRecord[]> {
const limit = Math.max(1, Math.min(1_000, Math.trunc(query.limit ?? 100)));
return (await this.readRecords())
.filter((record) => sameRun(record, query.workspaceId, query.taskId, query.attemptId))
.sort((left, right) => left.sequence - right.sequence)
.slice(-limit);
}
async append(input: PhaseTransitionAppendInput): Promise<PhaseTransitionAppendResult> {
const record = phaseTransitionRecordSchema.parse(input.record);
await this.prepareParent();
return withFileLock(this.filePath, async () => {
const records = await this.readRecords();
const existing = records.find(
(candidate) =>
sameRun(candidate, record.workspaceId, record.taskId, record.attemptId) &&
candidate.operationId === record.operationId
);
if (existing) return idempotentResult(existing, input);
const current = currentFor(records, record.workspaceId, record.taskId, record.attemptId);
const conflict = compareAndSetConflict(current, input);
if (conflict) return { record: current ?? undefined, appended: false, reason: conflict };
if (record.sequence !== input.expectedSequence + 1) {
return { record: current ?? undefined, appended: false, reason: 'stale-sequence' };
}
await this.appendRecord(record, records);
return { record, appended: true };
});
}
private async prepareParent(): Promise<void> {
const parent = path.dirname(this.filePath);
await mkdir(parent, { recursive: true, mode: 0o700 });
const stat = await lstat(parent);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error('Phase transition directory is not a private regular directory.');
}
}
private async readRecords(): Promise<PhaseTransitionRecord[]> {
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
handle = await open(this.filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
const stat = await handle.stat();
if (!stat.isFile() || stat.size > MAX_TRANSITION_LOG_BYTES) {
throw new Error('Phase transition log is not a bounded regular file.');
}
const content = await handle.readFile({ encoding: 'utf8' });
if (!content.trim()) return [];
const lines = content.split(/\r?\n/).filter(Boolean);
if (lines.length > MAX_TRANSITION_RECORDS) {
throw new Error('Phase transition log reached its bounded record limit.');
}
return lines.map((line) => phaseTransitionRecordSchema.parse(JSON.parse(line)));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
if ((error as NodeJS.ErrnoException).code === 'ELOOP') {
throw new Error('Phase transition log is not a bounded regular file.', { cause: error });
}
throw error;
} finally {
await handle?.close();
}
}
private async appendRecord(
record: PhaseTransitionRecord,
existing: PhaseTransitionRecord[]
): Promise<void> {
if (existing.length >= MAX_TRANSITION_RECORDS) {
throw new Error('Phase transition log reached its bounded record limit.');
}
const line = `${JSON.stringify(record)}\n`;
const existingBytes = existing.reduce(
(total, candidate) => total + Buffer.byteLength(JSON.stringify(candidate), 'utf8') + 1,
0
);
if (existingBytes + Buffer.byteLength(line, 'utf8') > MAX_TRANSITION_LOG_BYTES) {
throw new Error('Phase transition log reached its bounded byte limit.');
}
const handle = await open(
this.filePath,
constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY | constants.O_NOFOLLOW,
0o600
);
try {
await handle.write(line, undefined, 'utf8');
await handle.sync();
} finally {
await handle.close();
}
}
}
export class InMemoryPhaseTransitionRepository implements PhaseTransitionRepository {
private readonly records: PhaseTransitionRecord[] = [];
async getCurrent(
workspaceId: string,
taskId: string,
attemptId: string
): Promise<PhaseTransitionRecord | null> {
return currentFor(this.records, workspaceId, taskId, attemptId);
}
async getByOperationId(
workspaceId: string,
taskId: string,
attemptId: string,
operationId: string
): Promise<PhaseTransitionRecord | null> {
const record = this.records.find(
(candidate) =>
sameRun(candidate, workspaceId, taskId, attemptId) && candidate.operationId === operationId
);
return record ? structuredClone(record) : null;
}
async list(query: PhaseTransitionQuery): Promise<PhaseTransitionRecord[]> {
const limit = Math.max(1, Math.min(1_000, Math.trunc(query.limit ?? 100)));
return this.records
.filter((record) => sameRun(record, query.workspaceId, query.taskId, query.attemptId))
.sort((left, right) => left.sequence - right.sequence)
.slice(-limit)
.map((record) => structuredClone(record));
}
async append(input: PhaseTransitionAppendInput): Promise<PhaseTransitionAppendResult> {
const record = phaseTransitionRecordSchema.parse(input.record);
const existing = this.records.find(
(candidate) =>
sameRun(candidate, record.workspaceId, record.taskId, record.attemptId) &&
candidate.operationId === record.operationId
);
if (existing) return idempotentResult(existing, input);
const current = currentFor(this.records, record.workspaceId, record.taskId, record.attemptId);
const conflict = compareAndSetConflict(current, input);
if (conflict) return { record: current ?? undefined, appended: false, reason: conflict };
if (record.sequence !== input.expectedSequence + 1) {
return { record: current ?? undefined, appended: false, reason: 'stale-sequence' };
}
this.records.push(structuredClone(record));
return { record: structuredClone(record), appended: true };
}
}
function currentFor(
records: PhaseTransitionRecord[],
workspaceId: string,
taskId: string,
attemptId: string
): PhaseTransitionRecord | null {
const current = records
.filter((record) => sameRun(record, workspaceId, taskId, attemptId))
.sort((left, right) => right.sequence - left.sequence)[0];
return current ? structuredClone(current) : null;
}
function sameRun(
record: PhaseTransitionRecord,
workspaceId: string,
taskId: string,
attemptId: string
): boolean {
return (
record.workspaceId === workspaceId && record.taskId === taskId && record.attemptId === attemptId
);
}
function compareAndSetConflict(
current: PhaseTransitionRecord | null,
input: PhaseTransitionAppendInput
): PhaseTransitionAppendResult['reason'] | undefined {
if ((current?.sequence ?? 0) !== input.expectedSequence) return 'stale-sequence';
const currentDigest = current?.effectiveEvidence.digest ?? input.record.priorEvidence.digest;
if (
currentDigest !== input.expectedPhaseEvidenceDigest ||
input.record.priorEvidence.digest !== input.expectedPhaseEvidenceDigest
) {
return 'stale-phase-evidence';
}
if (
(current && current.manifestDigest !== input.expectedManifestDigest) ||
input.record.manifestDigest !== input.expectedManifestDigest
) {
return 'stale-manifest';
}
return undefined;
}
function idempotentResult(
existing: PhaseTransitionRecord,
input: PhaseTransitionAppendInput
): PhaseTransitionAppendResult {
const same =
existing.sequence === input.expectedSequence + 1 &&
existing.priorEvidence.digest === input.expectedPhaseEvidenceDigest &&
existing.effectiveEvidence.digest === input.record.effectiveEvidence.digest &&
existing.manifestDigest === input.expectedManifestDigest;
return {
record: structuredClone(existing),
appended: false,
...(same ? {} : { reason: 'operation-reused' as const }),
};
}

View file

@ -1256,6 +1256,34 @@ export const SQLITE_BASE_MIGRATIONS: readonly SqliteMigration[] = [
ON run_tool_catalogs(provider, created_at DESC);
`,
},
{
version: 22,
name: '0022_phase_transition_journal',
up: `
CREATE TABLE phase_transitions (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL DEFAULT 'local'
REFERENCES workspaces(id) ON DELETE CASCADE,
task_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
sequence INTEGER NOT NULL CHECK (sequence > 0),
operation_id TEXT NOT NULL,
from_evidence_digest TEXT NOT NULL,
to_evidence_digest TEXT NOT NULL,
manifest_digest TEXT NOT NULL,
transition_json TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (workspace_id, task_id, attempt_id, sequence),
UNIQUE (workspace_id, task_id, attempt_id, operation_id)
);
CREATE INDEX idx_phase_transitions_run_sequence
ON phase_transitions(workspace_id, task_id, attempt_id, sequence DESC);
CREATE INDEX idx_phase_transitions_created
ON phase_transitions(workspace_id, created_at DESC);
`,
},
];
export function sortedMigrations(migrations: readonly SqliteMigration[]): SqliteMigration[] {

View file

@ -0,0 +1,183 @@
import type {
PhaseTransitionAppendInput,
PhaseTransitionAppendResult,
PhaseTransitionQuery,
PhaseTransitionRecord,
} from '@veritas-kanban/shared';
import { phaseTransitionRecordSchema } from '../../schemas/phase-capability-schemas.js';
import type { PhaseTransitionRepository } from '../interfaces.js';
import type { SqliteDatabase } from './database.js';
interface PhaseTransitionRow {
transition_json: string;
}
export class SqlitePhaseTransitionRepository implements PhaseTransitionRepository {
constructor(private readonly database: SqliteDatabase) {}
async getCurrent(
workspaceId: string,
taskId: string,
attemptId: string
): Promise<PhaseTransitionRecord | null> {
const row = this.database
.getConnection()
.prepare(
`SELECT transition_json
FROM phase_transitions
WHERE workspace_id = ? AND task_id = ? AND attempt_id = ?
ORDER BY sequence DESC
LIMIT 1`
)
.get(workspaceId, taskId, attemptId) as PhaseTransitionRow | undefined;
return row ? phaseTransitionRecordSchema.parse(JSON.parse(row.transition_json)) : null;
}
async getByOperationId(
workspaceId: string,
taskId: string,
attemptId: string,
operationId: string
): Promise<PhaseTransitionRecord | null> {
const row = this.database
.getConnection()
.prepare(
`SELECT transition_json
FROM phase_transitions
WHERE workspace_id = ? AND task_id = ? AND attempt_id = ? AND operation_id = ?`
)
.get(workspaceId, taskId, attemptId, operationId) as PhaseTransitionRow | undefined;
return row ? phaseTransitionRecordSchema.parse(JSON.parse(row.transition_json)) : null;
}
async list(query: PhaseTransitionQuery): Promise<PhaseTransitionRecord[]> {
const limit = Math.max(1, Math.min(1_000, Math.trunc(query.limit ?? 100)));
const rows = this.database
.getConnection()
.prepare(
`SELECT transition_json
FROM (
SELECT sequence, transition_json
FROM phase_transitions
WHERE workspace_id = ? AND task_id = ? AND attempt_id = ?
ORDER BY sequence DESC
LIMIT ?
)
ORDER BY sequence ASC`
)
.all(
query.workspaceId,
query.taskId,
query.attemptId,
limit
) as unknown as PhaseTransitionRow[];
return rows.map((row) => phaseTransitionRecordSchema.parse(JSON.parse(row.transition_json)));
}
async append(input: PhaseTransitionAppendInput): Promise<PhaseTransitionAppendResult> {
const record = phaseTransitionRecordSchema.parse(input.record);
const connection = this.database.getConnection();
connection.exec('BEGIN IMMEDIATE');
try {
const existingRow = connection
.prepare(
`SELECT transition_json
FROM phase_transitions
WHERE workspace_id = ? AND task_id = ? AND attempt_id = ? AND operation_id = ?`
)
.get(record.workspaceId, record.taskId, record.attemptId, record.operationId) as
PhaseTransitionRow | undefined;
if (existingRow) {
const existing = phaseTransitionRecordSchema.parse(JSON.parse(existingRow.transition_json));
connection.exec('COMMIT');
return idempotentResult(existing, input);
}
const currentRow = connection
.prepare(
`SELECT transition_json
FROM phase_transitions
WHERE workspace_id = ? AND task_id = ? AND attempt_id = ?
ORDER BY sequence DESC
LIMIT 1`
)
.get(record.workspaceId, record.taskId, record.attemptId) as PhaseTransitionRow | undefined;
const current = currentRow
? phaseTransitionRecordSchema.parse(JSON.parse(currentRow.transition_json))
: null;
const conflict = compareAndSetConflict(current, input);
if (conflict || record.sequence !== input.expectedSequence + 1) {
connection.exec('COMMIT');
return {
record: current ?? undefined,
appended: false,
reason: conflict ?? 'stale-sequence',
};
}
connection
.prepare(
`INSERT INTO phase_transitions (
id, workspace_id, task_id, attempt_id, sequence, operation_id,
from_evidence_digest, to_evidence_digest, manifest_digest,
transition_json, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
record.id,
record.workspaceId,
record.taskId,
record.attemptId,
record.sequence,
record.operationId,
record.priorEvidence.digest,
record.effectiveEvidence.digest,
record.manifestDigest,
JSON.stringify(record),
record.createdAt
);
connection.exec('COMMIT');
return { record, appended: true };
} catch (error) {
connection.exec('ROLLBACK');
throw error;
}
}
}
function compareAndSetConflict(
current: PhaseTransitionRecord | null,
input: PhaseTransitionAppendInput
): PhaseTransitionAppendResult['reason'] | undefined {
if ((current?.sequence ?? 0) !== input.expectedSequence) return 'stale-sequence';
const currentDigest = current?.effectiveEvidence.digest ?? input.record.priorEvidence.digest;
if (
currentDigest !== input.expectedPhaseEvidenceDigest ||
input.record.priorEvidence.digest !== input.expectedPhaseEvidenceDigest
) {
return 'stale-phase-evidence';
}
if (
(current && current.manifestDigest !== input.expectedManifestDigest) ||
input.record.manifestDigest !== input.expectedManifestDigest
) {
return 'stale-manifest';
}
return undefined;
}
function idempotentResult(
existing: PhaseTransitionRecord,
input: PhaseTransitionAppendInput
): PhaseTransitionAppendResult {
const same =
existing.sequence === input.expectedSequence + 1 &&
existing.priorEvidence.digest === input.expectedPhaseEvidenceDigest &&
existing.effectiveEvidence.digest === input.record.effectiveEvidence.digest &&
existing.manifestDigest === input.expectedManifestDigest;
return {
record: existing,
appended: false,
...(same ? {} : { reason: 'operation-reused' as const }),
};
}

View file

@ -13,6 +13,7 @@ import { SqliteOperationalProvenanceRepository } from './provenance-repository.j
import { SqliteSetupContextRepository } from './setup-context-repository.js';
import { SqliteRunEventRepository } from './run-event-repository.js';
import { SqliteRunApprovalRepository } from './run-approval-repository.js';
import { SqlitePhaseTransitionRepository } from './phase-transition-repository.js';
import { SqliteRunSupervisorRepository } from './run-supervisor-repository.js';
import { SqliteToolControlPlaneRepository } from './tool-control-plane-repository.js';
import { createDefaultConfig, normalizeAppConfig } from '../../services/config-service.js';
@ -35,6 +36,7 @@ export class SqliteStorageProvider implements StorageProvider {
readonly setupContext: SqliteSetupContextRepository;
readonly runEvents: SqliteRunEventRepository;
readonly runApprovals: SqliteRunApprovalRepository;
readonly phaseTransitions: SqlitePhaseTransitionRepository;
readonly runSupervisors: SqliteRunSupervisorRepository;
readonly toolControlPlane: SqliteToolControlPlaneRepository;
@ -58,6 +60,7 @@ export class SqliteStorageProvider implements StorageProvider {
this.setupContext = new SqliteSetupContextRepository(this.sqlite);
this.runEvents = new SqliteRunEventRepository(this.sqlite);
this.runApprovals = new SqliteRunApprovalRepository(this.sqlite);
this.phaseTransitions = new SqlitePhaseTransitionRepository(this.sqlite);
this.runSupervisors = new SqliteRunSupervisorRepository(this.sqlite);
this.toolControlPlane = new SqliteToolControlPlaneRepository(this.sqlite);
}

View file

@ -1,6 +1,9 @@
import type { RunApprovalActor, RunApprovalRequest } from './run-approval.types.js';
export const PHASE_CAPABILITY_PROFILE_SCHEMA_VERSION = 'phase-capability-profile/v1' as const;
export const PHASE_CAPABILITY_EVIDENCE_SCHEMA_VERSION = 'phase-capability-evidence/v1' as const;
export const PHASE_TRANSITION_INTENT_SCHEMA_VERSION = 'phase-transition-intent/v1' as const;
export const PHASE_TRANSITION_RECORD_SCHEMA_VERSION = 'phase-transition-record/v1' as const;
export const PHASE_NAMES = ['explore', 'plan', 'implement', 'verify', 'publish'] as const;
export type PhaseName = (typeof PHASE_NAMES)[number];
@ -156,3 +159,96 @@ export interface PhaseCapabilityCompilerInput {
sources: PhaseCapabilityCompilerSources;
planArtifact?: PhasePlanArtifactRequest;
}
export type PhaseTransitionPolicyDecision =
'allow' | 'approved-expansion' | 'emergency-override' | 'override-expired';
export interface PhaseAuthorityDeltaEntry {
dimension: PhaseAuthorityDimension;
addedScopes: string[];
removedScopes: string[];
}
export interface PhaseAuthorityDelta {
classification: 'same' | 'narrowing' | 'expanding' | 'mixed';
entries: PhaseAuthorityDeltaEntry[];
}
export interface PhaseEmergencyOverrideEvidence {
permission: 'admin:manage';
justification: string;
expiresAt: string;
}
/**
* One append-only, applied phase transition.
*
* `eventReference` is the deterministic run-event dedupe key. This lets an
* idempotent retry reconcile a missing projection without mutating the
* transition.
*/
export interface PhaseTransitionRecord {
schemaVersion: typeof PHASE_TRANSITION_RECORD_SCHEMA_VERSION;
id: string;
workspaceId: string;
taskId: string;
attemptId: string;
sequence: number;
operationId: string;
priorEvidence: PhaseCapabilityEvidence;
effectiveEvidence: PhaseCapabilityEvidence;
authorityDelta: PhaseAuthorityDelta;
actor: RunApprovalActor;
reason: string;
policyDecision: PhaseTransitionPolicyDecision;
approvalId?: string;
emergencyOverride?: PhaseEmergencyOverrideEvidence;
manifestDigest: string;
eventReference: string;
createdAt: string;
}
export interface PhaseTransitionRequestInput {
attemptId: string;
operationId: string;
expectedSequence: number;
expectedPhaseEvidenceDigest: string;
expectedManifestDigest: string;
reason: string;
fromEvidence?: PhaseCapabilityEvidence;
targetEvidence: PhaseCapabilityEvidence;
approvalId?: string;
approvalTtlMs?: number;
emergencyOverride?: {
justification: string;
expiresAt: string;
};
}
export interface PhaseTransitionQuery {
workspaceId: string;
taskId: string;
attemptId: string;
limit?: number;
}
export interface PhaseTransitionAppendInput {
record: PhaseTransitionRecord;
expectedSequence: number;
expectedPhaseEvidenceDigest: string;
expectedManifestDigest: string;
}
export interface PhaseTransitionAppendResult {
record?: PhaseTransitionRecord;
appended: boolean;
reason?: 'stale-sequence' | 'stale-phase-evidence' | 'stale-manifest' | 'operation-reused';
}
export interface PhaseTransitionResult {
status: 'applied' | 'approval-required';
current: PhaseTransitionRecord | null;
record?: PhaseTransitionRecord;
approval?: RunApprovalRequest;
targetEvidenceDigest: string;
}

View file

@ -181,6 +181,11 @@ const ROUTE_PERMISSIONS: RoutePermissionConfig[] = [
{ methods: ['POST'], path: /^\/[^/]+\/launch-preview\/?$/, permissions: 'agent:read' },
{ methods: ['POST'], path: /^\/[^/]+\/(start|stop)\/?$/, permissions: 'agent:write' },
{ methods: ['POST'], path: /^\/[^/]+\/message\/?$/, permissions: 'task:write' },
{
methods: ['POST'],
path: /^\/[^/]+\/phase\/transitions\/?$/,
permissions: 'task:write',
},
{
methods: ['POST'],
path: /^\/[^/]+\/conversation\/steer\/?$/,