Files
kandev/docs/specs/workflow-evaluate-only-operation-marking/spec.md
T

32 KiB

status, created, updated, owner
status created updated owner
draft 2026-09-05 2026-09-05 Kandev

EvaluateOnly operation marking in the workflow engine

Decisions:

  • ADR-0004 task-model-unification — establishes internal/workflow/engine as the shared coordinator every trigger routes through, which is why this is a contract change, not a call-site patch.

Origin:

  • Review comment 3921830599 on PR #3315 (thread PRRT_kwDOQ2-eWs6ezhf9). The defect predates that PR; #3315 added the fourth EvaluateOnly call site and the reviewer noticed the shared gap from it.

Related spec (same subsystem, adjacent defect class):

Why

Engine.handleTrigger records an operation as applied even when it has deliberately not committed that operation's effect. In EvaluateOnly mode the engine skips ApplyTransition and leaves the commit to its caller, then marks the operation applied anyway before returning. If the caller's commit does not land, the idempotency marker still says it did, and every later delivery of the same operation short-circuits on Idempotent: true. The task stays on its old step with no further recovery attempt.

Verified 2026-09-05 against ddcc4fc6a: handleTrigger's tail (apps/backend/internal/workflow/engine/engine.go) marks unconditionally, while the commit that mark claims sits inside processActions behind if !in.EvaluateOnly — so in EvaluateOnly mode applyTransition never runs, yet the mark still does. Source quoted in verification.md.

This is not a new design position. Engine.reevaluateGuardedTransitions already states the same rule in its own doc comment and commits in-call before marking, so EvaluateOnly is the one mode that marks an outcome it did not determine. Quoted and dissected in verification.md.

Corrections to the reported scope

The originating comment lists four call sites. Two pass an operation id and use the marker. The two turn callbacks omit one, so markOperationApplied returns nil for them by construction. Verified by cross-referencing every non-test EvaluateOnly: true site with OperationID::

Call site EvaluateOnly OperationID passed Affected today
event_handlers_agent_error.go:284 (dispatchKanbanAgentErrorTrigger) true yes (agentErrorOperationID) yes
event_handlers_workflow.go:6074 (on_turn_complete) true no no — mark is a no-op
event_handlers_workflow.go:6662 (on_turn_start) true no no — mark is a no-op
event_handlers_children_completed.go:228 (on_children_completed) true yes (operationID) yes — explicit DeferOperationMark path
office/engine_dispatcher/dispatcher.go:166 false yes no — engine commits, then marks
orchestrator/workflow_callbacks.go:142 (switchWorkflowDispatcher) false yes no — engine commits, then marks

on_children_completed is the explicit exception: it passes operationID with DeferOperationMark: true, then keeps its two-phase bracket — childCompletionAlreadyApplied before the engine call and markChildCompletionApplied after the commit. The outer caller owns the marker; this path is registered in AC-EO-15.

This is a latent contract defect with one default engine-deferral caller and one explicit caller-owned deferral. The ACs target the engine contract and require both paths to preserve commit-then-mark order.

Blast radius, measured rather than assumed

workflowStore.IsOperationApplied / MarkOperationApplied (internal/orchestrator/workflow_store.go:886-899) are backed by appliedOps sync.Map (:104), and there is no applied_operations table and no operation-id column anywhere in the schema (measurement receipts in verification.md). Two consequences the fix must be specified against:

  1. Suppression is process-scoped, not permanent — a restart clears every marker. The defect strands a task until then, which on a long-lived install is hours to days, but "permanently" overstates it.
  2. There is no marker-clearing verb, which rules out the "each caller re-opens the marker on commit failure" option: adding one grows TransitionStore for every implementation, and the clear leaves a crash window that reproduces the original bug. Not marking has no such window.

The suppression window is also wider than the "transient DB failure" the report imagines. AC-EO-11 enumerates the four reachable non-committing paths, most of them ordinary: a credential preflight failure is a routine operator condition, and today it burns the operation id for the process's lifetime. Worse, where AgentExecutionID is empty the key falls back to one per session, so a suppression there disables on_agent_error recovery for that whole session until restart, not just for one failure (receipts in verification.md).

Prior art

Three legs; receipts, findings, and what we do differently in verification.md. The wiki and saas-kb legs DID NOT RUN (tools absent from this sandbox — redo them from an unsandboxed checkout before treating this contract as unprecedented); the in-repo leg RAN and produced the two precedents dissected above.

