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_typenodes:resource is Trails::Resource::<TypeName>@shapenodes: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:
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.
Consent Receipts¶
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:
- Tutorial: Growing your KG app -- the step-by-step walk from hello world through reasoning and policy
- Middleware guide --
@before,@after,@around,@on_errorfor cross-cutting concerns - Agent runtime guide -- sessions, planners (ReAct, Plan-and-Execute, Reflexion), budgets
- MCP guide -- expose your capabilities to Claude Desktop and other MCP clients
- Testing guide --
isolated_kernel,mock_llm,capture_events - Vision -- the full design philosophy
The reference guides at docs/guides/ cover every
surface in detail. The ADRs document every architectural
decision and the reasoning behind it.