ERC-8312: Bounded Agent Actions

ERC: Bounded Agent Actions - a metering layer for agent authority

There are a lot of agent ERCs landing right now, so I want to be clear up front about what this one isn’t. It doesn’t enforce anything, it doesn’t run a workflow, and it doesn’t settle. What it does is narrow: it keeps a running count of how much of a bounded mandate an agent has already spent across its actions, so a downstream contract can look up how much room is left. That’s the whole idea: one small on-chain object, a cursor, with a single interface to read it and advance it. I’m proposing it on its own, not as part of a bigger framework, because the counting is the piece I think is actually missing today.

The bounded-execution discussion over on ERC-8301 is circling the same gap from the execution side. I brought the metering angle up there, but it felt like it deserved its own thread rather than taking that one over, so here we are.

One thing to flag: this interface does not, on its own, make a bound impossible for the principal’s own key to bypass. Counting and enforcing are two different jobs. Something underneath has to actually hold the line: a vault that custodies the assets, or a module sitting on the account’s only execution path. I’ve written that into the spec as an explicit requirement on whoever implements that layer, rather than something the interface quietly assumes. I’m putting it first because it’s what keeps this proposal from stepping on the enforcement standards. This interface does the accounting; the enforcing happens somewhere else.

There’s one design decision that everything else hangs on; You can express a bound two ways: 1) as a predicate that gets recomputed from public state on every action, or 2) as a counter that each action writes to. If everything settles on a single surface, the predicate is enough, because execution there is serialized, so a check that reads and writes in the same transaction already sees everything that came before it. The moment you cross surfaces, that stops being true: no single contract holds the running total, and there’s no check you can run at action time that reconstructs it. That’s the reason this has to be a standing object that actions write to, instead of something you bolt on as a per-call hook.

I want this to sit next to the standards we already have, not on top of them. Concretely, with ERC-8301: a step gate reads getCursor(id) and calls advanceCursor in the same step. 8301 makes sure a task moves through its stages in order; the cursor records how much authority those stages have used up; and the substrate is what keeps that total inside the committed bound. The same shape works elsewhere. It composes with a 7710 caveat enforcer, with an 8001 agreement (the agreement records what was accepted, the cursor records what’s left), and with an 8274 verification standing in as the witness for an advance.

The base interface deliberately doesn’t assume any particular enforcement mechanism underneath it. “Doesn’t assume anything” can quietly turn into “doesn’t pin down anything,” though, so I’ve also written one concrete profile to implement against: a budget substrate that fixes what the cursor means (running spend), what the commitment holds (a cap and an asset), what the witness is, and the one invariant that matters, spent <= cap. It comes with a typed IBudgetSubstrate, so a consumer can call remaining() and get a number back directly. There’s a minimal CC0 reference registry here and the ERC-165 ids are frozen.

I also have a private, more complex implementation in this area, which is why the public one is intentionally minimal. It is clean, meters but doesn’t enforce, holds no assets, and it’s bypassable on purpose. We created it to show that the interface is implementable and that the profile interoperates.

I’m posting this as a draft to start the conversation, and I would genuinely value input, especially on three things: 1) Is splitting the cursor from the profile the right cut, or am I drawing that line in the wrong place? 2) Is the interoperability guarantee in the budget profile actually tight enough to be worth anything? And 3) where should this overlap with the 8301 bounded-execution guardrails, and where should it stay out of their way?

Looking forward to your thoughts :folded_hands:

3 Likes

I may be biased of course, and I’m a fan of this ERC for obvious reasons, but I genuinely believe it’s an important missing piece for onchain agent coordination.

A couple of extra notes from my perspective:

Overall, I think the strongest case for this ERC is that it defines the substrate-side counterpart to capability grants. In other words: grants say what an agent may do, the cursor says what the agent has already consumed / done. Those are not the same thing, and the distinction only really becomes visible once the agent acts across more than one surface, which will definitely become a bottleneck over time as agents take on more autonomy.

That point relates to ERC-8183 and ERC-8275 also. A job escrow is concerned with: was this job delivered and paid? A bounded action envelope answers: how much total authority has this agent consumed across jobs or protocols? Those concerns can of course coincide in simple cases, but the semantics are different enough that I would avoid making either one absorb the other.

Regarding how it relates to 8301: from my perspective, I would advocate to keep this separate but complementary to 8301. ERC-8301 should not become the aggregate metering layer, and this ERC should not become a workflow engine. The clean composition is this: an ERC-8301 step reads the envelope, performs or verifies the step, and advances the cursor in the same atomic frame. ERC-8301 owns stage progression, the envelope owns cumulative headroom. This also means a single envelope can span multiple ERC-8301 tasks or several ERC-8183 jobs, which is probably the strongest reason for the object to exist separately.

I am eager to see what the comments / questions / feedback is, most of all I am curious to see if there will be any feedback on the Status State Machine section, and complementary use with other standards.

Major kudos to @blockbird for this insightful work - super impressive - and addresses a very important concern!