What

The contract

The engine records an operation as applied when, and only when, its own call has fully determined that operation's transition outcome. Work the engine defers to its caller is not the engine's to claim.

In EvaluateOnly mode a transition is deferred work. Nothing else is: callbacks have already executed by the time processActions returns, and a run that produces no transition has no transition outstanding.

Ownership of the marker therefore transfers to the caller exactly when a transition is deferred, and the engine says so in its result — HandleResult.OperationMarkDeferred — rather than leaving each caller to re-derive the rule from EvaluateOnly.

The invariant is scoped to transitions, deliberately and narrowly. It is not "the call fully determined every effect". One effect is knowingly excluded: in EvaluateOnly mode an evaluation that produces a data patch and no transition drops that patch, because the engine skips PersistData and the caller only persists result.DataPatch inside its transition branch. That is a real, separate defect, tracked in Out of scope. AC-EO-2 still marks on that path, and this is a decision rather than an oversight: marking there is the pre-existing behavior, withholding the mark would strand the operation forever without persisting the patch, and no re-delivery can recover a patch that no code path writes. Widening the invariant to cover patches would mean fixing the drop — a different change with a different owner. Naming the exclusion here keeps AC-EO-2 and the invariant consistent rather than quietly contradictory.

Terms

  • Deferred transition — a HandleTrigger call where HandleInput.EvaluateOnly is true and evaluation selected a target step different from the current step (the condition that sets HandleResult.Transitioned).
  • Marker — the (operation id → applied) entry read by TransitionStore.IsOperationApplied and written by TransitionStore.MarkOperationApplied.
  • Commit — the caller's persistence of a deferred transition. For the live caller this is Service.applyEngineTransition returning true.
  • Deferred-mark flag — HandleResult.OperationMarkDeferred, the new exported boolean defined by AC-EO-4. The identifier is fixed here: AC-EO-10 reads it and AC-EO-15 scans for it by name.

Engine acceptance criteria

  • AC-EO-1: WHERE HandleInput.EvaluateOnly is true AND the call produces a deferred transition, Engine.HandleTrigger SHALL NOT invoke TransitionStore.MarkOperationApplied for HandleInput.OperationID.

  • AC-EO-2: WHERE HandleInput.EvaluateOnly is true AND the call produces no deferred transition, Engine.HandleTrigger SHALL invoke TransitionStore.MarkOperationApplied for a non-empty HandleInput.OperationID, unchanged from current behavior. This includes the case where callbacks executed and returned a data patch but no transition action fired. A data patch SHALL NOT change marker ownership. This is not a claim that the patch was persisted — on this path it is dropped, per the carve-out in The contract and the entry in Out of scope. Marking is still correct here because withholding it fixes nothing and strands the operation instead.

  • AC-EO-3: WHERE HandleInput.EvaluateOnly is false, Engine.HandleTrigger SHALL invoke TransitionStore.MarkOperationApplied after processActions returns a nil error, unchanged from current behavior, whether or not a transition occurred.

  • AC-EO-4: HandleResult SHALL expose an exported boolean field named exactly OperationMarkDeferred, reporting that the engine skipped the mark and the caller now owns it. It SHALL be true if and only if AC-EO-1 applied and HandleInput.OperationID is non-empty. It SHALL be false on every other return from HandleTrigger, including: the idempotent short-circuit, a step declaring no actions for the trigger, EvaluateOnly: false, a deferred transition with an empty OperationID, and every error return.

  • AC-EO-5: IF processActions returns a non-nil error, THEN Engine.HandleTrigger SHALL NOT invoke MarkOperationApplied and SHALL return the zero HandleResult. (Current behavior; pinned so the fix cannot regress it.)

  • AC-EO-6: WHERE the step declares no actions for the trigger, Engine.HandleTrigger SHALL invoke MarkOperationApplied regardless of EvaluateOnly, because nothing was deferred.

  • AC-EO-7: WHERE HandleInput.OperationID is the empty string, Engine.HandleTrigger SHALL NOT invoke IsOperationApplied or MarkOperationApplied, unchanged from current behavior.

  • AC-EO-8: HandleTriggerSessionShapedOnly SHALL follow AC-EO-1 through AC-EO-7 identically to HandleTrigger; the action-kind filter SHALL NOT change marker ownership. Both route through the same handleTrigger body.

    AC-EO-1 holds vacuously on this entry point, by construction rather than by omission: sessionShapedActionKinds and the kinds isTransitionAction admits are disjoint sets, and evaluateActions applies the filter before the transition branch, so no deferred transition is reachable here. Observable coverage is therefore AC-EO-2, AC-EO-4's false, and AC-EO-7 through this entry point, plus a structural assertion that sessionShapedActionKinds admits no transition kind — that assertion is what fails if a later change widens the filter and makes AC-EO-1 reachable, which is the "a fix applied to only one entry point fails" property this AC exists for. Satisfying this AC by admitting a transition kind into the session-shaped set is FORBIDDEN: it would double-dispatch step entry against AC-OFFICE-STEP-ENTRY-001.

  • AC-EO-9: Engine.reevaluateGuardedTransitions SHALL continue to mark its own decision:<task>:<step>:<decision> operation id after applyFirstSatisfiedGuardedTransition returns, unchanged. Its commit is in-call, so it is not deferred work and is outside AC-EO-1.

