fix(daemon): PL-004 Phase D R2 — durable current-step binding + allowed_exits enforcement at projection

Repairs guard BLOCK on R1 commit 4a66e3c:

Blocker 1 — waiting/resume semantics still wrong (trail-based inference):

R1 preserved the closed packet on currentFrontier when exit=waiting,
but the next project() call inferred current step from "last trail row
+ 1," skipping the parked step. POC contract is to extract step_id
from the current packet binding, not from trail order.

R2 fix — durable current_step_id binding on workflow_instances:

  - Migration 034_workflow_instances extended IN PLACE with
    current_step_id TEXT column. (In-place because Phase D has not
    been promoted past base 1ab394e; same pattern guard accepted for
    Phase C R1's actionable/last_actionable_at columns on 031.)
  - WorkflowInstance.currentStepId field; rowToInstance reads it.
  - WorkflowInstanceStore.create() accepts currentStepId; updateFrontier
    accepts currentStepId of "preserve" | "clear" | <stepId>.
  - WorkflowRuntime.instantiate() sets current_step_id to the entry
    step at instance creation time.
  - WorkflowProjector.project() replaces inferCurrentStep (trail-based)
    with resolveCurrentStep (reads instance.currentStepId). Falls back
    to entry step ONLY when current_step_id is null (defense-in-depth).
  - Frontier-update transitions:
      handoff → current_step_id = nextStep.id
      waiting → current_step_id preserved (resume on same packet
                still on same step)
      done / failed → current_step_id cleared (terminal)

Resume regression added (canonical "drive→park→resume on same id" per
skill lesson feedback_resume_after_parked_state_regression):

  - "R2 resume regression: handoff → waiting on step N → done on same
    qitem closes step N (not N+1)": instantiate three-step workflow,
    handoff produce → review, waiting on review qitem (status=waiting,
    current_step_id="review", frontier preserves review qitem), then
    done on the SAME review qitem. Asserts: trail's last entry has
    stepId="review" (NOT "finalize"); workflow ends as completed
    because no successor projected; NO trail row written for "finalize"
    (would have indicated a step skip).

Blocker 2 — projector did not enforce step.allowed_exits at runtime:

R1 left allowed_exits as a validation-only decoration. The R1 done test
encoded the wrong behavior (projecting exit=done on a step that only
allows handoff and asserting closure). R2 makes allowed_exits a runtime
contract that fires BEFORE any side effect.

R2 fix:

  - WorkflowProjector.project() validates input.exit ∈
    currentStep.allowed_exits when the step declares allowed_exits.
    Throws WorkflowProjectorError {code: "exit_not_allowed",
    details: {stepId, attemptedExit, allowedExits}} BEFORE queue close,
    trail append, frontier update, or event persist. No side effects
    on rejection.

R2 regression tests added:

  - "R2 allowed_exits enforcement (positive): exit included in
    step.allowed_exits succeeds": handoff on a step that allows handoff
    succeeds and returns the next step.
  - "R2 allowed_exits enforcement (negative): disallowed exit throws
    exit_not_allowed with NO side effects": done on a step that allows
    only handoff throws WorkflowProjectorError(exit_not_allowed) with
    structured details; queue state, instance frontier, current_step_id,
    trail count, and event count are ALL unchanged after the rejection.

Test fixture changes:

  - Added PERMISSIVE_SPEC fixture for closure-shape unit tests
    (project(done), project(waiting), project(failed), instance-not-active
    rejection). The main 3-step SPEC has allowed_exits=[handoff] on the
    entry step; tests that deliberately exercise non-handoff closures on
    the entry packet now use PERMISSIVE_SPEC's "act" step which allows
    all four exits. R1 tests that incorrectly assumed the produce step
    accepted any exit are corrected.

Skill lesson filed at memory/feedback_resume_after_parked_state_regression.md
per guard's call-out: every preservation repair (waiting, blocked, parked,
restore-resumed) needs a "drive→park→resume on same id" regression that
asserts the resumed action targets the same logical entity that was
parked, not an inferred-from-side-effect successor.

