# Execution Policy

**0.17.0.** Every dispatched action that declares a policy now runs behind an access-control layer (see Known limits for the one gap in that "every"), and execution policy is the half of it that answers a narrower question than `@Security` does: not *who* may call an action, but *what kind of trusted origin* is asking. Evaluation goes through the security SPI's `SecurityEnforcerHandle.enforceAccess(AccessRequest)`, not a separate resource-permission engine. It fails closed by default: include `tnsai-quality` at runtime for declared policies to be enforced, or a missing provider denies every declaring action.

```java
import com.tnsai.annotations.ActionSpec;
import com.tnsai.annotations.ExecutionPolicy;
import com.tnsai.enums.ActionType;
import static com.tnsai.enums.ExecutionCaller.*;

@ActionSpec(type = ActionType.LOCAL,
    execution = @ExecutionPolicy(execute = {USER, OTHER_AGENT}))
public String answer() { return "Ready"; }
```

`ExecutionCaller` has three values. `SELF` is the executing agent's own autonomous initiative. `USER` is a human interaction, including work an agent plans on the human's behalf. `OTHER_AGENT` is work requested by a different agent. Planning or selecting a tool never turns a `USER` or forwarded request into `SELF` -- origin is about who asked, not which code path ran. This API does not classify read/write effects, resource ownership, or data-access permissions; that stays `@Security`'s job.

## Declaring a policy

`ActionSpec.execution()` and `Tool.execution()` default to an empty annotation array, and absence adds no execution restriction -- existing `@Security`, principal, authority and approval checks still apply on their own. An explicit `execution = @ExecutionPolicy(execute = {})` denies every origin. There is no permissive override marker and no separate "policy mode" setting.

A standalone `@ExecutionPolicy` also works directly on methods and types, or through a composed annotation at any depth. Restrictions from the class, the method, superclasses, interfaces, generic ancestors (including a `T[]` or `T...` parameter), composed annotations and dynamic registration all **intersect** -- an override, including a method that omits the annotation entirely, can narrow an ancestor's restriction but never widen it. A dynamically registered tool takes an `ExecutionRule`: `.execution(new ExecutionRule(Set.of(USER)))`, where `null` means absent and an empty set denies. No annotation, tool name, HTTP verb or model argument implies `SELF` on its own.

## Establishing the caller

```java
AccessSubject agent = new AccessSubject(AccessSubject.Kind.AGENT, "agent-a", Set.of());
AccessSubject human = new AccessSubject(AccessSubject.Kind.HUMAN, "local-chat", Set.of());
executor.setPrincipal(principal);
executor.setDefaultScope(authority);
executor.configureExecution(agent);
InvocationContext invocation = InvocationContext.user(human, agent, principal.id(), authority);
// call(Callable<T>) declares throws Exception -- the enclosing method needs
// its own throws clause or a try/catch around this call.
Object result = invocation.call(() -> executor.execute(action, target, parameters, Map.of()));
```

The host picks the ingress factory: `InvocationContext.user(human, executingAgent, executorId, authority)`, `.autonomous(executingAgent, executorId, authority)`, or `.otherAgent(callerAgent, executingAgent, executorId, authority)`. `.unknown(executorId)` grants no execution category at all. These factories are trusted Java APIs you call from your own host code -- not an authentication protocol and not a sandbox against a hostile JVM. Never read the origin, category or `SELF` claim from a request body, header, query parameter or model-generated argument; an active invocation cannot be replaced by an unrelated caller to change its origin mid-flight.

A local human-chat endpoint may mint a fixed anonymous `HUMAN` subject and `USER` category on the backend. That denotes the endpoint's *interaction category*, not a verified account, login, owner or tenant.

The executor's principal and the actual executing agent are distinct concepts: two agents can share one principal and remain different agents. Configure the real executing agent once, on its `ActionExecutor` or `SCOPBridge`, with `configureExecution(AccessSubject)`. A protected invocation has to match both that agent binding and the current principal. `AuthorityScope` expiry, action types and target systems are additional bounds layered on top; root and delegated authority scopes intersect, so a later, narrower scope can never widen an earlier one.

The origin survives planning, nested tool calls, asynchronous work (`InvocationContext.wrap`, `AgentContext.wrap`) and delegation (`delegateTo`). Ordinary Java threads that aren't wrapped this way do not inherit identity.

## Delegation and asynchronous work

`delegateTo(nextAgent, nextExecutorId, nextAuthority)` derives the departing agent from the existing context -- it never accepts an arbitrary claimed sender. What the destination requires depends on the transition:

| Transition | Required origins at destination |
|---|---|
| `USER`, local nested work | `USER` |
| `USER`, agent A delegates to B | `USER` and `OTHER_AGENT` |
| Autonomous A delegates to B | `OTHER_AGENT` |
| Forwarded A → B → A | `OTHER_AGENT` remains |
| B's own independent autonomous initiative | `SELF` |

Every effective policy has to allow *all* the required categories at once, so a `USER` request that arrives through another agent needs `{USER, OTHER_AGENT}` declared, not either alone. Nesting within the same actual agent preserves its origin regardless of executor-principal equality.

## Where the check runs -- and where it deliberately doesn't

Managed Role, Object-target (SCOP), and static/dynamic tool dispatch all check the policy before hooks, retrieval, cache lookup, approval consumption and the action body run. Inputs and post-hook maps are copied for the check; a policy or host change between two checks denies rather than reusing a stale verdict. Approval is an additional condition on top, consumed only after this authorization succeeds. No action can bypass the real dispatch gate just because it appeared in a tool list offered to the model -- that list filtering is advisory only.

**Plan steps and message callbacks are the two exceptions**, because both invoke action methods directly rather than going through `ActionExecutor`: `PlanExecutor`, the bundled `PlannerHandle.execute()` (and `Agent.executePlan()` when it runs the bundled planner), and `MessageCallbackDispatcher` never ran the gate for them before 0.17.0. So that no declared action can run ungated, a plan containing a step whose action declares `@ExecutionPolicy` or `@Security` **declared directly** (not through a composed annotation -- see Known limits) is refused with `SecurityException(UNAUTHORIZED)` before its first step runs, and a role whose `@OnMessageReceived` callback declares either, directly, is refused at registration. Dispatch an action like that through `ActionExecutor` instead.

**A custom `PlannerHandle` is not covered by this refusal.** `Agent.executePlan()` calls a `PlannerHandle` supplied through `AgentBuilder.plannerHandle`, `Agent.setPlannerHandle` or another `PlannerHandle.Factory` without checking it first -- only the bundled planner refuses protected steps. `PlanExecutor.refuseProtectedSteps` lives in `tnsai-intelligence`, so a `tnsai-core`-only custom handle cannot call it even if it wanted to; a custom `PlannerHandle` has to refuse its own protected steps.

`ActionExecutor.authorizeAction(...)` and `SCOPBridge.authorizeAction(target, actionName, params)` run the same check without side effects and return the checked `AccessRequest` -- `null` for an action that declares nothing -- for a host that wants to preflight a call. `SCOPBridge.executeAction(target, actionName, params, resolvedPaths, sourceOverrides, invocation)` dispatches under a given `InvocationContext` directly.

## Idempotency is partitioned by caller

Stored idempotency results, including explicit keys, are partitioned by the caller: the executor id, the executing agent, the origin categories, and every subject with its roles, plus -- for an action that declares a policy or `@Security` -- the checked target binding. A retry by the same caller under a fresh context still deduplicates; a different caller, role set, origin or agent never reads another caller's entry, and each hit is reauthorized against current policy and security state regardless. The `Idempotency-Key` header sent upstream stays the plain key and carries no caller identity of its own, so an upstream service that deduplicates on that header alone still treats identical keys from different callers as one request. `InvocationContext.unknown` contexts get a random partition and never share entries with anything.

## Known limits

This API doesn't cover everything that touches security in the framework:

- Native SCOP ingress and replay, and real HTTP `USER` integration, are the host's responsibility to wire correctly -- this contract assumes the host did.
- Remote MCP permissions and arbitrary direct Java calls are outside this guarantee entirely.
- A discovered MCP alias of an already-declared endpoint still needs explicit dynamic registration; discovery alone does not carry a policy over.
- **`@Security` placed only on a composed (meta-)annotation is not detected on *any* dispatch path** -- `ActionExecutor`, plans and message callbacks included, whether or not `tnsai-quality` is present. `@ExecutionPolicy` resolves through composed annotations correctly; the equivalent lookup for `@Security` does not exist. This is a known, open gap, not something 0.17.0 fixed. Declare `@Security` directly -- on the method, on an overridden declaration, or on the role class or one of its ancestors; see the `@Security` section of [Approvals and Annotations](approvals-and-annotations.md).

Protect every sensitive action explicitly -- an absent policy intentionally adds no restriction of its own.