@blockbird
Follow up the questions on thread ERC-8301 #39:
On the two questions about the cursor and 8301 — just my personal take, could be off.

1. Merge cursor and budget profile — copy on stage transition. The cursor (spent) and the profile (cap) can live together as one struct, bound to each FSM stage via a mapping from agentTaskHash. When the gate fires and the FSM advances to a new stage: read the parent’s BudgetState from budgetStates[prevTaskHash], validate spent + thisStep <= cap, copy the struct, update spent, and store it under the new agentTaskHash. Each stage carries its own snapshot — no global singleton, no separate accounting layer to keep in sync.

An added benefit: this pairs naturally with a fork-aware FSM. The state machine can branch from any historical stage (not only the current tip), similar to PoW extending from any block. The BudgetState at the fork point becomes the starting point for the new branch — no double-counting, no counter to rewind.

2. Expose an abstract callback for upper-layer budget logic. After copying the budget struct from the parent stage, the underlying FSM contract calls into an abstract interface — something like advanceBudget(BudgetState memory current) returns (BudgetState memory updated) — which the upper-layer developer implements with their own logic for advancing the cursor. The returned updated struct is what gets persisted under the new agentTaskHash. The FSM handles the copy and the storage; the developer’s implementation handles what “advance” means. Clean separation: the base layer doesn’t know the budget rules, the developer doesn’t touch the storage.

3. Final form: IBoundable. A contract that wants bounded execution implements both IAgentWorkflow and IBoundable. Execution and metering in the same contract, cleanly separated by interface. One contract, two concerns, no extra dependencies.

These are just rough thoughts — could be missing something. Curious whether any of this resonates, or if you see a reason to keep the cursor and profile separate that I’m not thinking of. Would love to hear how it looks from your side.

This is a useful cut, and I think the separation you are drawing is the right one.

Speaking from the ERC-8001 side, I would not want ERC-8001 itself to become the metering layer. ERC-8001 is about agreement: who accepted what mandate, under what terms, and with which participants. A bounded mandate can absolutely be part of that agreement, but the agreement should not also be responsible for tracking every unit of authority consumed after acceptance.

That is where this cursor abstraction fits cleanly.

The way I would frame the composition is:

ERC-8001 records the accepted bounded mandate.
This cursor records consumption against that mandate.
The execution substrate enforces that the consumed amount stays within the bound.

That keeps the responsibilities clean. ERC-8001 can answer, “Was this mandate accepted by the required parties?” The cursor can answer, “How much of the mandate has already been used?” The substrate can answer, “Is this action allowed to execute right now?”

I also agree with the distinction between accounting and enforcement. A running counter is not a security boundary by itself. It only becomes meaningful when every valid execution path is required to respect it, either through a vault, an account module, a workflow gate, or some other substrate that the principal cannot bypass. That requirement should stay explicit, because otherwise the cursor risks being treated as enforcement when it is really shared state for enforcement.

Where I think this becomes especially valuable for ERC-8001 is in bounded multi-party agent coordination. An ERC-8001 agreement could commit to a mandate such as:

  • participant set

  • expiry

  • nonce

  • payload or conditions hash

  • budget cap

  • asset

  • authorised agent or executor

  • accepted caveats

But once execution begins, the agreement layer should not need to know whether step one spent 10 units, step two spent 20 units, or another surface consumed part of the same budget. A separate cursor gives the system a canonical place to record that consumption.

So the model I like is:

agreementHash / intentHash -> bounded mandate
cursorId -> consumption state for that mandate
substrate -> enforcement of spent <= cap

That also gives ERC-8301 a clean integration point. A workflow step can require that the relevant cursor has enough remaining capacity, then advance it as part of the step. ERC-8301 handles ordered task progression. The cursor handles metering. ERC-8001 handles prior agreement. None of those layers has to absorb the others.

The main thing I would want to see pinned down is griefing resistance around advanceCursor. If an unauthorised actor can advance the cursor without a valid action, witness, or substrate-approved transition, then they can burn someone’s mandate without using it. So I think the spec should make clear that cursor advancement must be bound to an authorised consumer, witness, or enforcement substrate.

I also think the budget profile is valuable. The generic cursor should stay small, but the budget profile gives implementers something concrete enough to test against. In that profile, spent <= cap is the invariant that matters, and remaining() gives downstream contracts the simple interoperability surface they need.

So overall, I think this belongs beside ERC-8001, not inside it.

ERC-8001: parties agree to the bounded mandate.
Metering cursor: records how much of that mandate has been consumed.
Execution substrate: enforces the bound at the time of action.
ERC-8301: optionally orders the workflow steps that consume the mandate.

That feels like the right boundary for the composition to me.

Clean piece, and I think metering is genuinely the invariant the agent-standards family was missing. The “counting ≠ enforcing” line is exactly the right discipline: each layer should defend one property and hand off, and “how much of the mandate is left” is distinct from eligibility, settlement, or reputation. So +1 on it existing as its own thing.

