Your project tracker has a "move to stage" action. A manager drags a card. A colleague types "move Atlas to Done" into the assistant. A partner system POSTs to /projects/{id}/stage. An agent on another team calls the MCP tool. A nightly job promotes anything idle for 14 days. Five callers, one business action.
Now count the places the permission check lives.
Why doors multiply
Each surface was built in a different sprint by a different person. The UI route checks the session's role. The API checks a scope claim in the JWT. The MCP server has an allowlist in a config file. The agent's tool has a decorator. The cron job runs as a service account that was granted admin in 2024 because it was faster.
You now have five permission checks, five audit shapes, and five places a bug can live. When the PMO decides that transitions to Done require the project manager's approval, someone fixes the UI and the API, forgets the cron, and the next morning 31 projects are Done.
That is door drift, and the market has measured its consequences. Salesforce's 2026 Connectivity Benchmark of 1,050 IT leaders found an average of 12 agents per enterprise, with 50 percent operating "in isolation, no data sharing, no coordination, no handoffs between systems" (Salesforce, via Enterprise DNA, April 2026). Okta's Global CISO Insights 2026 found 47 percent of companies could say they knew all the AI agents on their network and 46 percent controlled those agents' access to corporate data (Cybersecurity Dive, July 30, 2026). Retool, launching governance for AI-coded apps, said plainly that such apps lack "a comprehensive data permission model, a vetted and secure resource access layer, or detailed audit logs," and that "the governance risk lands entirely on the enterprise" (Retool, June 17, 2026).
Forrester's prescription is the right one: "a policy document can't control an autonomous, tool-invoking system," so identity and policy have to be "enforced as code rather than written down," and every agent should be treated as "a governed identity" with unique credentials and least privilege (Forrester, June 3, 2026). The question for an architect is what "enforced as code" looks like when the same action has five entry points.
Logical identity versus transport identity
The root mistake is letting a business operation acquire a different policy identity because of how it was called. project.stage.transition is one thing. HTTP, MCP, a UI command, an agent's tool call, and a schedule are transports. When policy is attached to the transport, you get five policies. When it is attached to the operation, you get one.
That sounds obvious. It is hard because each transport has real, native semantics you should not flatten. An HTTP route returns synchronously. An MCP tool is discovered by a model. A workflow target may run for days. A UI command has a page behind it. The design goal is a shared policy identity without a universal runtime that erases those differences.
The design
Here is the shape that has held up for us, stated without reference to any product.
Define the operation once. A logical operation declares its capability, its effect class (read, domain write, external write), its approval posture, its scope behavior, its input and output contracts, and its idempotency posture. Then bind it to native surfaces: a read or write route, an MCP tool, a direct host, an agent target, a workflow target, a schedule, an event ingress. The bindings are the doors. The operation is the thing behind all of them.
Compile the application into an immutable version. Take the strict source, resolve every referenced agent, workflow, tool source, and extension manifest to an exact identity or content digest, derive the operation catalog and bindings, and compute a content digest over the authority-bearing fields. Then check coverage: every exported native surface must map to a declared operation. A route cannot remain an unclassified back door because its name resembles a governed one. Activation moves one pointer to that version. Rollback moves it back.
Admit once, before dispatch. Every call to an exported target passes one fail-closed sequence. Authenticate the caller and preserve the whole actor chain, both who initiated the call and what kind of thing made the immediate request. Resolve the application, its active version, the caller's access profile, the logical target, the native binding, the exact resource references, the scope, and the continuation posture (does this call start new work, join an exact registered wait, or carry nothing). Only when all of those agree do you claim an idempotency identity, persist the execution record, write the audit evidence, and dispatch through the native host. Audit is written before anything runs, so a crash after dispatch leaves a record that says "left the platform, outcome unknown" rather than nothing.
Narrow children. A root call resolves against the current active version. A child call, say an agent's tool call inside a chat turn, must stay inside its parent's pinned authority and also pass live authorization. A child can never exceed its parent, and a policy change made after the parent started can still deny a later child.
Fail closed, and never fall back. Missing application state, an inactive version, an unknown target, an invalid scope, an inconsistent manifest, an uncertain authorization answer, an invalid continuation: each is a denial. During migration, do not write "legacy authorization OR new authorization." That preserves the weaker path forever. Require both until the new path is authoritative.
Do not disclose. An unauthorized resource is absent from listings and resolves as not-found. A refusal is recorded in the audit trail; the caller learns nothing about what exists.
Do not trust the page. A browser saying "the user is looking at project ATLAS" is a hint. Authority comes from the server, which resolves what is visible, signs a short-lived snapshot, and rechecks live authorization when a target uses it.
How Pyrana does it
An Agentic App is authored as strict configuration and compiled into an immutable, content-digested version. Activation moves one revisioned pointer, guarded by revision fences so two administrators cannot silently overwrite each other; rollback moves it back, bounded by evidence leases so a version whose dependents have moved on cannot be reactivated blindly. That lifecycle is live. So are access profiles and correlated scopes, OpenFGA relationship checks, row-level tenant isolation, and the extension contract. Per-invocation admission with a unified AppExecution record is built and enabled per App; an installation moves through legacy, shadow, dual, and App-enforced states, and dual means both must allow, never either.
Here is an excerpt of a compiled version, illustrative.
# illustrative: excerpt of compiled App version it-pm@sha256:1220a7ff…f843
operations:
project.stage.transition:
capability: project.write
effect_class: domain_write
approval: policy:stage.transition.to_done # PM lane when target stage is Done
scope: project
idempotency: required
bindings:
ui: POST /bff/projects/{id}/stage
mcp: project_change_stage
http: POST /api/v1/projects/{id}/stage
schedule: nightly-stale-promotion
Now the five callers, one afternoon.
A project manager, user:pm.sato, signed in through Entra ID, drags ATLAS to Done. The actor chain is one link. Admission resolves her profile, "PM in Portfolio North," the exact resource project:ATLAS, the UI binding, and the operation. The Done transition requires the PM lane; she is the PM; the obligation is satisfied by the caller and the call dispatches.
A colleague, user:j.lee, asks the App's assistant to move ATLAS to Done. The chain is user:j.lee -> agent:pm-assistant@v4. The agent's tool call re-enters admission as a child. j.lee holds a viewer profile on ATLAS. The operation is absent from what the agent may do on that resource, so the assistant reports that it cannot make the change. The record shows a refused admission under j.lee's identity. The agent never held authority of its own to borrow.
A partner integration, service:jira-bridge, POSTs to the HTTP route with OAuth client credentials scoped to a project set. Same operation, same policy. It passes for the projects in its scope and sees nothing else.
An agent on the release team calls the MCP tool while acting for user:a.ruiz under a delegated token. The chain is user:a.ruiz -> agent:release-bot. The MCP door does not get a different policy because a model discovered it.
At 02:00 the nightly workflow, its own principal workflow:stale-promotion@v2, starts with continuation posture NEW and tries to promote an idle project to Done. The approval posture on that transition names the PM lane. The invocation freezes and waits. The nightly workflow waited for a person, which is exactly what the PMO asked for.
# illustrative: five AppExecution rows, one operation
exec_01J9R2A0 op=project.stage.transition app=it-pm@1220a7ff actor=user:pm.sato via=ui result=applied
exec_01J9R2A7 op=project.stage.transition app=it-pm@1220a7ff actor=user:j.lee->agent:pm-assistant@v4 via=agent result=refused
exec_01J9R2B3 op=project.stage.transition app=it-pm@1220a7ff actor=service:jira-bridge via=http result=applied
exec_01J9R2C1 op=project.stage.transition app=it-pm@1220a7ff actor=user:a.ruiz->agent:release-bot via=mcp result=applied
exec_01J9R2D8 op=project.stage.transition app=it-pm@1220a7ff actor=workflow:stale-promotion@v2 via=schedule result=awaiting_approval
Same operation, same App version, same policy, five real identities. If an auditor asks who moved ATLAS, the answer is a row.
The extension case
The project graph in that App does not live in Pyrana. It lives in the client's own extension, a sidecar in the client's repository with its own database and its own stage-transition invariants. Two client extensions run in production this way today.
The extension publishes a content-digested manifest declaring its routes, MCP tools, required capabilities, effect types, and outbound invocations. The compiler pins that digest into the App version and checks that every route and tool maps to a declared operation. At runtime, admission issues a short-lived signed token whose claims bind tenant, actor chain, extension, capabilities, and guardrail scope. The sidecar verifies the token, then applies the stage transition inside the same database transaction as the project update.
It returns a typed result and typed effects. One of those can be a queued outbound invocation: the client wants the Done transition mirrored into Clover, so the sidecar asks for it.
# illustrative: effect returned by the it-pm sidecar
effects:
- type: tool_source.invoke
mode: queued
invocation: clover.projects.sync
arguments: { project_id: "ATLAS", stage: "done" }
source_revision: 418
idempotency_key: eff_3b9d…c07e
The sidecar holds no Clover credential. Pyrana validates the effect against the pinned manifest, policy, and idempotency posture, supplies the platform-held connection, performs the call, owns retry, and returns a signed callback correlated to the original effect. If Clover's answer is ambiguous, the effect goes to reconciliation rather than being re-emitted by the sidecar. The client's domain code stays native to its domain, and the platform keeps admission, credentials, retry, and audit.
Audit your own stack for door drift
Pick one business action that matters, and answer these before your next architecture review.
- How many code paths can trigger it, and can you list them from configuration rather than from memory?
- Is the permission check for each path the same function, or five functions that are supposed to agree?
- Does an agent calling it carry the person's identity, its own identity, or a shared service account?
- When a scheduled job calls it, who approved, and where is that written?
- If you tightened the policy today, which paths would still run under the old rule tomorrow?
- Can you produce the record of one call that was refused, with the caller's real identity on it?
The last one is the tell. If the refused call is not in the audit trail, the door was never really governed.
Sources
- Enterprise DNA, "Enterprises Run 12 AI Agents on Average. Half Are Siloed" (April 7, 2026), reporting the Salesforce 2026 Connectivity Benchmark (February 2026): https://enterprisedna.co/resources/news/enterprise-12-ai-agents-siloed-connectivity-benchmark-2026/
- Cybersecurity Dive, "Shadow AI, leadership resistance make AI governance tough for worried CISOs" (July 30, 2026), reporting Okta's Global CISO Insights 2026: https://www.cybersecuritydive.com/news/ai-governance-shadow-cisos-okta/826587/
- Retool, "Retool unveils first platform to extend enterprise governance to all AI-coded apps" (June 17, 2026): https://retool.com/newsroom/retool-extends-enterprise-governance-to-vibe-coded-apps
- Forrester, "The State Of Agentic AI In 2026: Companies Are Chasing, Few Are Catching" (June 3, 2026): https://www.forrester.com/blogs/the-state-of-agentic-ai-in-2026-companies-are-chasing-few-are-catching/