Skip to content

Trust, Policy, and Identity

Overview

Most frameworks bolt on security after the fact. Trails builds it into the kernel. This chapter covers the trust stack: Cedar policies for access control, DID identity for principals, cost envelopes for budget enforcement, PROV-O provenance for auditability, and consent receipts for data governance. Every primitive is opt-in -- you do not pay for what you do not use -- but when you reach for it, there is nothing to install or wire up.

Learning Objectives

After this chapter you will be able to:

  • Write Cedar policies to control who can invoke which capabilities
  • Understand how DID identity works for principals
  • Set cost envelopes on capabilities to enforce budgets
  • Query provenance to trace what happened and why
  • Explain the security model at a high level

Cedar Policies

Cedar is a purpose-built policy language created by AWS. Trails uses a subset of Cedar for authorization. Policies are declarative rules evaluated before any handler code runs.

Your first policy

Create a Cedar file next to your capability:

// notes.cedar

// Authors can create notes
permit(
    principal,
    action == Action::"create_note",
    resource
)
when { principal.role == "author" };

// Everyone else is denied by default
forbid(
    principal,
    action == Action::"create_note",
    resource
)
unless { principal.role == "author" };

Attach it to a capability with @policy:

from trails import capability
from trails.policy import policy

@capability
@policy("notes.cedar::permit_authors")
def create_note(ctx, title: str, body: str) -> dict:
    note = Note(title=title, body=body)
    ctx.kg.add(note)
    return {"id": note.id}

Now only principals with role == "author" can invoke create_note. Everyone else gets a PermissionError -- before the handler runs, before anything touches the graph.

Decorator ordering

@capability must be the outermost decorator, @policy directly below it. Reversed order raises TrailsError with a fix hint:

@capability                    # outermost -- always first
@policy("notes.cedar")         # directly below
def create_note(ctx, ...): ...

Policy file resolution

  • Absolute paths are used verbatim
  • Relative paths resolve from the decorated function's source file directory
  • Files are loaded lazily on first invoke()

Head clauses

Each policy statement has three head slots -- principal, action, resource -- followed by optional body conditions.

Principal clauses:

principal                              // unconstrained
principal == User::"alice"             // exact match
principal in Group::"admins"           // group membership

Action clauses:

action                                 // unconstrained
action == Action::"create_note"        // exact match
action in [Action::"create_note",
           Action::"update_note"]      // set membership

Resource clauses:

resource                               // unconstrained
resource == Note::"123"                // exact match
resource is Trails::Resource::Note     // type match

Body conditions:

when { principal.role == "admin" };
when { principal.department == "engineering" && resource.status == "draft" };
unless { principal.suspended == true };

Setting up principals

Register principal attributes so the policy engine can resolve them:

from trails import register_principal_attrs

register_principal_attrs("alice", {"role": "author", "department": "content"})
register_principal_attrs("bob", {"role": "reader"})

Strongest-available-type matching

The policy engine does not require every entity to have a full RDF type. It uses whatever typing is present:

  • Label-only nodes: resource is Trails::Resource::<label>
  • @node_type nodes: resource is Trails::Resource::<TypeName>
  • @shape nodes: resource is Trails::Resource::<ShapeClass>

This means policies work from day one, even before you add shapes or ontology. The engine automatically uses the strongest type available.

For the complete policy API, see the Policy guide.


DID Identity

Trails uses Decentralized Identifiers (DIDs) as the identity primitive for both human and agent principals. DIDs are self-issued, globally unique identifiers that do not depend on a central authority.

Supported DID methods

Method Use case Example
did:key Local/dev, self-issued did:key:z6Mkf5r...
did:web Production, domain-bound did:web:example.com:users:alice

How DIDs work in Trails

Every capability invocation carries a principal identifier. In development, this defaults to a local DID. In production, the principal presents a signed token (ACT -- Agent Capability Token) that the kernel verifies.

# In development, principals are registered directly
from trails import register_principal_attrs

register_principal_attrs("did:key:z6Mkf5rGMoatrSj1f...", {
    "role": "author",
    "name": "Alice",
})

DIDs integrate with the policy layer -- Cedar policies reference principals by their DID, and the identity module handles resolution, verification, and key rotation.

For the full identity model, see the architecture doc's security model section.


Cost Envelopes

Every capability has a cost envelope -- a budget that limits how many resources (tokens, USD, compute time) a single invocation can consume. This is a framework primitive, not a userland decorator.

Why cost matters

When agents call capabilities that call LLMs, costs can spiral. A recursive agent loop that re-invokes itself can burn through an API budget in minutes. Cost envelopes make this impossible.

Setting a cost envelope

@capability(id="summarize", description="Summarize a document")
def summarize(ctx, text: str) -> dict:
    # ctx.llm calls are tracked against the capability's cost envelope
    response = ctx.llm.complete(f"Summarize: {text}")
    return {"summary": response.text}

Cost envelopes are configured in trails.toml:

[cost]
default_max_tokens = 10000
default_max_usd = 0.50

When a capability exceeds its budget, the framework raises BudgetExceededError (HTTP 429) -- the handler is interrupted, the graph transaction rolls back, and the remaining budget is released.

Nested cost tracking