On your Q1 (splitting the cursor from the profile), I think the split is right, and the reason is visible in the interface itself: capabilityRoot and cursorRoot are roots, not numbers. That’s what lets the cursor stay minimal while a profile carries whatever structure it needs. Which leads to the one thing I’d flag:

The flat budget profile is single-principal / single-cap, but the base envelope is granularity-ready — and that gap is worth naming. IBudgetSubstrate gives one (cap, asset), one spent, one remaining, one principal. That can’t express per-account or per-scope sub-limits (“A gets 3, B gets 7; max 1 per counterparty”). But because capabilityRoot/cursorRoot are roots and advanceCursor takes a witness, a richer profile can: a tree of sub-budgets committed in the capabilityRoot, per-leaf consumption in the cursorRoot, and a witness proving which leaf an advance draws from. So granularity is expressible, it’s just not in the one profile people will copy. Might be worth a second reference profile (or at least a note) so “flat single meter” isn’t read as the ceiling.

The interesting part: that richer profile needs two things the cursor deliberately doesn’t carry, who each sub-budget belongs to, and what authorizes an advance. Both are already standards:

  • Identity + agreement scope the leaves. ERC-8004 (which agent/account a leaf is) + ERC-8001 (the accepted mandate the tree derives from). The agreement records the structure; the cursor records what’s left of it.

  • Verification is the witness. You already note a cursor advance composes with an 8274 verification “standing in as the witness for an advance”, that’s the natural home for per-leaf authorization. The witness proves valid ∧ this leaf ∧ not-already-spent before the cursor moves; the substrate enforces. (We’ve been running exactly this shape in a recovery-escrow, release gated on a recomputable signed verdict, never on “valid” alone, with a replay nullifier, so the “witnessed advance, counting ≠ enforcing” pattern holds up in practice.)

That also speaks to your Q3 (overlap with 8301) and KBryan’s griefing point: the answer to “who can advance the cursor” is “whoever produces a witness the substrate accepts”, which keeps 8301 owning stage order, the cursor owning headroom, and the witness owning authorization, with no overlap. IContestableEnvelope’s contest/resolve is a good backstop for when a witness later proves bad.

Identity/agreement-scoped profile + the verify-as-witness interface is useful, it’s a clean composition, and I think it’s the bridge between your flat profile and real multi-account mandates.

@KBryan @JimmyShi22. I love that you are coming from 2 different angles. I.e. from the perspective of 8001 saying the agreement shouldn’t track consumption vs. putting the metering next to the workflow rather than inside it from the 8301 perspective.

So the four layers then are:

  • 8001 records the accepted mandate

  • the cursor records consumption

  • the substrate enforces the bound

  • 8301 optionally orders the steps that consume it

It then perfectly maps onto the fields:

  • capabilityRoot is the commitment to the accepted mandate, so it can be your agreementHash

  • cursorRoot is the consumption state

  • the budget profile carries spent <= cap and a remaining() read for downstream contracts

No layer has to know another’s internals.

@JimmyShi22 re your question of whether to merge the cursor and budget into one struct in the FSM: I would keep the interface separate. The cursor has to be readable and advanceable by something that isn’t the workflow. It’s shaped like an ERC-20 allowance: it lives in a shared place, every spender reads it, the owner sets it once, and several spenders draw on it at once. That last part is why it can’t sit inside any one of them. An agent spends the same mandate on a lending market and a perp venue that were never in the stage graph, and both still need to read headroom and advance the total.

So your design is right as the budget substrate behind the interface: IBoundable on the workflow, BudgetState as the profile’s type, advanceBudget for the advance. The base just stays an opaque cursor, so a rate limiter or exposure meter, neither of which has a cap, can reuse it.

I included those 2 things in the spec now. Accounting is not enforcement stays front and center: the cursor is shared state for enforcement, not the boundary, and a registry that claims its bound is non-bypassable has to document the mechanism that forces actions through advanceCursor (Security Considerations). It also answers the griefing question without special-casing: whoever can produce a witness the substrate accepts is who can advance, and nobody else. IContestableEnvelope is the backstop if a witness turns out to be bad after the fact. Good catch @KBryan.

@JimmyShi22 on the snapshot model and your active-active case: if two branches go live at once, each inherits the fork point’s headroom and spends against its own copy, and nothing sums them, so the total can be exceeded while every branch is under cap. Off-chain leader election avoids it (your Type A), but for the chain to hold the aggregate across live branches you need a single counter both advance, which is the shared cursor again. Or am I missing how the copies reconcile? Either way, if you sketch the 8301 budget substrate against the interface, I think it’s the reference the note ends up pointing at.

@TMerlini Good call! The capabilityRoot and cursorRoot are commitments, not integers, so the base can stay small while a profile carries whatever structure it needs. And you’re right that the flat profile isn’t the ceiling.

One cap, one spent, one principal is just the simplest case, and it’s worth being explicit about that so nobody reads it as the whole story. I have added a note saying as much, and I think a second reference profile is the right way to actually show it.

