Skip to main content
A chain answers “what caused what”. Given one anchor, it returns the connected graph around it — the automation rule that started it, the issue, the routine it is bound to, the runs that fired, the nested runs those called, the assignments agents were given, who executed them, and the inbox items the whole thing produced. It exists because that answer is spread across two execution substrates that share no table. 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

Auth: bearer token, workspace-scoped (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 number 1 — 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_ms 0 on 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.
This is the same decision the dashboard’s 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 00 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 migration 20260807160100 — 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_kind is unknown, and routine_id/routine_slug are 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" and started_by_id; only the name goes, and started_by falls 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 fill issues[] 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

Auth: bearer token, workspace-scoped (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

An automation 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_id is a record — that run exists because that rule fired.
  • automations.action_config_json.routine_slug is 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.
So the direction depends on which end you anchored, because the anchor is the question. Anchored on the rule, the rule is the subject and its configuration is the answer — “aimed at 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_ms is nullable, and 0 is meaningful. 0 means 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 treats 0 and absent alike will draw an in-flight run as instantaneous.
  • ended_at is never sent without occurred_at. An assignment cancelled before it ever started holds finished_at with started_at still 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.
The two instants are normalised: always UTC, always RFC3339 with a 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 every run 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:
Do not try to derive membership from the edges. The obvious rule — “a sibling arrives over a 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. Two links the product implies are not in the schema, and are reported rather than guessed. Every response carries them in gaps[], 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:
See crewship chain.