Skip to main content

Chains

A continuation chain is the population of generations that descend from one root because each declared the previous one as its initiator_generation_id.

Overview

Chains are how work outlives the request that started it. An approval decided three days later resumes the turn that proposed the call — as a new generation, linked back. That resumption can propose another gated call, approved later still, and so on; the chain is the whole tree that grows out of the first turn.

This module is the record of that tree: how large it has grown, whether it is still alive, and why it stopped. It is read-only — a chain is written by the continuation path, never by a caller — and it is created lazily by its first continuation, so a generation that never continues another is not a chain and gets no record. The table holds runaway candidates, not one row per turn.

The behavior that produces a chain lives with the agent: see Continuation chains for how a resumption is linked and bounded. A chain is not the same thing as a trace tree — that one runs inward through the calls a single turn makes, while a chain runs forward in time through turns resumed after their request is gone; that section spells out the difference and why the two are kept independent.

See the Permissions Reference for the IAM action strings for this module.

Data Model

FieldTypeDescription
idstringPublic ID, chain_ prefix
project_idstringOwning project
agent_idstring | nullThe agent whose continuation opened the chain
statusstringactive, concluded, expired, budget_exhausted
generation_countintegerGenerations in the chain, the root included
last_generation_atstring | nullWhen the chain last gained a generation
created_atstringCreation timestamp
updated_atstringLast update timestamp

agent_id names the agent that opened the chain, not an owner — a chain can span agents. It is held as a plain id rather than a maintained reference, so deleting that agent leaves the chain's record, and the evidence of what it did, intact.

There is no root_generation_id on the wire: the root is the chain's internal key, and exposing it would create a second handle for the same thing. Every generation in a chain carries chain_id instead — including the root — so filtering generations by that id returns the chain's members, and generation_count is exactly how many that filter returns.

Key Concepts

Status

StatusMeaning
activeHops are still being spawned
concludedA member finished with nothing left pending
expiredA held approval lapsed and nothing resumed the chain
budget_exhaustedA resumption was refused by the chain budget

concluded is not terminal. A chain is quiescent, not finished: an approval resolved months from now spawns another hop and the chain returns to active. The status answers the operator's actual question — which chains might still be spending? — for which a value that could only ever be set once would be useless.

expired is distinguished from concluded because nothing chose to stop: a deadline did. See Approval Expiry for when an expired approval ends a chain instead of reporting to the agent.

Status is observability, not a gate

The budget is enforced by counting a chain's member generations directly, never by reading this record. A chain row that is missing, stale, or wrong therefore cannot let a runaway through — and every write to it is best-effort, because failing a generation in order to record a status about it would trade the thing that matters for the thing that describes it.

Trust status and generation_count for triage; do not build enforcement on them.

Bounding a chain

A chain is unbounded by construction — each hop is a fresh turn with a fresh step budget, so max_call_depth, which bounds recursion within a request, never sees it. Three ceilings apply, and the smallest wins:

CeilingSet onScope
max_chain_generationsthe agent's stop_conditionsone agent
max_chain_generationsthe projectevery chain in one project
MAX_CONTINUATION_CHAIN_GENERATIONSthe deployment's environmentevery chain

Each narrower scope can be stricter than the one above it but never looser: an agent author can cap their own chains below their project's number, and a project owner can cap every chain in the project without that author's cooperation, but neither can raise a ceiling. The outer bound stays a backstop, which is the one thing it cannot be if an inner scope could raise it — the agent that runs away is precisely the one whose configuration is wrong.

Where two scopes name the same number the broader one is reported as the source, since raising the narrower one alone would not move the budget.

All three are read from the current configuration each time a hop is spawned, not captured when the chain started, so lowering any of them can stop a chain that is already running.

When a resumption is refused, three things happen: the chain moves to budget_exhausted, the refused turn is recorded on a trace with stop_reason: "chain_limit", and a chain_limit exception is filed against the chain's root. The exception is what actually reaches a human — a chain is usually resumed by a background sweep with nobody waiting on the answer — and it names which of the three ceilings refused the turn, so the fix is unambiguous.

Examples

# Chains that may still be spending
soat list-chains --project-id proj_01 --status active

# Chains a budget stopped
soat list-chains --project-id proj_01 --status budget_exhausted

# One chain, then the generations in it
soat get-chain --chain-id chain_01
soat list-generations --chain-id chain_01

# Cap an agent's chains at 20 generations
soat update-agent --agent-id agent_01 \
--stop-conditions '[{"type":"max_chain_generations","max_generations":20}]'

# Cap every chain in the project at 25, whatever its agents declare
soat update-project --project-id proj_01 --max-chain-generations 25