Caller acceptance criteria

  • AC-EO-10: WHEN dispatchKanbanAgentErrorTrigger receives a HandleResult whose OperationMarkDeferred is true, it SHALL invoke MarkOperationApplied for that operation id if and only if applyEngineTransition returned true. The live call site discards that return value today; capturing it is part of this AC.

  • AC-EO-11: IF applyEngineTransition returns false for a deferred transition, THEN the operation SHALL remain unmarked, so a later delivery carrying the same operation id re-evaluates instead of short-circuiting on Idempotent: true. This SHALL hold for all four non-committing return paths reachable from this caller: target step not found, credential preflight failure, source-step load failure, and commit error — not only the commit-error path.

    applyEngineTransitionWithCommitMode has a fifth non-committing return, applied == false, and it is unreachable from applyEngineTransition by construction — the closure trace is in verification.md. No test is required for it and its absence from the test table is deliberate, not an omission. If a future change makes that return reachable from this caller, this AC extends to it unchanged.

  • AC-EO-12: IF the caller's own MarkOperationApplied returns an error, THEN the caller SHALL log at warn level with the task id, session id and operation id, and SHALL NOT treat the transition as failed. The transition is already committed; the consequence is a possible re-delivery, whose behavior AC-EO-17 defines.

    This is a code-shape requirement, not a runtime-observable one, and no test is required for it. The branch is unreachable and not injectable — see the agentErrorDispatchDeps.store entry under Out of scope for why. Enforcement is static instead: errcheck is active for this package via golangci-lint v2's default set — absent from apps/backend/.golangci.yml's enable: list, which sets no linters.default, so confirm it with golangci-lint linters rather than by grepping that file — so the error cannot be silently discarded, and this AC fixes what the handler does with it. Do not manufacture a seam to test it, and do not drop the handler because it cannot be tested — it becomes reachable the moment the marker is persisted.

  • AC-EO-13: dispatchKanbanAgentErrorTrigger SHALL serialize concurrent invocations that carry the same operation id. The lock SHALL be acquired before the task, session and MachineState used for evaluation are loaded, and held across the engine call, the commit, and the caller's mark. Locking only the engine-call-to-mark span breaks AC-EO-17: a racer that blocked on the lock wakes holding a PreloadedState built before the winner's commit, which the engine consumes verbatim, so it re-evaluates the source step. The operation id derives only from the failure event, so it is computable at entry, before any load. See Concurrency for why this becomes required rather than merely advisable.

    The lock SHALL be a new, separate ref-counted per-operation-id helper on Service — the same shape as lockChildCompletionOperation, not the same instance. Service.childCompletionLocks is named for child-completion ids; admitting agent_error:* keys couples two unrelated triggers' contention and lets a change to either one's lock lifetime silently affect the other. Naming is the builder's; the contract is not: keyed by operation id, created on first acquire, ref-counted, and deleted when the last holder releases, so the map does not grow without bound.

  • AC-EO-14: On the deferred path, action callbacks SHALL be understood as at-least-once. When a commit fails and the operation is re-delivered, the source step's callbacks run again alongside the re-evaluation (AC-EO-17 covers the case where the commit instead succeeded). This is the intended trade: on the failure path, re-running recovery actions is preferable to a task stranded with no recovery at all. Callers that cannot tolerate a callback replay must not defer.

  • AC-EO-15: A regression guard SHALL fail when a non-test engine.HandleInput composite literal pairs EvaluateOnly: true with a non-empty OperationID and its enclosing function is not in a registered set. The predicate below is this AC's exact and intended reach, not an approximation of a broader rule: the blind spot it names is part of the requirement, not a shortfall against it. This is the AC that makes the contract survive the next caller. Its mechanism and predicate are fixed here because the obvious reading — verify the caller consults OperationMarkDeferred — is a data-flow property no source scan can decide, and attempting it false-fires on any caller that delegates the check into a helper, the way dispatchKanbanAgentErrorTrigger already delegates its commit into s.applyEngineTransition.

    • Mechanism: a closed-set allowlist pin, modelled on agent_error_fire_site_pin_test.go — a go/parser walk rooted at apps/backend, skipping _test.go files, collecting offending call sites keyed "pkgRelDir/ReceiverType.FuncName" and failing on any key not in a registered set. The guard SHALL NOT attempt to verify that OperationMarkDeferred is read. It does not check correctness; it forces a human to review any new caller.
    • Detection predicate, purely syntactic and therefore decidable: a composite literal of type engine.HandleInput (or HandleInput inside the engine package) having both an EvaluateOnly: key whose value is the literal true, and an OperationID: key whose value is anything other than the empty-string literal. Runtime non-emptiness is undecidable and SHALL NOT be attempted — every live site passes an identifier (OperationID: operationID), never a literal, so "key present and not literally empty" is the working definition of non-empty. A caller that omits the OperationID: key entirely is not detected, which is correct: AC-EO-7 makes it unaffected. Known blind spot: a caller that does not build a HandleInput composite literal in place — assigning fields on a variable, or receiving one from a helper — is not detected either. Accepted: no live site does it, and widening the walk to chase assignments reintroduces the data-flow analysis this AC exists to avoid.
    • Registered set has two entries: internal/orchestrator/Service.dispatchKanbanAgentErrorTrigger and internal/orchestrator/Service.evaluateChildrenCompleted. The latter is the explicit DeferOperationMark path in the scope table above.
    • Remediation when it fires: add the new call site to the registered set, in the same commit that makes that caller honour OperationMarkDeferred per AC-EO-10 and serialize per AC-EO-13. Editing the set is the intended remediation, not a workaround — it is the review gate. The failure message SHALL cite AC-EO-10, AC-EO-13 and AC-EO-15 so the next author reads the obligations before editing the set rather than after.
  • AC-EO-16: The two EvaluateOnly callers that pass no operation id — on_turn_complete and on_turn_start — SHALL remain unchanged. processOnChildrenCompleted is the explicit deferral exception: it passes operationID with DeferOperationMark: true, and SHALL keep its own IsOperationApplied / MarkOperationApplied bracket around the outer commit. AC-EO-15 pins it.

  • AC-EO-17: WHERE a deferred transition's commit succeeded but the operation was left unmarked, a re-delivery of that operation id SHALL evaluate the task's current step — which is now the transition's target step — and that step's own on_agent_error actions. It SHALL NOT re-evaluate the source step. A further transition produced by that evaluation is permitted. An evaluation that completes without a failed commit invokes the mark — via AC-EO-10 once the caller commits, or via AC-EO-2 when no transition fires — and once that invocation succeeds the next delivery short-circuits on Idempotent: true. An evaluation whose commit fails leaves it unmarked, which is the ordinary AC-EO-11 retry path, now anchored at whatever step the task occupies. Re-delivery is therefore bounded by the event source's own retry policy, not by this marker; the marker only guarantees a successful outcome is not re-run.

    This window is narrow but real. Three things can open it, and only one is reachable for the live caller today: a panic between commit and mark, swallowed by dispatchKanbanAgentErrorTriggerRecovered's recover(). The other two are shut by facts about this caller rather than by the engine, so a future caller inherits the duty to re-check both: a failing caller mark, which AC-EO-12 permits and which cannot occur while the store's MarkOperationApplied is infallible; and applyEngineTransition returning false after it has committed, which happens only in a lifecycle mode this caller never selects. Receipts for all three in verification.md. The reachable one is accepted rather than closed — one extra recovery evaluation against the step the task actually occupies is the same work on_agent_error would do if the agent failed again there, and strictly preferable to the defect being fixed here, where the task is stranded with no recovery at all. A caller that cannot tolerate it must not defer.