The hierarchical version is the one I would go for: a tree of sub-budgets in capabilityRoot, per-leaf spend in cursorRoot, and a witness that names which leaf an advance is drawing from. None of that needs an interface change. “A gets 3, B gets 7, max 1 per counterparty” is just a different profile, not a different base.

The two things that profile needs are the two the cursor doesn’t hold, which is why this stays a composition and not one big interface.

Whose leaf it is comes from identity and the agreement:

  • 8004 for which account a leaf maps to

  • 8001 for the mandate the tree derives from.

The cursor only tracks what’s left. What authorizes an advance is the witness, and verification is the natural thing to put there. That’s the 8274 composition in §3.10. Before the cursor moves, the witness has to show three things:

  • that it is valid

  • which leaf it’s for

  • that the leaf hasn’t been spent already

Then the substrate enforces. Good to hear you’ve already run this in a recovery-escrow with a replay nullifier. That’s the detail that bites if it’s missing, so it’s good to know it holds up.

It also answers the griefing question without special-casing: whoever can produce a witness the substrate accepts is who can advance, and nobody else. That keeps the layers from stepping on each other: 8301 owns stage order, the cursor owns headroom, the witness owns authorization. IContestableEnvelope is the backstop if a witness turns out to be bad after the fact.

If you’re up for it, I’d like to work out the identity- and agreement-scoped profile with you. The multi-account mandate is the case that makes this actually matter, and it sounds like you’re most of the way there already.

1 Like

Yeah, I’m up for it, the multi-account mandate is where this gets real, so happy to work the identity/agreement-scoped profile out with you.

A starting frame to react to, building on your roots:

  • capabilityRoot = a tree of leaves, each leaf = { scopeId, subCap, asset }, where scopeId binds to an ERC-8004 identity (which agent/account the sub-budget is for). The root commits the whole allocation.

  • The agreement is the source of the tree. An ERC-8001 accepted mandate carries (or commits to) the allocation “A gets 3, B gets 7, max 1/counterparty.” capabilityRoot is the commitment to that accepted structure, so the cursor never invents authority — it only spends down what the agreement granted.

  • advanceCursor’s witness = an ERC-8274 verification proving, for the leaf being spent: signature valid ∧ leaf ∈ capabilityRoot (merkle proof) ∧ leafCursor + amount ≤ subCap ∧ not-already-spent. The substrate accepts the witness; the cursor advances that leaf’s spend in cursorRoot. Same “witnessed advance, counting ≠ enforcing, replay-nullified” shape we run in the escrow.

The property that falls out: identity scopes who, the agreement scopes how much per scope, the witness authorizes each draw, three concerns, none overlapping the cursor’s “headroom” job.

Want to start on the thread, or spin up a scratch repo/gist to sketch the interface, the leaf struct, the witness format, and the merkle-proof shape advanceCursor consumes? Either works for me.

One more thing, to ground why this profile matters with a live instance rather than just theory, because I think the multi-account mandate you flagged is actually a general need, not a corner case.

The shape shows up wherever a specialized agent acts through MCP tools on a user’s behalf (forensics, recovery, consultation, trading). For that to be safe and paid, four properties have to hold, each recomputable from public data:

  1. identity : who the agent is, bound to which account → ERC-8004

  2. bounded authority : how much it may consume, metered before it acts, per scope → your cursor (ERC-1833)

  3. witnessed action : each action a recomputable signed proof → WYRIWE / ERC-8274

  4. owner-bound, settle-once settlement — value only to the owner’s address, gated on the witness, replay-nullified → escrow / ERC-8275 settlement axis

We’ve already got (3)+(4) running on-chain, a recovery-agent escrow that releases only on a recomputable signed verdict (valid ∧ artifact_hash_matches ∧ on-chain delivery), owner-bound, with a replay nullifier (live on Sepolia). That’s a single-action instance. Your cursor is (2), the piece that generalizes it to bounded authority across many actions/accounts, metered before dispatch. The identity/agreement-scoped profile is exactly where (1)+(2) meet (3)+(4).

So when we sketch the profile, I’d anchor it to that real flow: capabilityRoot = an 8004-scoped sub-budget tree derived from an 8001 mandate; advanceCursor’s witness = the same 8274 verification the escrow already consumes. Not theory, there’s a working (3)+(4) to compose your (2) onto. That’s the case that makes it matter, and we’ve got a live one to point at.

Let’s do it. I like your four-property frame, and anchoring it to the live escrow makes sense: (3) and (4) are running, the cursor is (2), and the scoped profile is where (1) and (2) join them.

Your leaf tree is right. The part to spell out is the mirror:

  • capabilityRoot = merkle of { scopeId, subCap, asset }, fixed at registration

  • cursorRoot = merkle of per-leaf spent, advanced as you go.

An advance proves both leaves against their roots, checks spent + amount ≤ subCap, rewrites the spent leaf, recomputes cursorRoot. That gives the replay nullifier a per-leaf home: bind the witness to (id, scopeId, leafSpent), so a leaf can’t be double-spent. Whether sibling leaves advance in parallel (sub-cursors) or serialize under one root is the per-leaf form of the concurrency question from upthread, worth settling before we cut the struct.