Verification:
  - tsc clean for daemon and cli.
  - Per-package vitest baseline under explicit OPENRIG_HOME +
    OPENRIG_DB=:memory: temp env:
    - packages/daemon: 2559 / 2559 pass (was 2556 in Phase D R1; +3
      R2 regressions: resume + allowed_exits positive + allowed_exits
      negative).
    - packages/cli: 15 / 15 watchdog + workflow tests pass; pre-existing
      CLI failures unchanged.

Boundaries preserved (R2):
  - Worktree-only on phase-d-coordination-primitive-system; main untouched.
  - Migration 034 extended in place (Phase D unpromoted; matches
    accepted Phase C R1 in-place 031 extension pattern).
  - No-touch surfaces unchanged: Phase A non-extension queue surfaces
    beyond the orch-ratified Phase D extension points, Phase B
    project/classifier/view, Lifecycle v2, migrations 001-033, 035-036,
    POC source.
  - workflow-keepalive still reads ONLY from SQLite.
  - Workflow runtime PROJECTS on closure; does NOT GATE closure (Phase A
    hot-potato strict-rejection remains the closure authority).
  - allowed_exits enforcement at projection complements (does not
    replace) the validator's spec-level allowed_exits check.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-03 08:13:57 -07:00
1 parent 4a66e3c8a5
commit b699f394dc
6 files changed
+365 -38

No files matched your search