Forced-to-invent pass

The axes below are settled here so a builder does not have to guess. Each states a decision, not a description.

Ordering

There is exactly one ordering constraint and it is total: commit, then mark. For a deferred transition the sequence is state load → IsOperationApplied → evaluate → caller commit → MarkOperationApplied, all inside AC-EO-13's lock. No step may be reordered and none may be skipped on the success path.

The marker write is unordered with respect to the transition's other post-commit work (step history row, processOnEnter, session state flip) because it shares no state with them. It SHALL be issued after applyEngineTransition returns, from the same goroutine, and SHALL NOT be moved into launchProcessOnEnter's goroutine, where a panic or early return would drop it silently.

Idempotency and retry

Marking an already-marked operation is a no-op: sync.Map.Store overwrites. A double mark is harmless and needs no guard.

The engine's Idempotent: true short-circuit keeps its current meaning — "this operation's outcome was already fully determined" — and after this change that statement is true, which it was not before.

A re-delivery of an unmarked operation re-runs the whole call against the task's current step, whatever that now is: assembleMachineState derives CurrentStepID from task.WorkflowStepID, and loadExecutionContext loads that step's spec. Which step that is depends on whether the first attempt's commit landed, and the two cases are not symmetric:

  • Commit did not land (the four AC-EO-11 paths). The task is still on the source step, so the re-delivery re-evaluates the same actions. Callbacks replay — at-least-once, per AC-EO-14 — and the transition is retried. This is the recovery the whole change exists to restore.
  • Commit landed but the mark did not. The task is now on the target step, so the re-delivery evaluates that step's actions instead. AC-EO-17 governs this case and permits it.