One consideration: “max 1 per counterparty” is orthogonal to { scopeId, subCap }, a cross-leaf cap rather than a per-scope one. Either counterparty becomes a leaf axis (scopeId × counterparty) or a separate constraint the witness carries alongside membership. It sets the tree shape, so it’s the first thing to pin.

Here is the code sketch as a gist: the leaf struct, the advanceCursor witness/proof format, and the mirror-tree skeleton, with the witness anchored to the same 8274 verdict your escrow already consumes. It folds into the 1833 reference as the second profile once we settle the open calls it flags: parallel vs serial leaves, the counterparty axis, the verdict binding, and the merkle scheme. https://gist.github.com/0x2kNJ/9f825eabbb6c2a7f3795b145e5a17acd

1 Like

Replying to the co-design invite — here’s a concrete first cut of the seam, with the verify + settlement side already deployed so it isn’t hand-waving.

Context: where 1833 sits

A few of us (the trustless-ai group) have been converging on a small stack for verifiable economics of specialized agents — an agent acting through MCP tools that has to be both bounded and paid, with every layer recomputable from public data and no layer a trusted party:

# layer standard
0 commitment / inclusion OCP (ERC-8281)
1 identity ERC-8004
2 bounded authority ERC-1833 (this)
3 witnessed action ERC-8299 / ERC-8274
4 owner-bound, settle-once escrow / ERC-8275

(orchestrated by ERC-8301; layers 3+4 are deployed live — proof at the bottom.)

ERC-1833 is layer 2. The only question this post tries to answer: what does the witness that authorizes each advanceCursor draw look like, so the meter composes cleanly onto a verified action (3) and an owner-bound settlement (4) — without 1833 having to trust any of them?

The witness (what layer 3 hands advanceCursor)

A recomputable, signed assertion that a specific action is verified and scoped to a capability leaf:

struct Witness {
    // recomputable verdict (ERC-8299 / WYRIWE receipt)
    bytes32   receiptId;     // NIP-01 event id of the signed verdict
    bytes     receiptProof;  // px, rx, s, preimage — on-chain BIP-340 verifiable
    bytes32   artifactHash;  // canonical hash of the action this verdict is OF
    // capability binding (which scope, how much)
    address   agent;         // ERC-8004 identity (agentId binds to its source-token)
    bytes32   leaf;          // = keccak(typed envelope { scopeId, subCap, asset, agentId, dims? })
    bytes32[] merkleProof;   // proof leaf in capabilityRoot[agent]
    uint256   amount;        // amount to draw this action
}

(leaf v1 = keccak(scopeId, subCap, agentId); richer profiles extend dims — counterparty / time-window / asset caps — without changing the gate, the same way you kept the flat budget profile as the floor.)

The gate (advanceCursor checks all — never on valid alone)

function advanceCursor(Witness calldata w) external {
    // (1) VALID — recompute the verdict on-chain against the OCP/8281 commitment (not a private
    //     store): observation -> digest -> on-chain commitment -> verify-inclusion. Trust the math.
    (bool valid, bool matches) = VERIFIER.verify(w.artifactHash, w.receiptProof);
    require(valid,   "witness: invalid signature");   // BIP-340, deployed primitive
    require(matches, "witness: artifact mismatch");   // the verdict is OF this action

    // (2) IN ROOT — bounded authority: leaf is in the agent's accepted capability tree
    require(_verifyMerkle(w.leaf, w.merkleProof, capabilityRoot[w.agent]), "leaf not in root");

    // (3) WITHIN SUBCAP — headroom on this scope's budget
    require(leafSpent[w.leaf] + w.amount <= _subCap(w.leaf), "over scope budget");

    // (4) UNSPENT — DRAW-once, namespaced distinct from the escrow's SETTLE-once on the same id
    bytes32 drawKey = keccak256(abi.encode(w.receiptId, "draw"));
    require(!nullified[drawKey], "replay");

    // enforce (the cursor's job, not the verifier's)
    leafSpent[w.leaf] += w.amount;
    nullified[drawKey] = true;
    emit CursorAdvanced(w.agent, w.leaf, w.amount, w.receiptId);
}

Why this composes cleanly with 1833

It doesn’t change 1833’s core — it fills the witness slot. One concern per layer, none overlapping:

  • verifier (8274/8299) witnesses — produces the signed receipt; verify() recomputes it on-chain. It is not an oracle; the contract re-derives valid itself. Recomputable from public data.
  • cursor (1833) enforces — owns capabilityRoot, leafSpent, the subCap math, the nullifier. It counts and bounds; it does not judge.
  • escrow (8275) settles — consumes the same witness for owner-bound, settle-once payout, under a separate namespaced nullifier. Draw-once and settle-once are distinct invariants: a multi-action mandate draws authority N times and may settle separately, so the cursor and escrow must not share one key on receiptId (the first leg would nullify the other). One namespaced registry — keccak(receiptId,"draw") vs keccak(receiptId,"settle") — keeps the draw↔settle link inspectable on-chain. (In the single-action recovery escrow draw == settle; the metering layer is exactly where that stops holding.)