When capability A invokes capability B, the costs nest. B's envelope is charged against A's budget. A CostScope tracks the hierarchy, and deduplication on call_id / parent_call_id prevents double-counting.


PROV-O Provenance

Every write to the knowledge graph records a PROV-O provenance trail. This is always on -- you cannot write without it. The provenance graph answers: who did what, when, with what inputs, producing what outputs.

What gets recorded

Each capability invocation creates a prov:Activity with:

  • Who: the principal's DID
  • What: the capability name and input parameters
  • When: start and end timestamps
  • Output: the result payload and any created IRIs
  • Trace: a trace ID linking related activities

Querying provenance

Use the CLI:

# List recent provenance records
trails prov list

# Show details for a specific activity
trails prov show <activity-iri>

# Timeline view
trails prov timeline

Or query programmatically:

@capability
def audit_trail(ctx, note_iri: str) -> list:
    rows = ctx.kg.query(f"""
        PREFIX prov: <http://www.w3.org/ns/prov#>
        SELECT ?activity ?time ?agent
        WHERE {{
            GRAPH <https://trails.dev/ns/prov/> {{
                ?activity a prov:Activity ;
                          prov:startedAtTime ?time ;
                          prov:wasAssociatedWith ?agent ;
                          prov:generated <{note_iri}> .
            }}
        }}
        ORDER BY DESC(?time)
    """)
    return [{"activity": r["activity"], "time": r["time"], "agent": r["agent"]}
            for r in rows]

Provenance on denied requests

Even denied or failed requests produce provenance. A @policy deny records a prov:Activity with trails:outcome "denied". A validation failure records "validation_failed". This ensures the audit trail has no gaps.

Assurance levels

Trails supports three provenance assurance levels:

Level What it means
L1 Unsigned, local only (default)
L2 Signed with the principal's key (ECT)
L3 Signed and anchored to an external audit ledger

L1 is the default and requires no configuration. L2 and L3 are opt-in for regulated environments.


For applications handling personal data (healthcare, fintech, govtech), Trails can emit consent receipts -- structured records of what data was accessed, by whom, for what purpose, and under what legal basis.

Consent receipts are emitted to the principal's wallet as part of the response envelope:

{
    "payload": { ... },
    "provenance": "https://trails.dev/prov/...",
    "consent_receipt": { ... },
    "cost": { "tokens": 1234, "usd": 0.02 },
    "trace_id": "..."
}

This is an advanced feature for regulated industries. See the architecture doc for details.


Security Model Overview

Trails' security is enforced at the kernel boundary, not by surface convention. Here is the high-level picture:

Layer What it does
Token integrity ACT tokens are verified (signature, replay cache, algorithm allowlist)
DID resolution TLS-pinned, TOFU-pinned DID documents
Policy evaluation Cedar PDP runs in a snapshot-isolated transaction
SPARQL bounds Wall-clock + memory + result-row caps on every query
Provenance exclusivity The prov: graph is kernel-write-only
FFI containment Rust panics become Python TrailsError, never process aborts
Cost enforcement Budget envelopes close even on panic (RAII)

The key design principle: the handler cannot bypass security. Policy, validation, provenance, and cost enforcement live in the coordinator, not in middleware that can be skipped. A handler sees ctx and can only do what the kernel permits.

For the full security model, see the architecture doc.


Putting It Together

Here is a complete example that uses policy, provenance, and cost:

from trails import capability, node_type, shape, predicate
from trails.policy import policy

@node_type("MedicalRecord", fields={
    "patient_name": str,
    "diagnosis": str,
    "notes": str,
})
@shape
class MedicalRecord:
    patient_name = predicate("schema:name", required=True, min_length=1)
    diagnosis    = predicate("schema:description", required=True)
    notes        = predicate("schema:text", max_length=10000)

@capability(id="records.create", description="Create a medical record")
@policy("records.cedar::permit_doctors")
def create_record(ctx, patient_name: str, diagnosis: str, notes: str = "") -> dict:
    record = MedicalRecord(patient_name=patient_name, diagnosis=diagnosis, notes=notes)
    ctx.kg.add(record)
    return {"id": record.id}

@capability(id="records.view", description="View a medical record")
@policy("records.cedar::permit_care_team")
def view_record(ctx, iri: str) -> dict:
    record = MedicalRecord.find(ctx, iri)
    return {
        "id": record.id,
        "patient_name": record.patient_name,
        "diagnosis": record.diagnosis,
    }

With the accompanying Cedar policy:

// records.cedar

permit(
    principal,
    action == Action::"records.create",
    resource
)
when { principal.role == "doctor" };

permit(
    principal,
    action == Action::"records.view",
    resource is Trails::Resource::MedicalRecord
)
when { principal.role == "doctor" || principal.role == "nurse" };

This gives you:

  • Typed, validated data (@node_type + @shape)
  • Access control (only doctors can create; doctors and nurses can view)
  • Provenance (every access is recorded automatically)
  • Cost tracking (if LLM calls are involved, they are budgeted)
  • Auditability (query the prov: graph for the full trail)

And none of it required changing the handler logic. The trust stack wraps the handler, it does not invade it.


What's Next

You have covered the four handbook chapters: getting started, core concepts, data operations, and the trust stack. From here:

The reference guides at docs/guides/ cover every surface in detail. The ADRs document every architectural decision and the reasoning behind it.