transfer.erc20, bridge.cctp, lend.borrow,
and the catalogue of them is the closed set of things any plan may ask for.
Adding an entry to the catalogue is the only way a pipeline gains a new
capability. The DSL has no escape hatch to an unregistered call. That is not a
convention; it is the reason the agent is safe to hand a model.
For the full generated list of all 58, see the catalogue.
The hard rule: every step is an adapter
Every step in the catalogue must be an adapter. A raw or direct call is acceptable as a throwaway while testing, and never as the thing that ships. Concretely, a catalogued id has to resolve all the way down: to an operation, to a named adapter, to a deployed contract, to an entry point with an argument shape and a digest view to check the message against, and its operations list must be non-empty, because the adapter base contract fails closed on an unlisted operation. Why: a step reaching the chain any other way is one the agent’s signature does not gate and the account’s installed-operations list does not bound. That is the whole Kernel model bypassed by something that merely looked like the other steps in the designer. The relayer submits and cannot authorise only because every call isact(params, signature) against an adapter that checks
the signer against the registry.
Why it is a test and not a paragraph: the failure is invisible. A catalogue
entry with no adapter compiles, validates, appears in the palette, and only stops
at execution: by which point somebody has authored a plan around it. The test
encodes both directions: every catalogued step reaches a deployed adapter, and
every deployed adapter is reachable from a step.
Versions are pinned, and an unversioned reference is refused
A pipeline is a sequence of versioned adapter invocations, written[email protected].
A pipeline can be in flight for a long time while adapters change underneath it:
a bug fix, a new router, a changed parameter meaning. Without pinning, a task
that survives a deploy silently starts executing different behaviour halfway
through.
- A newer minor satisfies a pin, because minors are additive by definition.
- A newer major does not. The plan was approved against the old semantics.
- Resolution returns the lowest compatible version, not the newest. Running on the newest available substitutes code the plan never saw.
Parameter kinds are a closed set
Twelve kinds, and there is deliberately nobytes and no any: either would
let a caller smuggle calldata through a parameter and defeat the property that
the agent supplies parameters and never instructions.
Three of those repay a closer look:
market is a name, not an id. Morpho Blue is permissionless, so an id would
let a plan reach a market nobody vetted, and since a market is its oracle,
that is a plan choosing what price it gets liquidated at.
property is separate from market even though both are curated closed
sets, because they are closed for different reasons. A Morpho market is closed
because the market is its oracle; a share register is closed because it is a
claim on a real building and there is exactly one contract per property. They
were once the same kind, and the designer offered lending markets for a field
that wants a property token.
price is two integers, never a decimal. A rate as a float has to be
reconciled with two different token decimalisations at some point, and every
place that conversion happens is a place a limit can be silently multiplied by a
million. As a pair of base-unit amounts there is nothing to convert.
What a step produces, and what its own amount means
Two separate questions that look like one, and conflating them is a plan that executes something nobody meant. Produces answers whether a later step may refer to this one’s result. Output answers what that number is. Both are needed because one adapter produces a lock NFT id and another produces an amount of USDC, and both areuint256 travelling down the same wire. Without a kind, “swap what the claim
produced” is a well-formed plan that swaps 374 wei: no error anywhere, just a
trade nobody meant.
The same distinction applies to a step’s own amount. It may be base units of an
asset, a whole number of property shares, a lock NFT id, a rent position id, a
Uniswap position id, or all of whatever is there.
That last one exists because a rent claim collects whatever has accrued: the
figure is not knowable when the document is written and the adapter does not read
it. Before the kind existed, the amount check refused 0 while two skills told
the author to write 0, and there was no third thing to write: a deadlock
between two individually-correct error messages, which is this codebase’s most
expensive shape.
Settling out of band
Most steps finish when their transaction confirms. Two do not: both bridges. Completion depends on something outside the transaction: an attestation, a solver filling an order. Those are the steps that need durable waiting rather than a receipt check, and they are why How it works is built around Temporal at all. Only one of the two is a live route.bridge.cctp is Circle’s, moves USDC and
nothing else, and takes roughly fifteen minutes. bridge.fake_across is a
fork-only stand-in (its own generated entry says so) kept because it
reproduces the shape that matters for testing: the deposit is final before
anything arrives, the fill comes from an inventory that can run out, and less
lands than left. Treat cross-chain as USDC over CCTP until a second real route
ships.
Batching, and why a group is not just an optimisation
Consecutive steps may execute as one atomic transaction. Batching is not free: a batch is all-or-nothing, so if the last call reverts the first is undone too. That is exactly what makes “approve then supply” safe: the approval cannot survive a failed supply as a standing allowance, and exactly what makes a long batch a bad idea, because one failure at the end throws away everything before it. Merging is free and stays maximal. A group is one transaction, so everything in it completes at the same instant. Splitting a group so a later one can “start sooner” is a strict loss: it cannot reduce the wave count and always costs a transaction. Measured: a draw plus two deposits plus a bridge ran as 3 transactions and 852k gas split, and 2 transactions and 739k gas merged, in the same two blocks.Order is derived, not positional
Steps are still written top to bottom, but the run does not execute them that way. Which steps genuinely depend on each other is derived, and independent ones run in the same wave, Bernstein’s conditions over two resources: balances, and lending positions. The trap, and it is silent. A step that the effect classifier does not recognise, or that touches no balance at all, gets no entry in the dependency graph, which means “waits for everything above it”. That is fail-closed and correct, but nothing says why: the plan still runs, just serially. So adding an adapter to the catalogue has an ordering consequence. Add it to the effect classifier, and to the position classifier if it touches a lending position, or every plan using it serialises from that step down. A declared dependency is a floor and never a ceiling; it can add order and cannot remove derived order, so a wrong declaration costs latency and never money.The runtime bus, and the one thing it cannot do
A swap’s output is not knowable when a plan is signed, so a group used to end after every swap and the step spending the proceeds paid for its own transaction. The runtime bus lets one adapter spend what an earlier step actually delivered. It is EIP-1153 transient storage, so it is scoped to exactly one transaction. A producer in transaction A and a consumer in transaction B finds the bus empty and reverts:NothingDelivered, whose first argument names the producing
adapter, so the revert reads as being about the step before.
That matters because a bridge deposit is deliberately placed alone in its own
transaction. “The two steps run on the same chain” is necessary and not
sufficient; the question is whether they share a transaction.
Both flags that turn this on are off by default, because it changes how money
moves.
Four bugs were found by attacking the bus, all the same family: type or identity
confusion, and the invariants that came out of them are worth stating plainly:
a non-consuming read is a double spend, so consumption clears; an amount with no
asset crosses tokens, because 128,649 is $100 of cbBTC or twelve cents of USDC;
a token id is not an amount and needs its own channel; and keying by the
producing contract collides two swaps, because one router is one address, so
it is keyed by the producing step.