pipeline_runs is the routine substrate, assignments is the
delegation substrate, and missions (issues) sits above both pointing at each
with an untyped string column. Without this endpoint a client makes five calls
and re-implements the join rules itself — including the non-obvious one where
pipeline_runs.triggered_by_id holds the issue identifier, not the mission
id.
Read-only and workspace-scoped. Every query in the walk carries the caller’s
workspace, which matters more here than usual: the walk hops between tables
on untyped string columns, and issue identifiers have only been unique per
workspace since the
20260806203901 migration.Endpoints
The two are a pair. The index answers “what ran here”, the walk answers “what
caused what” — and every row’s
origin is a valid anchor, so a client lists to
find a chain and walks to open it.
List chains
RequireAuth + RequireWorkspace).
The workspace comes from the authenticated session, never from a parameter.
One row per chain, not per run. A chain is identified by
pipeline_runs.chain_origin — the run that started it — which the executor
stamps on every run it persists: its own id when the run is the root, the
inherited one when a rule or a nested call carried it. The index is therefore a
GROUP BY over that column rather than a traversal.
Query parameters:
The page is capped because the query groups over this workspace’s runs, which
is the busiest table in the schema. There is no “return everything” mode.
Response:
200 OK
Why a row carries nouns
Two runs of one routine are the same sentence twice — the slug, the trigger kind, and the number1 — and a list of those is not navigable. What makes a
row identify a run is what that run actually touched: the issues it named
and the agents it put to work. Those are the fields you render beside the
routine slug; the routine slug alone is the thing the reader already knew.
duration_ms and issues[].created carry the two distinctions that survive
scanning: how long it took, and whether an issue exists because of this run.
How long it took
duration_ms is the wall clock between first_activity and
last_activity. It is deliberately not the sum of the runs’ own
pipeline_runs.duration_ms, for three reasons:
- a routine of agentless steps records
duration_ms0on every run, so the sum reports “instant” for a chain that took three minutes; - a nested run’s time is counted twice, once in itself and once inside the run that contains it;
- the sum cannot see the gaps between runs, and on a composed chain — one waiting on a rule’s debounce — those gaps are most of the elapsed time.
chainElapsedMs made client-side; the
server now answers it so every client answers it the same way.
null means there is nothing to measure between: one datable moment, which on
this endpoint is a single run that has not ended yet (last_activity falls back
to started_at). Render it as “running”. Do not substitute 0 — 0
asserts the work was instantaneous, which is a different and wrong claim.
Fan-out and its cap
issues[] and agents[] are capped at 5 per row. This is a list: every
noun costs a join over tables that grow with every dispatch, and doing that per
row without a bound is the slow query behind an authenticated route — the same
argument that caps the page itself.
The cap is per row, so one request costs at most limit × 5 issues and
limit × 5 agents. Both queries are issued once per page, not once per row,
and keep the top N per chain with a window function.
issue_count and agent_count are not capped. A short list next to a
larger count is how a client knows it was cut; a capped count would leave a
truncated row indistinguishable from a complete one.
Ordering inside a capped list is stable and meaningful rather than arbitrary:
issues the chain created come first, then the ones it touched most; agents come
by how much work they took.
The columns behind the nouns
A gap worth knowing. Issue writes an agent makes from inside its own
assignment do not appear on the row. Those journal entries carry the
assignment’s own run id as their trace, and that id is not recorded on the
assignments row, so no column joins them back to the chain. The issues a
chain’s routine runs touched are complete; the ones its agents touched
are not reachable from here. The agents themselves are — they come from
assignments.chain_origin, which is exact.mission.created is not in the entry-type list above because nothing emits
it: the journal type exists but the issue action behind it has no producer, so
matching on it would be a predicate that can never fire. Creation is read from
missions.author_run_id instead, which is a column production actually writes.What the index does not cover
Runs recorded before migration20260807160100 — the one that added
chain_origin — carry NULL and are excluded.
They cannot be backfilled: the link was never written, so nothing in the row
says whether it was a chain root or the third hop of a composed chain that has
since been swept. Synthesising an origin from the run’s own id would assert the
former for every one of them, which is exactly the claim the data cannot
support — and it would show a composed chain from that era as three unrelated
single-run chains, silently.
So the index covers chains recorded since the column landed, and says so
through has_unrecorded_runs rather than leaving the absence to be inferred
from a short list.
Two related cases that are not excluded:
- A chain whose root run has been swept by retention is still listed: its
surviving runs still carry the origin id.
started_by_kindisunknown, androutine_id/routine_slugare absent, because the row that held the provenance is gone. - A chain started by a rule that was soft-deleted since keeps
started_by_kind: "automation"with the rule’s id. The label falls back to"automation"rather than inventing a name — the run records that a rule started it, and that fact outlives the rule. - A chain started by a person who has since left the workspace keeps
started_by_kind: "user"andstarted_by_id; only the name goes, andstarted_byfalls back to"manual". The name is resolved through workspace membership, so a former member no longer supplies it — the same rule as the deleted rule above: the fact that a person started this outlives their membership, and the identifier is what carries it.
Tenancy
The joins that fillissues[] and agents[] carry the workspace on every arm,
for the same reason the label lookups do: assignments.chain_origin,
journal_entries.trace_id, journal_entries.mission_id and
missions.author_run_id are untyped string columns whose foreign keys (where
they exist at all) constrain the row but not the tenant. Two distinct holes are
closed, not one — a row another tenant stamped with your chain’s origin, and a
row of yours pointing at another tenant’s agent or issue.
Every predicate carries the workspace, including the five label lookups. That
is not defence in depth layered over a scoped entry point: chain_origin and
triggered_by_id are untyped string columns with no foreign key behind them,
and issue identifiers are only unique per workspace, so an unfenced label
lookup would happily hand another tenant’s issue title or rule name to a run of
yours. The labels are correlated subqueries rather than joins for the same
reason a duplicate must not matter: a subquery cannot multiply the row count,
so one chain can never split into two index rows.
Errors:
An empty
chains[] is 200, not 404: a workspace where nothing has run yet
is a valid answer, and one where everything predates chain recording is a
different answer that has_unrecorded_runs distinguishes.
Get a chain
RequireAuth + RequireWorkspace).
The workspace comes from the authenticated session, never from the path.
Path parameters:
Query parameters:
Both are clamped server-side. An unparseable or out-of-range value falls back
to the default or the ceiling rather than returning
400, and truncated
reports the consequence.
Request: no body.
Response: 200 OK
Edge kinds and the columns behind them
Every edge is backed by a real column. Nothing is inferred.triggered_by_id is polymorphic — a schedule id, a webhook id, a parent run
id, or an issue identifier depending on triggered_via — so it is only ever
dereferenced against the table triggered_via names. A schedule id that
happens to equal a run id is not followed as a parent run.
Automations: the origin of a composed chain
Anautomation is a stored rule that turns a journal event into a routine run.
It is the thing that makes a chain begin, so without it a topology can draw
routine → run → agent and never say why any of it happened.
The link back is exact, not inferred: a run a rule started carries
triggered_via='automation' with triggered_by_id set to the automations.id.
An automation is also a valid anchor — GET /api/v1/chains/aut_01hx... returns
the routine the rule is wired to and every run it has caused.
An automation node carries the rule’s name as label, its event_type as
key, and "enabled" / "disabled" as status.
A rule is only drawn where it actually fired
Walking a routine does not return the rules that merely point at it. This is deliberate, and it is the one place where the two automation links are not symmetrical:pipeline_runs.triggered_by_idis a record — that run exists because that rule fired.automations.action_config_json.routine_slugis a standing intent — it says where a rule is aimed, not that it ever went off. One routine can be named by unboundedly many rules, and none of them need ever have fired.
triage, has caused nothing” is
correct and complete, and the absence of run nodes is itself the finding.
Anchored on a routine or a run, a rule is being offered as an
explanation, and a rule that did not fire would be drawn with the identical
triggers edge as the one that did. A graph titled “how this happened” listing
four candidate causes for a run you started by hand is not an incomplete
answer, it is a wrong one that looks authoritative.
Nothing is lost by this. The rules that did fire stay reachable from a
routine through the runs they caused (routine → run → automation), which is
precisely the evidence that they fired.
Two consequences worth knowing:
- Disabling a rule does not erase the runs it already caused; those edges remain, so switching a rule off never rewrites history.
- Soft-deleting a rule does remove it from the walk, because a deleted rule
is not-found on every other surface and a chain that resurrected it would
show a node you cannot click through to. The run keeps
triggered_via='automation', so the fact that a rule started it survives in the run record even when the rule itself is no longer readable.
When a node happened
A chain answers “what caused what”.occurred_at / ended_at / duration_ms
answer “and when”, which is what a timeline needs — but only three of the seven
kinds can answer it honestly, and the other four send nothing rather than a
plausible number.
Absence is the answer, not a gap. A zero timestamp renders as 1 January 1970
and sorts above everything real, and a zero duration reads as “it was instant”;
both are confident lies about work whose time simply is not recorded. Do not
substitute
0 or "" on the way to a chart.
Two details the wire format pins down:
duration_msis nullable, and0is meaningful.0means the work finished inside a millisecond. Absent means the span could not be derived — it has not finished, or a stamp was unreadable. A client that treats0and absent alike will draw an in-flight run as instantaneous.ended_atis never sent withoutoccurred_at. An assignment cancelled before it ever started holdsfinished_atwithstarted_atstill NULL. A node with an end and no beginning is not a shorter bar on a timeline, it is an unplaceable one, so both are withheld.
Z,
whatever syntax the underlying column held. The columns behind them are written
by several producers in three different shapes — including SQLite’s
2026-08-07 09:41:02.317, which new Date() reads as local time in V8 and
rejects outright elsewhere — so the endpoint parses and re-emits rather than
passing the stored string through. A stamp it cannot parse is reported as
absent rather than forwarded raw.
Telling members from siblings
Not everyrun node in the response belongs to the chain you anchored on.
This surprises every consumer once, so it is worth stating plainly.
The walk expands a run up to its routine, and a routine down to every run of
it. So anchoring on one run returns that run, its routine, and every other run
that routine has ever had — a chain of one run can come back carrying eight
run nodes. That is correct for the picture the walk draws; it is wrong for any
caller counting runs.
Filter on chain_origin. A run node belongs to the chain when its
chain_origin equals the origin you anchored on:
runs edge, so keep everything else” — is false: a
routine fired by a rule gets an automation --triggers--> run edge for every
run that rule ever caused, so its siblings arrive over triggers as well and
survive the filter. That rule happens to work for manual and schedule
triggers and silently fails for automations, which is the commonest shape in the
product.
A run whose chain_origin is absent predates chain recording. It cannot be
attributed to any chain — see What the index does not cover
— so treat it as unattributable rather than as a member. If no run node in a
response carries the field, you are talking to a server older than this field
and should keep every run rather than drop them all.
Links that do not exist
Two links the product implies are not in the schema, and are reported rather than guessed. Every response carries them ingaps[], and the nodes at those
boundaries carry partial: true with the same reason.
Guessing either one produces confident nonsense — matching an escalation to a
run by crew and timestamp is wrong the moment two runs overlap — so the
endpoint states the hole instead.
agent nodes are also marked partial, for
a different reason: expanding one would pull in that agent’s entire assignment
history, which is not part of this chain.
Truncation
truncated is always on the wire, so a short chain is never silently
presented as a complete one. When it is true, truncated_by names which cap
bit first — the first one, not the last, because once the node cap has stopped
the walk the depth cap is never reached and reporting that would send you to
raise the wrong limit.
Edges are only returned when both endpoints are in nodes[]. An edge to a
node the cap dropped is dropped with it, so a client never has to distinguish
a dangling reference from a rendering bug of its own.
Errors:
CLI
The same data — the index as a table, the walk as a tree:crewship chain.