Skip to content

Principals

A principal carries identity context -- who initiated the tool call, what role they have, what ticket authorized it. Edictum does not authenticate principals. Your application sets the principal, and contracts evaluate against it.

When to use this

Read this page when you need to attach identity context to tool calls -- role-based permissions, tenant isolation, or compliance attribution. Principals carry fields like role, user_id, org_id, and ticket_ref that contracts can evaluate against and that appear on every audit event. OTel spans include role, ticket_ref, user_id, and org_id as edictum.principal.* attributes. Note: service_id is a Principal field but is not emitted to OTel spans. The code also checks for team in the principal dict, but since team is not a named Principal field it will only appear if the application explicitly adds it to the dict. For contracts that use principal fields, see contracts. For how principals flow through the pipeline, see how it works.

Principal Fields

from edictum import Principal

principal = Principal(
    user_id="alice",
    service_id="billing-agent",
    org_id="acme-corp",
    role="analyst",
    ticket_ref="INC-1234",
    claims={"department": "finance", "clearance": "confidential"},
)
Field Type Description
user_id str or None The human or service account that initiated the session
service_id str or None The agent or service making the tool call
org_id str or None Organization or tenant identifier
role str or None Role used for role-based contract conditions
ticket_ref str or None Change management ticket (Jira, ServiceNow, PagerDuty)
claims dict Arbitrary key-value pairs for custom authorization context

All fields are optional. Use only what your contracts need.

Attaching a Principal

Pass the principal when creating an adapter. It is carried through every tool call and audit event in that session.

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

guard = Edictum.from_yaml("contracts.yaml")
principal = Principal(role="analyst", ticket_ref="INC-1234")

adapter = LangChainAdapter(guard=guard, principal=principal)
wrapper = adapter.as_tool_wrapper()

Or use Edictum.run() directly:

result = await guard.run(
    "query_db",
    {"query": "SELECT * FROM users"},
    query_fn,
    principal=principal,
)

Using Principals in Contracts

Contracts reference principal fields through the principal.* selectors.

Require a ticket for non-admin writes

- id: require-ticket-for-writes
  type: pre
  tool: "*"
  when:
    all:
      - principal.role: { not_in: [admin, sre] }
      - principal.ticket_ref: { exists: false }
  then:
    effect: deny
    message: "Non-admin tool calls require a ticket reference."

When a principal with role: "analyst" and no ticket_ref calls any tool, this contract fires. An admin or SRE can proceed without a ticket.

Gate production deploys by role

- id: prod-deploy-requires-senior
  type: pre
  tool: deploy_service
  when:
    all:
      - environment: { equals: production }
      - principal.role: { not_in: [senior_engineer, sre, admin] }
  then:
    effect: deny
    message: "Production deploys require senior role (sre/admin)."

Use custom claims for fine-grained access

- id: only-platform-can-scale
  type: pre
  tool: scale_service
  when:
    principal.claims.department: { not_equals: platform }
  then:
    effect: deny
    message: "Only the platform team can scale services."

The claims dict supports dotted path access: principal.claims.department resolves to principal.claims["department"]. If the key is missing, the condition evaluates to false and the contract does not fire.

Principal Propagation

Set the principal once at the adapter level. It propagates automatically to:

  • Every ToolEnvelope built for each tool call
  • Every precondition and postcondition evaluation
  • Every AuditEvent emitted by the pipeline
  • Every OpenTelemetry span (as edictum.principal.* attributes for role, ticket_ref, user_id, and org_id -- note that service_id is not included in OTel spans; the code also checks for team but it is not a named Principal field)

You do not need to pass the principal on each tool call. The adapter carries it for the entire session.

Missing Principal

If no principal is set:

  • Contracts that check principal.* fields see null values.
  • A condition like principal.role: { not_in: [admin] } evaluates to false (missing field behavior), so the contract does not fire.
  • A condition like principal.ticket_ref: { exists: false } evaluates to true, because the field is absent.

Design your contracts to handle the no-principal case explicitly if you need to enforce principal requirements:

- id: require-principal
  type: pre
  tool: "*"
  when:
    principal.user_id: { exists: false }
  then:
    effect: deny
    message: "A principal with user_id is required for all tool calls."

Next Steps