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 its request: an approval decided days later resumes the proposing turn as a new generation, linked back; that resumption can propose another gated call, and so on. The chain is the whole tree.

This module is the record of that tree: size, liveness, why it stopped. Read-only, written by the continuation path, and created lazily by the first continuation, so a generation that never continues another has no record.

How a resumption is linked and bounded: Continuation chains. A chain is not a trace tree, which runs inward through one turn's calls; a chain runs forward in time through resumed turns.

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 is the agent that opened the chain, not an owner; a chain can span agents. It is a plain id, so deleting the agent leaves the record intact.

There is no root_generation_id on the wire; the root is the internal key. Every member generation, root included, carries chain_id, so filtering generations by it returns exactly generation_count rows.

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: an approval resolved months later spawns another hop and the chain returns to active. The status answers which chains might still be spending?

expired differs from concluded because a deadline, not a choice, stopped it. See Approval Expiry.

Status is observability, not a gate​

The budget is enforced by counting member generations directly, never by reading this record; a missing, stale, or wrong row cannot let a runaway through, and every write to it is best-effort. Use status and generation_count for triage, not enforcement.

Bounding a chain​

Each hop is a fresh turn with a fresh step budget, so max_call_depth (recursion within a request) never sees a chain. Three ceilings apply; 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

A narrower scope can be stricter than the one above, never looser, so the outer bound stays a backstop. Where two scopes name the same number, the broader one is reported as the source.

All three are read from current configuration at each hop, so lowering one stops a running chain.

On refusal: 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 root, naming which ceiling refused it.

The exception rides a generations.chain_limit webhook event on the root generation: data is the root as GET /api/v1/generations/{generation_id} answers it, plus chain_size, limit, limit_source, and the refused turn's refused_agent_id and refused_initiator_generation_id.

Who may act on a chain​

Every route that acts on one chain is authorized against that chain's SRN, srn:<project_id>:chain:<chain_id>, not against the project. A policy may therefore name the chains it covers:

{
"statement": [
{
"effect": "Allow",
"action": ["chains:GetChain"],
"resource": ["srn:proj_V1StGXR8Z5jdHi6B:chain:chain_V1StGXR8Z5jdHi6B"]
}
]
}

Refusals keep the shapes IAM defines: a read the caller may not perform is 404 (a chain it may not see does not announce itself), a write is 403, and a credential scoped to another project is 403 API_KEY_PROJECT_SCOPE. A write on a resource in a project none of the caller's policies name is 404 too — the same answer their read would get, so a refusal never confirms existence across a tenant boundary.

Listing chains stays project-scoped: GET /api/v1/chains asks whether the caller may list chains in a project at all, so a policy that names individual chains grants no listing.

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