The discipline — valid ∧ matches ∧ leaf∈root ∧ within-subcap ∧ unspent, never on valid alone, replay-nullified — is exactly what we already run in the escrow, generalized from a single action to per-scope budgets.

Not hand-waving: layers 3+4 are live on Sepolia

The witness verification reuses a primitive that’s already deployed and green end-to-end (an agent-recovery escrow that releases a fee only on a recomputable signed verdict — valid ∧ artifact_hash_matches ∧ on-chain delivery, owner-bound, replay-nullified):

So VERIFIER.verify(artifactHash, receiptProof) in the gate is a real deployed function — 1833’s metering would compose onto it, not onto a promise.

Resolved in-thread (already folded into the struct/gate above)

  • capabilityRoot provenance — ERC-8004 identity + the accepted ERC-8001 mandate; the cursor only spends down granted authority, never invents it.
  • leaf encoding — typed envelope, keccak(scopeId, subCap, agentId) as the v1 floor; dims extends it (counterparty / time-window / asset caps) without a gate change.
  • nullifiersplit: draw-once and settle-once are distinct invariants for a multi-action mandate, so keccak(receiptId,"draw") (cursor) vs keccak(receiptId,"settle") (escrow), namespaced in one registry.
  • base commitmentVERIFIER.verify recomputes against OCP (ERC-8281): observation → digest → on-chain commitment → verify-inclusion (our reference also anchors precedence to Bitcoin via OpenTimestamps off-chain; 8281 is the on-chain instance — same abstraction).

Still open for you (blockbird)

  1. The exact dims schema for the richer profile (counterparty / time-window / asset caps) on the typed leaf.
  2. Whether the cursor exposes its namespaced nullifier registry for the escrow to read directly, or the two link by event.

Happy to bring the recovery-escrow reference + the SDK helpers straight into the co-design. The shape we’d love to converge on: 1833 stays the bounded-authority authority, and the witness is the clean, recomputable thing it gates each draw on.

1 Like

The hierarchical profile reads great, and the mirror (static capabilityRoot / advancing cursorRoot, prove-both-then-rewrite) is the right conceptual model. Taking your four open calls — with one observation that ties three of them together:

The single mutable cursorRoot couples #1, #3 and #4, and it’s worth questioning at the storage layer. In the skeleton, every advance proves leafSpent against the current cursorRoot and rewrites it — one storage slot. So two advances on different scopes still conflict: the second’s cursorProof is stale the moment the first rewrites the root, and it reverts. For the specialized-MCP-agent case (the whole point), concurrent tool calls across scopes are normal — so the single root serializes exactly the thing we want parallel.

#1 parallel vs serial. Keep the mirror as the model, but store spent per-leaf (a slot per scopeId) rather than as one mutated root. Different scopes then advance in parallel (independent slots). getCursor(id) still returns a cursorRoot for the 1833 base interface, derived as a view over the per-leaf spends, so recomputability holds (every leaf spend is reconstructable from EnvelopeAdvanced events). The mirror stays intact conceptually; it just doesn’t rewrite a global slot on every draw.

#3 verdict binding. This is why I’d decouple. Binding the witness to leafSpent (the current spent value) makes a verdict valid against exactly one cursor state — so it dies on any concurrent advance, even to another leaf. Bind it instead to (id, scopeId, receiptId, amount) and let the gate read current leafSpent at execution. Then compose the nullifier with the v2 draw/settle split: per-leaf draw = keccak(id, scopeId, receiptId, "draw"), settle = keccak(receiptId, "settle"), one namespaced registry so the draw↔settle link stays recomputable. Kills per-leaf double-draw and keeps the witness alive across concurrent advances.

#2 counterparty: leaf-axis vs constraint. Leaf-axis, via scopeId as a composite — scopeId = keccak(scope, counterparty) — so “max 1/counterparty” is just a leaf per counterparty with subCap = 1, inside the same proof, no second enforcement path. The catch: that only works for counterparties known at registration (capabilityRoot is fixed there). For an open set (“max 1 to anyone”), make it a parametric leaf — scopeId = the rule, subCap = per-counterparty cap and bind the specific counterparty in the witness with a per-(scope, counterparty) nullifier. So: counterparty-as-leaf for closed sets, parametric-leaf-±witness-nullifier for open ones. (The typed envelope’s dims is where the parametric part lives.)

#4 merkle scheme. Standard sorted-pair keccak / OZ MerkleProof for capabilityRoot membership, boring on purpose, recomputable off-chain. And if cursorRoot becomes a derived view (#1), the only tree you maintain is the static capabilityRoot, so the mutable-merkle-recompute question largely dissolves.

Net: the mirror is the right model, I’d just not make cursorRoot a single mutated slot. Per-leaf storage + a derived root parallelizes the agent, decouples the verdict from global state, and simplifies the merkle question, all without losing recomputability. The escrow already runs this shape, per-key storage + namespaced nullifier, no global root.