@@ -8,6 +8,15 @@ import type { Migration } from "../migrate.js";
* filesystem reconciliation. The current_frontier_json column holds the
* array of qitem_ids that are the active step packets at any given time.
*
* R2 fix (guard blocker 1): durable current-step binding via
* `current_step_id`. Replaces trail-based "last_step + 1" inference,
* which produced wrong results for reused-frontier packets (e.g.,
* waiting → resume on same packet would skip a step). Mirrors POC
* which extracts step_id from the current packet binding rather than
* inferring from trail order. v1 supports a single active frontier
* packet; multi-frontier (parallel branches) is graduation and would
* need a packet→step map.
*
* Status enum (matches POC + PRD):
* - active: instance has at least one frontier packet in-flight
* - waiting: instance has a frontier packet blocked on an external gate
@@ -41,6 +50,7 @@ export const workflowInstancesSchema: Migration = {
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
current_frontier_json TEXT NOT NULL DEFAULT '[]',
current_step_id TEXT,
hop_count INTEGER NOT NULL DEFAULT 0,
fallback_synthesis TEXT,
last_continuation_decision_json TEXT,
@@ -20,6 +20,7 @@ interface InstanceRow {
created_at: string;
status: string;
current_frontier_json: string;
current_step_id: string | null;
hop_count: number;
fallback_synthesis: string | null;
last_continuation_decision_json: string | null;
@@ -31,6 +32,8 @@ export interface CreateWorkflowInstanceInput {
workflowVersion: string;
createdBySession: string;
initialFrontier?: string[];
/** R2: durable current-step binding set at instantiate time. */
currentStepId?: string;
}
export class WorkflowInstanceError extends Error {
@@ -58,8 +61,8 @@ export class WorkflowInstanceStore {
.prepare(
`INSERT INTO workflow_instances (
instance_id, workflow_name, workflow_version, created_by_session,
created_at, status, current_frontier_json, hop_count
) VALUES (?, ?, ?, ?, ?, 'active', ?, 0)`,
created_at, status, current_frontier_json, current_step_id, hop_count
) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, 0)`,
)
.run(
instanceId,
@@ -68,6 +71,7 @@ export class WorkflowInstanceStore {
input.createdBySession,
createdAt,
JSON.stringify(frontier),
input.currentStepId ?? null,
);
return this.getByIdOrThrow(instanceId);
}
@@ -122,19 +126,38 @@ export class WorkflowInstanceStore {
lastContinuationDecision?: Record<string, unknown> | null;
fallbackSynthesis?: string | null;
completedAt?: string | null;
/**
* R2: explicit next current_step_id. When provided, OVERWRITES
* the column (including to NULL by passing the empty string for
* "clear"). When omitted, current_step_id is preserved (the
* frontier packet is reused, e.g., on waiting). To clear pass
* the symbol "clear-current-step" (typed as the literal below).
*/
currentStepId?: string | "preserve" | "clear";
} = {},
): void {
const setHop = opts.bumpHopCount ? "hop_count = hop_count + 1, " : "";
this.db
.prepare(
`UPDATE workflow_instances SET
${setHop}status = ?, current_frontier_json = ?,
let currentStepClause = "";
let currentStepValue: string | null | undefined;
if (opts.currentStepId === "preserve" || opts.currentStepId === undefined) {
currentStepClause = "";
currentStepValue = undefined;
} else if (opts.currentStepId === "clear") {
currentStepClause = "current_step_id = NULL, ";
} else {
currentStepClause = "current_step_id = ?, ";
currentStepValue = opts.currentStepId;
}
const sql = `UPDATE workflow_instances SET
${setHop}${currentStepClause}status = ?, current_frontier_json = ?,
last_continuation_decision_json = COALESCE(?, last_continuation_decision_json),
fallback_synthesis = COALESCE(?, fallback_synthesis),
completed_at = COALESCE(?, completed_at)
WHERE instance_id = ?`,
)
.run(
WHERE instance_id = ?`;
const stmt = this.db.prepare(sql);
if (currentStepValue !== undefined) {
stmt.run(
currentStepValue,
nextStatus,
JSON.stringify(nextFrontier),
opts.lastContinuationDecision ? JSON.stringify(opts.lastContinuationDecision) : null,
@@ -142,6 +165,16 @@ export class WorkflowInstanceStore {
opts.completedAt ?? null,
instanceId,
);
} else {
stmt.run(
nextStatus,
JSON.stringify(nextFrontier),
opts.lastContinuationDecision ? JSON.stringify(opts.lastContinuationDecision) : null,
opts.fallbackSynthesis ?? null,
opts.completedAt ?? null,
instanceId,
);
}
}
}
@@ -154,6 +187,7 @@ function rowToInstance(row: InstanceRow): WorkflowInstance {
createdAt: row.created_at,
status: row.status as WorkflowInstanceStatus,
currentFrontier: JSON.parse(row.current_frontier_json) as string[],
currentStepId: row.current_step_id,
hopCount: row.hop_count,
fallbackSynthesis: row.fallback_synthesis,
lastContinuationDecision: row.last_continuation_decision_json
@@ -133,12 +133,39 @@ export class WorkflowProjector {
{ currentPacketId: input.currentPacketId },
);
}
const currentStep = inferCurrentStep(spec, instance, this.trailLog);
// R2 fix (guard blocker 1): read durable current_step_id from instance
// rather than inferring "last_trail_step + 1". Reused-frontier packets
// (waiting then resume) now correctly resume the SAME step.
const currentStep = resolveCurrentStep(spec, instance);
if (!currentStep) {
throw new WorkflowProjectorError(
"current_step_unknown",
`workflow instance ${instance.instanceId} cannot infer the current step from spec ${spec.id}@${spec.version} + trail; check that the trail is consistent and that the spec has not changed shape mid-instance`,
{ instanceId: instance.instanceId, frontier: instance.currentFrontier },
`workflow instance ${instance.instanceId} has no resolvable current step (current_step_id=${JSON.stringify(instance.currentStepId)}). The instance may be in a terminal state, or the spec ${spec.id}@${spec.version} may have changed shape mid-instance.`,
{ instanceId: instance.instanceId, currentStepId: instance.currentStepId, frontier: instance.currentFrontier },
);
}
// R2 fix (guard blocker 2): enforce currentStep.allowed_exits at
// projection time. POC contract: an exit not in the step's
// allowed_exits is rejected with a structured error and NO state
// mutation. Validation happens BEFORE any side effect (queue close,
// trail append, frontier update, event persist). When allowed_exits
// is omitted on the step, no enforcement (operator opted out at
// spec authoring time).
if (
currentStep.allowed_exits &&
currentStep.allowed_exits.length > 0 &&
!currentStep.allowed_exits.includes(input.exit)
) {
throw new WorkflowProjectorError(
"exit_not_allowed",
`step "${currentStep.id}" allows exits ${JSON.stringify(currentStep.allowed_exits)}; got "${input.exit}". Either close with a permitted exit, or amend the spec.`,
{
instanceId: instance.instanceId,
stepId: currentStep.id,
attemptedExit: input.exit,
allowedExits: currentStep.allowed_exits,
},
);
}
@@ -269,6 +296,19 @@ export class WorkflowProjector {
} else {
nextStatus = "active";
}
// R2 fix: set durable current_step_id transition.
// handoff → next step
// waiting → preserve (resume on same packet still on same step)
// done / failed → clear (terminal)
let currentStepIdUpdate: "preserve" | "clear" | string;
if (input.exit === "handoff" && nextStep) {
currentStepIdUpdate = nextStep.id;
} else if (input.exit === "waiting") {
currentStepIdUpdate = "preserve";
} else {
// done or failed
currentStepIdUpdate = "clear";
}
this.instanceStore.updateFrontier(instance.instanceId, nextFrontier, nextStatus, {
bumpHopCount: input.exit === "handoff",
lastContinuationDecision: {
@@ -278,8 +318,10 @@ export class WorkflowProjector {
nextPacket: nextQitemId,
resultNote: input.resultNote ?? null,
blockedOn: input.blockedOn ?? null,
currentStep: currentStep.id,
},
completedAt,
currentStepId: currentStepIdUpdate,
});
// 6. Persist workflow events within the same txn.
@@ -354,27 +396,36 @@ export class WorkflowProjector {
}
/**
* Infer the current step from the trail. v1 uses a simple rule:
* - If trail is empty → entry step is the current step.
* - Else → the step after the most recently-trailed step.
* R2 fix (guard blocker 1): resolve the current step from durable
* instance.currentStepId, NOT from trail order. The previous trail-based
* inference produced wrong results for reused-frontier packets:
* waiting → resume on same packet would skip a step, because the
* waiting close wrote a trail row that the next inference treated as
* "last step + 1".
*
* Multi-active-frontier instances (parallel steps) are a graduation;
* v1 always has at most one active frontier packet at a time.
* Falls back to the entry step ONLY when current_step_id is null
* (instantiate didn't set it; defense-in-depth, not the production
* path).
*
* Multi-active-frontier instances (parallel steps) remain a graduation;
* v1 supports a single active frontier packet, so a single
* current_step_id column suffices.
*/
function inferCurrentStep(
function resolveCurrentStep(
spec: WorkflowSpec,
instance: WorkflowInstance,
trailLog: WorkflowStepTrailLog,
): WorkflowStepSpec | null {
const trail = trailLog.listForInstance(instance.instanceId, 1);
if (trail.length === 0) {
const entryRole = spec.entry?.role;
return spec.steps[0] ?? (entryRole ? spec.steps.find((s) => s.actor_role === entryRole) ?? null : null) ?? null;
if (instance.currentStepId) {
return spec.steps.find((s) => s.id === instance.currentStepId) ?? null;
}
const lastStepId = trail[0]!.stepId;
const idx = spec.steps.findIndex((s) => s.id === lastStepId);
if (idx === -1) return null;
return spec.steps[idx + 1] ?? null;
// Defense-in-depth: instance lacks current_step_id. Fall back to
// entry step (matches behavior of pre-R2 instances if any survive).
const entryRole = spec.entry?.role;
return (
spec.steps[0] ??
(entryRole ? spec.steps.find((s) => s.actor_role === entryRole) ?? null : null) ??
null
);
}
/**
@@ -142,6 +142,10 @@ export class WorkflowRuntime {
workflowVersion: specRow.version,
createdBySession: input.createdBySession,
initialFrontier: [],
// R2 fix: set durable current_step_id at instantiate so the
// projector resolves the correct step on the first project()
// call without any trail-based inference.
currentStepId: entryStep.id,
});
instanceId = instance.instanceId;
@@ -81,6 +81,15 @@ export interface WorkflowInstance {
status: WorkflowInstanceStatus;
/** qitem_ids that are currently active step packets. */
currentFrontier: string[];
/**
* R2 fix: durable current-step binding (which step the active
* frontier packet represents). Set on instantiate (entry step) and
* on handoff (next step). Cleared on terminal closure. Read by
* WorkflowProjector instead of inferring from trail order — fixes
* the waiting-resume "skip a step" bug. v1 supports a single active
* frontier packet.
*/
currentStepId: string | null;
hopCount: number;
fallbackSynthesis: string | null;
lastContinuationDecision: Record<string, unknown> | null;
+230 -11
View File
@@ -51,6 +51,44 @@ const SPEC = `workflow:
- handoff
- waiting
- done
- failed
`;
// Permissive spec for testing closure-shape variants (done, waiting, failed)
// without walking through the whole workflow. The entry step allows all four
// exit kinds; otherwise R2's allowed_exits enforcement (correctly) rejects
// non-handoff closures on a step that only declares handoff.
const PERMISSIVE_SPEC = `workflow:
id: pd-permissive
version: 1
objective: closure-shape unit tests
entry:
role: anyone
roles:
anyone:
preferred_targets:
- anyone@rig
next:
preferred_targets:
- next@rig
steps:
- id: act
actor_role: anyone
allowed_exits:
- handoff
- waiting
- done
- failed
- id: follow
actor_role: next
allowed_exits:
- done
invariants:
allowed_exits:
- handoff
- waiting
- done
- failed
`;
describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scribe LOAD-BEARING)", () => {
@@ -60,6 +98,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
let runtime: WorkflowRuntime;
let tmp: string;
let specPath: string;
let permissiveSpecPath: string;
beforeEach(() => {
db = createDb();
@@ -79,6 +118,8 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
tmp = mkdtempSync(join(tmpdir(), "wf-proj-"));
specPath = join(tmp, "spec.yaml");
writeFileSync(specPath, SPEC);
permissiveSpecPath = join(tmp, "permissive-spec.yaml");
writeFileSync(permissiveSpecPath, PERMISSIVE_SPEC);
runtime = new WorkflowRuntime({ db, eventBus: bus, queueRepo });
});
@@ -145,7 +186,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
it("project(done) without exposed closure overrides defaults closure_reason to no-follow-on", async () => {
const inst = await runtime.instantiate({
specPath,
specPath: permissiveSpecPath,
rootObjective: "x",
createdBySession: "ops@rig",
});
@@ -155,7 +196,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
instanceId: inst.instance.instanceId,
currentPacketId: inst.entryQitemId,
exit: "done",
actorSession: "producer@rig",
actorSession: "anyone@rig",
});
const closedItem = queueRepo.getById(inst.entryQitemId);
expect(closedItem?.state).toBe("done");
@@ -165,7 +206,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
it("project(waiting) uses Phase A blocked state + closure_reason=blocked_on + blocked_on column", async () => {
const inst = await runtime.instantiate({
specPath,
specPath: permissiveSpecPath,
rootObjective: "x",
createdBySession: "ops@rig",
});
@@ -173,7 +214,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
instanceId: inst.instance.instanceId,
currentPacketId: inst.entryQitemId,
exit: "waiting",
actorSession: "producer@rig",
actorSession: "anyone@rig",
blockedOn: "external-gate-x",
});
const closedItem = queueRepo.getById(inst.entryQitemId);
@@ -308,7 +349,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
it("project rejects on completed instance → 409 instance_not_active", async () => {
const inst = await runtime.instantiate({
specPath,
specPath: permissiveSpecPath,
rootObjective: "x",
createdBySession: "ops@rig",
});
@@ -316,7 +357,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
instanceId: inst.instance.instanceId,
currentPacketId: inst.entryQitemId,
exit: "done",
actorSession: "producer@rig",
actorSession: "anyone@rig",
});
expect(projected.instance.status).toBe("completed");
try {
@@ -324,7 +365,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
instanceId: inst.instance.instanceId,
currentPacketId: inst.entryQitemId,
exit: "done",
actorSession: "producer@rig",
actorSession: "anyone@rig",
});
throw new Error("should have thrown");
} catch (err) {
@@ -339,7 +380,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
// single-packet waiting workflows with no owner to wake.
it("project(waiting) sets status=waiting AND preserves the closed packet on currentFrontier (workflow-keepalive resolvability)", async () => {
const inst = await runtime.instantiate({
specPath,
specPath: permissiveSpecPath,
rootObjective: "x",
createdBySession: "ops@rig",
});
@@ -348,7 +389,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
instanceId: inst.instance.instanceId,
currentPacketId: inst.entryQitemId,
exit: "waiting",
actorSession: "producer@rig",
actorSession: "anyone@rig",
blockedOn: "external-gate",
});
expect(projected.instance.status).toBe("waiting");
@@ -362,13 +403,191 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
expect(reread.currentFrontier).toEqual([inst.entryQitemId]);
});
// R2 fix (guard blocker 1): durable current-step binding. After
// handoff to step N then waiting on the same packet, resuming with
// done/handoff must close step N — NOT step N+1 (the trail-based
// bug R1 didn't catch). This is the canonical "resume after parked"
// regression per skill lesson feedback_resume_after_parked_state_regression.
it("R2 resume regression: handoff → waiting on step N → done on same qitem closes step N (not N+1)", async () => {
// Use a 3-step spec where step "review" allows handoff + waiting + done
// (so we can park at step 2 and resume).
const resumeSpec = `workflow:
id: pd-resume-after-waiting
version: 1
entry:
role: producer
roles:
producer:
preferred_targets:
- producer@rig
reviewer:
preferred_targets:
- reviewer@rig
finalizer:
preferred_targets:
- finalizer@rig
steps:
- id: produce
actor_role: producer
allowed_exits:
- handoff
- id: review
actor_role: reviewer
allowed_exits:
- handoff
- waiting
- done
- id: finalize
actor_role: finalizer
allowed_exits:
- done
invariants:
allowed_exits:
- handoff
- waiting
- done
`;
const resumeSpecPath = join(tmp, "resume.yaml");
writeFileSync(resumeSpecPath, resumeSpec);
// Step 1: instantiate, then handoff produce → review.
const inst = await runtime.instantiate({
specPath: resumeSpecPath,
rootObjective: "resume regression",
createdBySession: "ops@rig",
});
const handed = await runtime.project({
instanceId: inst.instance.instanceId,
currentPacketId: inst.entryQitemId,
exit: "handoff",
actorSession: "producer@rig",
});
expect(handed.nextStepId).toBe("review");
const reviewQitemId = handed.nextQitemId!;
// current_step_id now bound to "review".
let instAfterHandoff = runtime.instanceStore.getByIdOrThrow(inst.instance.instanceId);
expect(instAfterHandoff.currentStepId).toBe("review");
expect(instAfterHandoff.currentFrontier).toEqual([reviewQitemId]);
// Step 2: park review on the same qitem.
const waited = await runtime.project({
instanceId: inst.instance.instanceId,
currentPacketId: reviewQitemId,
exit: "waiting",
actorSession: "reviewer@rig",
blockedOn: "external-gate",
});
expect(waited.instance.status).toBe("waiting");
// Frontier preserved AND current_step_id still "review" (NOT advanced
// to "finalize" by trail order).
let instWaiting = runtime.instanceStore.getByIdOrThrow(inst.instance.instanceId);
expect(instWaiting.currentFrontier).toEqual([reviewQitemId]);
expect(instWaiting.currentStepId).toBe("review");
// Step 3: resume with done on the SAME packet. Must close step "review",
// not "finalize". And the workflow_step_trails should have a "review"
// closure (the R1 bug was that trail-based inference would have
// resumed as if we were now on "finalize" — wrong).
const resumed = await runtime.project({
instanceId: inst.instance.instanceId,
currentPacketId: reviewQitemId,
exit: "done",
actorSession: "reviewer@rig",
resultNote: "review complete",
});
// Last trail entry is for step "review", NOT "finalize".
const trail = runtime.trailLog.listForInstance(inst.instance.instanceId);
const lastTrail = trail[0]!; // DESC order
expect(lastTrail.stepId).toBe("review");
expect(lastTrail.closureReason).toBe("done");
expect(lastTrail.priorQitemId).toBe(reviewQitemId);
// The done exit on review with no successor on the frontier means the
// workflow is now... actually the spec says "review" can exit with done,
// and the next step "finalize" exists but isn't projected because we
// chose "done" not "handoff". So the workflow ends here as completed.
expect(resumed.instance.status).toBe("completed");
expect(resumed.instance.currentFrontier).toEqual([]);
// Critical: NO trail row was written for "finalize" — the bug would
// have either skipped to finalize OR made the closure target the
// wrong step.
expect(trail.find((t) => t.stepId === "finalize")).toBeUndefined();
});
// R2 fix (guard blocker 2): allowed_exits enforcement positive case.
it("R2 allowed_exits enforcement (positive): exit included in step.allowed_exits succeeds", async () => {
const inst = await runtime.instantiate({
specPath, // produce step allows handoff
rootObjective: "x",
createdBySession: "ops@rig",
});
// handoff IS in produce's allowed_exits — must succeed.
const projected = await runtime.project({
instanceId: inst.instance.instanceId,
currentPacketId: inst.entryQitemId,
exit: "handoff",
actorSession: "producer@rig",
});
expect(projected.nextStepId).toBe("review");
});
// R2 fix (guard blocker 2): allowed_exits enforcement negative case
// with rollback/no-side-effect coverage.
it("R2 allowed_exits enforcement (negative): disallowed exit throws exit_not_allowed with NO side effects", async () => {
const inst = await runtime.instantiate({
specPath, // produce step allows handoff ONLY
rootObjective: "x",
createdBySession: "ops@rig",
});
const beforeQueueState = queueRepo.getById(inst.entryQitemId)?.state;
const beforeFrontier = inst.instance.currentFrontier;
const beforeStepId = inst.instance.currentStepId;
const beforeTrailCount = runtime.trailLog.countForInstance(inst.instance.instanceId);
const eventsBefore: Array<{ type: string }> = [];
bus.subscribe((e) => eventsBefore.push(e));
// done is NOT in produce.allowed_exits — must throw.
let threw = false;
try {
await runtime.project({
instanceId: inst.instance.instanceId,
currentPacketId: inst.entryQitemId,
exit: "done",
actorSession: "producer@rig",
});
} catch (err) {
threw = true;
expect(err).toBeInstanceOf(WorkflowProjectorError);
const e = err as WorkflowProjectorError;
expect(e.code).toBe("exit_not_allowed");
expect(e.details?.allowedExits).toEqual(["handoff"]);
expect(e.details?.attemptedExit).toBe("done");
expect(e.details?.stepId).toBe("produce");
}
expect(threw).toBe(true);
// NO SIDE EFFECTS: queue state, instance frontier, current_step_id,
// trail count, events all unchanged.
expect(queueRepo.getById(inst.entryQitemId)?.state).toBe(beforeQueueState);
const instAfter = runtime.instanceStore.getByIdOrThrow(inst.instance.instanceId);
expect(instAfter.currentFrontier).toEqual(beforeFrontier);
expect(instAfter.currentStepId).toBe(beforeStepId);
expect(runtime.trailLog.countForInstance(inst.instance.instanceId)).toBe(beforeTrailCount);
// No workflow.* events emitted by the rejected projection.
expect(eventsBefore.find((e) => e.type === "workflow.step_closed")).toBeUndefined();
expect(eventsBefore.find((e) => e.type === "queue.updated")).toBeUndefined();
});
// R1 fix (guard blocker 2a): exit=failed sets workflow status=failed
// (not completed) AND emits workflow.failed (not workflow.completed).
it("project(failed) sets workflow status=failed AND emits workflow.failed (NOT completed)", async () => {
const events: Array<{ type: string }> = [];
bus.subscribe((e) => events.push(e));
const inst = await runtime.instantiate({
specPath,
specPath: permissiveSpecPath,
rootObjective: "x",
createdBySession: "ops@rig",
});
@@ -376,7 +595,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
instanceId: inst.instance.instanceId,
currentPacketId: inst.entryQitemId,
exit: "failed",
actorSession: "producer@rig",
actorSession: "anyone@rig",
resultNote: "could not produce artifact",
});
expect(projected.instance.status).toBe("failed");