Files
kandev/docs/specs/workflow-on-enter-action-dispatch/spec.md
T

1573 lines
114 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: draft
created: 2026-08-19
updated: 2026-08-31
owner: Kandev
---
# Workflow `on_enter` Action Dispatch
Decisions:
- [ADR-0004 task-model-unification](../../decisions/0004-task-model-unification.md) — establishes the
workflow engine as "the universal coordinator for task-scoped agent runs" and defines
`queue_run_for_each_participant` and `clear_decisions` as `on_enter` actions.
## Why
A workflow step declares what should happen when a task arrives at it, under
`events.on_enter`. Today that declaration is only partly honoured, and which part
depends on **how the task arrived at the step**. There are two implementations of
`on_enter` in the tree and neither is complete:
| Arrival path | Dispatcher | Actions honoured |
|---|---|---|
| Agent-turn auto-advance (`on_turn_complete` → transition) | `orchestrator.processOnEnter` | `reset_agent_context`, `configure_session`, `enable_plan_mode`, `set_session_mode`, `auto_start_agent` |
| Manual move / WIP-queue promotion | `orchestrator.processOnEnter` | same five |
| `switch_workflow` action | `engine.HandleTrigger(TriggerOnEnter)` | **nine of ten** — every type except `configure_session` |
The third row is not a typo, and it is the single most misread fact in this area.
`switchWorkflowDispatcher` calls `engine.HandleTrigger(TriggerOnEnter)` with
`processOnEnter` **absent from the call path entirely**, and the engine's registry is not
the reduced one it might appear to be. `buildWorkflowCallbacks` registers
`enable_plan_mode`, `disable_plan_mode`, `reset_agent_context`, `auto_start_agent`,
`set_workflow_data` and `set_session_mode` **unconditionally** — unlike the Phase-2 kinds,
which are nil-guarded on their adapters — and `compileOnEnter` compiles all nine
non-`configure_session` types. So `switch_workflow` already executes the five
session-lifecycle types through engine callbacks, *and* is the only path that honours
`clear_decisions` and `queue_run_for_each_participant`.
Two consequences follow, and both are load-bearing:
1. The ownership partition below is stated **per entry path**, not globally. A partition
naming one owner per action type with no reference to arrival path would be false for
`switch_workflow` on five of ten types, and a builder enforcing it literally would
delete four working callback registrations and silently regress that path.
2. `configure_session` is the one type `switch_workflow` genuinely drops. That is a real
AC-A2 gap; it is named and excluded (Out of scope) rather than left silent, and
AC-A6 requires it to warn on that path.
On the ordinary path, `processOnEnter` handles session actions and classifies engine-owned
actions. Before this fix, `ensure_participant_seat` lacked a no-op case and triggered a
false warning.
The `Office Default` workflow depends entirely on the actions that fall through. Its
Review and Approval steps declare:
```yaml
on_enter:
- type: clear_decisions
- type: queue_run_for_each_participant
config: { role: reviewer, reason: review_started }
```
Neither executes on an ordinary transition. No reviewer is ever woken, so no decision is
ever recorded, so the `wait_for_quorum` guard on `on_turn_complete` can never be
satisfied, so the task never leaves Review. Every Office task stalls permanently at the
first review gate, with nothing anywhere saying why.
This was confirmed on 2026-08-19 by driving a real task (Epic: Git-sync for Kandev
automations, workspace `95542bf3`) end to end against `Office Default`
(workflow `7561d51f`). The card advanced Work → Review correctly and the reviewer was
attached to the Review step; no run was ever queued and no diagnostic was produced.
Three things make this expensive to diagnose, and the fix must remove all three:
1. **The declaration validates.** `config/workflows/loader.go` accepts
`clear_decisions` and `queue_run_for_each_participant` under `on_enter`
(`validOnEnter`), and the workflow import path accepts them too. Authoring gives a
clean bill of health for a step that will do nothing.
2. **The capability exists on the happy path only.** `engine.ClearDecisionsCallback` and
`engine.QueueRunForEachParticipantCallback` are implemented, their adapters are wired
in `backendapp`, and `TestOfficeDefaultWorkflow_FullCycleSmoke` walks the shipped
`Office Default` template Work → Review → Approval → Done and asserts the fan-out
count. It passes. It calls `HandleTrigger(TriggerOnEnter)` directly — an entry point
no ordinary transition ever reaches. A green test certifies an unreachable path, and
it certifies only the path where nothing fails: the failure-isolation behaviour this
spec requires (AC-C1, AC-C3) is the **opposite** of what those callbacks do today, so
"the capability exists" must not be read as "the capability is complete". See
[Scope reality](#scope-reality).
3. **The behaviour is inconsistent, not merely absent.** The same step, with the same
declaration, behaves differently depending on arrival route. Anyone who reproduces
through `switch_workflow` sees it work.
## Ownership decision
**On every entry path where `processOnEnter` runs, it is the single owner of the five
session-lifecycle actions and the workflow engine is the single owner of the other five.
On the one entry path where `processOnEnter` does not run at all (`switch_workflow`), the
engine owns every type it compiles.** Each action type has exactly one owner *per entry
path*, no action type is executed twice within one step entry, and the partition is stated
in full below.
Rationale, and why the alternative is rejected:
- ADR-0004 already assigns the coordinating role to the engine, and defines both missing
actions specifically as `on_enter` actions.
- Adding the two missing cases to `processOnEnter` closes this card and leaves the trap.
`run_code_review` is already the next victim: it is compiled by the engine, dispatched
by no orchestrator path, and reachable through imported and git-synced workflows. A
third partial switch is not a fix.
- Two implementations of one trigger is the underlying defect. The observable contract
below is written so that it cannot be satisfied by a second partial switch: it requires
identical action coverage across all arrival paths.
This decision constrains *where action semantics live*. It does **not** move session
lifecycle work. `processOnEnter` remains responsible for the session concerns it owns
today — agent-profile switching, the stale-`RUNNING` flip, plan-mode resolution,
passthrough vs ACP prompt delivery, waiting-state publication and queued-message drain.
Those are preserved unchanged (AC-D1).
### The ownership partition
This table is normative, and it is indexed by **entry path** because ownership genuinely
differs between them (see [Why](#why)). `E1`–`E3` are the paths on which `processOnEnter`
runs today; `E4` is `switch_workflow`, on which it does not. **`E5` joins the
`processOnEnter` column**: those two sites dispatch no `on_enter` at all today, and AC-A2
requires them to, so the dispatch this card adds there is `processOnEnter`'s — same owner,
same fixed positions, same partition. Read every `E1`–`E3` below as `E1`–`E3`, `E5`.
| Action type | Owner on `E1`–`E3`, `E5` | Owner on `E4` | Notes |
|---|---|---|---|
| `reset_agent_context` | `processOnEnter` | engine (`resetAgentContextCallback`) | Fixed position, first, on `E1`–`E3` (AC-A9) |
| `configure_session` | `processOnEnter` | **nobody** | Fixed position, second, on `E1`–`E3` (AC-A9). Not compiled by `compileOnEnter`; warns on `E4` (AC-A6), and closing that is Out of scope |
| `enable_plan_mode` | `processOnEnter` | engine (`enablePlanModeCallback`) | Session-scoped |
| `set_session_mode` | `processOnEnter` | engine (`setSessionModeCallback`) | Session-scoped |
| `auto_start_agent` | `processOnEnter` | engine (`autoStartAgentCallback`) | Session-scoped; prompt delivery differs between the two owners, deliberately (Out of scope) |
| `clear_decisions` | engine | engine | |
| `queue_run_for_each_participant` | engine | engine | |
| `queue_run` | engine | engine | |
| `run_code_review` | engine | engine | |
| `ensure_participant_seat` | engine | engine | |
Two properties this table exists to guarantee, neither of which is "one global owner":
- **No double execution within one entry (AC-A11).** The two dispatchers **share one
callback registry**: `buildWorkflowCallbacks` registers `enable_plan_mode`,
`reset_agent_context`, `auto_start_agent` and `set_session_mode` unconditionally, and
`compileOnEnter` compiles all four from the same declarations. A `processOnEnter` that
delegated its whole declared list to `HandleTrigger` would execute those four **twice** —
once through its own direct calls, once through the engine callbacks — and
`autoStartAgentCallback` is a third, different auto-start path from `autoStartStepPrompt`
/ `autoStartPassthroughPrompt`. The `E1`–`E3` column is what forbids that outcome.
- **The `E4` column is a description of today, and is preserved, not changed (AC-A13).**
Those four registrations stay wired. Removing them to enforce a global "one owner"
reading would strip `switch_workflow` of plan-mode, context-reset, session-mode and
auto-start behaviour, which is a regression this card does not make and AC-D1 does not
license.
`configure_session` is **deliberately** absent from `compileOnEnter`, so on `E1`–`E3` it is
orchestrator-owned and needs no engine case. Its absence is only a defect on `E4`, where it
is the one declared type that reaches no executor; AC-A6 requires that to warn, AC-A2
exempts it by name, and giving `E4` a `configure_session` executor is Out of scope. This is
a named hole, not a silent one.
## What
### Scope
A **step entry** is any event that makes a task **admitted to** step `S` when it was not
admitted to `S` immediately before. Admission, not the field write, is what defines it.
That phrasing is deliberate and it covers **two** shapes, only the first of which is a
`workflow_step_id` change:
- **(a) field change with admission** — `workflow_step_id` becomes `S` from some other
step, with `wip_admitted` true. This is the ordinary case.
- **(b) admission in place** — the task is already parked at `S` with `wip_admitted`
false and `queued_for_step_id` set, and a later promotion flips it to admitted
**without touching `workflow_step_id` at all**. This is a real entry: it is the moment
the task begins occupying the step, and it is when `on_enter` must dispatch.
An earlier revision of this spec defined an entry as a field change *and* admission,
which silently excluded shape (b) — while the Execution model simultaneously named
shape (b) as the place the record is allocated. The two statements contradicted each
other, and the path they disagreed about (`workflowStore.promoteSameStepTask`) is a
shipped WIP-queue path. Shape (b) is an entry. A placement that sets `workflow_step_id`
while leaving the task queued is **not** an entry on its own — nothing dispatches and no
record is allocated — but the promotion that later admits it **is**, under shape (b).
The complete, exhaustive list of code sites that constitute an entry, and the equally
explicit list of step-field writes that do **not**, is in the Execution model's
[write-site inventory](#the-write-site-inventory). Five entry paths exist:
- `E1` agent-turn auto-advance (`on_turn_complete` → `applyEngineTransition`)
- `E2` manual move (`move_task_kandev`, board drag, API)
- `E3` WIP-queue promotion (a task admitted after waiting on a step's WIP limit)
- `E4` `switch_workflow` action
- `E5` step change through a path that dispatches no `on_enter` today — the generic task
update API (`UpdateTask` with `workflow_step_id`) and task start
(`startTask` → `moveTaskToWorkflowStep`). These are entries by the definition above, so
AC-A2 requires them to dispatch and AC-B7 requires them to allocate. They are named
here rather than left implicit because they are the paths this spec's previous revision
missed entirely
The ten `on_enter` action types in scope are the eight the embedded-YAML allow-list
accepts (`enable_plan_mode`, `auto_start_agent`, `reset_agent_context`,
`set_session_mode`, `clear_decisions`, `queue_run_for_each_participant`, `queue_run`,
`ensure_participant_seat`)
plus `run_code_review` and `configure_session`, which are declarable through the
import/sync path though not through `validOnEnter`.
### Execution model
This section defines the mechanism the acceptance criteria in group B and group F depend
on. It exists because those criteria are otherwise unsatisfiable: the only operation
marker in the tree today (`workflowStore.IsOperationApplied` /
`MarkOperationApplied`) is backed by an in-memory `sync.Map` that is empty after a
restart, is read before execution and written only after every action succeeds, and so
provides neither durability nor an atomic claim.
**What this model covers, and what it deliberately does not.** It governs the five
**engine-owned** action types only — `clear_decisions`, `queue_run_for_each_participant`,
`queue_run`, `run_code_review`, `ensure_participant_seat`. The five orchestrator-owned
types are **outside** it
(AC-B8). That boundary is a decision, not an omission, and it is what keeps the protocol
below free of a lease and a reaper: `reset_agent_context` and `auto_start_agent` are not
safe to re-execute after a crash, so admitting them would force a liveness mechanism this
card does not need. They already carry their own guards, AC-D1 requires them unchanged, and
they are not what stalls the Office loop.
**The step-entry record.** Every step entry has exactly one durable record. It carries:
- the **entry identity**: the tuple `(task_id, step_id, entry_seq)`, where `entry_seq` is
an integer allocated at entry and strictly greater than any `entry_seq` previously
allocated for that `(task_id, step_id)` pair. Monotonicity is enforced by a **UNIQUE
constraint on `(task_id, step_id, entry_seq)`**, not by the allocator reading a maximum
and adding one: two allocators racing on the same pair would both read the same maximum
and write the same successor, and only the constraint makes the loser fail. A losing
allocator's transition fails as a unit under AC-B7 and is recoverable by replay;
- an **action marker per engine-owned `on_enter` action**, keyed by the pair
`(position in the declared list, action kind)` — see *Marker keying* below;
- a **declaration digest**: a canonical digest over the ordered list of
`(position, action kind)` pairs of the whole `on_enter` declaration as read at
allocation. It is what makes a position number mean the same action later that it meant
at allocation — see *Marker keying* below;
- a **terminal flag** for the entry, set when every engine-owned position **in the step's
declaration as read at that moment** holds a terminal marker. Evaluating the flag against
the current declaration rather than against a snapshot means a declaration that shrank
cannot leave an entry permanently non-terminal on account of a position that no longer
exists. (A declaration that shrank also fails the digest comparison, which terminalises
the entry outright; the flag rule is the belt to the digest's braces.) The flag exists so
recovery is one indexed query rather than a per-entry join;
- a foreign key on `task_id` to `tasks` with **`ON DELETE CASCADE`**, so records cannot
outlive the task they describe (AC-B9(e)).
**Who allocates it, and when.** The step-entry record is allocated **by the step
transition itself, in the same transaction as the `workflow_step_id` change**. This is
the load-bearing rule, and it is what makes replay and re-entry distinguishable at all.
The naive alternative — "a record already exists for `(task_id, step_id)`, so this is a
replay" — is **wrong**, and wrong in the direction that silently re-creates this card's
stall. The transition commits before `on_enter` dispatches (Persistence guarantees), so
at a genuine re-entry the task's `workflow_step_id` is *already* the destination step and
a record from the previous occupancy *already* exists. Both halves of that test are true
for a re-entry, so it would classify every Review → reject → Work → Review round trip as
a replay, reuse the old `entry_seq`, and let the 24-hour idempotency window suppress the
fan-out. No reviewer would be woken on any rejection round.
Allocating at the transition avoids this because a step *change* is exactly what defines
an entry:
- A **new entry** changed `workflow_step_id`, so it allocates a fresh `entry_seq`.
- A **replayed transition** finds the task already at the destination step, changes
nothing, and therefore allocates nothing.
- A **replayed `on_enter` dispatch** reads the record the transition already committed and
reuses its `entry_seq` verbatim.
The identity is therefore **deterministic** in the sense AC-B1 requires — it is *read
from committed state*, never recomputed from transition fields — while still being
distinct per entry. That is what lets AC-B2 and AC-B3 hold at the same time.
All five entry paths `E1`–`E5` shall allocate the record this way, at every site in the
[write-site inventory](#the-write-site-inventory)'s entry table. A path that admits a task
to a step without allocating one leaves that entry with no replay protection and no claim,
and does not satisfy AC-B2 or AC-F1.
A record is required only for a step whose `on_enter` declares at least one **engine-owned**
action. A step with an empty `on_enter` list (AC-E5), or one declaring only
orchestrator-owned types, has nothing this model governs and allocates nothing — which also
keeps the AC-B9 scan proportional to Office-style steps rather than to every transition on
every board.
**Ordering guarantee.** The step-entry record is committed **before** any `on_enter`
action for that entry executes — which the allocation rule above gives for free, since
the transition commits first. A crash between the transition and the dispatch leaves a
committed record with no markers at all, and recovery runs the entry from its first
engine-owned action.
**Persistence shape — mandated, because AC-B6 and AC-B7 together over-constrain it.**
The record and its markers shall live in a dedicated table in the **same database, reached
through the same writer handle**, as `tasks` and `workflow_step_decisions`. This is not a
free choice:
- AC-B7 requires allocation to share the *transition's* write, which is issued through the
task repository;
- AC-B6 requires the `clear_decisions` marker to share the *delete's* transaction, which is
issued through the workflow repository (`Repository.ClearStepDecisions`).
`backendapp/storage.go` builds both repositories over one shared `writer`/`reader`
`*sqlx.DB` pair, so a single transaction spanning both is possible — but **no plumbing for
one exists today**, and there is not one place to put it.
### The write-site inventory
**There is no single "the transition".** This is the most under-estimated fact about this
card, and a plan that misses it ships a fix that works on some entry paths and silently
does nothing on others.
The inventory below was rebuilt in Spec round 5 from an exhaustive
`grep -rn '\.WorkflowStepID = \|SET workflow_step_id'` over `apps/backend/internal/`
with `_test.go` excluded. The previous revision of this section was wrong in a way worth
recording, because the mistake is easy to repeat: it named `applyAdmissionPlacement` as
the `E3` WIP-promotion site "inside `PromoteQueuedTaskIfWorkflowStepHasCapacity`".
`applyAdmissionPlacement` has exactly **one** caller — `CreateTaskWithWorkflowStepAdmission`,
the task **creation** path — and the promoter neither calls it nor makes any placement
decision. Everything the previous revision said about "the site that decides the landing
step inside its own transaction" was therefore true of task creation and false of `E3`.
Do not re-derive this table from the function names alone; check the callers.
**Sites that constitute a step entry and therefore allocate a record.** Integration
watch-config writes (github, gitlab, jira, linear, sentry, azuredevops) are excluded
throughout: they write watch rows, not tasks.
| # | Write site | Entry path | Shape today |
|---|---|---|---|
| 1 | `orchestrator/event_handlers_workflow.go` `executeStepTransition` → `updateTransitionTaskWithCapacity` | `E1` (`on_turn_complete`, `on_turn_start`) | writes the field, then a repository update; **no transaction** |
| 2 | `orchestrator/workflow_store.go` `applyTransition` → `updateTransitionTask` → `repo.UpdateTask`, reached from `engine.applyTransition` | `E1` (engine), `E4` re-entry | **non-transactional** GetTask → mutate → `UpdateTask` |
| 3 | `task/service/service_workflow.go` `MoveTaskWithOptions` → `updateMovedTask` | `E2` manual move | delegates a changed-step move to `UpdateTaskWithWorkflowStepAdmissionAndState` → `updateTaskWithWorkflowStepAdmission`, which **owns a `BeginTx`**; commits, *then* publishes `task.moved`, and the orchestrator dispatches `on_enter` later off that event |
| 4 | `orchestrator/workflow_store.go` `pullOneFeederTask` | `E3` promotion | sets the landing step **before** the transaction, then calls the promoter |
| 5 | `task/service/service_workflow.go` `promoteFeederQueuedTask` | `E3` promotion | same shape as site 4, in a different package |
| 6 | `orchestrator/workflow_store.go` `promoteSameStepTask` | `E3` admission in place | **writes no step field at all** — sets `WIPAdmitted`, clears `QueuedForStepID` / `QueuedAt`, then calls the promoter. This is Scope's shape (b) |
| 7 | `task/repository/sqlite/task.go` `PromoteQueuedTaskIfWorkflowStepHasCapacity` | `E3` (shared) | owns a `BeginTx`; writes the **caller-supplied** `task.WorkflowStepID`; makes **no** placement decision. Four production call sites: `promoteQueuedTaskAtomically`, `promoteFeederQueuedTask`, `pullOneFeederTask`, `promoteSameStepTask` |
| 8 | `task/repository/sqlite/workflow.go` `AddTaskToWorkflow` | `E4` `switch_workflow` | owns a `BeginTx`, on a **different repository** from `workflowStore`; receives IDs only |
| 9 | `task/service/service_tasks.go` `UpdateTask` | `E5` generic API | sets `task.WorkflowStepID` unconditionally from `req.WorkflowStepID`; **no admission logic, no `on_enter` dispatch today** |
| 10 | `orchestrator/task_operations.go` `moveTaskToWorkflowStep`, reached from `startTask` | `E5` task start | guards on the field actually differing, plain `UpdateTask`, publishes `task.updated`; **no `on_enter` dispatch today** |
Sites 4, 5 and 6 are the real `E3` shape, and they share one property that matters: **the
landing step is chosen by the caller, before the transaction, in a package above the
repository.** Site 7 is the transaction they all funnel through, and it is where the
record must be written — but it cannot decide, on its own, which step the entry is for.
That is the opposite of the previous revision's claim and it changes the allocation
contract; see [Allocation inputs](#allocation-inputs).
**`E5` is new in this revision and it is deliberately named rather than left silent.**
Sites 9 and 10 change a task's step through paths that dispatch no `on_enter` at all
today. They are entries by Scope's definition, so under AC-A2 they must dispatch, and
under AC-B7 they must allocate. Naming them is what stops the next reader concluding the
inventory was sloppy; excluding them silently is precisely how this card's own defect got
into the tree.
**Step-field writes that are NOT entries, and why.** A named exclusion is a contract;
silence is a defect. These are the remaining non-test writes:
| Write site | Why it is not an entry |
|---|---|
| `task/repository/sqlite/task.go` `applyAdmissionPlacement`, from `CreateTaskWithWorkflowStepAdmission` | Task **creation**. There is no previous occupancy to leave, so nothing is re-entered. Creation into a step declaring engine-owned `on_enter` actions is **Out of scope** for this card and named there |
| `applyAdmissionPlacement`'s queued branch (sets `workflow_step_id`, leaves `wip_admitted` false, sets `queued_for_step_id`) | The task is waiting, not occupying. Scope's shape (b) covers the later promotion that admits it, and that promotion is site 6 |
| `orchestrator/task_operations.go` `advanceTaskWorkflowStep`, from `StartSessionForWorkflowStep` | Its guard returns early unless the step differs, and **every** current caller supplies the task's own step: the internal caller is `autoStartAgentCallback` passing `in.Step.ID` after the `E4` transition, and the external `LaunchSessionRequest` constructors pass the task's just-resolved or current step. It is a no-op on every path that exists, so allocating there would produce records for entries that never happen. **If a future caller supplies a differing step id, it becomes site 11** — `ResolveIntent` routes any request carrying both `SessionID` and `WorkflowStepID` here, so the API surface does not prevent it |
| `task/service/service_tasks.go` `RestoreTaskMessageRollback` / `task/repository/sqlite/task.go` `RestoreTaskMessageRollbackIfSessionState` | Message-rollback restore returns a task to a step it previously occupied as part of undoing history, not as a workflow advance. Re-dispatching `on_enter` there would re-fan-out reviewers for a round the user is explicitly rewinding. **Out of scope**, named |
**AC-B7's transaction requirement binds every site in the entry table.** Sites 3, 7 and 8
already own a transaction and need the allocation added inside it. Sites 1 and 2 own none
and need one introduced. Sites 4, 5 and 6 do not write the field themselves — they mutate
the in-memory task and hand it to site 7 — so their allocation happens inside site 7's
transaction, with the landing step and declaration passed down (see Allocation inputs).
Sites 9 and 10 own no transaction and need one introduced.
Two named consequences of missing a site, because both are silent failures rather than
errors:
- A path that admits a task to a step **without** allocating a record leaves that entry
with no marker to compare-and-set. There is then nothing for AC-F1 to suppress and
nothing for AC-B2 to make idempotent, and a dispatcher that requires a record has none.
`E2` is the path a human uses to unstick a stalled Office card, so an `E2` gap
reproduces this card's own stall on the recovery route.
- `E4` is today the **only** path that executes these actions correctly. A fix that leaves
site 8 unallocated regresses working behaviour, which AC-A13 forbids.
### Allocation inputs
The record carries a declaration digest computed at allocation, and a record is required
only for a step whose `on_enter` declares at least one engine-owned action. Both facts
mean **the allocating code must have the landing step's `on_enter` declaration in hand at
the moment it writes**. Who supplies it is a contract, not an implementation detail,
because the two sites that own the transaction live in packages that cannot read it.
Verified constraints that make this load-bearing:
- `task/repository/sqlite` has no access to workflow declarations. It imports `wfmodels`
only for `NewWIPLimitError`, never for `Events` / `StepEvents` or their JSON. The stub
`workflow_steps` table its own `base_schema.go` creates carries an `events` column, but
the comment there states it exists only so the workflow repository's later
`ALTER ADD COLUMN`s become no-ops; nothing in the package reads or parses it.
- `AddTaskToWorkflow` (site 8) receives `taskID, workflowID, workflowStepID, position` —
IDs only.
- `PromoteQueuedTaskIfWorkflowStepHasCapacity` (site 7) receives a task plus step ids and
a limit, and the landing step was already chosen by its caller (sites 4, 5, 6).
**The contract.** Allocation takes exactly two inputs, both resolved by the caller in the
package that owns the workflow domain, and both passed down to the site that writes:
1. **the resolved landing step id** — the step the task will actually be admitted to, as
that call path has already decided it, never the originally requested target; and
2. **the ordered `on_enter` declaration snapshot** for that step, read once, through the
workflow repository's normal accessor — the same decode path every other reader uses.
The allocating site does not re-read it, does not parse `events` JSON itself, and does
not issue SQL against `workflow_steps`.
A caller that cannot resolve one of these shall not allocate and shall fail the
transition under AC-B7 rather than allocate a record with an unknown declaration.
Two consequences follow, and both are the point of stating this:
- **A site cannot decide for itself whether a record is needed.** "Does this step declare
an engine-owned action?" is answered from input 2, by the caller, before the write.
- **Sites 4, 5 and 6 resolve both inputs and hand them to site 7.** They already choose
the landing step, they already sit in packages that can read the declaration, and site 7
is the transaction. Site 6 resolves the step it is admitting the task to even though it
writes no step field, because under Scope's shape (b) that is the entry.
The alternative shapes are both rejected explicitly, so a builder does not have to weigh
them: raw `SELECT events FROM workflow_steps` inside the repository transaction duplicates
a decode path that will drift the first time the declaration shape changes; and a
cross-repository call reaching from the task repository into the workflow repository
mid-transaction is the plumbing this section's own persistence note says does not exist,
and this card does not add it.
`task.Metadata[models.MetaKeyAppliedDeferredMoves]` remains the in-tree precedent for the
**compare-and-set pattern** — `markDeferredMoveApplied` performs exactly the check-and-set
shape this model needs and returns `errDeferredMoveAlreadyApplied` on a duplicate — but it
is explicitly **not** the recommended *location*: a marker on the task row cannot share a
transaction with a `workflow_step_decisions` delete, so it cannot satisfy AC-B6.
**Marker keying, and the declaration digest that makes positions meaningful.** A marker is
keyed by `(entry identity, position, action kind)`. But a position number is only a stable
name for an action *while the declaration is unchanged*, and workflows in this repo are
git-synced, so a declaration can change between an entry and a later recovery. The record
therefore also carries a **declaration digest**, computed at allocation over the ordered
list of `(position, action kind)` pairs of the **whole** `on_enter` declaration.
"Position" throughout this spec means the index in the **whole** declared `on_enter` list,
including orchestrator-owned entries, not the index among engine-owned actions only. The
digest covers that whole list for the same reason: inserting an orchestrator-owned action
shifts every engine-owned position after it.
The digest's **inputs** are fixed by this spec — the ordered `(position, action kind)`
pairs, kinds serialized by their declared type string. Its **algorithm and encoding** are
the builder's choice, exactly as AC-H7 leaves them, because neither has an observable
consequence as long as it is stable within a process and across a restart.
The digest, not the per-position kind, is the authority:
- **The declaration is read once at the start of a dispatch, and that read serves the whole
dispatch.** The digest is compared once, against that read; the declaration is not
re-read between positions. Re-reading mid-dispatch would let a sync land between two
actions of one entry and leave half of it executed against each version. This mirrors
AC-F2's read-once rule for the participant set, and for the same reason.
- Every dispatch and every recovery re-dispatch recomputes the digest from the declaration
as read at that moment and compares it to the record's.
- **If it matches**, positions name the same actions they named at allocation and the
markers are honoured as written.
- **If it differs**, the entry is finished. One WARNING is emitted for the entry naming
the workflow id, step id, task id and both digests (AC-A6), no further `on_enter` action
executes for that entry, and the entry sets its terminal flag.
**The position set to be marked is the one from the declaration just read for this
digest comparison** — the same read, not a reconstruction of the declaration in force at
allocation. This is stated explicitly because the record persists a *digest*, not the
declaration, so the allocation-time position list is not recoverable from it, and a
builder who reads this bullet in isolation will reasonably ask which set is meant. Every
engine-owned position of the current declaration that does not already hold a terminal
marker is marked `skipped(declaration_changed)`; markers left over from positions that no
longer exist in the current declaration are never read again and are not cleaned up. This
is the same "as read at that moment" convention the terminal-flag bullet above uses, and
for the same reason.
- A marker whose kind does not match its position's current kind cannot occur while the
digest matches. If one is observed anyway it is data corruption, and it takes the same
disposition: `skipped(declaration_changed)`, warn, entry terminal.
**Per-position kind matching alone is not sufficient, and this is why.** It detects a
*substitution* at a fixed index. It does not detect an insertion, a deletion, or a reorder
of same-kind actions, which is an ordinary template edit. Worked example: an entry runs
`[reset_agent_context, clear_decisions, queue_run_for_each_participant]`, commits
`clear_decisions` at position 1 as `done`, and the backend crashes. The template is then
synced to
`[reset_agent_context, clear_decisions, clear_decisions, queue_run_for_each_participant]`.
Under per-position matching, position 1's `done` marker matches the *newly inserted*
declaration's kind, so a `clear_decisions` that has never run is treated as complete; and
position 2, which carries no marker and whose kind also matches, is the *already-committed*
one, so it is executed again — a second **committed** `clear_decisions` delete for one
entry, which AC-B2 forbids in those words. A deletion shifts positions the other way, so a
`done` marker lands on an action that never ran and that action is skipped forever, which
is this card's own bug class reintroduced by its fix. The digest closes both directions
with one comparison.
**What the digest deliberately does not cover.** It is computed over kinds and order only,
**not** over action configuration. A change to an action's `config` at the same position and
kind therefore does **not** invalidate the entry — the entry already executed that action
under the configuration in force at the time, and re-running it under new configuration is
worse than not running it. (The separate per-action config digest of AC-H7 serves the
idempotency key and has nothing to do with this one.)
**The cost, stated rather than discovered.** A declaration edited mid-entry abandons that
entry's remaining actions instead of guessing which marker belongs to which action. The
remedy is a fresh step entry, which is the same remedy AC-B5's terminal `skipped` already
prescribes and which Out of scope already names. Abandoning is the safe direction: the
alternative is executing the wrong action or silently skipping a real one, and both are
worse than a warned no-op.
**Marker states.** A marker is in exactly one of:
| State | Meaning | Terminal? |
|---|---|---|
| absent | never attempted for this entry | no |
| `in_progress(epoch)` | claimed by the backend process identified by `epoch`; outcome unknown | no |
| `done` | executed successfully | yes |
| `skipped(reason)` | reached its dispatcher and was determined not to execute | yes |
| `failed(cause)` | executed and failed | yes |
`skipped` reasons are `unrecognised` (AC-A6), `malformed` (AC-E3), `no_executor`
(AC-A6(b)), `declaration_changed` (above), `step_left` (AC-B9(a1)), `superseded`
(AC-B9(a2)), and `unresolvable` (AC-B9(e)). The list is closed: a builder needing an eighth
has found a case this spec did not anticipate and should route it back rather than
inventing one.
**Claim — per action, and it is the only suppression mechanism.** Before executing an
engine-owned action a dispatcher performs an atomic compare-and-set on that action's
marker from `absent` to `in_progress(this process's epoch)`. Only the winner executes.
After the effect commits it compare-and-sets `in_progress` to one of the three terminal
states.
There is deliberately **no entry-level exclusive lock**. An entry-level claim cannot work
here, because the step-entry record is created by the *transition*, so by the time any
dispatcher runs, the record always already exists — creation can therefore never serve as
the dispatcher's claim. Per-action compare-and-set is what actually delivers AC-F1, and it
delivers it without a lock that a dead claimant could hold forever.
**A dispatcher that loses a claim stops. It does not skip ahead.** When a compare-and-set
fails because another live dispatcher holds the marker, the losing dispatcher shall
**abandon the whole entry and return** — it shall not advance to the next position, and it
shall not attempt any later action of that entry. This is the rule, not an implementation
detail, and it is stated because "the claim attempt returns" and "the dispatcher returns"
are both readable into a per-action mechanism and only one of them is correct.
Skipping ahead breaks two criteria at once. AC-A3 requires the `clear_decisions` delete to
commit *before any `on_enter` action declared after it is executed*; a loser that advances
past a `clear_decisions` still held by the winner can enqueue the reviewer fan-out against
uncleared decisions. AC-A12 requires declared order across the whole list; a loser running
position 2 while the winner is still on position 1 violates it directly. And if the
winner's `clear_decisions` then fails, the loser has already executed exactly the actions
AC-C2 says must not run.
Nothing is lost by stopping: the winner is working through the same declaration in the same
order and will reach every remaining position itself. If the winner dies mid-entry its
markers become stale by epoch and AC-B9's startup scan resumes the entry. The loser is not
a fault and emits no diagnostic (AC-F1).
**Epoch, and how a dead claimant's marker is distinguished from a live peer's.** The
`epoch` recorded in an `in_progress` marker identifies the backend **process** that wrote
it. It shall be a value generated fresh at each backend start and **guaranteed not to
repeat across restarts** — a random identifier minted at boot satisfies this; a process id
does not, because operating systems reuse them, and neither does a whole-second timestamp,
because a fast restart can land inside the same second. Reusing an epoch would make a dead
process's marker indistinguishable from a live one's and strand the entry. The epoch
changes on every backend start. Exactly one backend process owns the database at a
time — `internal/backendapp/ownershiplock` acquires an OS lock over the runtime target at
startup and fails with "already owned by another backend" otherwise — so:
- an `in_progress` marker whose epoch **equals** the current process's epoch was written by
a live peer goroutine. The observer does **not** re-claim, does **not** execute, and per
the stop rule above **abandons the entry** rather than moving to the next position; it
returns without a diagnostic. This is AC-F1's losing caller, and it is a normal outcome.
- an `in_progress` marker whose epoch **differs** from the current process's is definitionally
stale — the process that wrote it is gone. It may be re-claimed by compare-and-set from
`in_progress(old_epoch)` to `in_progress(current_epoch)`.
No lease, no timeout, and no periodic reaper is required, because staleness is decided by
identity rather than by elapsed time.
**Why re-claiming is safe, per action type.** Re-claiming means the effect may run a second
time, so each engine-owned type must be safe under that:
- `clear_decisions` — safe. AC-B6 puts the marker in the same transaction as the delete, so
an `in_progress` marker proves the delete did **not** commit. This is the reason AC-B6 is
stated as a transaction requirement rather than an ordering preference.
- `queue_run_for_each_participant` and `queue_run` — safe. Every run they enqueue carries
the entry-scoped idempotency key of AC-B1, so a duplicate is suppressed by the runs queue
and reported as a success (AC-B4).
- `run_code_review` — **not** idempotent, and this is an accepted, named outcome. It is
at-most-once in normal operation and at-least-once across a crash: a backend that dies
between launching a review pass and marking it may launch a second pass on recovery. A
duplicate review is wasteful, not corrupting, and AC-A8 already makes a failure to start
non-blocking. Making it exactly-once is Out of scope.
**Recovery — a bounded startup scan, not a reaper.** On backend start, once the store is
open and before the workflow engine begins serving triggers, the system selects step-entry
records whose terminal flag is unset and re-dispatches each one. This is a single indexed
query executed once per process start. It is not periodic, holds no lease, and applies no
timeout. Re-dispatch resumes in declared order, skipping every position already in a
terminal state, and re-claiming stale `in_progress` markers per the epoch rule above.
Without this scan a crash mid-entry would leave an Office task stalled at Review with no
diagnostic — the exact defect class this card exists to remove — so the scan is in scope
and AC-B9 states it.
**The scan runs before the engine serves triggers, so its error behaviour decides whether
the backend starts.** That makes the failure rules part of the contract rather than an
implementation detail:
- **The scan shall never prevent the backend from starting.** No record, and no number of
records, may block boot. A backend that refuses to start because one row is unreadable
has converted a stalled card into a dead installation.
- **A record whose task, step, workflow, or `on_enter` declaration cannot be resolved** —
the row is gone, or a git sync removed the step — is marked terminal with
`skipped(unresolvable)` and warns once (AC-A6). It is not executed and it is not
re-selected on the next start.
- **A record that fails to process for a transient reason** — a repository read error, a
contended write — is logged at ERROR, left non-terminal so a later start retries it, and
the scan **continues to the next record**. One bad record does not abandon the rest.
- **If the scan's own query fails**, the failure is logged at ERROR and startup proceeds.
The consequence is that entries left incomplete by the previous crash are not resumed
until the next start, which is strictly better than not starting.
**Orphan records are prevented at the schema AND handled at the scan, and both are
required.** The step-entry table's `task_id` carries a foreign key to `tasks` with
`ON DELETE CASCADE` — the established in-tree pattern for task-scoped side tables
(`gitlab/store_mr_automation.go`, `office/repository/sqlite/tree_holds.go` both declare
exactly this against `tasks(id)`).
The cascade alone is **not** sufficient, and this is a repo-specific hazard rather than a
general one. SQLite enforces foreign keys only when `PRAGMA foreign_keys` is on for the
connection, and this codebase already carries explicit workarounds for databases where it
was not on at attach time — `linear/store_issue_watch.go` and `jira/store.go` both perform
a guard child `DELETE` with a comment saying exactly that. A `ON DELETE CASCADE` that is
silently a no-op on an older database would leave records whose task no longer exists, and
the startup scan would then dereference a missing task on every boot.
So both mechanisms are load-bearing: the cascade is the primary and keeps the table from
accumulating a row per deleted task forever, and AC-B9(e)'s `skipped(unresolvable)` is the
**backstop that must not be omitted on the grounds that the cascade makes it unreachable**.
`skipped(unresolvable)` also covers what no cascade can: a step or workflow removed by a
git sync, and a transient read failure.
### Acceptance criteria
#### A. Dispatch completeness
- **AC-A1** The system shall execute every `on_enter` action declared on the destination
step, in the order declared, exactly once per step entry — subject to the **six** stated
carve-outs, and to no others: AC-A9 (fixed positions for three types, on `E1`–`E3`,
`E5`), AC-A13 (which component executes a type on `E4`, and in what order), AC-H4/AC-H8
(a repeated declaration of `reset_agent_context` or `configure_session` executes once in
total, not once per declaration), AC-A6/AC-E3 (an action that reaches no executor, or is
malformed, does not execute and is marked terminal instead), **AC-A8** (`run_code_review`
is at-most-once in normal operation and at-least-once across a backend crash), and
**AC-B8** (for the five orchestrator-owned types, "exactly once" is guaranteed within a
single dispatch and not across a crash, because they hold no marker).
The last two are carve-outs against the *durability* of "exactly once" rather than
against its ordering, and they are enumerated here because an earlier revision said "the
four stated carve-outs, and to no others" while AC-A8 and AC-B8 each stated a fifth and
sixth — making the criterion unsatisfiable as written. Without the AC-H8 carve-out an
implementation satisfying AC-A1 literally would fire `configure_session` twice for a
step declaring it twice, which AC-A11 and AC-D1 forbid.
- **AC-A2** WHEN a task enters a step through any of `E1`–`E5`, the system shall execute
the same set of `on_enter` actions as it would through any other entry path. "Same set"
means action *coverage*: no entry path may silently discard a declared action that
another entry path executes. It does not require unifying prompt-delivery mechanics that
already differ per path (see Out of scope).
**Two exemptions, both by name, and no others:**
(i) `configure_session` is not executed on `E4`. The discard is not silent — AC-A6
requires it to warn on that path — and giving `E4` a `configure_session` executor is Out
of scope.
(ii) **Ordering is not required to be identical across entry paths**, because AC-A9's
fixed positions exist only where `processOnEnter` runs. On `E1`–`E3` and `E5`,
`reset_agent_context` and `configure_session` are hoisted to the front and
`auto_start_agent` is deferred to the end; on `E4` the engine executes the compiled list
in declared order with no hoisting. A step declaring
`[clear_decisions, reset_agent_context]` therefore runs reset-then-clear on `E1`–`E3` /
`E5` and clear-then-reset on `E4`, and **that is conforming**. What this criterion
requires across paths is coverage and per-path internal ordering (AC-A9, AC-A12), not a
single global order.
This exemption is stated rather than resolved because the alternative is worse in both
directions: hoisting inside `compileOnEnter` would change `E4` behaviour that works
today, which AC-A13 forbids, and dropping AC-A9's hoist on `E1`–`E3` would change
`reset_agent_context`-before-`auto_start_agent` ordering that AC-D1 freezes. Closing the
divergence is Out of scope.
No type other than (i) may be discarded by any path.
- **AC-A3** WHEN a step declaring `clear_decisions` is entered, the system shall delete
every `workflow_step_decisions` row whose `task_id` is the entering task and whose
`step_id` is the **destination** step, before any `on_enter` action declared after it
is executed. It shall not clear decisions for the step being left.
- **AC-A4** WHEN a step declaring `queue_run_for_each_participant` with `role: R` is
entered, the system shall enqueue exactly one run for each **distinct** participant of
that step whose role equals `R`, and shall enqueue no run for any participant whose
role does not equal `R`. Distinctness is defined by AC-A5.
- **AC-A5** The participant set for AC-A4 shall be the merged template-level
(`task_id = ''`) and per-task (`task_id = <entering task>`) rows for the step, with the
per-task row winning on a `(role, agent_profile_id)` collision, enumerated in
`role ASC, position ASC, id ASC` order. The merged list shall then be **de-duplicated
on `(role, agent_profile_id)`, retaining the first row in that order**; there is no
UNIQUE constraint on `workflow_step_participants`, so two rows in the same tier can
carry the same pair. Runs shall be enqueued in the resulting order. This
de-duplication applies to the fan-out only. It shall not change the participant set
`evaluateWaitForQuorum` reads, which is out of scope for this card; a builder shall not
"fix" quorum's input while implementing AC-A4.
- **AC-A6** IF a step declares an `on_enter` action that does not reach execution, THEN
the system shall emit a log record at **WARNING** naming the workflow id, step id, step
name, and action type, and shall continue executing the remaining `on_enter` actions.
This covers **both** points at which an action is discarded today:
(a) an action type no dispatcher translates into an executable action at all,
and (b) a translated action for which no callback is registered in the running
configuration. Both are silent today; both must warn.
Whether a type is a candidate for this warning is decided **per entry path**, by the
ownership partition's column for that path, not globally. A type with an owner on the
entry path in question reaches execution and shall not warn: on `E1`–`E3` and `E5` that
includes
`configure_session` and `reset_agent_context`, neither of which shall ever warn there
even though `compileOnEnter` does not compile the former. A type with no owner on the
path in question shall warn: on `E4` that is exactly `configure_session`, and the record
shall additionally name the entry path so the reader can tell this expected, excluded
gap from an unexpected one.
- **AC-A7** WHEN a step declaring `queue_run` on `on_enter` is entered, the system shall
enqueue one run against the action's resolved target, using the same target resolution
(`primary`, `workspace.ceo_agent`, `participant_role:<role>`, `agent_profile_id:<id>`)
the engine applies to `queue_run` under any other trigger. The run it enqueues shall
carry the AC-B1 entry-scoped idempotency key and shall be excluded from the coalescing
window under AC-H9, on the same terms as a fan-out run. A resolution failure is an
ordinary action failure under AC-C3. This criterion exists as a separate statement
because specifying entry identity for the fan-out alone would leave an `on_enter`
`queue_run` with no replay protection under AC-B2 and coalescing-eligible on re-entry,
reproducing this card's stall in miniature for that action type.
- **AC-A8** WHEN a step declaring `run_code_review` on `on_enter` is entered, the system
shall start a code-review pass for the task. "Started" is observable as a
`task_review_runs` row carrying the entering task's id, the destination step's id as
`workflow_step_id`, and trigger `workflow_step`; that row is the artifact a test asserts
on. A failure to start shall not block the entry and shall not fail the action —
`runCodeReviewCallback` logs and returns nil today, and that is preserved. This action is
at-most-once per entry in normal operation and at-least-once across a backend crash
(Execution model, *Why re-claiming is safe*); a duplicate review pass after a crash is an
accepted outcome, not a defect.
- **AC-A9** **On the entry paths where `processOnEnter` runs — `E1`–`E3` and `E5`, and
not `E4`** — three action types keep their current fixed position regardless of where
they appear in the declared list:
`reset_agent_context` **first**, then `configure_session`, both before every other
`on_enter` action; and `auto_start_agent` **last**, after every other `on_enter` action.
AC-A1's declared-order rule applies to the remaining actions among themselves.
All three positions are today's behaviour, verifiable in `processOnEnter`: the first two
are dispatched ahead of the action loop, and `auto_start_agent` is not executed in the
loop at all — the loop only records that it was declared, and the prompt is built and
dispatched after the loop ends. An earlier revision named only the first two, which left
AC-A12 requiring `auto_start_agent` in declared order against a structure that cannot
produce it without moving prompt delivery — a change AC-D1 explicitly freezes.
**On `E4` this criterion does not apply**: the engine compiles and executes the declared
list in order, with no hoisting and no deferral, and AC-A13 preserves that. AC-A2(ii) is
the corresponding cross-path exemption.
- **AC-A10** `clear_decisions`, `queue_run_for_each_participant`, `queue_run`,
`run_code_review`, and `ensure_participant_seat` do not depend on a live agent
session. WHEN a task enters a step
declaring one of them and the task has no running session, the system shall still
execute it. Session-scoped actions (`enable_plan_mode`, `set_session_mode`,
`reset_agent_context`, `configure_session`, `auto_start_agent`) remain skipped in that
case, as today.
Today the whole `on_enter` sequence is abandoned when no session resolves
(`fromStepAndTargetForTaskMoved` returns early on `session == nil`), so an Office task
moved to Review by hand — a normal path — queues nothing even once the dispatch defect
is fixed. Meeting this card's stated acceptance for entry path `E2` requires closing
this.
- **AC-A11** Each `on_enter` action type shall be executed by exactly one component per
step entry, per the ownership partition. No action type shall be executed by both
`processOnEnter` and the engine for the same entry. Specifically: the action list
`processOnEnter` delegates to the engine shall contain only engine-owned kinds, and the
five orchestrator-owned kinds shall not be executed through a shared callback registry
as part of that delegation.
- **AC-A12** AC-A1's declared order shall hold **across** the ownership partition, not
only within each owner's subset. Splitting the declared list by owner and running all
orchestrator-owned actions before all engine-owned ones does not satisfy AC-A1 unless
the declared list already has that shape. Delegating a contiguous run of engine-owned
actions in a single call is permitted, because that preserves declared order; delegating
a non-contiguous set as one batch is not. `reset_agent_context`, `configure_session` and
`auto_start_agent` keep their fixed positions and are exempt (AC-A9) — the first two
leading, `auto_start_agent` trailing. Concretely, a step declaring
`[auto_start_agent, clear_decisions]` conforms when the clear runs first and the agent
starts after it, on the paths where AC-A9 applies; this criterion does not require the
auto-start dispatch to be re-ordered, and re-ordering it would breach AC-D1.
**A test for this criterion shall not place `auto_start_agent` last in its declared
list**, since that is the one position where the exemption and a violation are
indistinguishable.
- **AC-A13** On `E4` (`switch_workflow`), where `processOnEnter` is not in the call path,
the engine shall remain the executor of every type it compiles, and the unconditional
registrations of `enable_plan_mode`, `reset_agent_context`, `auto_start_agent` and
`set_session_mode` in `buildWorkflowCallbacks` **shall remain wired**. Removing them to
enforce a single global owner per action type is a regression, not a conformance fix: it
would strip `switch_workflow` of plan-mode, context-reset, session-mode and auto-start
behaviour that works today. AC-A11's prohibition is on one *entry* executing a type
twice; it is not a prohibition on two entry paths having different executors.
#### B. Identity, idempotency, and re-entry
- **AC-B1** Each step entry shall carry the entry identity defined in Execution model.
Every run enqueued by **any** `on_enter` action during that entry — the
`queue_run_for_each_participant` fan-out and a bare `queue_run` alike — shall carry an
idempotency key derived from that identity together with the step id, task id, the
action's position in the declared list, the target agent profile id, and the action's
canonical configuration digest (AC-H7). The position is part of the key so that two
declarations at different positions that resolve identically are still distinguishable
from a replay of one of them.
- **AC-B2** IF the same step entry is processed more than once — a replayed event, a
retried transition, or a process restart mid-entry — THEN the system shall not enqueue
a second run for a participant already enqueued for that entry, and shall not perform a
second *committed* `clear_decisions` delete for that entry. The markers that guarantee
this shall be durable across process restart.
"Second committed delete" is the precise form, and the precision matters: a
`clear_decisions` whose marker is `in_progress` after a crash **is** re-executed on
recovery, and that is correct rather than a violation, because AC-B6 puts marker and
delete in one transaction, so an `in_progress` marker proves the earlier attempt never
committed. What this criterion forbids is a delete running again after one has committed,
which the terminal `done` marker prevents (AC-B5).
- **AC-B3** WHEN a task re-enters a step it has previously occupied (the Review →
rejection → Work → Review loop), the system shall treat it as a new step entry per the
distinguishing rule in Execution model: a new `entry_seq`, a fresh `clear_decisions`,
and a fresh fan-out to every matching participant. The runs enqueued by that fan-out
shall not be suppressed by the 24-hour idempotency window applied to the previous
entry's runs, because their keys differ in `entry_seq`.
- **AC-B4** IF the runs queue idempotency-suppresses a fan-out run — the 24-hour window
on an identical AC-B1 key — THEN the system shall treat that as a successful enqueue,
not a failure, and shall not retry it. Because keys differ in `entry_seq` (AC-B3) and in
declared position (AC-B1), an identical key means exactly **one** thing: a replay of the
same action, at the same position, within the same entry (AC-B2). It never means a
distinct entry, and it never means a second declaration within one entry — those carry
different positions and therefore different keys (AC-H5). A suppression observed under
any other circumstance is a defect in the key, not a duplicate to be tolerated.
- **AC-B5** The system shall record a durable marker for each engine-owned `on_enter`
action position it *reaches* within a step entry — not only for those it completes —
using the state machine and keying defined in Execution model. It shall not execute an
action whose marker for that entry is already terminal (`done`, `skipped`, or `failed`).
Specifically:
(a) an action that executed successfully is marked `done`;
(b) an action that reached its dispatcher and was determined not to execute is marked
`skipped(reason)`, drawn from the closed list in Execution model — `unrecognised` and
`no_executor` (AC-A6), `malformed` (AC-E3), `declaration_changed` (Execution model),
`step_left` (AC-B9(a1)), `superseded` (AC-B9(a2)), `unresolvable` (AC-B9(e)). `skipped`
is **terminal**: recovery
does not resume at it, and its AC-A6 / AC-E3 diagnostic is emitted once, at the
transition into `skipped`, not on every subsequent observation. `declaration_changed` is
the one reason applied to an entry's whole remaining tail at once rather than to a single
position, because the digest comparison that produces it is an entry-level fact;
(c) an action that executed and failed is marked `failed(cause)`, which is likewise
**terminal**. Recovery does not retry it, consistent with Out of scope's exclusion of
retry. For a partially-failed fan-out this means the participants whose enqueue succeeded
keep their runs, and the participants whose enqueue failed are not retried and are
recorded only by their AC-C1 ERROR records; the `cause` recorded in the marker is the
attempted count, the failed count, and the first failure's cause in AC-A5 order, as
AC-C1 specifies.
Both halves of this are deliberate and both have a cost: marking a non-executing action
terminal means an action that would become executable later — a session appears, an
adapter is wired — does **not** run for that entry, and the remedy is a fresh entry. Not
marking it would leave recovery resuming at it forever, so the entry would never reach a
terminal state and the AC-A6 / AC-E1 diagnostics would repeat on every replay, degrading
the signal group G exists to provide. Terminal-on-reach is the chosen trade.
- **AC-B6** The completion marker for `clear_decisions` shall be committed in the **same
transaction** as the delete it marks. A marker written after a separately-committed
delete leaves a window in which a crash re-runs the clear on recovery and discards
decisions recorded in between — the exact corruption AC-C2 and AC-F3 exist to prevent.
Actions whose own effect is independently idempotent (the fan-out and `queue_run`, whose
runs carry the AC-B1 key) do not require this and may mark completion separately.
This is also what makes re-claim safe for `clear_decisions`: because marker and delete
share one transaction, an `in_progress` marker proves the delete did not commit, so a
recovering process may re-run it (Execution model, *Why re-claiming is safe*). Satisfying
this criterion constrains where the marker lives — a marker on the task row cannot share
a transaction with a `workflow_step_decisions` delete — which is why Execution model
mandates the store rather than leaving the shape free.
- **AC-B7** IF the step-entry record cannot be allocated, THEN the step transition that
would have carried it shall fail as a unit — record and step change share one
transaction, so neither lands — and the system shall emit a log record at **ERROR**
naming the task id and step id. This is the one case where the step change does not
stand, and it does not contradict AC-C4: AC-C4 governs a *committed* transition whose
`on_enter` actions then fail, whereas here the transition itself never committed. Executing actions
without a committed record would leave them with no replay protection at all, which is
worse than not executing them; the entry is recoverable by a later replay, and the
ERROR record is what makes the stall visible rather than silent.
- **AC-B8** The five orchestrator-owned action types shall **not** participate in the
step-entry marker system. They write no marker, are not consulted by recovery, and their
behaviour under a replayed or crash-recovered dispatch is exactly today's. In particular
a session action skipped for want of a live session (AC-A10) records nothing durable, so
it can never be resurrected by a later dispatch and can never execute after an
engine-owned action, which is what keeps AC-A9's fixed leading position and AC-A12's
declared order intact under recovery. This exclusion is what allows the model to work
without a lease or a reaper: `reset_agent_context` and `auto_start_agent` are not safe to
re-execute after a crash, and admitting them would require one. Their exclusion means
AC-A1's "exactly once" is, for these five and only these five, guaranteed only within a
single dispatch and not across a crash — stated here rather than left to be discovered.
- **AC-B9** On backend start, once the store is open and before the workflow engine begins
serving triggers, the system shall select every step-entry record whose terminal flag is
unset and re-dispatch it, resuming in declared order and skipping positions already in a
terminal state. This shall be a single query executed once per process start; it shall not
be periodic, shall hold no lease, and shall apply no timeout. Without it a backend crash
mid-entry leaves the task stalled at Review with no diagnostic, which is the defect class
this card exists to remove. Six rules bound what it may do:
(a) **It shall not re-dispatch an entry the task has left, and it shall not re-dispatch a
superseded entry into a step the task has since re-entered.** Two separate tests, both
required:
(a1) a record whose `step_id` is not the task's current `workflow_step_id` shall be marked
terminal with `skipped(step_left)` and not executed. Without this rule a crash during
Review followed by a manual move to Done would, on the next start, fan out reviewers for a
step the task no longer occupies.
(a2) among the records for a `(task_id, step_id)` pair that survives (a1), **only the one
with the greatest `entry_seq` may be re-dispatched**; every other non-terminal record for
that pair shall be marked terminal with `skipped(superseded)` and not executed. The
greatest `entry_seq` is the task's current occupancy of that step, because `entry_seq` is
allocated monotonically per pair and the task is presently at that step.
Test (a1) alone is **not** sufficient, and this is the case it misses: an entry left
non-terminal, whose task then leaves the step and returns before the next restart, has a
`step_id` that once again equals the task's current step. It passes (a1), and rule (c)'s
`entry_seq` ordering then re-dispatches the **older** record first — re-running
`clear_decisions` against the *live* entry's decisions and fanning the reviewers out a
second time. That is not a crash-only path: rule (f) deliberately leaves a transiently
failed record non-terminal so a later start retries it, and the task can leave and return
in between, so the design's own recovery behaviour produces the input that corrupts it.
(b) **It shall not resume an entry holding a `failed` marker on a `clear_decisions`
position** (AC-C2), which it shall mark terminal instead.
(c) Records shall be processed in `(task_id, step_id, entry_seq)` order so the scan is
deterministic and reproducible in a test.
(d) **It shall not resume an entry whose declaration has changed.** A record whose
declaration digest does not match the step's current `on_enter` declaration shall be
marked terminal per the Execution model's digest rule, and not executed.
(e) **It shall not resume an entry it cannot resolve.** A record whose task, step,
workflow, or `on_enter` declaration cannot be loaded shall be marked terminal with
`skipped(unresolvable)` and shall emit the AC-A6 WARNING naming the task id and step id.
(f) **It shall not prevent the backend from starting, under any condition.** A record
that fails to process for a transient reason shall be logged at **ERROR**, left
non-terminal so a later start retries it, and the scan shall continue with the remaining
records; a failure of the scan's own query shall be logged at **ERROR** and startup shall
proceed. A backend that refuses to boot because one step-entry row is unreadable has
turned a stalled card into a dead installation, which is strictly worse than the defect
this scan exists to fix.
#### C. Failure isolation
- **AC-C1** IF enqueuing a run for one participant fails, THEN the system shall still
attempt every remaining matching participant, and shall emit a log record at **ERROR**
naming the task id, step id, participant id, agent profile id, and the cause. This
requires changing `QueueRunForEachParticipantCallback` from its current
abort-on-first-error loop to collect-and-continue; the fan-out reports failure only
after every matching participant has been attempted.
**AC-C1 is the complete diagnostic contract for `queue_run_for_each_participant`, and
AC-C3 does not additionally apply to it.** A fan-out in which some participants failed
emits its per-participant ERROR records and its one AC-G1 INFO record, and **no** AC-C3
action-level ERROR record. Stated because AC-C3 is otherwise written broadly enough to
cover the fan-out, which would produce N+1 error records for one event with no rule for
which is authoritative.
**The `failed(cause)` marker AC-B5(c) writes for a partial fan-out shall record the
number of participants attempted (the size of the AC-A5 de-duplicated matching set, not
the number of participants on the step), the number that failed, and the cause of the
**first** failure in the AC-A5 enumeration order.** That order is deterministic, so the
recorded cause is reproducible in a test rather than being whichever goroutine lost a
race. A single scalar cause cannot represent three participants failing three different
ways, and the alternative — concatenating every cause into one field — produces an
unbounded value in a marker that is read by recovery.
- **AC-C2** IF `clear_decisions` fails, THEN the system shall not execute **any**
remaining `on_enter` action for that step entry, and shall emit a log record at
**ERROR** naming the workflow id, step id, task id, and cause. (The field list is stated
because AC-G2 requires "the identifying fields named in that criterion" to be discrete
fields, and every other diagnostic criterion names its own; without it AC-G2 has nothing
to assert against for this record.) Stale decisions plus a fresh fan-out can satisfy a quorum guard that no
reviewer has actually voted for in this round; the entry stops rather than risk a
spurious advance. This is deliberately broader than "skip the queue actions": a
`run_code_review` declared after a failed `clear_decisions` starts an agent pass
against a step whose decision state is unknown, so it is stopped too. AC-C3 does not
apply to actions following a failed `clear_decisions`. Those remaining actions are left
with **no marker at all** — they never reached their dispatcher, so AC-B5's
terminal-on-reach rule does not apply to them. The entry therefore never sets its
terminal flag, so AC-B9 would otherwise re-dispatch it on the next backend start and run
those actions against a step whose decisions were never cleared — a worse outcome than
stopping. AC-B9's scan shall therefore treat an entry holding a `failed` marker on a
`clear_decisions` position as terminal and shall not resume it.
- **AC-C3** IF an `on_enter` action other than `clear_decisions` **and other than
`queue_run_for_each_participant`** fails, THEN the system shall attempt the remaining
declared actions and shall emit a log record at **ERROR** naming the workflow id, step
id, action type, and cause. The fan-out is excluded because AC-C1 already specifies its
complete diagnostic contract; the continue-past-failure half of this criterion still
governs the fan-out, which is to say a failed fan-out does not stop the actions declared
after it.
- **AC-C4** IF any `on_enter` action fails, THEN the step transition shall remain
committed. The task shall not be rolled back to its previous step.
- **AC-C5** The continue-past-failure behaviour required by AC-C1 and AC-C3 shall apply
to the `on_enter` dispatch path only. `engine.evaluateActions` is shared by every
trigger and aborts on the first callback error today; `on_turn_start`,
`on_turn_complete`, `on_exit`, and the Phase-2 event triggers shall keep that
abort-on-first-error behaviour unchanged. A change that makes all triggers
continue-on-error does not satisfy this criterion.
#### D. Non-regression
- **AC-D1** For a step whose `on_enter` declares only the action types
`processOnEnter` recognises today, the observable behaviour shall be unchanged:
agent-profile session switching, the stale-`RUNNING` → `WAITING_FOR_INPUT` flip, plan
mode, session permission mode, agent-context reset ordering (before `auto_start_agent`),
conditional session configuration, ACP vs passthrough prompt delivery, the
profile-switch auto-launch when no `auto_start_agent` is declared, waiting-state event
publication, and queued-message drain. In particular none of these shall fire twice
(AC-A11).
- **AC-D2** WHERE `features.office` is disabled and the run-queue, participant, and
decision adapters are consequently unwired, kanban workflows shall behave exactly as
before, and a step declaring an office-only `on_enter` action shall produce the AC-A6
warning rather than an error, a stall, or a failed transition.
- **AC-D3** The regression test for this card shall be: a task entering a step whose
`on_enter` declares `queue_run_for_each_participant{role: reviewer}` with exactly one
reviewer participant attached, arriving through an ordinary `on_turn_complete`
transition, produces exactly one queued run, for that reviewer's agent profile, with
the action's configured reason.
- **AC-D4** A second entry into the same step for the same task, following a rejection
round, shall produce a further run for that same reviewer. This criterion exists
because AC-D3 alone passes under an entry identity that suppresses every re-entry
inside the 24-hour idempotency window, which would silently reproduce the stall this
card fixes.
#### E. Empty, nil, and boundary behaviour
- **AC-E1** IF `queue_run_for_each_participant` matches zero participants, THEN the
system shall enqueue no runs, shall not fail the entry, and shall emit a log record at
**WARNING** naming the step id, step name, and configured role. A fan-out that matches
nobody is indistinguishable at runtime from a stalled step, and is a misconfiguration
worth naming.
- **AC-E2** IF a matching participant has an empty `agent_profile_id`, THEN the system
shall skip that participant, emit the AC-C1 ERROR record, and continue with the
remaining participants.
**Such a participant counts as a failure**, on the same terms as an enqueue that
returned an error: it is included in the failed count of the AC-B5(c) `failed(cause)`
marker, it is eligible to be the first failure whose cause that marker records (in AC-A5
order), and it is **excluded** from AC-G1's "runs enqueued" count while still being
included in AC-G1's "matching participants" count. Consequently a fan-out in which
**every** matching participant has an empty profile enqueues nothing, emits N ERROR
records plus one AC-G1 INFO record with enqueued = 0, and marks the action
`failed(cause)` — terminal, so AC-B9 does not resume it. This is stated because
"skip … and continue" reads as a benign no-op, and the choice between `done` and
`failed` decides whether recovery ever revisits the entry.
- **AC-E3** IF `queue_run_for_each_participant` is declared with an empty or absent
`role`, THEN the system shall enqueue no runs and shall emit a log record at **ERROR**
naming the workflow id and step id. An empty role shall not be interpreted as "all
roles".
- **AC-E4** WHEN `clear_decisions` runs against a `(task, step)` pair with no recorded
decisions, the system shall complete successfully as a no-op, and shall record its
AC-B5 completion marker.
- **AC-E5** WHEN a step declares an empty or absent `on_enter` list, the system shall
behave exactly as it does today (AC-D1's session-lifecycle path, including the
no-`on_enter` early return and its active-turn guard). No step-entry record is required
for such a step, nor for a step whose `on_enter` declares only orchestrator-owned types,
because the execution model governs engine-owned actions only (AC-B8). A non-empty list
containing at least one engine-owned action does require a record, even if every action
in it turns out to be `skipped`.
#### F. Concurrency
- **AC-F1** IF two dispatches **of the same step entry** — the same
`(task_id, step_id, entry_seq)` — are processed concurrently, THEN at most one shall
execute each engine-owned `on_enter` action. The suppression shall come from the
per-action compare-and-set defined in Execution model: not from a read-then-write pair
over an in-memory map, which both callers can pass, and not from an entry-level exclusive
lock, which cannot work here because the step-entry record is created by the transition
and therefore always already exists by the time any dispatcher runs. The caller that
loses a claim is a normal outcome, not a fault: it shall not emit a WARNING or ERROR
record, so that the diagnostics AC-A6 and AC-C1–C3 introduce stay meaningful.
**A caller that loses a claim shall abandon the entry and shall not attempt any later
position of it.** "At most one executes each action" is a per-action guarantee, but a
loser that skipped to the next position could execute a later action while the winner is
still committing an earlier one — enqueueing the fan-out before the `clear_decisions`
delete AC-A3 requires to precede it, and violating AC-A12's declared order. The winner is
walking the same declaration in the same order and will reach the remaining positions
itself; if it dies first, AC-B9's scan resumes them.
This criterion governs duplicate dispatches of **one** entry only. Two *distinct* entries
carry different `entry_seq` values, hold independent markers, and shall **both** execute
in full; suppressing the second would contradict AC-B3 and re-create this card's stall on
the rejection round.
- **AC-F2** IF a step entry's `on_enter` actions run concurrently with a participant
being added to or removed from that step, THEN the fan-out shall act on the participant
set as read once at the start of that action's execution, in a single query. A
participant added after that read shall not receive a run for that entry, and a
participant removed after that read may still receive one. The system shall not
partially re-read the set mid-fan-out.
- **AC-F3** IF a step entry's `on_enter` actions run concurrently with a decision being
recorded for the same `(task, step)`, THEN the `DELETE` statement's commit is the
linearization point and every decision row falls on exactly one side of it: a row
committed **before** it shall be deleted, and a row committed **after** it is unaffected
and may exist alongside a successful clear. The system shall not produce a state in which
`clear_decisions` reports success while a decision row committed **before** the delete
still remains.
The prohibition is scoped to rows committed before the delete deliberately. Stated
without that scope it would forbid its own permitted outcome — a decision inserted an
instant after the delete commits both "survives it" and "remains while the clear reported
success" — and would be unsatisfiable by any implementation short of serialising the
whole step.
This guarantee rests on the clear being a **single** `DELETE` statement over
`(task_id, step_id)`, which is atomic in SQLite; a clear implemented as a read followed
by a delete of the read ids does not satisfy this criterion.
#### G. Observability
- **AC-G1** WHEN `queue_run_for_each_participant` finishes — whether it succeeded, or
failed for some participants under AC-C1 — the system shall emit a log record at **INFO**
naming the task id, step id, configured role, the number of matching participants, the
number of runs enqueued, and the number of runs idempotency-suppressed. "Enqueued" counts
every participant whose enqueue returned success, **including** those suppressed by the
24-hour window, because AC-B4 declares a suppressed run a successful enqueue; the separate
suppressed count is what keeps the two distinguishable to a reader. The record is emitted
on the partial-failure path too, so an absent record means the action never finished
rather than that it matched nobody.
- **AC-G2** Every diagnostic required by AC-A6, AC-C1, AC-C2, AC-C3, AC-E1, and AC-E3
shall be a structured record carrying the identifying fields named in that criterion as
discrete fields, not interpolated into a message string.
#### H. Defaults and repeated declarations
- **AC-H1** WHEN `queue_run_for_each_participant` omits `reason`, the system shall use the
literal string `on_enter` as the queued run's reason. It shall not leave the reason
empty and shall not substitute the step name. (This is what
`queueRunForEachParticipantReason` produces today via `string(in.Trigger)`; it is stated
so it survives the dispatch change.)
- **AC-H2** WHEN `queue_run_for_each_participant` omits `payload`, the system shall
enqueue the run with no payload (SQL `NULL` / absent), not an empty object.
- **AC-H3** The `queue_run_for_each_participant` fan-out shall select participants by role
alone. It shall **not** filter on `decision_required`, and shall not default that flag
to any value. (See "Related stalls" for the consequence.)
- **AC-H4** WHEN a step declares the same `on_enter` action type more than once, each
declaration shall be executed in declared order, subject to AC-H8.
- **AC-H5** IF two `queue_run_for_each_participant` declarations on the same step resolve
to the same role, reason, and payload, THEN **both shall fan out**, and each matching
participant shall receive one run per declaration. The two declarations sit at different
positions in the declared list, AC-B1 puts that position in the idempotency key, so their
keys differ and the runs queue suppresses neither.
This criterion is a **consequence of AC-B1**, not a separate mechanism: a builder shall
add neither a de-duplication pass to collapse the two, nor a position-blind key to make
the runs queue collapse them. Both would be a second mechanism fighting the first.
It is stated explicitly because the natural reading of "the same action declared twice"
is that the second is redundant. It is not treated as redundant here, and the reason is
that this spec cannot tell an accidental duplicate from a deliberate one: the same
action at two positions with different neighbours (a `clear_decisions` between them, say)
is a legitimate declaration whose second fan-out is the point. A step that declares the
same fan-out twice and did not mean to is a misconfiguration, and it is visible — AC-G1's
INFO record is emitted once per action, so two records name it.
- **AC-H6** The 24-hour idempotency window the runs queue applies to fan-out runs is fixed
at its current value and is not made configurable by this change.
- **AC-H7** The configuration digest in the AC-B1 key shall be canonical: computed over
the action's `role`, `reason`, and `payload` **after defaults are applied**, so an
omitted `reason` and an explicit `reason: on_enter` produce the same digest (AC-H1), and
an omitted `payload` and an explicit null produce the same digest (AC-H2). Map keys
shall be serialized in sorted order so the digest is stable across runs. Two
declarations that differ only in a field not named here shall not produce different
keys.
- **AC-H8** `reset_agent_context` and `configure_session` shall each execute at most once
per step entry regardless of how many times they are declared. Their fixed position
(AC-A9) makes "each declaration in declared order" meaningless for them, so AC-H4 does
not apply to these two types.
- **AC-H9** Runs enqueued by `queue_run_for_each_participant` shall be **excluded from the
5-second coalescing window**. Coalescing is keyed on `(agent_profile_id, reason)`, which
carries no entry identity, so a re-entry whose previous run is still `status='queued'`
would otherwise be merged into it and produce no new runnable run — contradicting AC-B3
and reproducing this card's stall in miniature. It applies to every run enqueued by an
`on_enter` action, fan-out and bare `queue_run` alike (AC-A7, AC-B1). Idempotency
suppression (AC-B4) is unaffected and still applies.
**No general exclusion mechanism exists today and this card must add one.** The only
opt-out currently implemented is specific to task comments —
`shouldCoalesceRun(req)` returns `!commentkeys.HasTaskCommentPrefix(req.IdempotencyKey)`,
with a matching `idempotency_key NOT LIKE '<comment prefix>%'` in `CoalesceRun` — and
`QueueRunRequest` carries no other field that could signal exclusion. The mechanism this
criterion needs is therefore precisely the one it forbids reusing: overloading the comment
prefix would make a fan-out run indistinguishable from a comment run in both predicates
and in the `runs` table, and is not permitted.
The exclusion shall be carried by a **discrete field on the request**, read by both
`shouldCoalesceRun` and `CoalesceRun`'s SQL exclusion, and not encoded in the idempotency
key. The field's name is the builder's choice; its shape is not. `QueueRunRequest` is
declared **twice** — `internal/runs/service/service.go` and
`internal/workflow/engine/adapters.go`, which the service's own doc comment requires to
match — so the field lands in both, and a plan budgeting for one will not compile.
### Related stalls this does **not** fix
These are real, reproduce today, and survive this change. They are named here so a
builder does not assume the Office loop is end-to-end after this card, and so a reviewer
does not file them as regressions.
- **Quorum requires `decision_required`, fan-out does not.**
`queue_run_for_each_participant` selects participants by role alone;
`evaluateWaitForQuorum` counts only participants with `decision_required = true`. A
reviewer attached with `decision_required = false` will be woken and will vote, and its
vote will be ignored by the guard.
- **Zero required participants stalls permanently.** `evaluateWaitForQuorum` fails closed
when the required set is empty. A Review step with no `decision_required` reviewer never
advances, no matter how many runs are queued. After this change AC-E1 at least makes it
visible.
- **`auto_start_agent` on Office tasks** and the status-driven review path have their own
defects, tracked separately.
## Scope reality
The originating card framed this as two missing `switch` cases. It is larger, and the
plan should be sized accordingly:
- durable per-entry state with a per-action compare-and-set (Execution model) — **in
scope**, a new table, not a reuse of `appliedOps`;
- **record allocation at TEN separate step-change write sites, not one.** This is the
single largest item and the easiest to under-budget. The Execution model's
[write-site inventory](#the-write-site-inventory) enumerates them with their transaction
shapes; do not re-derive it from function names, because the revision that did so named a
task-creation helper as the WIP-promotion site. They span three packages
(`orchestrator`, `task/service`, `task/repository/sqlite`) and two repositories. Three
sites already hold a transaction; four hold none and need one introduced; one commits the
step change in a different call stack from the `on_enter` dispatch that follows it; three
choose the landing step above the repository and hand it down. Two of the ten (`E5`) do
not dispatch `on_enter` at all today and need the dispatch added, not just the
allocation. **A plan that budgets for `applyTransition` alone covers one of the ten and
silently leaves `E2`, `E3`, `E4` and `E5` with no step-entry record at all**;
- **transaction plumbing that does not exist yet** at sites 1 and 2: `applyTransition` is a
non-transactional GetTask → mutate → `UpdateTask` today, and AC-B7 requires the record to
share the transition's transaction while AC-B6 requires the `clear_decisions` marker to
share the delete's. Both repositories sit on one shared writer handle, so this is
possible, but no method currently spans them;
- **a new discrete field on `QueueRunRequest` in both of its declarations**, plus the
matching `shouldCoalesceRun` predicate and `CoalesceRun` SQL exclusion (AC-H9) — the
coalescing opt-out this card needs does not exist today in any general form;
- a bounded startup re-dispatch scan for entries left incomplete by a crash (AC-B9);
- rewriting `QueueRunForEachParticipantCallback`'s fan-out loop from abort-on-first-error
to collect-and-continue (AC-C1);
- introducing continue-past-failure on the `on_enter` dispatch path **without** changing
`evaluateActions` for the other triggers (AC-C5);
- resolving the shared-registry double-execution hazard (AC-A11) **without** unwiring the
four registrations `E4` depends on (AC-A13).
None of these is "route the call somewhere else". A plan that budgets only for dispatch
routing will under-scope this card.
## Out of scope
Named exclusions, not silence. Each of these is a decision to leave the behaviour as it
is today.
- **Retrying a failed fan-out.** AC-C1 requires the failure be attempted-past and logged.
It does not introduce a re-queue, a backoff, or a parked state for the entry.
- **Migrating session-lifecycle work into the engine.** The ownership decision covers
action semantics only; `processOnEnter` keeps everything AC-D1 enumerates.
- **A metric or expvar counter for fan-out.** Structured logs (AC-G) are the required
surface. Adding `workflow_on_enter_*` counters alongside the existing `workflow_*`
family is a reasonable follow-up and is not required here.
- **Changing `wait_for_quorum` semantics**, including the `decision_required` /
role-filter mismatch and the fail-closed empty-set behaviour described above.
- **Widening `validOnEnter`** to admit `run_code_review` or `configure_session` in
embedded-YAML templates. AC-A8 covers dispatching them when they arrive through the
import or git-sync path, which is how they are declarable today.
- **Unifying `auto_start_agent` prompt delivery across entry paths.** `E1`–`E3` deliver
the step prompt through `processOnEnter`, which handles passthrough sessions by writing
to the PTY; `E4` delivers it through `autoStartAgentCallback`, which skips passthrough
entirely. That divergence predates this card, AC-A2 deliberately does not require
closing it, and closing it here would put a passthrough-launch change in a defect fix.
- **Retiring `appliedOps`.** The in-memory operation marker keeps its current role for
the triggers that use it today. This card adds durable per-entry state for `on_enter`;
it does not migrate the rest.
- **A lease, a timeout, or a periodic reaper for abandoned claims.** Staleness is decided
by process epoch rather than elapsed time (Execution model), so none is needed. The
bounded **startup** re-dispatch scan of AC-B9 is a different thing and is **in** scope.
- **Making `run_code_review` exactly-once across a crash.** It is at-most-once in normal
operation and at-least-once under recovery (AC-A8). A duplicate review pass is wasteful,
not corrupting, and giving it an entry-scoped guard would mean a schema change in the
review subsystem for no correctness gain on this card.
- **Executing `configure_session` on `E4`.** It is the one declared type `switch_workflow`
drops. AC-A6 requires it to warn there and AC-A2 exempts it by name, so the gap is
visible rather than silent; giving the engine a `configure_session` executor is a
separate change.
- **Bringing the five orchestrator-owned action types into the marker system.** AC-B8
excludes them. Their "exactly once" is guaranteed within a dispatch and not across a
crash, which is today's behaviour and is stated rather than left to be discovered.
- **Resurrecting a `skipped` action later in the same entry.** AC-B5 makes `skipped`
terminal, so an action that found no session or no registered callback does not run if
one appears afterwards. The remedy is a fresh step entry.
- **Reconciling an entry against an edited declaration.** When the declaration digest
differs, the entry is abandoned rather than re-aligned. Working out which surviving
marker corresponds to which action after an insert, delete or reorder is a diff problem
with no safe default, and getting it wrong either re-runs a committed `clear_decisions`
or silently skips an action that never ran. Abandoning is a warned no-op; the remedy is a
fresh entry.
- **Aggregating every cause of a partially-failed fan-out into the marker.** AC-C1 records
the per-participant causes in full; the marker keeps counts plus the first cause in AC-A5
order. Concatenating N causes into a durable field read by recovery would make its size
a function of participant count for no gain the ERROR records do not already provide.
- **A second diagnostic for a failed fan-out.** AC-C3 is scoped away from
`queue_run_for_each_participant` deliberately. Emitting both an action-level record and
the per-participant records would double-report one event with no rule for which is
authoritative.
- **Adding a UNIQUE constraint to `workflow_step_participants`.** AC-A5 de-duplicates at
read time instead; changing that table is a migration this card does not need. (The new
step-entry table does carry a UNIQUE constraint, on `(task_id, step_id, entry_seq)`;
that is a different table and AC-B7 depends on it.)
- **Pruning or retaining terminal step-entry records.** They accumulate one row per step
entry per task and are never read once terminal. A retention policy is a reasonable
follow-up; this card neither prunes them nor promises to.
- **Any frontend change.** No new panel, indicator, or copy. The user-visible consequence
is that an Office reviewer's run appears where today nothing appears.
- **Dispatching `on_enter` on task creation.** A task created directly into a step whose
`on_enter` declares engine-owned actions does not dispatch them and allocates no record.
Creation is not a step entry — there is no previous occupancy to leave — and the shipped
Office flow creates tasks into the first step, not into Review. Named because
`applyAdmissionPlacement`, the creation-path helper, was misidentified as the WIP
promotion site in an earlier revision, and a reader who follows that mistake back will
land here.
- **Re-dispatching `on_enter` on a message-rollback restore.** Returning a task to a step
it previously occupied as part of undoing history is not a workflow advance; re-running
the fan-out would wake reviewers for a round the user is explicitly rewinding.
- **Closing the `E4` ordering divergence.** AC-A2(ii) names it as conforming. Hoisting
`reset_agent_context` / `configure_session` inside `compileOnEnter`, or deferring
`auto_start_agent` there, would change `switch_workflow` behaviour that works today,
which AC-A13 forbids; dropping the hoist on `E1`–`E3` would change ordering AC-D1
freezes. Either direction is a separate change.
- **Seeding reviewer or approver participants into the `Office Default` template.**
Participants are attached per task; a task with no reviewer attached still stalls, and
that is existing intended behaviour.
## Failure modes
| Condition | Behaviour | Criterion |
|---|---|---|
| Unrecognised `on_enter` action type | WARNING, marked `skipped(unrecognised)`, remaining actions continue | AC-A6, AC-B5 |
| Type with an owner on this entry path | Executes via that owner, no warning | AC-A6, partition |
| `configure_session` on `E4` only | WARNING naming the entry path, no execution | AC-A6, AC-A2, Out of scope |
| Step declaration changed between entry and recovery (kind substituted, action inserted, removed, or reordered) | Declaration digest differs; every non-terminal position marked `skipped(declaration_changed)`, one WARNING, entry terminal | AC-A6, AC-B9(d), Execution model |
| Action's `config` changed, kinds and order unchanged | Digest matches; markers honoured; entry continues | Execution model |
| `clear_decisions` fails | ERROR, **all** later actions skipped, transition stands | AC-C2, AC-C4 |
| One participant's enqueue fails | ERROR, remaining participants attempted | AC-C1 |
| Fan-out matches zero participants | WARNING, no runs, entry succeeds | AC-E1 |
| Fan-out role empty or absent | ERROR, no runs | AC-E3 |
| Participant has no agent profile | ERROR for that participant, others continue | AC-E2 |
| Duplicate `(role, agent_profile_id)` rows | De-duplicated at read, one run | AC-A5 |
| Run idempotency-suppressed (same entry replay) | Treated as success, no retry | AC-B4 |
| Re-entry within 5s, prior run still queued | New run enqueued; coalescing excluded | AC-H9 |
| Re-entry within 24h | New run enqueued; keys differ in `entry_seq` | AC-B3, AC-D4 |
| Office adapters unwired (kanban-only) | AC-A6 warning, no error, no stall | AC-D2 |
| Entry replayed after restart | No duplicate runs, no second `clear_decisions` | AC-B2, AC-B5 |
| Two concurrent dispatches of **one** entry | At most one executes each action, via per-action CAS; loser abandons the entry and is silent | AC-F1, Execution model |
| Loser of a claim reaches a later position | Forbidden: it stops at the lost claim and does not advance | AC-F1, AC-A3, AC-A12 |
| Two **distinct** entries, same task and step | Both execute in full; keys differ in `entry_seq` | AC-F1, AC-B3 |
| Backend dies mid-entry | Startup scan re-dispatches; stale `in_progress` re-claimed by epoch | AC-B9, Execution model |
| Recovered entry whose task has since left the step | Marked `skipped(step_left)`, not executed | AC-B9(a) |
| Two allocators race the same `(task_id, step_id)` | UNIQUE constraint fails one; its transition fails as a unit | AC-B7, Execution model |
| Step declares only orchestrator-owned types | No step-entry record allocated | AC-E5, AC-B8 |
| Action fails | Marked `failed`, terminal, not retried on recovery | AC-B5, AC-C1, AC-C3 |
| Entry whose `clear_decisions` is marked `failed` | Treated as terminal; not resumed | AC-C2, AC-B9 |
| Orchestrator-owned action after a crash | No marker, no recovery; today's behaviour | AC-B8 |
| Step-entry record cannot be allocated | ERROR, transition and record fail as a unit | AC-B7 |
| Step entry with no running session | Session-independent actions still run | AC-A10 |
| Entry path changes the step without allocating a record | Forbidden: that entry has no claim and no replay protection | AC-B7, Execution model |
| WIP promotion into a full target lands the task in the feeder step | Record allocated for the **feeder**, the step actually occupied | AC-B7, Execution model |
| Placement sets `workflow_step_id` but leaves the task queued, not admitted | Not a step entry; no record, no dispatch. The later same-step promotion allocates, under Scope shape (b) | AC-E5, Execution model |
| Queued task admitted in place, `workflow_step_id` unchanged | **Is** a step entry (shape (b)); record allocated, `on_enter` dispatches | Scope, AC-B7 |
| Step changed through the generic task-update API or task start (`E5`) | Is a step entry; record allocated and `on_enter` dispatches, neither of which happens today | Scope, AC-A2, AC-B7 |
| `advanceTaskWorkflowStep` called with the task's own current step | Not an entry; guard returns early, no record | Execution model exclusion table |
| Task created directly into a step declaring engine-owned actions | Not an entry; no record, no dispatch | Out of scope |
| Message-rollback restore returns a task to a former step | Not an entry; no record, no dispatch | Out of scope |
| Recovery finds two non-terminal entries for one `(task, step)` after a leave-and-return | Only the greatest `entry_seq` resumes; older ones `skipped(superseded)` | AC-B9(a2) |
| Every matching participant has an empty `agent_profile_id` | N ERROR records, one INFO with enqueued = 0, action marked `failed`, terminal | AC-E2, AC-C1, AC-G1, AC-B5(c) |
| Same declared list entered via `E1`–`E3`/`E5` and via `E4` | Same action coverage; **ordering may differ** — hoists apply only where `processOnEnter` runs | AC-A2(ii), AC-A9, AC-A13 |
| Step declares `auto_start_agent` before another action | On `E1`–`E3`/`E5` the agent still starts last; conforming | AC-A9, AC-A12 |
| Declaration mutated between two positions of one dispatch | Dispatch completes against the version read at its start | Execution model |
| Recovered record whose step, workflow, or declaration cannot be loaded | Marked `skipped(unresolvable)`, WARNING, not executed | AC-B9(e) |
| Recovered record fails to process for a transient reason | ERROR, left non-terminal for a later start, scan continues | AC-B9(f) |
| Startup scan's own query fails | ERROR, **startup proceeds**; entries resume on a later start | AC-B9(f) |
| Task deleted while a non-terminal record exists | Record removed by `ON DELETE CASCADE`; never seen by the scan | AC-B9(e), Execution model |
| Same fan-out declared twice on one step | **Both** fan out; keys differ in declared position | AC-H5, AC-B1 |
| Fan-out partially fails | AC-C1 records only, no AC-C3 record; marker cause = attempted/failed counts + first cause in AC-A5 order | AC-C1, AC-B5, AC-C3 |
## Persistence guarantees
- A **step-entry record** is allocated by the step transition, in the same transaction as
the admission it records, **at every site in the Execution model's write-site inventory
entry table**, and is therefore committed before any `on_enter` action for that entry
executes. It carries the entry identity `(task_id, step_id, entry_seq)`, a marker per
engine-owned action position, the declaration digest, and a terminal flag. It lives in a
dedicated table in the same database, on the same writer handle, as `tasks` and
`workflow_step_decisions` — a constraint AC-B6 and AC-B7 jointly impose — with a
`task_id` foreign key to `tasks` `ON DELETE CASCADE`. It survives restart. The
**per-action** compare-and-set on its markers is what AC-F1 relies on; creating the
record is not, because the transition always creates it first.
- A run enqueued by AC-A4 or AC-A7 is a row in `runs` carrying `agent_profile_id`,
`task_id`, `workflow_step_id`, the action's `reason`, the AC-B1 idempotency key, and the
AC-H9 coalescing-exclusion field. It survives restart and is claimed by the runs
scheduler.
- A code-review pass started by AC-A8 is a row in `task_review_runs` carrying the task id,
the step id as `workflow_step_id`, and trigger `workflow_step`.
- `clear_decisions` is a committed single-statement delete of `workflow_step_decisions`
rows for the `(task_id, step_id)` pair.
- The step transition is committed before `on_enter` actions execute and is never rolled
back by their failure (AC-C4).
## Verification
Every criterion above is observable without reading application logs *except* the
diagnostic criteria, which are observable by asserting on the structured log record.
Every criterion has at least one line below. The list is written to be checkable against
the criteria themselves: a criterion appearing in no line is a defect in this section.
- AC-A1, AC-A3, AC-A4, AC-A5, AC-B2, AC-B3, AC-B5, AC-E1–E4, AC-C1–C3, AC-F2, AC-H1–H5,
AC-H7–H9:
backend Go tests over the dispatch path reached through an **ordinary transition**, not
through a direct `HandleTrigger(TriggerOnEnter)` call. A test that calls the engine
directly does not satisfy these criteria — that is precisely the gap
`TestOfficeDefaultWorkflow_FullCycleSmoke` left open.
- AC-A2: the coverage assertion executed once per entry path `E1`–`E5`. Ordering is
**not** asserted across paths — AC-A2(ii) exempts it — so a test that asserts one global
order across `E1`–`E5` is asserting something this spec does not require and shall not be
written. `E5`'s two sites need the `on_enter` dispatch added, so this line fails today for
reasons the other paths do not share.
- AC-A9: a step declaring `clear_decisions` *before* `reset_agent_context` and
`auto_start_agent` *before* `clear_decisions`, entered via `E1`, asserting reset runs
first and the agent starts last. **Asserted on `E1`–`E3` and `E5` only.** The same
declaration entered via `E4` shall be asserted to run in **declared** order — that is
AC-A2(ii)'s exemption, and asserting a single global order instead would fail a
conforming implementation.
- AC-A2(ii) and AC-A13 together: the identical declared list driven through `E1` and `E4`,
asserting the same action *coverage* on both and explicitly asserting the **different**
orders. This is the test that fails if a builder "fixes" the divergence by hoisting
inside `compileOnEnter`.
- Allocation inputs: a step whose `on_enter` declares only orchestrator-owned types
asserted to allocate **no** record on every entry site, which is only decidable if the
declaration reached the allocator; plus a promotion whose landing step differs from the
requested target, asserting the digest stored is the **landing** step's.
- AC-A10: a manual move (`E2`) of a task with no running session into a step declaring
`queue_run_for_each_participant`, asserting the fan-out still happens.
- AC-A11: a step declaring `reset_agent_context`, `enable_plan_mode`, `set_session_mode`
and `auto_start_agent` alongside an engine-owned action, asserting each session action
takes effect exactly once — a counter or spy on the underlying service method, not just
an end-state check, because a double-fire of an idempotent setter is invisible in the
end state.
- AC-A6: asserted at both discard points named in that criterion, not just one, plus two
negative assertions and one positive: `configure_session` produces no warning on
`E1`–`E3`, `reset_agent_context` produces none on any path, and `configure_session`
**does** warn on `E4` with the entry path named in the record.
- AC-A7: a step declaring `queue_run` on `on_enter`, entered through an ordinary
transition, asserting one `runs` row against the resolved target carrying an idempotency
key that differs across two entries of the same step (the AC-B3 shape applied to
`queue_run`), and carrying the AC-H9 exclusion field.
- AC-A8: a step declaring `run_code_review` on `on_enter`, asserting a `task_review_runs`
row with the entering task's id, the destination step's id, and trigger `workflow_step`;
plus a launch forced to fail, asserting the entry still completes and later actions run.
- AC-A13: `switch_workflow` into a step declaring `enable_plan_mode`,
`reset_agent_context`, `set_session_mode` and `auto_start_agent`, asserting all four
still take effect on that path. This is the regression test for the partition being
misread as global; it fails if the four registrations are unwired.
- AC-B1: two entries of the same step asserted to produce different idempotency keys, and
two declarations at different positions asserted to produce keys differing in position.
- AC-H5: a step declaring `queue_run_for_each_participant{role: reviewer}` **twice** with
identical config, one reviewer attached, asserted to produce **two** runs for that
reviewer — not one. This is the assertion that fails under a de-duplication pass or a
position-blind key, which are the two ways a builder can re-create the contradiction
this criterion was rewritten to remove.
- AC-B4: an identical key re-submitted within the window, asserting the action reports
success and does not retry. Plus the negative that keeps AC-B4's "exactly one thing"
honest: the two-identical-declarations case of AC-H5 asserted **not** to be an
idempotency suppression.
- AC-B8: a crash-recovery dispatch asserted **not** to re-run `reset_agent_context` or
`auto_start_agent` through the marker system, because they hold no marker — the negative
that keeps the exclusion honest.
- AC-B9: a step-entry record left non-terminal, a freshly constructed store standing in for
a restart, asserting the scan re-dispatches it, resumes at the first non-terminal
position, and re-claims an `in_progress` marker written under a different epoch. Plus the
AC-C2 case: an entry whose `clear_decisions` is `failed` is **not** resumed; and the
AC-B9(a) case: a non-terminal record whose `step_id` is not the task's current step is
marked `skipped(step_left)` and its actions do not run.
- AC-B9(d) and the declaration digest: an entry with position 1 marked `done` and position
2 absent, recovered after an action of the **same kind** is inserted earlier in the
declaration, asserting the entry is terminalised with `skipped(declaration_changed)` and
that **no second `clear_decisions` delete commits**. The same test with an action
**removed** instead, asserting no position is executed under a marker that belongs to a
different action. A per-position kind check passes both of these while doing the wrong
thing, so asserting the digest comparison alone does not satisfy this line — assert the
observable effect. Plus the negative: an action's `config` changed with kinds and order
unchanged, asserting the entry is **not** terminalised and its remaining actions run.
- AC-B9(e) and AC-B9(f): a non-terminal record whose step has been deleted, asserting
`skipped(unresolvable)` and the WARNING; a record whose processing raises a transient
error, asserting an ERROR record, the record still non-terminal, and **the remaining
records still processed**; and the scan's query itself forced to fail, asserting startup
completes. The startup assertion is the load-bearing one — an implementation that
propagates the error would fail it and no other line in this section would catch that.
- Task-delete cascade: a task with a non-terminal step-entry record deleted, asserting the
record is gone and the next scan does not see it.
- AC-C4: an `on_enter` action forced to fail, asserting the task's `workflow_step_id` is
still the destination step afterwards. This is the criterion that keeps a failed fan-out
from rolling the card back, and it needs its own assertion rather than being assumed.
- AC-F3: a decision committed before the delete asserted deleted, and a decision committed
after it asserted to survive alongside a successful clear — both, since the criterion has
two sides and asserting one would pass an implementation that violates the other.
- AC-G1: the INFO record asserted on both the all-succeeded path and the
partial-failure path, with the enqueued and suppressed counts asserted separately.
- AC-G2: each record required by AC-A6, AC-C1, AC-C2, AC-C3, AC-E1 and AC-E3 asserted to
carry its named fields as discrete structured fields, not interpolated into the message.
- AC-B9(a2): two non-terminal records for one `(task_id, step_id)` — the older left
`in_progress` under a previous epoch, the newer current — with the task presently at that
step; asserting the older is marked `skipped(superseded)`, that its `clear_decisions`
does **not** run, and that the newer one resumes. A scan implementing only the `step_id`
equality test passes AC-B9(a1) and fails this line, which is the whole point of splitting
them.
- AC-E2 accounting: a fan-out of three matching participants of which one has an empty
`agent_profile_id`, asserting one ERROR record for it, an AC-G1 INFO record with matching
= 3 and enqueued = 2, and a `failed(cause)` marker counting it among the failures. Plus
the all-empty case, asserting enqueued = 0 and a terminal `failed` marker that AC-B9 does
not resume.
- AC-A1's carve-outs: `run_code_review` asserted to run a second time across a simulated
crash (AC-A8), and `reset_agent_context` asserted **not** to (AC-B8) — the pair that
makes AC-A1's "exactly once" honest about what it does and does not guarantee across a
restart.
- AC-H6: no test; it is a statement that a constant is not made configurable.
- AC-A12: a step whose declared list interleaves an orchestrator-owned and an
engine-owned action (for example `set_session_mode`, `clear_decisions`,
`auto_start_agent`), asserting the three take effect in declared order. A test whose
declared list happens to be owner-contiguous cannot distinguish a conforming
implementation from a split-by-owner one and does not satisfy this criterion.
- AC-B2 and AC-B5: the entry dispatched twice against the same committed step-entry
record, asserting one `clear_decisions` and one run per participant. The restart case is
covered by constructing the second dispatch against a freshly built store that shares
only the durable state.
- AC-B6: a `clear_decisions` whose delete commits and whose marker does not, asserting
that recovery does not re-clear. Reachable by asserting the marker and the delete share
one transaction rather than by fault injection, if the former is simpler.
- AC-B7: record allocation forced to fail, asserting the task's `workflow_step_id` is
unchanged, no `on_enter` action ran, and the ERROR record carries the task and step ids.
Plus the race: two allocators against one `(task_id, step_id)`, asserting the UNIQUE
constraint rejects one rather than both committing the same `entry_seq`.
- AC-B3 and AC-D4: enter, reject, return, re-enter **inside** the 24-hour window, and
assert a second run is enqueued. This is the criterion that fails if the entry identity
is derived from transition fields alone.
- AC-C5: the existing `evaluateActions` behaviour for a non-`on_enter` trigger asserted
unchanged — a failing action still aborts the remaining actions for that trigger.
- AC-F1: two concurrent dispatches of the same entry, asserting exactly one execution per
action and no WARNING or ERROR record from the losing caller; plus two *distinct* entries
asserted to both execute in full, which is the half that fails if the claim is scoped to
`(task_id, step_id)` instead of to the entry. **Plus the stop rule**: a step declaring
`[clear_decisions, queue_run_for_each_participant]`, dispatcher A holding the
`clear_decisions` claim and blocked mid-delete, dispatcher B arriving and losing that
claim — assert B enqueues **no** runs before A's delete commits. A test that only counts
final executions passes under skip-ahead, because the winner eventually runs everything
and the totals come out right; the assertion has to be about ordering, not totals.
- AC-C1 vs AC-C3 precedence: a fan-out with three matching participants of which two fail
for different reasons, asserting exactly two AC-C1 ERROR records, **zero** AC-C3
action-level ERROR records, one AC-G1 INFO record, and a `failed(cause)` marker carrying
attempted=3, failed=2, and the first failure's cause in AC-A5 order. Asserting the marker
cause pins the enumeration order, which is what makes the value reproducible.
- AC-B7 across every entry path: record allocation asserted on **every site in the
write-site inventory's entry table**, driven through each site's real entry point —
`on_turn_complete`, an engine transition, a manual move, both feeder promotions, the
same-step admission, `switch_workflow`, the generic task update, and task start. This is
the line that fails if a plan budgets for `applyTransition` alone, and it is deliberately
written per-site rather than per-path because three sites serve `E3` alone.
Plus the two cases a happy-path WIP test will not reach: a promotion into a **full**
target that lands the task in the feeder step, asserting the record's `step_id` is the
feeder and not the requested target; and a **queued** placement (`wip_admitted` false,
`queued_for_step_id` set), asserting **no** record is allocated then and that one **is**
allocated by the later same-step promotion that admits the task — which is Scope's
shape (b) and writes no step field, so a test keyed on the field changing will miss it.
Plus the negatives that keep the exclusion table honest: `advanceTaskWorkflowStep`
invoked with the task's own current step, asserting **no** record is allocated; and a
message-rollback restore, asserting the same.
- Declaration read-once: a dispatch of a multi-action entry with the step's declaration
mutated between two positions, asserting the dispatch completes against the version read
at its start and does not execute a mix of the two.
- AC-D1, AC-D2, AC-E5: the existing orchestrator `processOnEnter` suite must pass
unchanged; no assertion in it may be weakened to accommodate the new dispatch.
- AC-D3: the named single-reviewer regression test.