It does not reduce to "the second evaluation finds targetStepID == state.CurrentStepID and produces no transition" — that assumes the source step is re-read, and it is not. AC-EO-17 governs what the second evaluation does and when it terminates; only a repeatedly failing commit keeps the operation re-evaluating, which is the retry this change exists to restore.

Concurrency

Two callers holding the same operation id concurrently is not newly possible, but it is newly consequential.

Today IsOperationApplied and MarkOperationApplied are separate sync.Map operations with no compare-and-set between them, so two callers can both pass the check and both proceed. The window between check and mark is currently the engine call. After this change, for a deferred transition, it widens at both ends: forward to include the caller's commit — processOnExit, a credential preflight, a DB write and a step-history write — and back over the caller's own state load, because the engine reads PreloadedState verbatim and never re-reads the store. AC-EO-13 closes the whole widened window for the live caller with a per-operation-id mutex, which is the same instrument processOnChildrenCompleted already uses and which is sufficient because both racers are in-process by construction (the marker itself is process-local, so a cross-process racer could never have been deduplicated by it anyway).

The engine SHALL NOT serialize on the caller's behalf. It does not today, its store interface has no claim/release verb, and adding one would make every TransitionStore implementation responsible for lock lifetime to fix a race only one caller can currently experience.

That decision pushes an obligation onto callers, so it is stated as a general rule rather than only for the one that exists: any caller that pairs EvaluateOnly: true with a non-empty OperationID inherits two obligations, not one — honour OperationMarkDeferred (AC-EO-10) and serialize the load → check → evaluate → commit → mark window against its own operation id (AC-EO-13). AC-EO-15's guard cannot verify either; it fires so a human checks both before the caller ships. A future caller that takes the flag but skips the lock reintroduces exactly the race this section describes, widened.

Two callers with different operation ids on the same task remain unserialized by this spec. That race is real and predates it — it is the race ApplyTransitionIfAtStep (AC-46/48) exists for — and it is untouched here.

Nil, empty and error

Each case names the AC that governs it; the ACs are authoritative.

  • Empty OperationID: no store calls at all (AC-EO-7), OperationMarkDeferred is false (AC-EO-4). This is the current behavior of the two no-id callers and must keep working. Child completion is the explicit deferred-mark exception.
  • processActions error: no mark, zero HandleResult (AC-EO-5). Every caller already discards the result on a non-nil error; this spec introduces no partial result.
  • MarkOperationApplied error in the engine (AC-EO-2/3 paths): returned as HandleTrigger's error alongside a populated HandleResult, unchanged. Pre-existing and deliberately left alone — see Out of scope.
  • MarkOperationApplied error in the caller: warn and continue (AC-EO-12); unreachable today.
  • Nil TransitionStore: out of contract. The engine already dereferences e.store unconditionally and New accepts no nil store from any production wiring.

