Core Concepts¶
Overview¶
This chapter explains the mental model behind Trails. Once you understand how knowledge graphs, capabilities, node types, shapes, and the context object fit together, everything else in the framework becomes predictable. Take 30 minutes here and save hours of guesswork later.
Learning Objectives¶
After this chapter you will be able to:
- Explain what a knowledge graph is and why Trails uses one
- Describe what capabilities, node types, and shapes are
- Use the context object (
ctx) to access the graph, LLM, and config - Articulate the progressive-enhancement philosophy
- Explain how Trails differs from Django/Rails
The Knowledge Graph Mental Model¶
A knowledge graph stores data as a web of connected facts. Every fact is a triple -- three parts that read like a sentence:
subject -- predicate --> object
"Alice" -- "works_at" --> "Acme Corp"
"Alice" -- "role" --> "engineer"
"Acme" -- "located" --> "Munich"
That is the entire data model. No tables, no columns, no foreign keys. Just subjects connected to objects through predicates.
Nodes and Edges¶
In graph terms:
- Nodes are the things (Alice, Acme Corp, Munich)
- Edges are the connections between things (works_at, role, located)
A node can have any number of edges pointing to other nodes or to literal values (strings, numbers, dates). There is no fixed schema unless you choose to add one.
Triples¶
The formal term for a subject-predicate-object fact. Trails stores triples in RDF format using Oxigraph as the embedded triple store. You do not need to know RDF to use Trails -- the framework handles serialisation for you. But when you hear "triple", it means one fact.
IRIs¶
Every node in the graph has a globally unique identifier called an IRI (Internationalized Resource Identifier). Think of it as a URL that uniquely names a thing:
Trails mints IRIs automatically using UUIDv7 (time-sortable). You never need to construct one by hand unless you want to.
SPARQL¶
SPARQL is the query language for knowledge graphs, the way SQL is the query language for relational databases. Trails generates SPARQL for you behind the ORM, but you can always drop to raw SPARQL when needed:
Most of the time, you will use Note.where(title="Hello").fetch(ctx)
instead. The ORM compiles that to SPARQL so you do not have to.
Capabilities¶
A capability is a named function that Trails can discover, invoke, and audit. It is the primary unit of functionality -- the equivalent of a controller action in Rails or a view function in Django.
from trails import capability
@capability
def create_note(ctx, title: str, body: str) -> dict:
note = Note(title=title, body=body)
ctx.kg.add(note)
return {"id": note.id}
What the @capability decorator gives you:
- Discovery -- the function appears in
trails routes, MCPtools/list, and the HTTP OpenAPI spec - Dispatch --
trails.invoke("create_note", {"title": "Hi", ...})calls it through the runtime, which handles policy, validation, provenance, and cost - Provenance -- every invocation records a PROV-O activity triple in the graph. Who called what, when, with what inputs, producing what outputs. Automatic, always on.
- Type checking -- arguments are validated against the function signature before the handler runs
Every capability is exposed as an MCP tool automatically. When trails dev
or trails serve starts, it reads all registered capabilities and derives
the MCP tool manifest — name, description, and typed inputSchema — directly
from your Python type annotations. If you change a capability or model while
trails dev is running, it hot-reloads and sends
notifications/tools/list_changed to connected clients. You never write MCP
code.
Capabilities can invoke other capabilities:
@capability
def create_and_list(ctx, title: str, body: str) -> dict:
created = ctx.invoke("create_note", title=title, body=body)
all_notes = ctx.invoke("list_notes")
return {"created": created, "all": all_notes}
For the complete decorator API, see the Capabilities guide.
Node Types¶
A node type declares a data entity with named, typed fields. It is the equivalent of a Django model or a Rails ActiveRecord class.
The recommended way is Graph-as-Code (GaC): define your model as a
plain Python class with Annotated[] fields, then decorate it with
@app.model. Constraints live right next to the field declarations and
compile to SHACL automatically.
# Modern form — constraints inline via Annotated[]
from typing import Annotated
from trails import App
from trails.gac import required, optional, min_value, max_value
app = App("clinic")
@app.model
class Patient:
name: Annotated[str, required(), min_value(1)]
age: Annotated[int, optional(), min_value(0), max_value(150)]
You can also use the explicit decorator form when you need to reference an existing class or prefer the explicit field dict:
# Explicit form — same result, more verbose
from trails import node_type
@node_type("Patient", fields={"name": str, "age": int})
class Patient: ...
What both forms give you:
- Validation -- writes to the graph are checked against the field types. Pass a string where an int is expected and you get a clear error, not silent corruption.
- IRI minting -- each instance gets a unique
trails://IRI automatically - ORM methods --
Patient.find(ctx, iri),Patient.where(age__gte=18).fetch(ctx),patient.save(ctx),Patient.delete(ctx, iri),Patient.count(ctx) - Hydration -- query results come back as Python objects with
attribute access (
patient.name, notrow["name"])
Node types are additive. Adding @node_type("Patient", ...) does not
affect any existing capability that does not mention Patient. There
is no migration step, no schema change to coordinate.
Linking node types¶
Reference another node type to create typed edges:
@node_type("Doctor", fields={"name": str})
class Doctor: ...
@node_type("Patient", fields={"name": str, "doctor": Doctor})
class Patient: ...
The doctor field stores an IRI reference. Query across the link with
property-path traversal:
# All patients of a doctor named "Smith" -- resolved in one SPARQL query
Patient.where(doctor__name="Smith").fetch(ctx)
For the complete ORM API, see the ORM guide.
Shapes¶
Shapes express constraint validation that goes beyond type checking: minimum length, numeric ranges, allowed values, regex patterns.
GaC path (recommended)¶
When you use @app.model, every Annotated[] marker is a constraint.
Trails compiles them to SHACL automatically -- no separate decorator
needed.
from typing import Annotated
from trails.gac import required, optional, min_length, max_length, pattern
@app.model
class Note:
title: Annotated[str, required(), max_length(120)]
body: Annotated[str, required(), min_length(1)]
Constraint markers quick reference
| Marker | What it checks |
|---|---|
required() |
field must be present (sh:minCount 1) |
optional() |
field may be absent (default) |
min_length(n) / max_length(n) |
string length bounds |
min_value(n) / max_value(n) |
numeric range |
pattern(r"...") |
regex pattern |
one_of([...]) |
enumeration of allowed values |
For cross-property rules, add a method decorated with @constraint:
@app.model
class Note:
title: Annotated[str, required(), max_length(120)]
body: Annotated[str, required()]
@constraint
def body_longer_than_title(self):
return len(self.body) > len(self.title)
Explicit path (advanced)¶
Use @shape + predicate() when you need raw SPARQL constraints,
custom predicate IRIs, or you are adding shapes to a class defined
with the explicit @node_type form:
from trails import node_type, shape, predicate
@node_type("Note", fields={"title": str, "body": str})
@shape
class Note:
title = predicate("schema:name", required=True, max_length=120)
body = predicate("schema:description", required=True, min_length=1)
Either path generates the same SHACL triples under the hood. You can
export them with trails export --format ttl for interop with other
RDF tools.
For the complete shapes API, see the Shapes guide.
The Context Object¶
Every capability receives a ctx parameter. This is the framework's
handle to the outside world:
@capability
def my_capability(ctx, title: str) -> dict:
# The knowledge graph
ctx.kg.add(note) # write a node
ctx.kg.query("SELECT ...") # raw SPARQL
notes = Note.where().fetch(ctx) # ORM query
# LLM client (if configured)
response = ctx.llm.complete("Summarize this...")
# Configuration
project_name = ctx.config["project"]["name"]
# Invoke another capability
result = ctx.invoke("other_capability", arg="value")
return {"done": True}
ctx.kg -- the Knowledge Graph handle¶
This is the primary surface you interact with. It provides:
- ORM methods via node types:
Note.where().fetch(ctx),note.save(ctx),Note.find(ctx, iri) - Label-first methods for untyped data:
ctx.kg.node(labels=["Tag"], properties={"name": "urgent"}),ctx.kg.edge(...),ctx.kg.match(...) - Raw SPARQL:
ctx.kg.query("SELECT ..."),ctx.kg.update("INSERT DATA { ... }")
The ORM and label-first surfaces coexist on the same handle. You can mix them in the same capability. See Working with Data for details.
ctx.llm -- the LLM Client¶
When configured, provides access to language models:
Supports Anthropic, Ollama, OpenAI, and a mock backend for testing. Each call is tracked in provenance (which model, how many tokens). See the LLM guide.
ctx.config -- Configuration¶
Access to trails.toml settings. Project name, base IRI, store
backend, LLM provider config.
ctx.invoke -- Capability dispatch¶
Call another capability from within a capability. The nested call goes through the full dispatch pipeline (policy, validation, provenance), so it is audited just like a top-level call.
Progressive Enhancement¶
This is the design philosophy that makes Trails different from every other framework. It means:
Every feature is additive. Code you write on day one keeps working on day 365. New features never force rewrites.
In practice, this creates a ladder you climb at your own pace:
| Level | What you add | What you get | Old code changes? |
|---|---|---|---|
| 1 | @capability |
Named function, provenance, discovery | -- |
| 2 | ctx.kg.node() |
Label-first graph storage | No |
| 3 | @node_type |
Typed fields, validation, ORM | No |
| 4 | @shape |
Constraint validation (SHACL) | No |
| 5 | ontology/*.ttl |
Reasoning, cross-system interop (OWL) | No |
| 6 | @policy + .cedar |
Access control (Cedar) | No |
| 7 | @before/@after |
Middleware (logging, metrics) | No |
The right column is the key. At no point do you rewrite what you already have. You only add.
This is not a theoretical promise -- it is enforced by the architecture. The kernel uses "strongest available type" matching: Cedar policy, SHACL validation, and provenance all inspect each entity and act on whatever typing is present -- label only, JSON-Schema type, SHACL shape, or OWL class. One code path, feature-detected at dispatch time.
The normative decision behind this is ADR-0021. The tutorial that walks the entire ladder is Growing Your KG App.
How Trails Differs from Django and Rails¶
If you are coming from a web framework, some things will feel familiar and some will not. Here is a quick orientation.
What is similar¶
| Concept | Django/Rails | Trails |
|---|---|---|
| Data model | Model / ActiveRecord |
@node_type |
| Request handler | View / Controller action | @capability |
| Validation | Form / Validator | @shape + predicate() |
| Query API | QuerySet / ActiveRecord | Model.where().fetch(ctx) |
| Middleware | Middleware classes | @before / @after / @around |
| Generators | rails generate |
trails g cap\|sh\|res |
| Dev server | manage.py runserver |
trails server --watch |
| Console | rails console |
trails console |
What is different¶
Graph, not tables. Data is stored as triples (subject-predicate- object), not rows in tables. There are no migrations because there is no fixed schema to migrate -- adding a field to a node type does not require altering a table. Old nodes simply lack the new field.
IRIs, not integer IDs. Every entity has a globally unique IRI, not an auto-incrementing integer. This makes federation and cross-system references natural.
SPARQL, not SQL. The query language underneath is SPARQL. The ORM hides this most of the time, but when you need raw queries, it is SPARQL you write.
Provenance is always on. Every write records who did what, when, producing what. There is no equivalent in Django or Rails -- you would have to add an audit library. In Trails, it is built into the kernel.
Policy is declarative. Access control is written in Cedar (a purpose-built policy language), not as conditional checks in handler code. The framework evaluates policy before your handler runs.
Capabilities, not routes. The primary abstraction is the capability (a named, typed, auditable function), not an HTTP route. Routes and MCP tools are projections of capabilities, generated automatically.
What's Next¶
Now that you understand the mental model, put it to work:
Working with Data -- Create, query, filter, and aggregate data using the full ORM surface.
For deep dives into individual concepts:
- Capabilities guide -- all decorator options, dispatch lifecycle, cost envelopes
- ORM guide -- field types, inheritance, async, dirty tracking
- KG guide -- label-first operations, traverse, match
- Shapes guide -- SHACL export, shape-from- annotation