Skip to content

Activity exits: the workflow stitches the graph, not the activities - #501

Merged
m2ux merged 5 commits into
mainfrom
feat/496-activity-exits
Aug 24, 2026
Merged

Activity exits: the workflow stitches the graph, not the activities#501
m2ux merged 5 commits into
mainfrom
feat/496-activity-exits

Conversation

@m2ux

@m2ux m2ux commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Summary

An activity in this system did a piece of work and then named the activity that ran next, by
identifier, in its own file. So a workflow file did not describe its own shape — the shape was
spread across the activities, each holding a fragment, and seeing the whole meant reading every file
and assembling the pieces by hand. It also meant an activity could not be borrowed and rearranged:
remediate-vuln builds itself almost entirely from work-package's activities and worked only
because it reproduced work-package's order.

After this change an activity declares exits — named outcomes in its own vocabulary, each with
the predicate that selects it — and the workflow declares a graph binding every exit to a
destination. The activity says what happened; the workflow says what follows.

Closes #496. Paired with the corpus change in #500, which this pointer bump adopts.

What happens today

Routing is declared in three places, all of them on activities: transitions[], decision branches,
and transitionTo inside a checkpoint option's effect. Measured over the corpus at the commit this
branch starts from, 117 activity files across 17 workflows carry 176 route declarations — 127
transitions (48 with a condition), 15 decision branches, 34 checkpoint-option routes — and not one
of them lives in a workflow file.

Three consequences follow, each measured rather than predicted.

A quarter of the graph is invisible where you would look for it. Reading an activity's
transitions does not tell you where it can go, because 34 of the routes sit inside checkpoint
options instead.

A borrowed activity brings its routing with it. remediate-vuln borrows 14 activity files from
work-package, 13 of which carry routes. Every one names an activity remediate-vuln also
contains, so nothing is broken — which is the same as saying the borrowing workflow has been obliged
to mirror the lending one.

A decision the user makes cannot end the activity that asked. A checkpoint option can name where
to go next, but the route is enacted after the activity finishes, so the remaining steps run first.
Of the 34 routed options, 17 are followed by steps and 10 by steps with no gate to stop them. In one,
a user who aborts watches all five remaining steps run. In another, aborting a repository binding is
followed by eleven steps, five of them ungated. In a third, aborting a submission is followed by
eleven steps, one of which tells the user their pull request is ready for review.

The change

An activity declares its exits. An exit is a named outcome — converged, revision-needed,
aborted, or plain done for an activity that simply finishes. It carries the inline predicate that
decides whether it was taken, because that predicate reads the activity's own state. What the
activity loses is any knowledge of what follows.

The workflow binds each exit to a destination. The workflow file gains the graph: for each
activity, which exit leads where. A destination of __terminal__ ends the run without landing on an
activity.

A checkpoint option selects an exit rather than a destination. The option is the decision; the
destination is the consequence, and the consequence is the workflow's to state. present_checkpoint
resolves each option's exit through the binding and returns the destination alongside it, so the
orchestrator can say what an option will do before the user chooses it.

An exit may end the activity where it is selected. By default an exit is recorded when chosen and
taken when the step sequence ends, which is what happens now. An exit declared immediate ends the
sequence at the point of selection. The step-manifest check reads the recorded exit and treats the
steps after that checkpoint as accounted for, so an early exit does not produce a warning that would
make the feature unusable.

The load fails where the two halves disagree. An unbound exit, a binding naming an exit or a
destination that does not exist, an option selecting an exit its activity never declared, and an
activity with several exits and no single default each stop the workflow loading. A session cannot be
walked through a graph with a hole in it, so this is an error rather than a warning.

What this removes

transitions, decision branches and the route-around field are retired into the exit vocabulary,
and with them:

  • conditionToString — a prose renderer that existed only to turn a transition's condition into
    text for the transition_condition match.
  • validateTransitionCondition — that match itself, which compared an agent's claimed condition
    string against rendered prose. next_activity now takes an exit and checks it against the
    binding, which is a fact in the workflow file rather than a string to be matched.
  • effect.skipActivities, the route-around list. Nothing in the corpus used it and nothing in
    the server read what it recorded.
  • skippedActivities on the session, whose only writer was that field, and
    decisionOutcomes, which every session initialised and no handler ever wrote.

The two graph accessors stay two: one returns an activity's exits paired with the destinations bound
to them, the other the bare list of destinations. getValidTransitions and getTransitionList, which
unioned the three sources, are replaced by them. The variable-contract reachability walk reads the
workflow's graph directly instead of assembling one from the activities.