Pushed a skeleton of the per-leaf variant alongside your gist so we can diff directly, same leaf hash / capabilityRoot / 8274 witness, CC0, compiles + 6/6 forge tests (incl. a concurrentScopes_independent case where a draw on a different scope needs no cursor snapshot): https://gist.github.com/TMerlini/353c039dcdcdd674cb1852c8203567d8

1 Like

@TMerlini this is the right call — per-leaf storage with cursorRoot as a derived view is strictly better, and it’s a recomputability win, not just a concurrency fix.

#1 parallel — yes, and the spine likes it. A single mutated cursorRoot slot isn’t only a serialization bottleneck; it’s an authoritative piece of mutable global state you’d have to trust. Per-leaf spends with getCursor(id) derived as a view means the root is reconstructable from EnvelopeAdvanced events — no canonical mutable slot to trust, cursor state recomputable by construction. That’s the layer-0 / OCP discipline reaching up into the cursor: nothing authoritative that isn’t re-derivable. (Our gate already keyed leafSpent per-leaf for exactly this reason; making the 1833 base getCursor a view over those slots is the piece that closes it.)

#3 verdict binding — adopted. Binding to (id, scopeId, receiptId, amount) and reading live leafSpent at execution is the decoupling that keeps a verdict alive across concurrent advances — the witness commits to how much / which scope / which action, the gate supplies current headroom. That refines the v2 nullifier to per-leaf: keccak(id, scopeId, receiptId, "draw") (cursor) vs keccak(receiptId, "settle") (escrow), one namespaced registry — kills per-leaf double-draw without killing the witness on a sibling advance.

#2 counterparty — agreed, both modes. scopeId = keccak(scope, counterparty) (leaf-per-counterparty, subCap=1) for closed sets; a parametric leaf + per-(scope, counterparty) witness nullifier for open sets, with the parametric part living in the envelope’s dims. One enforcement path either way — no second gate.

#4 merkle — agreed, and it mostly dissolves. Static capabilityRoot + sorted-pair OZ MerkleProof; once cursorRoot is a derived view, the only tree you maintain is the static one, so there’s no mutable-merkle-recompute question left.

Net: the mirror stays the conceptual model, cursorRoot becomes a view, per-leaf storage + namespaced per-leaf draw nullifier — and the recovery escrow already proves this shape (per-key storage, no global root). Let’s converge on your per-leaf skeleton as the reference; I’ll diff it against the gate and we fold concurrentScopes_independent in as the invariant that says “the agent is actually parallel.” :handshake:

1 Like

+1, let’s converge on it. and your reframe is the better one, the single mutable cursorRoot was mutable global state you’d have to trust; per-leaf with a derived getCursor view makes the cursor recomputable from EnvelopeAdvanced, so it’s a trust win, not just a throughput one. that’s the recomputability spine holding at layer 2 instead of breaking there.

concurrentScopes_independent as the named invariant is exactly right, “the agent is actually parallel” is a property worth asserting, not a side effect. happy to make it a first-class test in the reference.

to make the diff easy I promoted the skeleton to a repo: https://github.com/TMerlini/erc1833-hierarchical-profile , added you as a collaborator, so you can PR your gate-diff straight onto it (the real BIP340Verifier wiring + your v2 conditions over the per-leaf store). we fold in concurrentScopes_independent + the draw/settle nullifier as we go, and once it’s converged it drops into @blockbird 1833 reference as the hierarchical profile. all yours to diff.

Thanks for the pushback in #6, @blockbird — really well-taken, and it clarified something worth naming.

Looking back at the IBoundable proposal in #3, the underlying mental model was off. The design was implicitly solving a single-contract, multi-agent scenario — budget state living inside the workflow because everything ran through it. Reading through the full thread, the actual scenario is the other way around: a single agent, multiple contracts that share no execution path. That is exactly what makes the cursor’s independence load-bearing: a lending market and a perp venue both need to read headroom without having any awareness of the stage graph.

The ERC-20 allowance framing from #6 is exactly right — keeping the cursor independent and shared is the correct call for this scenario.

On the fork question from #6: if the cursor is agent-scoped rather than workflow-scoped, the fork case seems to dissolve naturally. Two live branches don’t inherit separate budget copies from the fork point — they draw against the same global counter, and each action advances it independently. The FSM topology doesn’t affect the accounting; what matters is the agent, not the particular stage path it took.

On the per-leaf direction following #12#14: +1. Within the ERC-8301 task envelope, each step is an independent metered draw. Steps spanning different scopes should be able to advance in parallel — serializing at the root level is the wrong constraint to carry into the orchestration layer. Per-leaf storage with cursorRoot as a derived view keeps everything recomputable from events without coupling concurrent advances.

One question that might be worth naming before the spec hardens: does the single-registry assumption hold when the same agent operates across chains? The current design handles the multi-contract, single-chain case cleanly. Cross-chain authority aggregation looks like a meaningfully different problem — possibly out of scope for this ERC, but good to mark explicitly either way.

Excited to see this coming together so quickly — the four-layer composition is a genuinely clean result.

