Task: Hold a workflow as a dependency graph
Table of Contents
This page documents a task in the Gather a run's inputs by declared dependency story. It captures the goal, current status, acceptance, and any notes or results.
1. Goal
The chain is a std::vector<workflow_step_def> built in code, and the
instance stores each step's ordinal, so the only edge in the graph is the
ordinal. That is enough while every step produces exactly one artifact and the
chain is a line, and it stops being enough the moment a step gathers per trade set:
an ordinal says which step is next, not what a step is waiting for.
Persist the graph with the run — steps as vertices, artifact dependencies as
edges — and reconstruct it as a boost::adjacency_list to decide what is
dispatchable.
2. Status
| Field | Value |
|---|---|
| State | DONE |
| Parent story | Gather a run's inputs by declared dependency |
| Now | Nothing. |
| Waiting on | Declare what a step consumes and produces |
| Next | Nothing. |
| Last touched | 2026-10-07 |
3. Acceptance
- A step's dependencies are persisted with the run and read back at dispatch; the ordinal alone no longer decides what runs.
- The engine dispatches a step only when its declared inputs exist, whatever position it holds.
- A consumer of an artifact that N producers write runs only once all N have finished, and runs once, not N times.
- A run in progress can be resumed after a restart by reconstructing the graph from the database, not from the code that built the chain.
4. Plan
Vertices are steps; an edge runs from the producer of an artifact to each of its consumers. The ready set is every vertex whose incoming edges are all satisfied, which is what the engine dispatches from in place of the ordinal.
The precedent is in this tree: ores.dq's publish_service.cpp builds a
boost::adjacency_list over datasets and their declared dependencies, orders
it with boost::topological_sort, and publishes in that order. The same shape
serves here, and a reviewer already knows how to read it.
Two things must survive the change. Compensation is a saga walk of the
satisfied prefix in reverse, so it needs a defined order from a structure that
is no longer a line; and recover_in_progress must rebuild the graph from the
database, which is what makes persistence the point rather than a convenience.
Where the edges are persisted is a decision to take with the schema in hand:
an edge table keeps a step's dependencies open-ended, while a depends_on
column on the step keeps the read simple and would have to become a list. The
edge table is the one that survives a step with two producers without a second
migration.
5. Measurements
Every number this task claims about a tree, with the command that produced it
and the commit it was measured at. A number without both is not a measurement,
because a clean-up record describes a tree and trunk moves under a branch: a
consumer count, a gate's component count and a suite's size all change with no
code changing, and the reader of a bare number cannot tell which tree it came
from. Where the raw output matters, put it in an evidence/ file beside the
task and name that file in the Command column.
| What | Value | Command | Commit |
|---|---|---|---|
6. Notes
Landed so far. The graph itself, and the engine deferring to it.
workflow_graph holds a run's steps as vertices and an edge from a step to
every step that reads its result, built with boost::adjacency_list and
queried through in_edges. It answers two questions: whether the chain can be
run at all, and which steps may run given what has answered.
It is rebuilt from the run's own materialised_steps_json, not from the
definition as it stands now, so a definition edited after a run started cannot
change what that run was waiting for. That is the persistence the task asked
for: the graph travels with the run and a restart reconstructs it from the
database.
Three chains are refused before a run exists, and each is a definition that cannot be executed rather than a state a run can reach: a consumer of a name no step produces; two steps with one name, because a consumer cannot say which it reads; and a consumer of a step the engine reaches only later.
That last refusal is deliberate and worth naming. The graph derives an order and would run a chain declared the other way round; the engine advances along the declaration, so it would dispatch the consumer first and find its input had not run. A chain the engine cannot walk is refused up front rather than left to fail at the step that discovers it. Dispatching from the graph's own order is what lifts the restriction.
At dispatch the engine asks the graph whether the step it is about to take is ready, and fails the run naming the inputs it is waiting on when it is not.
What the task still owes. Two things, and both are the same change.
The advance loop still walks the chain by index and dispatches one step per completion. Until it dispatches every ready step instead, a consumer of many producers — the fan-in the gathering redesign needs — is refused rather than made to wait, because nothing reconsults the graph when a producer answers. The readiness rule is already the fan-in rule: it waits on every producer that writes an input, proven by a test with two batch-keyed producers. What is missing is a loop that acts on it.
7. The chain's rows take the run's tenant
Re-pointing the engine at the two relations is done. The graph is drawn from
the plan rows and their edges. The deadlines and the step words a screen shows
come from the same rows. materialised_steps_json is gone from the model and
from every generated artefact.
One thing about the write is worth recording, because the symptom points somewhere other than the cause.
The chain's rows carry tenant_id. Written with the engine's own tenant, the
plan step's insert is refused by its own soft-FK check:
Invalid workflow_id: <id>. No active workflow instance found with this id.
The cause is not the row-level security policy, and the row was never hidden. The trigger's first statement rewrites the tenant it was handed:
NEW.tenant_id := ores_iam_validate_tenant_fn(NEW.tenant_id);
That function maps the system tenant to whatever the session says. Its own
comment states the rule: Treat system tenant ID (nil UUID) as placeholder
meaning "use session tenant". The engine holds the system tenant while it
starts runs for everyone. So a row written with the engine's tenant becomes a
lookup for instances under the system tenant, and the run's own instance
carries the run's tenant. The lookup finds nothing.
Measured on one instance, in one session, with only the written tenant varying:
| Tenant written on the row | Rows the trigger finds |
|---|---|
| the engine's (system) tenant | 0 |
| the run's tenant | 1 |
The execution rows insert in the same run because they already take
instance.tenant_id. The engine states that three screens above the write, in
a comment on the execution step.
So the caller was wrong, and the engine already held the answer. Each row of the chain takes the instance's tenant. Nothing in a policy, a check or a generator changes.
The rewrite inside ores_iam_validate_tenant_fn is a pre-existing
compatibility affordance, and it is not the fault. A caller that states the
wrong tenant must be corrected whether or not the function hides it. Removing
the placeholder would change every generated insert trigger, so it is not part
of this task.
8. Test Scenarios
9. Test Scenarios
Manual QA scenarios (scaffolded via compass add test_scenario)
that verify this task. Link
new ones here as they're created; the scenario doc itself links back
via its "Verifies task" field.
| Scenario | State | Notes |
|---|---|---|
10. PRs
| PR | Title |
|---|---|
| 2783 | Hold a run's steps as a graph and dispatch on its answer |
11. Review
| Comment summary | File | Decision | Notes |
|---|---|---|---|
12. Result
Delivered and merged as 2783.
A run's chain is two relations rather than a document. workflow_plan_step holds
one step of the chain the run was started with and workflow_plan_dependency
holds one edge, so "what is waiting", "what may run now" and "what rolls back, in
what order" became queries over rows instead of a parse. materialised_steps_json
left the model, and regeneration removed it from the domain, protocol, entity,
mapper, service, shell commands and TypeScript bindings with it.
The engine dispatches every step the graph says may run rather than one per
completion. A run completes when every step of its chain has answered – read
from the chain, not counted from steps taken – and a run that can neither run a
step nor has one in flight is failed rather than left in progress for ever.
current_step_index went with the ordinal it belonged to; step_count stayed,
because it is the chain's length fixed at start.
Declaring what a step reads stopped being optional: a chain that declares nothing now runs flat. So every definition that meant one step after another says so – identity (a sequence of outcomes), bundle publishing (display order), and the provisioning chain, which states its real dependencies, a kind's needs sitting beside its words and its budget, with a need the run did not order answered by the nearest step it did.
Measured on a live tenant bootstrap from acme_demo: import_lei_hierarchy and
start_market_feeds were published in the same second, while provision_party
did not yet exist. One completion made two steps ready.
What this did not settle. The engine asks the graph what may run, but a step's
command is still built by the definition's own build_steps, so the rows are the
authority for the graph and not yet for the builder, and a non-deterministic
definition could still build a different chain than the run holds. Making the
rows the only authority is the next slice.