5215cd4555
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
310 lines
17 KiB
Markdown
310 lines
17 KiB
Markdown
---
|
|
status: draft
|
|
system: office
|
|
requirements:
|
|
- REQ-OFFICE-STEP-ENTRY-001
|
|
---
|
|
|
|
# Step Entry Sequence Execution System Design
|
|
|
|
## Purpose and boundaries
|
|
|
|
A workflow step declares an `on_enter` sequence. The compiler turns that
|
|
sequence into typed actions. On a real arrival in production, most of those
|
|
actions are never executed. This design makes them execute, exactly once per
|
|
arrival, without changing what any step declares and without touching any
|
|
trigger other than step entry.
|
|
|
|
It is separated from `review-participant-seats-01.md` because it is a
|
|
workflow-engine contract, not an Office one: every shipped workflow is affected
|
|
by it, and Office review seats are only the first consumer to depend on it. That
|
|
design links here rather than restating any of it.
|
|
|
|
Two things are settled here. **Which compiled actions are dispatched** on entry,
|
|
and which are excluded because the arriving session already ran them inline.
|
|
And **what identifies one entry**, which is what every at-most-once criterion in
|
|
this system is counted over.
|
|
|
|
## Requirement mapping
|
|
|
|
| Requirement | Design sections |
|
|
| --- | --- |
|
|
| REQ-OFFICE-STEP-ENTRY-001 | Why a declared entry sequence does not execute today; Entry identity; One failing action does not cancel the rest |
|
|
|
|
## Why a declared entry sequence does not execute today
|
|
|
|
The workflow engine compiles a step's `on_enter` list into typed actions,
|
|
including `clear_decisions` and `queue_run_for_each_participant`. Dispatching
|
|
those compiled actions on a real step entry is a different code path, and in
|
|
production it does not happen. Every production route by which a task arrives
|
|
at a step converges on the orchestrator's own entry handler, which switches on
|
|
a small set of session-shaped action kinds - plan mode, session mode,
|
|
auto-start agent, agent-context reset, session configuration - and never asks
|
|
the engine to handle a step-entry trigger. The only production caller that does
|
|
dispatch a step-entry trigger does so for workflow switching. The engine's
|
|
transition processing likewise reports which step was entered without chaining
|
|
into that step's entry sequence.
|
|
|
|
The consequence is that today's Office `review` step already declares
|
|
`clear_decisions` and a reviewer fan-out, and in a running installation neither
|
|
runs. The engine-level test that appears to cover this constructs the trigger
|
|
by hand, which is why the gap survived.
|
|
|
|
So the reported defect has two independent causes, and fixing either alone
|
|
changes nothing observable:
|
|
|
|
1. Nothing writes a reviewer seat. That is the card's finding.
|
|
2. Nothing executes the step's entry sequence, so even a correctly written seat
|
|
would not be woken.
|
|
|
|
**Design position.** Step-entry execution is split by whether an action needs
|
|
the arriving session. The two halves are disjoint by construction, and the
|
|
split is the contract.
|
|
|
|
*The session-shaped half is not moved, not re-run, and not dispatched.* The
|
|
entry handler already executes `enable_plan_mode`, `set_session_mode`,
|
|
`auto_start_agent` and `reset_agent_context` inline, and the callback registry
|
|
holds production callbacks for those same four kinds. Handing a step's whole
|
|
compiled entry sequence to that registry would therefore execute each of them a
|
|
second time: `office-default`'s own `work` step declares `auto_start_agent`, as
|
|
does a step in every other shipped workflow, so the agent would be launched or
|
|
prompted twice. `AC-OFFICE-STEP-ENTRY-001.3` forbids exactly that outcome, so
|
|
these four kinds are **excluded from dispatch by an explicit exclusion list**,
|
|
not by an assumption about what the registry happens to hold
|
|
(`AC-OFFICE-STEP-ENTRY-001.5`).
|
|
|
|
*The session-independent half is what this design newly dispatches:*
|
|
`clear_decisions`, `queue_run_for_each_participant`, `queue_run`,
|
|
`run_code_review`, and the seat-ensuring action introduced here. None of them
|
|
reads or mutates the arriving session; every one is task-scoped.
|
|
|
|
The two lists together are exhaustive over the kinds the compiler emits for
|
|
step entry, and a test asserts that: every compiled step-entry kind appears in
|
|
exactly one list. A kind added to the compiler later must be placed
|
|
deliberately, rather than defaulting into double execution or into silence.
|
|
|
|
**Disclosed consequence.** `queue_run` and `run_code_review` are compiled for
|
|
step entry today and, like the participant actions, never dispatched. No
|
|
shipped workflow declares either of them on entry, so this change is inert on
|
|
shipped configuration; an operator-authored step that declares one of them on
|
|
entry starts running it. That is what the configuration already asks for, and
|
|
it is named here rather than found later.
|
|
|
|
**Where the dispatch runs.** `AC-OFFICE-STEP-ENTRY-001.1` is quantified over
|
|
arrivals, not over one handler, and the entry handler is not on every arrival
|
|
path. It requires a live session, and the manual-move handler returns without
|
|
reaching it when the task has none. That is a supported route into the two
|
|
steps this capability exists for: `review` and `approval` both declare
|
|
`allow_manual_move: true` and neither declares `auto_start_agent`, so a task
|
|
moved into Review by hand with no live session takes it. Putting the
|
|
session-independent dispatch inside the entry handler would leave that task
|
|
unseated, and would make the acceptance test's result depend on how the test
|
|
happens to drive the task.
|
|
|
|
The session-independent dispatch is therefore driven from the arrival itself:
|
|
the committed change of `tasks.workflow_step_id`, which the system already
|
|
records exactly once per arrival in its step-transition ledger (see *Entry
|
|
identity*). Every route that changes a task's step writes that row; a route
|
|
that writes no row did not move the task. The dispatch runs once per committed
|
|
row, after it commits.
|
|
|
|
That binds `AC-OFFICE-STEP-ENTRY-001.1` to something checkable: one
|
|
step-entry dispatch per ledger row **that names a destination step**, no more
|
|
and no less. The qualifier is load-bearing rather than pedantic: the same
|
|
ledger also records a task being detached from its workflow, and such a row
|
|
carries no destination, so the task enters no step and there is no entry
|
|
sequence to run. A test written to the unqualified sentence fails on a detach
|
|
row. Nothing writes one today - the detach writer has no production caller -
|
|
which is exactly why the qualifier has to be in the contract rather than
|
|
discovered when one appears. A test asserts the correspondence, and it is the
|
|
test that fails if a future route changes a task's step without dispatching.
|
|
|
|
**How the dispatch is delivered.** Naming the trigger is not naming the
|
|
mechanism, and the mechanism is a real fork: a post-commit call, a poller over
|
|
unprocessed rows, and a transactional outbox all satisfy "once per committed
|
|
row, after it commits" and differ in cost, latency and crash behavior. This
|
|
design picks one.
|
|
|
|
*The dispatch is a synchronous post-commit call, made by the ledger's own
|
|
registered writers.* The set of writers permitted to change a task's step is
|
|
already closed and already enumerated in the code that writes the ledger; each
|
|
one, having committed, calls the step-entry dispatch on the same goroutine
|
|
before returning to its caller. No new table, no new process, no queue.
|
|
|
|
*Why not an outbox or a poller.* Both survive a crash between the commit and
|
|
the dispatch, and both cost more than the guarantee is worth here. An outbox
|
|
adds a table, a writer and a drainer to a system that already has no
|
|
asynchronous step-entry machinery; a poller adds latency that the acceptance
|
|
test would then have to be told to wait for, and a polling interval is a number
|
|
nobody has evidence for. The synchronous call has neither problem: when the
|
|
writer returns, the entry sequence has already run, so the pinned Playwright
|
|
assertion has a defined observation moment and needs no polling.
|
|
|
|
*The crash contract, stated rather than left to be discovered.* **A dispatch
|
|
lost to a process failure between the commit and the call is not recovered.**
|
|
The step change stands; the entry sequence does not run for that arrival. Such
|
|
a task is recovered the same way the requirement's out-of-scope line already
|
|
recovers every other stranded task - by arriving again - and there is no sweep.
|
|
This is the one guarantee traded away for the simplicity above, and it is
|
|
traded knowingly: the window is the duration of a function call, and the
|
|
alternative buys a table and a drainer to close it.
|
|
|
|
**Exactly one component dispatches.** `AC-OFFICE-STEP-ENTRY-001.5` requires
|
|
each declared action to take effect exactly once per arrival, and the exclusion
|
|
list alone does not deliver that, because it is keyed on action *kind* while
|
|
the risk is on the *route*. A workflow switch fires two paths for one arrival:
|
|
the writer that moves the task commits the step change and its ledger row
|
|
together, which is what the new dispatch keys on, **and** the switch's own
|
|
callback asks the engine to handle a step-entry trigger, which runs the whole
|
|
compiled sequence through the callback registry - including `clear_decisions`
|
|
and the participant fan-out, the two session-independent kinds this design
|
|
newly dispatches. Left alone, one arrival at `review` would clear decisions
|
|
twice and queue every reviewer's run twice, and the exhaustiveness test over
|
|
kinds would pass while it happened. The pre-existing applied-operation check on
|
|
that path does not prevent it either: it is keyed on the switch's own operation
|
|
identifier, not on entry identity.
|
|
|
|
So the switch route **stops dispatching the step's entry sequence**. The
|
|
ledger-driven path already covers it - a workflow switch commits a step change
|
|
and therefore writes a ledger row - so nothing is lost by removing the second
|
|
dispatcher, and the "one dispatch per ledger row" correspondence becomes
|
|
literally true rather than approximately true. `AC-OFFICE-STEP-ENTRY-001.8`
|
|
states the rule, and its test is on the route axis: drive a workflow switch and
|
|
assert the entry sequence ran once. A test over kinds cannot see this.
|
|
|
|
This is still the narrowest form of the fix in the dimension the out-of-scope
|
|
line names: it touches step entry and no other trigger.
|
|
|
|
## Entry identity
|
|
|
|
Both the at-most-once criteria and the fan-out's own deduplication need a value
|
|
that names one entry. Today an ordinary transition supplies none: the store's
|
|
public transition method passes an empty move identifier through to the engine,
|
|
and the engine treats an empty operation identifier as "do not deduplicate at
|
|
all" in both its already-applied check and its idempotency key. So today,
|
|
ordinary entries are not deduplicated, and a redelivery would re-run
|
|
everything.
|
|
|
|
**Design position.** The entry identity is the identifier of the ledger row
|
|
that recorded the arrival.
|
|
|
|
**Which ledger.** The system keeps two step histories and they are not
|
|
interchangeable, so this design names one.
|
|
|
|
- `task_step_transitions` is **task-scoped**, carries `task_id`,
|
|
`to_workflow_step_id` and `occurred_at`, is indexed on `(task_id,
|
|
occurred_at, id)`, and is written **synchronously** inside the same
|
|
transaction that commits the step change, by the registered set of writers
|
|
allowed to change a task's step. The row is committed before anything
|
|
downstream can observe the arrival. **This is the one.**
|
|
- `session_step_history` is **session-scoped** - its model carries no task
|
|
identifier at all - and is written onto a bounded channel that drops rows
|
|
when full, as best-effort telemetry. It cannot answer a question about a
|
|
task, and a stale or dropped entry would make a genuine re-entry look like a
|
|
redelivery, which silently reproduces the defect this card reports. A design
|
|
that said "the transition history" without naming which one could land here.
|
|
|
|
**Why the row identifier and not a count of rows.** A count-then-use read is a
|
|
read-modify-use with no serialization: two arrivals for the same task and step
|
|
could read the same count, derive the same identity, and one would be discarded
|
|
as a redelivery of the other; a redelivery straddling the write could derive a
|
|
new identity and double-queue the fan-out. The row identifier is allocated by
|
|
the insert itself, inside the transaction that commits the step change, so no
|
|
two arrivals can share one and none is assigned before its step change commits
|
|
(`AC-OFFICE-STEP-ENTRY-001.7`). There is also no off-by-one to decide,
|
|
because nothing is counted.
|
|
|
|
This satisfies the two criteria that pull in opposite directions:
|
|
|
|
- A redelivery of one arrival carries that arrival's row identifier, so the
|
|
seats requirement's at-most-once criteria
|
|
(`AC-OFFICE-REVIEW-SEATS-003.2`, `AC-OFFICE-REVIEW-SEATS-004.7`) hold.
|
|
- A task that leaves a gated step and returns writes a **new** ledger row and
|
|
therefore carries a different identity, therefore a fresh fan-out run, which
|
|
is what `AC-OFFICE-REVIEW-SEATS-003.6` requires and what a rejected-then-
|
|
reworked task needs. Re-entry is a new entry, not a duplicate of the old one.
|
|
|
|
A random per-dispatch identifier would satisfy the second and break the first;
|
|
a `(task, step)` pair would satisfy the first and break the second; a count of
|
|
prior entries would satisfy both only under a serialization the writer does not
|
|
provide.
|
|
|
|
**Where "this entry already ran" is recorded.** Settling what the identity *is*
|
|
does not settle what remembers it, and the two are separate decisions. The
|
|
system's one existing mechanism for "this operation was already applied" is an
|
|
in-process map held by the orchestrator's workflow store. **It is deliberately
|
|
not used here**, and the reason is in this requirement's own Terminology: a
|
|
crash-recovery re-dispatch is named as a redelivery, and after a restart that
|
|
map is empty, so every identity looks unseen and precisely the case the
|
|
Terminology singles out would be the one case the deduplication misses.
|
|
|
|
**Design position.** There is no separate already-ran marker. Each action the
|
|
step-entry dispatch runs is individually idempotent against **durable state
|
|
keyed by the entry identity**, so the deduplication survives a restart because
|
|
the record of it is the database rather than a process's memory:
|
|
|
|
- *Seat-ensuring* deduplicates against the seat rows themselves, by the
|
|
workflow-scoped write check inside the committing transaction and the natural
|
|
key's unique index behind it (`AC-OFFICE-REVIEW-SEATS-001.3`, `.4`).
|
|
- *The participant fan-out* deduplicates against the queued runs, by the entry
|
|
identity carried on the run it queues for a seat: a run already queued for
|
|
that seat and that entry is not queued again
|
|
(`AC-OFFICE-REVIEW-SEATS-003.2`), while a later entry carries a different
|
|
identity and therefore queues afresh (`AC-OFFICE-REVIEW-SEATS-003.6`).
|
|
- *`clear_decisions`* is idempotent by its own nature: clearing twice leaves
|
|
the same state as clearing once, so it needs no key.
|
|
- *`queue_run` and `run_code_review`* are inert on shipped configuration and
|
|
carry the entry identity as their idempotency key when an operator-authored
|
|
step declares them.
|
|
|
|
The consequence worth stating plainly: this design adds no table and no
|
|
process-lifetime state, and `AC-OFFICE-STEP-ENTRY-001.10` requires the
|
|
recognition to be durable rather than in-process, so a build that reaches for
|
|
the existing in-memory map fails a criterion instead of shipping a defect that
|
|
only appears after a restart.
|
|
|
|
**Two consequences a builder must handle.** The ledger writer does not return
|
|
the inserted row's identifier today, so it must; retrieval is
|
|
dialect-appropriate, since this path runs on SQLite and Postgres alike and the
|
|
code that creates this table already branches on the dialect. And the writer is
|
|
deliberately a no-op when the step does not actually change - a position-only
|
|
reorder, or a move re-issued to the step the task already stands on, writes no
|
|
row. That is the correct boundary and the requirement adopts it: such a move is
|
|
not an arrival, so no entry sequence runs, no seat is written, and no fan-out
|
|
is queued.
|
|
|
|
## One failing action does not cancel the rest
|
|
|
|
`AC-OFFICE-STEP-ENTRY-001.4` requires the remaining actions to be attempted
|
|
after one fails, and the failure to be recorded. The engine's shared action
|
|
loop returns on the first callback error. That loop also serves
|
|
`on_turn_start`, `on_turn_complete` and `on_exit`, so relaxing it in place
|
|
would change guard and transition failure semantics for every trigger, which
|
|
this requirement's out-of-scope line forbids.
|
|
|
|
**Design position.** The step-entry dispatch evaluates its own action list with
|
|
a record-and-continue loop, and the shared loop is left exactly as it is
|
|
(`AC-OFFICE-STEP-ENTRY-001.6`). A test asserts both halves: a failing action at
|
|
step entry leaves the rest of the sequence attempted, and a failing action on
|
|
`on_turn_complete` still aborts the rest. Without both halves the change reads
|
|
as a general relaxation of failure handling, which it is not.
|
|
|
|
## Observability
|
|
|
|
Each dispatched entry sequence records the arrival's entry identity, the step,
|
|
the action kinds attempted, and any action that failed with the reason. An
|
|
excluded kind is not logged as skipped work: exclusion is the contract, not an
|
|
anomaly. A redelivery discarded by entry identity is counted, because a
|
|
non-zero count there is the signal that a delivery path is retrying.
|
|
|
|
## Related decisions
|
|
|
|
- **ADR-0004, task model unification.** The step-entry execution path is one
|
|
ADR-0004 already specifies; this design completes it rather than introducing
|
|
a new boundary. If the build finds that completing it requires changing how
|
|
triggers are driven generally, a decision record becomes necessary and the
|
|
build should stop and say so.
|
|
- **ADR-0027, replayable schema migrations across SQLite and Postgres.**
|
|
Binding on returning the ledger row's identifier, which must be retrieved in
|
|
a dialect-appropriate way on both engines.
|