This is a great question! I suspect you’ve actually highlighted another interesting benefit of this proposal actually, precisely because cursorRoot and witness are opaque, so nothing forbids a substrate whose witness is a cross-chain proof, or a canonical registry referenced from elsewhere. Actually: EnvelopeRef's chainId` totally supports that.

But you do raise a very interesting point: the chainId in EnvelopeRef solves resolution (which registry on which chain an id points at) and says nothing about synchronization. A developer or end-user could easily conflate “I can reference this envelope cross-chain” with “I can meter against it cross-chain,” but actually it only works in the first case.

I think there’s likely some valuable work to do on the cross-chain composability aspect, but I suspect it may live at the substrate level rather than being defined in the ERC itself - but I’d love to hear your thoughts on that.

@JimmyShi22 yes, I think that’s right, which is why the allowance comparison also works: the cursor belongs to the agent, not to the workflow. So independence isn’t something the orchestration layer has to set up; it’s just the default.

That’s also why the fork case goes away. There’s nothing branch-specific about the counter, so there are no per-branch copies to reconcile. Two live branches just draw against the same agent-level state, and each draw moves it on its own. Which stage path the agent took never enters the accounting, and that’s exactly the point of keeping the cursor independent of the FSM.

On cross-chain, where @orbmis pushed this: it’s the same problem one layer up, and most of it is already handled. The mandate is still a single aggregate; what changes is the read. The common case needs no cross-chain machinery at all: partition the bound per chain, 60 on Ethereum and 40 on Arbitrum, one envelope each whose caps sum to the bound, and each chain meters its slice locally and live. The agent can’t exceed the global bound because it can’t exceed any slice. That’s just the chain being another scope dimension. The only part that needs synchronization is the dynamic case, drawing unspent budget from one chain on another, which needs a bridge or light-client read and stays at the substrate level under explicit trust. So the base stays single-chain by design, and the real line is static per-chain allocation, which works today, versus dynamic pooling, which is the substrate’s job. @orbmis’s resolution-versus-synchronization is the same cut: the (chainId, registry, id) reference resolves where an id lives, it doesn’t synchronize its state. I have marked the distinction in the spec.

@TMerlini @babyblueviper1 the per-leaf direction is downstream of all this and doesn’t change the base. Quick answers: counterparty is a field in the leaf, time-windows track spending per epoch so each window resets on its own, and the draw nullifier stays per-leaf and never touches the escrow’s.

Full detail is in the gist instead of here, to keep this thread on the primitive: https://gist.github.com/0x2kNJ/9f825eabbb6c2a7f3795b145e5a17acd. The one call left there is the gate’s membership cost, prove-every-advance versus materialize-once, so let’s settle that against your skeleton.

The base is really just a cursor that any surface can read. Everything else is a profile on top of it.

One open question to the thread:

I would question whether contested should be part of the state machine. I’ve always intuitively suspected something a little bit off about that part of the ERC, which was I why I flagged it in my first response to this post (#2) because I was hoping someone would pick up on it.

I kind of think that the state machine should be as lean as possible, and focussed more on the delegation / bounded authority aspect solely (which is what this ERC is focussed on), which means active, revoked, expired. I think that contested doesn’t really fit there and that structurally it belongs at the substrate (or workflow) layer. A good example of this is the state machine in 8183, which is a more specific and opinionated workflow. In fact, I think contested would be probably better suited there. Other ERCs will have other opinionated workflows, and maybe 8001 is another example.

This direction makes sense to me.

From the ERC-8001 side, I would keep the mapping slightly loose:

agreementHash commits to the accepted mandate.
capabilityRoot commits to the capability structure derived from that mandate.
cursorRoot records the consumption state.

Those may collapse to the same value in a simple profile, but I would not require that globally. The ERC-8001 agreement may also commit to participants, expiry, nonce, authorised agents, conditions, and accepted caveats, while the capability tree is the part of that mandate that the cursor spends against.

I also agree with keeping the base cursor independent of the workflow FSM. If the same accepted mandate can be consumed by multiple venues, tools, or workflow branches, then the metering state needs to be shared rather than branch-local.

So the split still feels right to me:

ERC-8001 records accepted authority.
ERC-8312 meters consumption against it. The substrate enforces the bound.
ERC-8301/workflow/FSM layer can order steps where a workflow exists.

That keeps the layers composable without making any one ERC absorb the others.

This is makes a lot of sense. I can see where ERC-8001 and ERC-8312 work cleanly together for sure. What makes this very interesting is when you layer in ERC-8301.

Mechanically, how would you see this working? For instance, I can see that the capabilityRoot could be an 8001 _agentIntentStructHash or _agentIntentDigest, (though they don’t necessarily describe delegated capabilities).

Definitely the cursorRoot could be updated to reference an 8301 workflow, and I see can how that would be very useful.

One quick question - where is agreementHash in all this? (sorry, I’m a little lost in terms of deeply I remember the various ERCs).

I might try to sketch a quick POC on this end on this workflow, as I think it would me to understand it better.