Edictum
Guides

Mutable Principal

Agents that serve multiple users, escalate privileges mid-session, or refresh auth tokens cannot use a fixed principal.

AI Assistance

Right page if: your agent needs to change its identity context mid-session -- multi-tenant per-call resolution, privilege escalation, or token refresh without losing session state. Wrong page if: you need a static principal that never changes -- see https://docs.edictum.ai/docs/concepts/principals. For the constructor signature shared by all adapters, see https://docs.edictum.ai/docs/adapters/overview. Gotcha: principal_resolver always wins over the static principal. If both are set, the static principal is ignored. Session state (attempt counts, limits) is preserved across set_principal() calls -- only the identity changes.

Agents that serve multiple users, escalate privileges mid-session, or refresh auth tokens cannot use a fixed principal. The identity context needs to change between tool calls without tearing down the adapter or losing session state.

from edictum import Edictum, Principal
from edictum.adapters.langchain import LangChainAdapter

guard = Edictum.from_yaml("contracts.yaml")

adapter = LangChainAdapter(
    guard,
    principal_resolver=lambda tool, args: Principal(
        org_id=args.get("tenant_id"),
        role="customer",
    ),
)

Every tool call now resolves the principal from its arguments. Contracts that check principal.org_id or principal.role enforce per-tenant limits automatically.


How it works

set_principal(principal)

Updates the principal stored on the adapter (or the Edictum instance). All subsequent tool calls use the new principal. In-flight calls are not affected -- only calls that start after set_principal() see the change.

Available on:

  • Edictum.set_principal(principal)
  • LangChainAdapter.set_principal(principal)
  • ClaudeAgentSDKAdapter.set_principal(principal)
  • CrewAIAdapter.set_principal(principal)
  • AgnoAdapter.set_principal(principal)
  • SemanticKernelAdapter.set_principal(principal)
  • OpenAIAgentsAdapter.set_principal(principal)

Session state (attempt counts, execution history) is preserved across set_principal() calls. Only the identity context changes.

principal_resolver

A callable with the signature:

def principal_resolver(tool_name: str, tool_input: dict[str, Any]) -> Principal:
    ...
  • tool_name: the name of the tool being called
  • tool_input: the arguments passed to the tool
  • Returns a Principal that overrides the static principal for that call

When set, the resolver is called on every tool call before the pipeline evaluates contracts. Its return value becomes the principal on the ToolEnvelope for that call.

Pass it through the constructor:

# On adapters
adapter = LangChainAdapter(guard, principal_resolver=my_resolver)

# On the Edictum class directly
guard = Edictum(
    contracts=[...],
    principal_resolver=my_resolver,
)

Resolution order

The pipeline resolves the principal in this order:

  1. If principal_resolver is set, call it with (tool_name, tool_input) and use its return value.
  2. Otherwise, use the static principal (set via constructor or set_principal()).
  3. If neither is set, the principal is None.

The resolver always wins. If you set both a static principal and a resolver, the resolver's return value is used for every call. The static principal is ignored while a resolver is active.


Contracts with mutable principals

Contracts that reference principal.* selectors work identically whether the principal is static or dynamic. The contract sees whatever principal is on the envelope at evaluation time.

Per-tenant rate limits

apiVersion: edictum/v1
kind: ContractBundle
metadata:
  name: tenant-limits
defaults:
  mode: enforce

contracts:
  - id: tenant-query-limit
    type: session
    limits:
      max_tool_calls: 100
    then:
      effect: deny
      message: "Tenant has exceeded 100 queries per session."

Combined with a principal_resolver that sets org_id per call, each tenant's queries are tracked against the session limit. Note that session contracts count at the session level -- if you need per-tenant counting, use separate session IDs per tenant.

Role-gated escalation

apiVersion: edictum/v1
kind: ContractBundle
metadata:
  name: role-gates
defaults:
  mode: enforce

contracts:
  - id: write-requires-operator
    type: pre
    tool: deploy
    when:
      principal.role: { not_in: [operator, admin] }
    then:
      effect: deny
      message: "Write operations require operator or admin role."

  - id: write-file-requires-operator
    type: pre
    tool: write_file
    when:
      principal.role: { not_in: [operator, admin] }
    then:
      effect: deny
      message: "Write operations require operator or admin role."

Before the human approval step, the agent has role: "analyst" and write tools are denied. After set_principal(Principal(role="operator")), the same contracts allow writes through.


Audit trail

Every AuditEvent includes the principal that was active at the time of the tool call. When the principal changes mid-session, the audit trail reflects the transition:

{"tool_name": "read_file", "principal": {"role": "analyst"}, "action": "call_allowed"}
{"tool_name": "deploy",    "principal": {"role": "analyst"}, "action": "call_denied"}
{"tool_name": "deploy",    "principal": {"role": "operator"}, "action": "call_allowed"}

The second deploy call succeeds because set_principal() updated the role between attempts. The audit trail makes this explicit.


Next steps

Last updated on

On this page