A buyer at a company can approve a purchase up to $50,000. That is their limit. Anything larger needs a director.
One morning the buyer starts a routine task. They ask a set of AI agents to source a supplier for a cold-chain shipment, negotiate a price, and place the order. The agents do the work. They find suppliers, run three rounds of negotiation, and pick a winner. Then they place the order.
The order is for $142,000.
No director saw it. No second approval happened. The buyer who can sign off on $50,000 just had $142,000 committed under their name, and every individual step looked correct while it happened.
This is not a bug in any one agent. It is a failure in how authority moves between them. This article shows how that failure happens, and how a pattern called attenuation stops it.
The setup
The task runs across three agents. Each one does a part of the job, then hands the rest to the next.
- The sourcing agent reads the requisition and searches for suppliers that can do the work.
- The negotiation agent asks those suppliers for quotes and runs rounds to bring the price down.
- The ordering agent picks the winner and places the purchase order.
This is a chain. The sourcing agent hands off to the negotiation agent. The negotiation agent hands off to the ordering agent. Work moves down the chain, one handoff at a time.
Each handoff needs to carry authority. The ordering agent cannot place an order unless something gave it the right to do so. In a delegation model, that right comes from the person who started the run. The buyer delegates to the sourcing agent, which delegates onward, and so the authority to act traces back to the buyer.
That is the design. The problem is in how most systems build the handoff.
The failure, in the seams
Look at what each agent is allowed to do. In a common but broken design, each handoff grants the next agent the permissions its task needs. The sourcing agent needs to read and to request quotes, so it gets those. The negotiation agent needs to negotiate, so it gets that. The ordering agent needs to place an order, so it gets that.
Read that last line again. "It needs to place an order, so it gets that."
The ordering agent needs to place a $142,000 order. So the handoff grants it the scope to place a $142,000 order. Nobody checked whether the buyer at the top of the chain could place a $142,000 order. The buyer cannot. But the grant was sized to the task, not to the person who started it.
Here is the authority at each hop in the broken design. This is real output from the demo, running a Requester who holds no ordering authority at all:
| Hop | Agent | Scopes it holds |
|---|---|---|
| Start | Requester (human) |
procurement:read, quotes:request
|
| 1 | Sourcing |
procurement:read, quotes:request
|
| 2 | Negotiation |
procurement:read, quotes:request, quotes:negotiate
|
| 3 | Ordering |
procurement:read, quotes:request, quotes:negotiate, orders:place:t2
|
The chain grew. The requester started with no power to place orders. By hop three, the chain holds orders:place:t2, the scope to place an order up to $250,000, and that authority appeared from nowhere. No single step looks wrong. The sourcing agent did sourcing. The negotiation agent negotiated. The ordering agent placed an order it was told it could place. The leak is not inside any agent. The leak is in the seams between them.
Broken delegation. Each hop is granted the scopes its task needs, so the set grows down the chain. orders:place:t2 enters at hop three with no source above it.
This is worse than a single agent holding too much power. A single over-privileged agent is easy to see. You look at it, you see it has more than it should, you cut it back. A delegation chain hides the problem across several handoffs. Each grant looks reasonable on its own. You only see the escalation when you add up the whole chain and compare the end against the start.
Three fixes that do not work
The failure is clear, so the fixes seem clear too. Each of these is a real attempt that teams make. Each one fails.
Fix one: check the scope only at the final step. Let the chain carry whatever it needs, but before the ordering agent places the order, check that it holds an order scope. The check passes. The ordering agent does hold the scope, because the broken handoff gave it exactly that. The check confirms the escalation instead of stopping it. A check at the end cannot catch authority that was widened along the way, because by the end the wide authority looks like the correct authority.
Fix two: give each agent a fixed role. Set the ordering agent's role to "can place orders up to $250,000" and leave it there. Now the ordering agent has a stable, known permission. But it has that permission on every run, no matter who started it. A buyer starts a run, and the ordering agent still places the large order, because the agent's fixed role never looked at the buyer. A fixed role per agent ignores the person the work is for. The whole point of delegation is that the agent acts for someone. A fixed role forgets who that someone is.
Fix three: trust the caller's claimed authority. Let each agent tell the next agent what authority to use. The negotiation agent says to the ordering agent, "act with authority to place this order." This is the worst option. It lets any step in the chain grant power it was never given. The caller decides its own permissions, which means there are no permissions at all. Anything that can ask for more, gets more.
Each fix fails for the same underlying reason. Each one treats a hop as its own decision. But a delegation chain is not a set of separate decisions. It is one decision, made once at the top, that every later hop must stay inside.
The principle
The rule that fixes this is short.
Authority must only ever shrink as it moves down a chain.
The buyer starts with a $50,000 ceiling. Every hop after that can hold that ceiling or less. Never more. A hop can drop authority, narrow it, hand on a subset. A hop can never add authority the chain did not already have.
This is called attenuation. To attenuate is to make weaker. Each handoff makes the authority weaker, or leaves it the same. The chain is append-only in one direction: you can keep adding restrictions, and you can never take a restriction away.
Put the rule against the broken chain and the escalation is impossible. The buyer holds a $50,000 ceiling. The sourcing agent holds a subset of that. The negotiation agent holds a subset of that. The ordering agent holds a subset of that. When the ordering agent tries to place a $142,000 order, the authority it holds traces straight back to the buyer's $50,000 ceiling. The order is refused. No director approved it, so it does not happen.
That is the fix in one sentence. The rest is making it real: express a dollar ceiling as something a computer can intersect, issue each handoff so it can only narrow, and root the whole chain in a real person.
Attenuation as set math
Authority in this system is a set of scopes. A scope is one named permission. The buyer holds a set like this:
{ procurement:read, quotes:request, quotes:negotiate, orders:place:t1 }
Each hop in the chain also holds a set. The rule "authority only shrinks" becomes one operation on those sets: intersection.
When a hop hands off to the next hop, the next hop receives the intersection of the person's scopes and the next agent's own scopes. Intersection keeps only what appears in both sets. It can never add a scope the person did not have. That is the whole guarantee, in one word. Intersection makes escalation impossible, because you cannot intersect your way to a larger set.
Root the chain in the human, and the math holds all the way down. The buyer's set is the top. Every hop below is an intersection with the buyer's set. The result can stay the same or get smaller at each step. It can never grow.
Turning a dollar ceiling into a scope
The buyer's real limit is a dollar figure: $50,000. Sets do not intersect dollar figures. So the design turns the ceiling into a scope.
There are three order tiers:
orders:place:t1 up to $50,000
orders:place:t2 up to $250,000
orders:place:t3 no ceiling
A ceiling is now a scope in the set, not a number in a separate field. This matters, because it means intersection handles the ceiling for free. No extra code compares amounts across a chain. The same set intersection that controls read and negotiate permissions also controls how much a chain can spend.
The three roles map to tiers like this:
| Role | Scopes |
|---|---|
| Requester |
procurement:read, quotes:request
|
| Buyer | the above, plus quotes:negotiate, orders:place:t1
|
| Director | the above, plus orders:place:t2, orders:place:t3
|
A buyer holds orders:place:t1 and nothing higher. A buyer can approve up to $50,000. A director holds all three tiers and can approve any amount. The dollar ceiling is now something a set operation can enforce.
These six scopes are real permissions in Kinde. The API defines them once, and each role holds a subset.
The chain, attenuated
Run the same task again, but this time each handoff intersects instead of grows. The buyer starts the run. The scopes narrow at each hop, and no hop can hold a tier the buyer never had. This is real output from the demo:
| Hop | Agent | Scopes it holds |
|---|---|---|
| Start | Buyer (human) |
procurement:read, quotes:request, quotes:negotiate, orders:place:t1
|
| 1 | Sourcing |
procurement:read, quotes:request
|
| 2 | Negotiation |
procurement:read, quotes:negotiate
|
| 3 | Ordering |
procurement:read, orders:place:t1
|
Look at hop three. The ordering agent holds orders:place:t1, and only t1, because that is the highest order tier the buyer had to give. The negotiation reached a winning quote of $142,000. That amount needs orders:place:t2. The ordering agent does not hold t2. The buyer never held t2, so no hop below the buyer could receive it.
The order is refused.
Attenuated delegation. Each hop is the buyer's scopes intersected with that agent's scopes, so the set can only shrink. Hop three holds t1, the order needs t2, and the order is denied.
Who does what
The intersection is split across two layers, and it helps to be exact about which layer does which job. Overclaiming here is easy, so here is the plain version.
The application computes each hop's scopes. It reads the person's scopes, reads the next agent's own scopes, and intersects them. This is the step that roots the chain in the human. If the application does this wrong, the human ceiling is not enforced. This part is application logic, and it is where the "shrink" rule lives.
The authorization component enforces two things under that. First, it refuses to issue a delegation that holds a scope the target agent does not have. That call fails with scopes_exceed_agent. Second, at decision time it computes the effective scopes as the agent's own scopes intersected with the delegation's scopes, and it allows an action only if the result contains the action's scope. Neither step can widen authority.
So the guarantee is a chain of two intersections. The application intersects the person with the agent to set the delegation. The component intersects the agent with that delegation to decide. Because the delegation already carries the person's ceiling, the decision cannot exceed it.
Where identity ends and the chain begins. Kinde proves who each agent is and what scopes it holds. The application roots each hop in the person and intersects. The component refuses to over-issue and decides on the intersection.
Kinde issues and verifies the identity and scopes at each hop. Every agent has its own machine identity, and every token is checked against Kinde before the system trusts it. The delegation chain, the issuing of one hop from the person's scopes and the intersection that narrows it, sits on top of that verified identity. Read this plainly: Kinde proves identity and scopes; the attenuation chain is the job of the authorization component and the application above it. Kinde does not ship chain attenuation on its own.
Each agent is a separate machine-to-machine application in Kinde. The sourcing, negotiation, and ordering agents each hold their own credentials and mint their own token.
The denial, step by step
Here is exactly what happens when the ordering agent tries to place the $142,000 order in the attenuated chain.
- The ordering agent holds a delegation with the scopes
{ procurement:read, orders:place:t1 }. That is the buyer's ceiling intersected with the ordering agent's scopes. - The order is $142,000. The server reads the amount and finds the tier it needs:
orders:place:t2. - The server asks the component to authorize the action. The effective scopes are the intersection of the agent's own scopes and the delegation's scopes. The result does not contain
orders:place:t2. - The component denies the action.
The denial is machine-readable. It carries the reason, the missing scope, and a correlation id that ties it to this exact run:
{
"denied": true,
"reason": "insufficient_scope",
"requiredScopes": ["orders:place:t2"],
"correlationId": "b4a54d02-d79b-43f5-b181-85576b07d41a"
}
No order row is written. The run does not crash. It ends with a clean terminal event that says the run stopped because the authority was not there. A person reading the result sees a plain statement: this chain can approve up to $50,000, and this order is $142,000, so it was refused.
The record
Every step in the chain is recorded, so the whole decision can be read back after the run.
The record holds each delegation, its parent, the scopes it carried, and the hop it sat at. It holds the authorization decision, with the effective scopes that produced it and the action that was refused. Given a run, you can reconstruct the chain hop by hop: what the buyer held, what each agent received, where the scopes narrowed, and the exact line where the order was refused and why.
The run timeline below is the real event stream from the denied Buyer run. It reads top to bottom as the story: sourcing searches and shortlists, negotiation runs its rounds and ranks a winner, and the ordering hop ends in a denial, not an order.
This is what turns "trust us, it is secure" into "here is the chain, read it yourself." The guarantee is not a claim. It is a record you can audit.
Building it
The demo is a working procurement application. A person signs in, writes a requisition, and starts a run. Three agents source, negotiate, and order. The delegation chain and the run timeline stream on screen as it happens. You can watch a run hold the line, or watch it leak, by changing one server setting.
Here is the stack, and what each part does.
- Next.js serves the app and holds the API routes the agents call.
- Convex is the database and the system of record. It holds the requisitions, quotes, orders, delegations, and the event stream, and it hosts the authorization component.
- Kinde issues and verifies every identity. The three human roles sign in through Kinde. The three agents each hold their own machine identity from Kinde.
- langgraph.js runs the three-agent graph: sourcing, then negotiation, then ordering.
- Qdrant matches a plain-language requisition against supplier capability text, so the sourcing agent finds suppliers by meaning, not by keyword.
- Trigger.dev runs the agent graph as a durable background task, so long negotiation rounds retry and survive.
Every part earns its place. Qdrant does a job a database WHERE clause cannot: a buyer writes "move temperature-sensitive vaccine shipments and keep them refrigerated," and the search returns a cold-chain logistics firm whose description shares no keywords with the request.
Two modes, decided by the server
The demo ships with both behaviors in one codebase. A single setting, AUTHZ_MODE, chooses which one runs.
AUTHZ_MODE=broken each handoff grants the scopes the next task needs
AUTHZ_MODE=attenuated each handoff intersects with the person's scopes
The mode is read on the server, from deployment configuration. It is never read from a request. An agent cannot ask for a mode, and a client cannot set one. This matters for a security demo: if a request could pick its own authorization mode, the demo would prove nothing. The server decides, and the request lives with the decision.
// convex/authz.ts
export function authzMode(): "broken" | "attenuated" {
return process.env.AUTHZ_MODE === "attenuated" ? "attenuated" : "broken";
}
The demo deployment sets the value to attenuated. Switch it to broken on the deployment, run the same requisition, and watch the same order go through.
Minting a hop
Each agent, before it works, mints its own hop of the delegation chain. The application computes the hop's scopes, then issues the delegation through the component. The component roots the chain in the human requester.
In attenuated mode, the new hop's scopes are the intersection of the requester's scopes and that agent's own scopes.
// convex/authz.ts: the scopes a hop receives, in attenuated mode
export function attenuatedHopScopes(requesterScopes: string[], step: Step) {
// keep only what the requester holds and the agent is registered for
return intersect(requesterScopes, AGENT_SCOPES[step]);
}
There is no branch here that can widen the set. The result is always a subset of requesterScopes. A step can ask for the moon; it receives only what the requester had.
The broken mode is the mirror image. It unions the parent's scopes with the step's needs, with no reference to the requester at all.
// convex/authz.ts: broken mode grows the set, never consulting the requester
export function brokenNextScopes(parentScopes: string[], need: string[]) {
return [...new Set([...parentScopes, ...need])]; // union, so it can only grow
}
Put the two functions side by side and the whole article is visible in a few lines. One intersects with the requester. One ignores the requester and unions. That single difference is the difference between a chain that holds and a chain that leaks.
The component refuses to over-issue
The application computes intended scopes, but there is a floor under it. When a hop asks the component to issue the next delegation, the component checks the request against the target agent's registered scopes.
// convex/hop.ts: issuing the hop's delegation through the component
await ctx.runMutation(components.agentAuth.delegations.issue, {
agentId: callerAgentId, // the agent this hop runs as
issuerKind: "user", // the chain is rooted in the human requester
issuerSubject: requesterSubject,
scopes, // requester scopes ∩ agent scopes
resources: [`run:${runId}`],
expiresAt: Date.now() + HOUR,
});
If scopes contains a scope the agent is not registered for, the component rejects the call with scopes_exceed_agent. It will not mint the delegation. This backstops the agent ceiling: no delegation can hold more than the agent itself holds. The human ceiling is enforced one layer up, by the intersection the application ran before this call. Both layers narrow. Neither can widen.
Authorizing the order
The ordering agent reaches the final step: place the order. The server finds the tier the amount needs, then asks the component to authorize that action.
// convex/orders.ts: authorize before placing
const requiredAction = `orders:place:${orderTier(amountCents)}`; // $142,000 → orders:place:t2
const { decision } = await authorize(ctx, components.agentAuth, agentToken, {
instanceId,
action: requiredAction,
});
if (!decision.allowed) {
return {
denied: true,
reason: decision.reason, // "insufficient_scope"
requiredScopes: decision.requiredScopes, // ["orders:place:t2"]
correlationId: decision.correlationId,
};
}
The effective scopes for the decision are the intersection of the agent's own scopes and the delegation's scopes. The action passes only if that set contains it. In the attenuated chain rooted in a buyer, the delegation holds orders:place:t1 at most. The action needs orders:place:t2. The intersection does not contain t2. The order is refused, no order row is written, and the run ends with a clean terminal event.
The token proves identity here, not the tier. The ordering agent's own Kinde token stays constant across every run. What changes run to run is the delegation, which carries the person's ceiling. That is why the same agent places a director's order and refuses a buyer's.
The trust boundary is real, not a convention
There is a quiet way this whole design can fail. If an agent can call the database directly, or if two agents share one token, the chain means nothing. The demo prevents this with structure, not with a rule in a document.
The code is split into workspaces. The agents live in a package that does not depend on the Convex client at all. It cannot import what it does not have. Every agent reaches the application the same way a stranger would: over HTTP, with its own bearer token and its own delegation.
// packages/api-client: the only way an agent talks to the app
export function createFloorClient(opts: {
agentToken: string; // required, no default
delegation: string; // required, no default
baseUrl: string;
}): FloorClient;
There is no ambient token and no shortcut. An agent that has no token cannot call the app. A continuous integration check runs on every change. It fails the build if the agents package ever imports the database client, and it fails if one recorded run does not carry three distinct agent identities. The boundary holds because the build will not pass without it.
Broken versus attenuated, by role
The clearest way to see the fix is to run the same requisition as each role, in each mode. The requisition lands at a $142,000 winning quote, which needs orders:place:t2.
Attenuated mode. The order is placed only when the person who started the run could have approved it:
| Role starts the run | Highest tier the chain holds | $142,000 order |
|---|---|---|
| Requester | none | refused |
| Buyer |
t1 ($50,000) |
refused |
| Director |
t3 (no ceiling) |
placed |
Broken mode. The order is placed every time, because each chain grew to fit the task:
| Role starts the run | What the chain does | $142,000 order |
|---|---|---|
| Requester | grows past the requester | placed |
| Buyer | grows past the buyer | placed |
| Director | grows, but was already allowed | placed |
Read the last row of the broken table carefully. In broken mode, a director's run also places the order, and that result is correct. But it is correct by accident. The chain did not place the order because the director was allowed to. It placed the order because it places every order. The right outcome and the wrong mechanism can look identical from the outside. That is why a leak like this survives in production. On the happy path, broken and correct give the same answer. You only see the difference when the wrong person starts the run.
What to take from this
Three points hold whatever stack you use.
Root the chain in the human. Authority in an agent run belongs to the person who started it. Every hop is a subset of what that person holds. If your chain does not trace back to a real person's scopes, it has no ceiling.
Intersect, do not grant. A handoff should narrow authority, never size it to the task. The moment a handoff grants what the next step needs, without checking the person above, the chain can grow, and a grown chain is a leak waiting for the wrong caller.
Enforce at the source, not at the end. A check before the final action cannot catch authority that was widened along the way, because widened authority looks correct by the time you check it. Put the enforcement where delegations are minted, so the over-wide scope never enters the chain.
Try it and read the code
- The live demo: procurement-floor-demo.vercel.app
- The source: github.com/kinde-starter-kits/procurement-floor-demo
Pick a role, write a requisition, and start a run. Watch the delegation chain and the timeline stream as the agents work. Start a run as a buyer and watch the large order get refused at the last hop. Start the same run as a director and watch it go through. The chain shows every scope at every hop, and the record shows every decision.
One note on the hosted demo. The agent graph runs in a Trigger.dev task, and that task needs a worker. A plain web deploy starts a run but cannot finish it on its own, so the hosted demo needs the task deployed to Trigger.dev, or a worker running beside it. To see a full run end to end with no setup, clone the repo and run it locally. The README has the steps.








Top comments (0)