Give each agent turn its own authority
I built our turn-grant design so replaying one admitted turn cannot mint a second live grant. The assistant can keep one recognizable identity while each operation carries the requesting person’s bounded authority.
That distinction becomes important as soon as the assistant moves from answering questions to reading private work, changing records or sending messages. The same familiar bot may help a project owner, a guest, an administrator and a person in a private conversation. Those people do not have interchangeable permissions. They also do not necessarily share the same audience.
In building our assistant implementation, the most useful design choice was separating the service that carries a conversation from the authority delegated for one piece of work. A stable persona makes the product coherent. A requester-bound, lease-lived grant makes individual operations attributable and constrainable.
Start with the identities your API actually needs
“The agent” hides several different actors. There is the service that receives an event, the persona that appears in chat, the human whose request initiated the work, the worker currently executing it, and the credential presented to a product API. An audit record that collapses all five into one service account cannot explain whose permissions justified an operation.
Consider a project member asking an assistant to summarize a private issue and post an update into a group conversation. The transport needs to receive and deliver messages. The worker needs a temporary right to execute the turn. The issue read depends on the member’s access. The group disclosure depends on the audience. Sending may also require a separate approval under product policy.
These checks answer different questions. Combining them into one long-lived key makes the key convenient, but transfers the policy problem into every caller that possesses it. Prompt instructions cannot compensate for that authority being available at the wrong boundary.
The useful split is a stable transport identity with narrow queue and conversation duties, plus a separate delegation for the turn. The transport does not gain broad business access merely because it can lease work. The turn credential names the requesting person, organization and admitted scope. The worker identity establishes ownership of the attempt; it does not become a substitute human principal.
Admit work and mint authority together
Our minting function requires an already leased turn. It checks the lease owner, expiry and whether delegation has already happened. Stored mode and audience fields also prevent a replay from silently creating another grant after a related row disappears.
That is an important transaction boundary. A request that reads “no token yet,” performs unrelated work and later inserts a credential can race another worker. The queue row and delegation state should be protected together. The implementation explicitly requires the caller to hold the locked turn row; that requirement must be honored by the actual leasing path.
A reduced example of the persisted delegation looks like this:
// Implementation shape, simplified for explanation.
const token = {
userId: turn.userId,
createdVia: 'commander_turn',
commanderTurnId: turn.id,
orgIds: [turn.orgId],
projectIds: scope.projectIds,
conversationScope: scope.conversationScope,
abilities: mode === 'plan' ? ['read'] : delegatedAbilities,
expiresAt: turn.leaseExpiresAt,
};
The expiry comes from the work lease. Extending a credential beyond the period during which the worker legitimately owns the task creates an additional lifetime to reconcile. Keeping the lifetimes aligned makes “the attempt no longer owns this work” an intelligible reason to refuse the next operation.
The raw secret is returned once; the stored record contains its hash and identity metadata. That helps limit accidental persistence. It does not make the secret harmless inside a worker. Process isolation, log hygiene and credential transport remain separate obligations.
A conversation has a disclosure boundary
The subtler part is the audience.
A person can be authorized to read a private conversation while participating in a larger group that cannot read it. If the assistant retrieves everything the requester can see and then answers in the group, it has crossed a disclosure boundary even though each individual read might pass an ordinary requester access check.
Our turn-scope code calculates conversation visibility for the relevant human participants and intersects the results. In reduced form, the core operation is:
readable = readable === undefined
? scope
: new Set([...readable].filter(id => scope.has(id)));
const audienceHash = audienceFingerprint(audience);
This is intentionally conservative. Material available only to one participant does not automatically become group context. The code also has a narrower mention case: an admitted mention outside an assistant thread is limited to that conversation rather than opening the requester’s broader conversation set.
There is a product tradeoff here. An assistant with an audience-safe scope may seem less knowledgeable than the same assistant in a private thread. That difference can be correct. The user experience should explain the boundary and offer an appropriate private workflow rather than silently copying private context into a shared response.
Audience intersection is not a complete information-flow system. Derived summaries, attachments, linked objects and cached context also need rules. The architectural principle is to make the destination audience part of the decision before private content reaches the model, then enforce disclosure again at the relevant output or write boundary.
Bind the turn to its actual audience
Membership can change while a model is thinking. Someone joins a group, leaves a project, loses access or removes the assistant from a conversation. A credential minted before the change should not be interpreted as an enduring snapshot of permission.
Our implementation stores an audience fingerprint on the turn and recomputes scope during live grant validation. A mismatch refuses further use. It also checks the token’s turn association, current lease state, expiry, organization execution controls, persona availability and credential state.
Here is the essential identity and lease check, reduced from the source:
if (
!turn ||
turn.userId !== token.userId ||
turn.grantTokenId !== token.id ||
turn.state !== 'leased' ||
!turn.leaseExpiresAt ||
turn.leaseExpiresAt <= now
) {
throw unauthorized('Turn lease ended');
}
The point is not the particular error string. It is the refusal to accept a credential solely because its signature or secret is valid. The surrounding work, person and audience must still justify using it now.
A fingerprint detects changes in the values it covers. It is not a magical substitute for current object authorization. If project permissions change without altering group membership, the product still needs live permission checks. If membership order is irrelevant, canonicalize it before hashing. If history floors affect what a new participant may see, include or independently enforce those semantics.
Separate delegation from action approval
A scoped grant says that the assistant may attempt operations within a delegated ceiling. It does not establish that the person approved every specific effect the model later proposes.
This distinction matters for outbound messages. The eventual recipient, attachment or share state may be unknown when the turn begins. Approval belongs to the actual action, with enough binding to prevent an edited payload from inheriting a previous decision.
The adjacent outbound implementation derives effect facts and hashes the canonical request body, raw query and those facts. It rejects a changed digest when an already consumed approval is reused. That gives a concrete implementation seam between “this turn can request a send” and “this particular send is approved.”
The policy still needs to define which actions require approval. Requiring a confirmation for every harmless read can make the assistant unusable and train people to click through. Granting unrestricted writes because repeated prompts are annoying creates a different problem. Use action classes with explicit ceilings and a user experience that makes the consequential decision legible.
Treat delegation ceilings as product policy
A new product ability should not automatically become available to every agent turn.
This is an important review question whenever the delegation function starts from the complete ability catalog and subtracts a list of prohibited abilities. Our snapshot uses that style. It deserves scrutiny when new abilities arrive: the default consequence of catalog growth is permission growth unless another guard blocks it.
An explicit, reviewed allowlist gives a different default. Newly added abilities remain unavailable until somebody decides they belong in delegated execution. Neither representation eliminates route-level authorization. The point is to choose the failure direction deliberately and test it when the catalog changes.
Keep especially consequential identity and security operations outside ordinary delegation. A service request should not become a way to mint broader credentials, rewrite the permission model or impersonate another person. If a privileged workflow is genuinely needed, give it a separate admission and approval contract instead of quietly broadening every turn.
Decide where freshness must be atomic
“Check permissions on every call” is necessary in many systems, but it is incomplete as a concurrency specification.
Suppose a handler checks membership, waits on another service, and later commits a sensitive change. Permission can be revoked in the gap. The relevant question is whether the product promises authorization at request admission, at effect commitment or continuously during a long-running operation.
For a local database effect, a transaction can coordinate the current authorization check with the committing write. The locking and isolation choices need to protect the specific records and invariants involved. For a remote effect, a local check cannot prevent all races after dispatch. The remote service may need a supported idempotency or authorization mechanism, and ambiguous outcomes need reconciliation.
The grant layer provides identity and scope. It does not automatically make every downstream handler’s check and effect atomic. Review each consequential sink against the promised boundary. This is where an architecture diagram becomes an executable acceptance contract rather than a reassuring label.
Revocation needs an in-flight story
Revoking a credential prevents a future authorized request when the server revalidates it. It does not recall a response already sent to a model or reverse an action already accepted by another system.
There are several distinct states to manage: work still queued, an active model call, a proposed tool action, an action awaiting approval, an external request already dispatched, and a completed result awaiting delivery. A kill control should specify its effect at each state.
For example, an organization pause may stop new tools and delivery while allowing the inference connection to close naturally. A stronger cancellation may interrupt the worker. Either can be useful, but the interface should not imply that canceling the worker proves the provider stopped processing or that an external message was never delivered.
Use current grant checks at meaningful boundaries and preserve the operation identity for any effect whose outcome is uncertain. The assistant should be able to report “this action requires reconciliation” without inventing either success or failure.
Context caches also carry authority
A session reused across different audiences can defeat otherwise careful retrieval authorization. Previously supplied context may remain in the model conversation even when a new request would no longer retrieve it.
Our turn-grant contract includes the audience hash in the model session key. That is a useful safeguard against treating a familiar conversation label as sufficient context identity. The fuller key may also need organization, principal, scope, relevant document versions and policy state, depending on the caching behavior.
There is a cost. Narrower cache reuse can mean more context reconstruction and fewer cache hits. Measure that cost instead of removing the identity dimension without acknowledging the security change. Cache invalidation belongs in the authorization design when cached material contains private context.
Test transitions, not just successful calls
A convincing test matrix starts with a valid operation, changes one relevant condition and observes refusal at the actual handler. If the setup never established a working permitted case, the denial may be measuring a missing fixture rather than the intended boundary.
For this architecture, I would require at least these transitions: requester loses project membership; another human joins the audience; the turn completes; its lease expires; the assistant is removed; an organization stop control changes; the same inbound message is replayed; a credential is presented to a different conversation; and a proposed send changes after approval.
Each test needs an independent observation. Check persisted product state for writes, not only a response that says “refused.” Check that no second live credential was minted on replay. Check that an external effect was not dispatched before the local rejection. For permission changes, make the allowed revocation window explicit rather than assuming every distributed update is instantaneous.
Alternate routes matter. A direct REST handler, an MCP adapter, a webhook path and a worker-internal service call can reach the same object through different middleware. Inventory the routes and prove where the shared check executes. One well-tested entry point does not clear the rest by implication.
Make the audit explain the authority chain
An operationally useful record should connect the requesting person, turn, worker attempt, credential, policy decision and resulting product object. Store the identifiers and outcome needed for investigation without dumping private prompts or raw secrets into the ledger.
The minting path in our code records both a token event and a grant-minted audit event with on-behalf-of attribution. That is a good foundation. The downstream operation still needs its own result identity so an operator can answer whether an approved action happened once, was refused, or remains unknown.
This is also a product-design benefit. When the assistant explains that it cannot read a source in the present group, the explanation can refer to a real authorization boundary. When an operator stops a turn, the audit can distinguish worker cancellation from external-effect reconciliation. Clear authority improves both containment and comprehensibility.
The decision I would carry forward
A shared assistant should have a stable identity and a deliberately scoped delegation for each admitted turn. Bind that delegation to the requester, tenant, work lease and permitted audience. Recheck the relevant authority at tools and delivery, and bind consequential approval to the exact proposed effect.
That architecture does not solve prompt injection by itself. A model can still choose poorly among allowed actions. It does, however, give the application a place to enforce the decisions that should never depend on a model interpreting a sentence correctly.
Before expanding an assistant’s tool catalog, trace one private read and one consequential write through the complete authority chain. Name the principal, audience, lease, approval and committing sink. Then change each of them in a controlled test. The design is ready to discuss seriously when those transitions produce the intended outcome, and the evidence says which routes and deployment versions were actually covered.
Source and evidence note
Implementation observations come from our GoodQA turn-grants, AI-invocation and outbound-approval sources and the requester-bound turn contract. Examples and reduced code explain the architecture; they are not reports of a newly executed production security test. The contract records feature switches that default off, and our ability-selection function uses a denylist-based ceiling. Production activation, every alternate route and broader hardening require their own evidence. No model/provider performance or incident-frequency claim is made here.
How this was verified
This article walks through that architecture, including the awkward parts: shared conversations, permission changes during inference, replayed work, approval boundaries and the cost of checking current state. The implementation examples come from our versioned implementation. They are evidence of mechanisms, not a claim that every production switch and execution route has been independently certified.