Verification

  • 1,097 tests pass, including new coverage for the binding check (unbound exit, unknown exit,
    unknown destination, missing and duplicated default, an option naming an undeclared exit), for one
    activity running in two workflows that bind its exits in opposite orders with neither editing the
    other, and for the manifest cut an immediate exit makes — at the checkpoint, at the enclosing
    top-level step when the checkpoint sits in a loop body, and at the base id when the checkpoint was
    yielded once per iteration.
  • Through the real server: present_checkpoint states each option's destination from the binding,
    and aborting a submission ends the activity there and reports no missing steps.
  • All 30 guards pass. check:when now covers the exit predicates.
  • The end-to-end walk snapshots are byte-identical. tests/e2e/snapshot.test.ts drives every
    corpus workflow from initialActivity to a terminal through the real MCP server; only the corpus
    SHA stamp changed. The relocated graph is the same graph.

Scope

Server schema, loader, tools, walker, guards, generated JSON schemas and documentation. The corpus
migration is the paired change this pointer bump adopts.

Non-goals

This does not introduce nested activities. It removes the obstacle to them — an activity that names
its outcomes rather than its neighbours can be nested, because a composite could bind its inner
activities' exits to its own — and the composite itself is separate work.

This does not change what a checkpoint is, how a response is recorded, or how a checkpoint reached
once per loop iteration is keyed. Blocking, default options and auto-advance timers keep their
current meaning; an auto-advance resolves its default option's exit, and a dismissed checkpoint
resolves to the activity's default exit, which is what the load-time default rule guarantees exists.

This is not a change to how the session moves. The server still transitions where the orchestrator
asks and still reports an illegal target as a warning.

grammar/activity.ebnf and docs/orchestra-specification.md describe a draft DSL that already
models flow differently and matches neither the schema before this change nor after it. They are left
alone.

Investigation detail

Route counts per workflow, the thirteen declarations that named nothing, the thirty exits whose names
could not be derived from a predicate, and the tail measured after every routed checkpoint option:
.engineering/artifacts/planning/2026-08-24-activity-exits/.

🤖 Generated with Claude Code

m2ux added 4 commits August 24, 2026 08:46
An activity declares exits — named outcomes in its own vocabulary, each with the
inline predicate that selects it — and the workflow's graph binds every exit to a
destination. The two halves are checked against each other at load: an unbound
exit, a binding naming an exit or destination that does not exist, and an activity
with several exits and no single default all fail the load.

A checkpoint option selects an exit rather than an activity, so present_checkpoint
can state each option's consequence from the binding before the user chooses. An
exit declared immediate ends the step sequence where it is selected, and the
step-manifest check reads the recorded exit to account for the steps it skipped.

Transitions, decision branches and the route-around field are retired into the exit
vocabulary, along with the session records that only they wrote.
Loader coverage for the binding check: an unbound exit, a binding naming an exit
or destination that does not exist, a missing or duplicated default, and an option
selecting an exit its activity never declared. One case runs a single activity in
two workflows that bind its exits in opposite orders, neither editing the other.

Manifest coverage for the immediate exit: the cut lands on the checkpoint that
selected it, on the enclosing top-level step when the checkpoint sits in a loop
body, and on the base id when the checkpoint was yielded per iteration. The same
manifest is clean with the exit recorded and reports its tail missing without it.

Through the server: present_checkpoint states each option's destination from the
binding, and aborting a submission ends the activity there.

The walk snapshots are unchanged, so the corpus walks the same paths it did when
its activities carried the routing.
The schema guide, state-management model, checkpoint model and fidelity layers
describe exits and the graph: an activity names what happened, the workflow file
says what follows, the load fails where the two disagree, and an immediate exit
ends the sequence where the user chose it.

Layer 4 of the fidelity stack now checks a reported exit against its binding
rather than matching a rendered condition string.
The corpus at this pointer carries the exits and graphs the schema here defines,
and the walk baseline is stamped against it. The engineering pointer carries the
survey the exit naming and the immediate-exit choices were measured from.
…on held

An exit carrying neither a `when` nor `isDefault` is selectable only by a checkpoint
option naming it, so the walker's forward advance and the enumerator's fork over
targets read the predicate exits alone. Offering them every exit let a walk arrive at
an activity without passing the gate that decides to go there, and the options behind
that gate then went untaken.

An activity's routing is its exits, and those include the branches a decision used to
hold. The walker never read decisions, so those branches were not edges and the
enumerator never forked on them; now they are, the fork tree is wider and the
dry-streak that ends a workflow's enumeration needs room for it. Fifty covers 154 of
275 options across the corpus, where thirty ends before three of workflow-design's
batch-review-attested options are reached.
@m2ux
m2ux merged commit c104e37 into main Aug 24, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Activity exits: the workflow stitches the graph, not the activities

1 participant