Defaults and boundaries

  • OperationMarkDeferred defaults to false — the zero value is the safe one, so a caller that ignores it behaves exactly as it does today, and a mistakenly zero-valued HandleResult never claims ownership the caller has not accepted.
  • No new configuration, no runtime flag, no profile entry. The correct behavior is unconditional; a flag would mean shipping the defect as a supported mode.
  • No schema change. The marker stays in sync.Map (see Out of scope).
  • HandleInput gains no field. EvaluateOnly plus the evaluated outcome already determines ownership; a second input flag would let a caller ask for the broken ordering.

Out of scope

Named exclusions, each with what a follow-up would need to know.

  • Persisting the operation marker. appliedOps has no eviction and no durability (see Blast radius). Fixing it means an applied_operations table with a retention policy, a migration, and a decision about what IsOperationApplied returns while that table is written under load — a separate durability requirement that changes behavior for callers this spec does not touch. This spec is correct under both the current in-memory store and a future persisted one, because it constrains only when the mark is issued.

  • Data patches dropped on the no-transition EvaluateOnly path. In EvaluateOnly mode the engine skips PersistData (engine.go:292) and the caller persists it — but applyEngineTransitionWithCommitMode only persists result.DataPatch inside its transition path and only when sessionLifecycle is set (event_handlers_workflow.go:5492), and it is only called at all when result.Transitioned is true. So an EvaluateOnly evaluation that produces a data patch and no transition drops the patch entirely. This is a real defect in the same mode and the same function, and it was found while confirming AC-EO-2's boundary. It is excluded because it is a different failure (silent data loss, not retry suppression), it has a different fix (deciding whether the engine or the caller owns patch persistence in evaluate-only mode), and no EvaluateOnly caller demonstrably hits it today — set_workflow_data is the only patch-producing callback and the shipped steps that declare it also declare a transition. A follow-up needs: those two file:line anchors, the fact that on_children_completed never persists patches at all, and a survey of whether any workflow YAML declares set_workflow_data without a sibling transition action. This exclusion is why The contract scopes its invariant to transitions and why AC-EO-2 marks on this path anyway; a follow-up that fixes the drop should revisit that carve-out, not just the persistence call.

  • HandleTrigger returning a populated HandleResult alongside a non-nil error. On the AC-EO-2/3 paths a mark failure yields a result describing a committed transition together with an error, and every caller discards the result and returns — so in non-EvaluateOnly mode the transition committed but the caller believes the call failed. Excluded: a wart on the non-deferred path, fixing it changes error semantics for the Office dispatcher and switchWorkflowDispatcher, and it is unreachable while MarkOperationApplied cannot fail. A follow-up should take it with the persistence work above, which is what makes it reachable.

  • Widening agentErrorDispatchDeps.store to an interface. The field, and Service.workflowStore behind it, are the concrete *workflowStore, whose MarkOperationApplied is return nil unconditionally. That is why AC-EO-12's handler cannot be driven from a test and is enforced statically by errcheck instead. Widening the field to a TransitionStore-shaped interface would make it testable, but it is a production dependency change for a branch that is unreachable until the marker is persisted, and it touches wiring this spec otherwise leaves alone. A follow-up should do it together with the persistence work above, which is what makes the branch reachable and the test worth having.

  • Changing child-completion marker ownership or lifecycle. Its operationID plus DeferOperationMark path is an explicit caller-owned exception. This spec does not change the outer commit-to-mark bracket.

  • Frontend, API and documentation surfaces. Not observable from any user-facing surface. See E2E decision.

E2E decision

No Playwright coverage. No user-visible surface: no API response shape, no WS event, no DTO field, no rendered copy. The entire observable footprint is the absence of a suppressed retry inside the backend, which no browser-level assertion can distinguish from an ordinary successful dispatch.

Surfaces touched: internal/workflow/engine (contract) and internal/orchestrator (one caller). Nothing under apps/web, docs/public, or pkg/api/v1. Verification is Go unit tests only — commands, failing-first list and per-AC coverage mapping are in verification.md.