03 — Detailed Design Specification¶
This document is the detailed design reference for the one-surface,
progressive-enhancement framing set by
ADR-0021, the rich
capability manifest of
ADR-0005, and the Cedar
unified matcher of
ADR-0022. Everything here
is authored, dispatched, and validated through the same @capability
surface regardless of whether the app uses labels only, @node_type,
@shape, or full OWL.
3.1 Repository layout¶
framework.trails/ # monorepo
├── README.md
├── LICENSE # Apache-2.0
├── CHANGELOG.md
├── trails.toml # workspace metadata
│
├── rust/ # Rust kernel workspace (7 active crates + 5 archived)
│ ├── Cargo.toml # workspace root
│ ├── crates/
│ │ ├── trails-graph/ # GraphStore trait + Oxigraph impl
│ │ ├── trails-shapes/ # SHACL validator
│ │ ├── trails-reason/ # OWL-RL / RDFS reasoner (opt-in, feature-detected)
│ │ ├── trails-policy/ # Cedar PDP/PEP + strongest-available-type matcher
│ │ ├── trails-prov/ # PROV-O writer; owns `Assurance`
│ │ ├── trails-caps/ # capability registry + manifest projectors
│ │ ├── trails-identity/ # DID + VC + ACT + biscuit + Signer
│ │ ├── trails-cost/ # cost accountant + CostScope nesting
│ │ ├── trails-adapters-fuseki/ # Apache Jena Fuseki adapter (async)
│ │ ├── trails-adapters-qlever/ # Qlever adapter (async)
│ │ ├── trails-ffi/ # PyO3 bindings
│ │ └── trails-wasm/ # WasmStore over OxigraphStore
│
├── python/ # Python surface
│ ├── pyproject.toml
│ ├── src/trails/
│ │ ├── __init__.py # re-exports @capability, @node_type, @shape, @policy, ...
│ │ ├── decorators.py # @capability, @before, @after, @on_error, @around
│ │ ├── runtime.py # invoke(); dispatch coordinator
│ │ ├── context.py # Context + KG (ctx.kg namespace)
│ │ ├── orm.py # @node_type, Model, Q, QueryBuilder
│ │ ├── shapes.py # @shape, predicate()
│ │ ├── policy.py # @policy (Cedar)
│ │ ├── llm.py # LLMClient (anthropic / ollama / mock)
│ │ ├── agent/ # Session, planners
│ │ ├── ingest/ # PDF / HTML / Markdown extractors + chunker
│ │ ├── vector/ # embedders + SqliteVecStore / QdrantStore
│ │ ├── observability.py # events + Span / tracer / metrics
│ │ ├── testing.py # isolated_kernel, mock_llm, capture_events
│ │ ├── mcp_server.py # MCP: Tools + Resources + Prompts (stdio + SSE)
│ │ ├── mcp_resources.py # @resource
│ │ ├── mcp_prompts.py # @prompt
│ │ ├── http_adapter.py # FastAPI mount
│ │ ├── rendering.py # bi-modal rendering
│ │ ├── cli/ # trails new / g / server / kg / onto / doctor ...
│ │ └── _core/ # compiled abi3-py311 extension (PyO3)
│ └── tests/
│
├── docs/
│ ├── guides/ # opinionated guides (orm, kg, capabilities, middleware, ...)
│ ├── tutorials/ # growing-your-kg-app
│ ├── concepts/ # progressive-enhancement, ...
│ ├── adrs/ # 22 ADRs + amendments
│ └── plans/sprints/ # milestone sprint plans
│
└── examples/
├── hello.py # bare @capability (ADR-0021 rung 1)
├── hello-orm.py # @node_type + ctx.kg (ADR-0021 rung 2)
├── dogfood-notes/
├── patient-intake/
├── agent-workflow/
├── research-assistant/
├── ai-classify/
├── content-pipeline/
├── dev-sdlc/
└── react-agent/
3.2 Kernel trait definitions (Rust)¶
3.2.0 Sync/async policy¶
Reconciled once for the whole kernel, binding on all implementations:
GraphStorehas sync AND async variants. Two traits:SyncGraphStore(blocking) andAsyncGraphStore(async fnmethods). The Oxigraph adapter implementsSyncGraphStorenatively (RocksDB is blocking) and wraps itself inspawn_blockingto satisfyAsyncGraphStore. Qlever and Fuseki adapters are async-native (HTTP) and implementAsyncGraphStoredirectly.- The dispatch coordinator is async from the outset. Handlers may be
async def, MCP SSE / HTTP streaming requires yielding, and ECT L3 audit-ledger anchoring is a network call. Making dispatch sync would force every streaming capability through a thread pool. - PyO3 surface uses
pyo3-async-runtimesto bridge Pythonasyncioto the Tokio runtime that drivesAsyncGraphStoreand the coordinator.@capability-decorated Python functions may be declared eitherasync defor plaindef; the framework normalizes both to async internally and awaits sync handlers insidespawn_blocking. - Implication for adapters. New graph backends SHOULD implement
AsyncGraphStoredirectly when they are I/O-bound; embedded/blocking backends MAY implement onlySyncGraphStoreand rely on the kernel'sspawn_blocking-wrapped adaptor for the async surface.
3.2.1 trails-graph¶
use iref::IriBuf;
use serde::{Deserialize, Serialize};
pub trait SyncGraphStore: Send + Sync {
fn query(&self, sparql: &str, ctx: &GraphContext) -> Result<Solutions>;
fn update(&self, sparql: &str, ctx: &GraphContext) -> Result<UpdateReport>;
fn load(&self, graph: &IriBuf, data: &[u8], fmt: Format) -> Result<()>;
fn begin(&self) -> Result<Box<dyn GraphTxn>>;
fn snapshot(&self) -> Result<Box<dyn GraphSnapshot>>;
}
#[async_trait::async_trait]
pub trait AsyncGraphStore: Send + Sync {
async fn query(&self, sparql: &str, ctx: &GraphContext) -> Result<Solutions>;
async fn update(&self, sparql: &str, ctx: &GraphContext) -> Result<UpdateReport>;
async fn load(&self, graph: &IriBuf, data: &[u8], fmt: Format) -> Result<()>;
async fn begin(&self) -> Result<Box<dyn GraphTxn>>;
async fn snapshot(&self) -> Result<Box<dyn GraphSnapshot>>;
}
pub trait GraphTxn: Send {
fn query(&self, sparql: &str) -> Result<Solutions>;
fn update(&self, sparql: &str) -> Result<UpdateReport>;
fn commit(self: Box<Self>) -> Result<()>;
fn rollback(self: Box<Self>) -> Result<()>;
}
#[derive(Serialize, Deserialize)]
pub struct GraphContext {
pub principal: IriBuf, // DID
pub named_graphs: Vec<IriBuf>,
pub trace_id: String,
}
pub struct OxigraphStore { /* ... */ } // SyncGraphStore + spawn_blocking -> AsyncGraphStore
pub struct QleverStore { /* ... */ } // AsyncGraphStore (HTTP, async-native)
pub struct FusekiStore { /* ... */ } // AsyncGraphStore (HTTP, async-native)
3.2.2 trails-shapes¶
pub trait Validator: Send + Sync {
fn validate(&self, data: &[Quad], shape: &IriBuf) -> Result<ValidationReport>;
fn register_shapes(&mut self, shapes: &[u8], fmt: Format) -> Result<()>;
}
pub struct ValidationReport {
pub conforms: bool,
pub violations: Vec<Violation>,
}
pub struct Violation {
pub focus_node: IriBuf,
pub path: IriBuf, // predicate
pub constraint: IriBuf, // SHACL constraint component
pub severity: Severity,
pub message: String,
pub value: Option<Term>,
}
The SHACL validator is invoked at ctx.kg.add / save time and on
capability input/output when an @shape-typed class is involved.
Label-only writes pass through untouched; @node_type writes are
validated against the JSON-Schema captured by the decorator. This is
the "strongest available type" rule (ADR-0022) at the validator layer.
3.2.3 trails-policy¶
pub trait PolicyEngine: Send + Sync {
fn decide(&self, req: &PolicyRequest) -> Result<PolicyDecision>;
fn load_policies(&mut self, src: &str) -> Result<()>;
}
pub struct PolicyRequest {
pub principal: IriBuf,
pub action: String, // e.g. "capability:patient.intake"
pub resource: Option<IriBuf>,
pub context: serde_json::Value,
}
pub struct PolicyDecision {
pub decision: Decision, // Allow | Deny
pub determining_policies: Vec<String>,
pub diagnostics: Vec<String>,
}
Cedar entity typing is not split across tier-specific prefixes. The engine resolves each resource to the strongest typing available — RDF class > SHACL shape > JSON-Schema type > label — and the policy author matches on whichever level is present (ADR-0022).
3.2.4 trails-prov¶
pub trait ProvenanceWriter: Send + Sync {
/// Write PROV-O triples AND emit an ECT in one atomic step (ADR-0013).
/// Returns both the activity IRI (for SPARQL queryability) and the ECT token
/// (for on-wire export). The writer carries its own `Assurance` — the
/// descriptor does not.
fn record_activity(&self, a: ActivityRecord) -> Result<(IriBuf, EctToken)>;
/// Emit an ECT for an activity already staged in the current dispatch.
fn emit_ect(&self, activity: &ActivityRecord) -> Result<EctToken>;
/// Verify an inbound ECT (e.g., when a Trails instance receives an ECT
/// from a peer and needs to attest its origin, signature, and claims).
fn verify_ect(&self, ect: &EctToken) -> Result<VerifiedEct>;
/// The writer's configured assurance level (L1 unsigned, L2 JOSE-signed,
/// L3 signed + audit-ledger anchor). Per ADR-0021 this lives on the
/// writer, not the `Capability` descriptor.
fn assurance(&self) -> Assurance;
}
pub enum Assurance { L1, L2, L3 }
pub struct ActivityRecord {
pub activity_iri: IriBuf,
pub activity_type: IriBuf, // e.g., capability IRI
pub agent: IriBuf, // principal DID
pub used: Vec<IriBuf>, // input entities
pub generated: Vec<IriBuf>, // output entities
pub started_at: DateTime<Utc>,
pub ended_at: DateTime<Utc>,
pub derived_from: Vec<(IriBuf, IriBuf)>, // (new, source)
pub outcome: Outcome, // Success | Denied | Failed
}
pub enum Outcome { Success, Denied, Failed }
Deny-path invariant (ADR-0009, ADR-0013). When policy denies an invocation
after the handler has already started to run, ProvenanceWriter MUST still
emit a prov:Activity with trails:outcome "denied". The kernel convention is
that the dispatch coordinator (§3.2.4a) orchestrates this: the prov:denied
record is emitted unconditionally (outside the dispatch rollback), so denied
invocations are never silently dropped from the audit trail.
3.2.4a Dispatch coordinator — cross-subsystem transaction¶
The dispatch coordinator owns the cross-subsystem transaction for a
capability invocation. It is the single authority that sequences
validation, policy, cost, handler, provenance, and ECT emission. It
lives in trails-caps and coordinates trails-graph, trails-shapes,
trails-policy, trails-prov, trails-cost, and trails-identity.
#[async_trait::async_trait]
pub trait DispatchCoordinator: Send + Sync {
async fn begin(
&self,
cap: &Capability,
principal: &IriBuf,
input: serde_json::Value,
) -> Result<Dispatch>;
}
pub struct Dispatch { /* handle carrying txn, snapshot, envelope, act, ect */ }
impl Dispatch {
pub async fn validate_input(&mut self) -> Result<()>;
pub async fn check_policy_pre(&mut self) -> Result<()>;
pub async fn open_cost_envelope(&mut self) -> Result<()>;
pub async fn run_handler<F, O>(&mut self, f: F) -> Result<O>
where F: FnOnce(&mut DispatchCtx) -> BoxFuture<Result<O>> + Send;
pub async fn validate_output(&mut self, out: &Output) -> Result<()>;
pub async fn record_prov_and_ect(&mut self) -> Result<EctToken>;
pub async fn close_cost_envelope(&mut self, actual: CostActual) -> Result<CostReceipt>;
pub async fn commit_or_rollback(self, outcome: Outcome) -> Result<DispatchReceipt>;
}
Ownership and atomicity. The coordinator opens one graph transaction and
a cost envelope, takes a read-snapshot the policy engine and handler share
(TOCTOU mitigation, §3.10 #6), and drives the lifecycle in a fixed order. If
any step fails after graph writes, all writes — including provenance —
are rolled back except the prov:denied / prov:failed record, which is
emitted on a separate write path so the audit trail cannot be erased by the
same transaction that caused the failure. close_cost_envelope is Drop-safe:
if the Dispatch is dropped without closing, a Drop impl reclaims the
reserved budget (§3.10 #5).
3.2.4b Signer — key custody for ACT issuance and ECT signing¶
Owned by trails-identity. Used by ACT issuance (capability mandates) and
ECT signing at L2/L3 per ADR-0013. The JOSE key material is held in the
Signer implementation; no other crate accesses it directly.
pub trait Signer: Send + Sync {
fn sign(&self, payload: &[u8], key_id: &str) -> Result<Signature>;
fn verify(&self, payload: &[u8], sig: &Signature, key_ref: &str) -> Result<()>;
fn rotate(&mut self, new_key: KeyMaterial) -> Result<()>;
}
3.2.4c Reasoner — opt-in entailment, feature-detected from ontology¶
Owned by trails-reason. Reasoning is not a declaration on the
capability; it is feature-detected from what the loaded ontology
contains (ADR-0021). If owl:Class / rdfs:subClassOf triples are
present, the reasoner runs on the affected named graphs; otherwise
this crate is inert. Invalidated by trails-graph on writes via an
event hook (the graph layer emits a commit event; the reasoner
subscribes and marks affected :inferred/<graph> caches stale per
ADR-0004).
pub trait Reasoner: Send + Sync {
fn entail(&self, graph: &IriBuf) -> Result<Vec<Quad>>;
fn invalidate(&self, graph: &IriBuf) -> Result<()>;
}
The reasoner does not hold a direct reference to the graph; it
receives invalidation events from the coordinator or from
trails-graph's commit bus. This avoids a layering cycle
(graph → reason → graph).
3.2.4d CapabilityMiddleware — cross-cutting pre/post hooks¶
For cross-cutting concerns: rate limiting, audit, tracing. Registered
via the app builder in the surface (runtime.py) or the Python
decorators @before / @after / @on_error / @around. Middleware is
confined to pre/post hooks — it has no access to a skip
primitive and cannot short-circuit policy or validation (which live in
the coordinator, §3.2.4a).
#[async_trait::async_trait]
pub trait CapabilityMiddleware: Send + Sync {
async fn before(&self, dispatch: &Dispatch) -> Result<()>;
async fn after(&self, dispatch: &Dispatch, outcome: &Outcome) -> Result<()>;
}
3.2.5 trails-caps — Capability descriptor¶
Canonical field set (source of truth; §3.3.2 Python decorator and §3.4.2 JSON
wire format MUST match this order and these names, per ADR-0005 as reframed by
ADR-0021). The descriptor no longer carries assurance, version_status, or
reasoning: assurance belongs on the ProvenanceWriter, version_status is a
registry-admin concern derived from deprecates, and reasoning is
feature-detected from the loaded ontology.
pub struct Capability {
pub id: String,
pub version: semver::Version,
pub description: String,
pub input_shape: IriBuf,
pub output_shape: IriBuf,
pub preconditions: Vec<Precondition>,
pub side_effects: SideEffects,
pub cost: CostEstimate,
pub policy_required: Vec<String>,
pub idempotent: bool,
pub deprecates: Option<String>,
}
// Alias for historical references; the preferred name is `Capability`.
pub type CapabilityDescriptor = Capability;
pub struct SideEffects {
pub graph_writes: Vec<IriBuf>,
pub prov_records: bool,
pub regulated: bool, // ADR-0013: regulated deployments pick L3 at the writer
}
pub trait ManifestProjector {
fn to_mcp_tool(&self, d: &Capability) -> mcp::Tool;
fn to_openapi(&self, d: &Capability) -> openapi::Operation;
fn to_jsonld(&self, d: &Capability) -> serde_json::Value;
}
3.2.6 trails-cost¶
pub trait CostAccountant: Send + Sync {
fn open(&self, cap: &str, principal: &IriBuf, est: &CostEstimate) -> Result<Envelope>;
fn close(&self, env: Envelope, actual: CostActual) -> Result<CostReceipt>;
fn check_budget(&self, principal: &IriBuf, cap: &str) -> Result<BudgetStatus>;
}
pub struct CostEstimate {
pub tokens_estimate: u32,
pub usd_estimate: f64,
pub latency_p50_ms: u32,
pub latency_p99_ms: u32,
}
pub struct CostActual {
pub tokens: u32,
pub usd: f64,
pub latency_ms: u32,
}
3.2.7 trails-identity¶
Per ADR-0013, ACT is the canonical capability mandate; biscuit is retained for attenuation under a parent ACT. The trait exposes both, plus DID resolution and VC verification.
pub trait IdentityResolver: Send + Sync {
fn resolve(&self, did: &str) -> Result<DidDocument>;
fn verify_vc(&self, vc: &str) -> Result<VerifiedClaim>;
// ACT — canonical capability mandate (ADR-0013).
fn issue_act(&self, claims: &ActClaims, key_id: &str) -> Result<ActToken>;
/// Verifies signature, `aud == audience`, non-empty `jti` (replay cache
/// check), and `nbf <= now < exp` with a +-60 s skew window.
fn verify_act(&self, token: &ActToken, audience: &str) -> Result<VerifiedAct>;
fn revoke_act(&self, jti: &str) -> Result<()>;
// Biscuit — attenuation layer under a parent ACT (ADR-0010, as updated by ADR-0013).
fn verify_biscuit(&self, token: &[u8], authorizer: &str) -> Result<Authorization>;
fn issue_biscuit(&self, claims: &[Claim], root_key: &[u8]) -> Result<Vec<u8>>;
}
3.3 Python surface API¶
3.3.1 The ladder: bare decorator → node-type → shape → OWL¶
All four rungs share one surface. The same app can mix them freely; adding a rung never forces a rewrite (ADR-0021).
Rung 1 — bare @capability, plain ctx.kg¶
# examples/hello.py
import trails
from trails import capability
@capability
def greet(ctx, name: str) -> str:
ctx.kg.add({"name": name, "role": "guest"}) # label-only write
return f"Hello, {name}"
if __name__ == "__main__":
print(trails.invoke("greet", {"name": "world"}))
No IRIs, no shapes, no ontology. Cedar, PROV-O, and SHACL all see a label-only node and act on labels alone.
Rung 2 — @node_type for JSON-Schema validation¶
# examples/hello-orm.py
import trails
from trails import capability, node_type
@node_type("Greeting", fields={"name": str, "times": int})
class Greeting:
pass
@capability
def greet(ctx, name: str, times: int) -> dict:
g = Greeting(name=name, times=times)
ctx.kg.add(g) # validated via JSON-Schema
return {"id": g.id}
@capability
def recall(ctx) -> list[dict]:
rows = Greeting.where().fetch(ctx)
return [{"id": r.id, "name": r.name, "times": r.times} for r in rows]
@node_type is additive: the greet from Rung 1 still works; the
only change is that writes to Greeting-labelled nodes now get
JSON-Schema validation.
Rung 3 — @shape for closed-world SHACL validation¶
from datetime import date
from trails import capability, shape, predicate
@shape # IRI auto-minted from `trails.toml` base_iri
class Patient:
name: str = predicate("schema:name")
dob: date = predicate("schema:birthDate", min_value=date(1900, 1, 1))
allergies: list[str] = predicate("myapp:hasAllergy", min_length=0)
@capability
def intake(ctx, name: str, dob: date) -> Patient:
p = Patient(name=name, dob=dob, allergies=[])
ctx.kg.add(p) # SHACL + JSON-Schema checks run
return p
@shape registers a SHACL shape with the kernel's Validator
(§3.2.2). trails onto export materialises the shape to
ontology/generated.ttl. Existing Rung-1 and Rung-2 code in the same
app is untouched.
Rung 4 — OWL for reasoning and cross-system interop¶
# ontology/vocab.ttl
@prefix : <https://myapp.example/ns/> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
:Admin rdfs:subClassOf :User .
:User a owl:Class .
No handler change. The reasoner feature-detects owl:Class +
rdfs:subClassOf in the loaded ontology and begins materialising
entailments on the affected named graphs (trails-reason, §3.2.4c).
3.3.2 Capability declaration — full form¶
The same decorator with keyword arguments produces a full rich
descriptor (ADR-0005). Every argument except id has a default; there
is no assurance, reasoning, or version_status field on the
descriptor (see §3.2.5).
from datetime import date
from pydantic import BaseModel
from trails import capability, node_type, policy
class PatientIntakeInput(BaseModel):
name: str
dob: date
consent_vc: str # verifiable credential
@node_type("Patient", fields={"name": str, "dob": date})
class Patient:
pass
@capability(
id="patient.intake",
version="1.0.0",
description="Register a new patient with verified consent.",
input_shape=PatientIntakeInput,
output_shape=Patient,
preconditions=["identity.verified", "consent.valid_for:patient.intake"],
side_effects={
"graph_writes": ["myapp:patients"],
"prov_records": True,
"regulated": False,
},
cost={
"tokens_estimate": 0,
"usd_estimate": 0.0001,
"latency_p50_ms": 20,
"latency_p99_ms": 120,
},
policy_required=["consent.cedar::intake_allowed"],
idempotent=False,
deprecates=None,
)
@policy("consent.cedar::intake_allowed")
def intake(ctx, name: str, dob: date, consent_vc: str) -> Patient:
p = Patient(name=name, dob=dob)
ctx.kg.add(p) # provenance auto-attached
return p
The ctx handler parameter is a per-invocation context object
injected by the framework (principal DID, trace ID, named-graph scope,
ctx.kg, optional ctx.llm). It is not a module-level import.
Declaring side_effects.regulated: True (see ADR-0013) signals that
the configured ProvenanceWriter should default to Assurance::L3
for this capability; the field lives on the writer, not the
descriptor.
3.3.3 Middleware¶
Cross-cutting concerns are written as decorators registered against capability ids or glob patterns. They cannot skip the dispatch spine.
from trails import after, around, before, on_error
@before("notes.*")
def audit_before(ctx, name: str, body: str) -> None:
ctx.log.info("about to invoke", cap=ctx.capability_id, principal=ctx.principal)
@after("notes.*")
def audit_after(ctx, result) -> None:
ctx.log.info("invocation ok", cap=ctx.capability_id)
@on_error("*")
def on_any_error(ctx, exc: Exception) -> None:
ctx.log.error("invocation failed", cap=ctx.capability_id, error=str(exc))
@around("notes.publish")
async def wrap_publish(ctx, call):
async with ctx.tracer.span("notes.publish"):
return await call()
See guides/middleware.md.
3.3.4 Policy file (Cedar)¶
// policies/consent.cedar
permit(
principal,
action == Action::"capability:patient.intake",
resource
)
when {
principal.has_valid_vc("ConsentForPatientIntake") &&
principal.consent_scope.contains(resource.org)
};
Cedar sees resource typed at the strongest available level
(ADR-0022): if Patient is declared as a @shape, resource attributes
include the SHACL shape IRI and all its predicates; if only
@node_type("Patient", ...) is declared, resource attributes come
from the JSON-Schema fields; if only a label is present, only the
label is exposed. Policy authors write one rule and the resolver
picks the right typing view.
3.3.5 Bi-modal template¶
{# views/patient.jinja.md — rendered as Markdown for humans, JSON-LD for agents #}
# Patient: {{ patient.name }}
Born: {{ patient.dob | date }}
Allergies: {{ patient.allergies | join(", ") or "none on file" }}
The JSON-LD projection is auto-generated from the node type or shape; this template only governs the human view.
3.3.6 Dev REPL¶
$ trails console
trails> from trails import invoke
trails> result = invoke("patient.intake", {"name": "A", "dob": "1980-01-01", "consent_vc": "..."})
trails> result["payload"]["id"]
'trails://patient/018f...'
trails> result["prov_iri"]
'myapp:prov/act-0192...'
trails kg query 'SELECT ?p WHERE { ?p a myapp:Patient }' debugs the
graph without writing Python.
3.4 Wire formats¶
3.4.1 Capability invocation envelope (response)¶
{
"@context": "https://trails.dev/context/v1",
"payload": { "@id": "myapp:patient/018f...", "name": "...", "dob": "..." },
"provenance": { "@id": "myapp:prov/act-0192...", "@type": "prov:Activity" },
"cost": { "tokens": 0, "usd": 0.0001, "latency_ms": 18 },
"consent_receipt": { "@id": "myapp:consent/r-0193..." },
"trace_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"capability": "patient.intake@1.0.0"
}
3.4.2 Capability descriptor (canonical form, JSON-LD)¶
{
"@context": "https://trails.dev/context/capability/v1",
"@id": "myapp:cap/patient.intake",
"@type": "trails:Capability",
"id": "patient.intake",
"version": "1.0.0",
"description": "Register a new patient with verified consent.",
"input_shape": { "@id": "myapp:PatientIntakeInput" },
"output_shape": { "@id": "myapp:Patient" },
"preconditions": [
{ "@type": "trails:Precondition", "kind": "identity.verified" },
{ "@type": "trails:Precondition", "kind": "consent.valid_for",
"scope": "patient.intake" }
],
"side_effects": {
"graph_writes": ["myapp:patients"],
"prov_records": true,
"regulated": false
},
"cost": {
"tokens_estimate": 0,
"usd_estimate": 0.0001,
"latency_p50_ms": 20,
"latency_p99_ms": 120
},
"policy_required": ["consent.cedar::intake_allowed"],
"idempotent": false,
"deprecates": null
}
3.4.3 MCP projection (for agents that only speak MCP)¶
{
"name": "patient.intake",
"description": "Register a new patient. Requires valid consent VC.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"dob": { "type": "string", "format": "date" },
"consent_vc": { "type": "string" }
},
"required": ["name", "dob", "consent_vc"]
}
}
The MCP server also projects @resource-decorated readers under
resources/list + resources/read (with resources/subscribe
emitting list_changed notifications) and @prompt-decorated
templates under prompts/list + prompts/get.
3.5 Configuration (trails.toml)¶
[app]
name = "patient-intake"
iri_base = "https://myapp.example/"
[ontology]
imports = ["schema.org", "foaf", "prov", "sosa"]
export_path = "ontology/generated.ttl"
[iri.Patient]
strategy = "uuidv7"
[iri.Observation]
strategy = "content"
fields = ["subject", "timestamp", "value"]
[backend.graph]
kind = "oxigraph"
path = ".trails/graph"
[backend.vector]
kind = "sqlite-vec" # sqlite-vec (embedded) | qdrant
path = ".trails/vectors.db"
[transport.mcp]
enabled = true
mode = "stdio" # stdio | sse | http
path = "/.well-known/mcp"
[transport.http]
enabled = true
port = 8000
[policy]
dir = "policies/"
mode = "strict" # strict | warn | off
[cost]
default_budget_per_principal_usd = 1.00
default_budget_per_capability_usd = 0.10
anomaly_threshold_p99_multiplier = 3.0
[identity]
did_methods = ["did:key", "did:web"]
biscuit_root_key_env = "TRAILS_BISCUIT_KEY"
[provenance]
assurance = "L1" # "L1" | "L2" | "L3" — lives on the writer
3.6 CLI commands¶
| Command | Purpose |
|---|---|
trails new <name> [--template minimal\|agent\|kg\|full] |
Scaffold a new project |
trails g cap\|sh\|res <name> [fields...] |
Generate capability, shape, or resource |
trails server [--watch] |
Unified server — autoloads app/capabilities + app/shapes, stdio MCP or HTTP |
trails dev |
Dev server with auto-reload |
trails http |
HTTP/FastAPI server explicitly |
trails console |
REPL preloaded with Q, planners, LLMClient, ctx |
trails kg query\|ask\|dump\|count |
Ad-hoc graph debugging |
trails onto export |
Emit SHACL/OWL from Python @shape classes |
trails onto evolve |
Interactive ontology migration |
trails routes |
Inspect registered capabilities |
trails prov list\|show\|timeline |
Browse provenance records |
trails trace list\|show\|stats\|clear |
Inspect OTLP traces |
trails sim run\|fuzz\|benchmark |
Agent simulation + fuzzing |
trails benchmark … |
Performance smoke (percentiles, JSON output) |
trails registry publish\|search\|list\|show\|remove |
Local capability registry |
trails doctor [--json] |
Health checks across python, venv, port, layout, data dir, FFI version |
3.7 Extension points¶
| Extension | Mechanism | Example |
|---|---|---|
| Graph backend | Implement SyncGraphStore or AsyncGraphStore |
trails-adapters-neptune (future) |
| Reasoner | Implement Reasoner trait |
custom rule set |
| Policy engine | Implement PolicyEngine trait |
OPA/Rego adapter |
| Identity method | Implement IdentityResolver |
new DID method |
| Cost accountant | Implement CostAccountant |
Stripe-backed billing |
| LLM provider | Implement LLMClient adapter |
new model provider |
| Vector store | Implement VectorStore (SqliteVecStore / QdrantStore are reference impls) |
new DB |
| Middleware | @before / @after / @on_error / @around |
audit, rate limit, tracing |
| Renderer | Custom Jinja filters / bi-modal templates | domain-specific views |
3.8 Security model¶
Threat surfaces¶
- Untrusted input: validated before the graph reaches it (JSON-Schema from
@node_type, SHACL from@shape, strongest-available-type). - Untrusted SPARQL: parameterized, no string concat at framework layer; default wall-clock timeout 2 s and memory cap 256 MB.
- Token forgery: ACT/ECT verification in kernel, not userland; biscuit verification kernel-side under a parent ACT.
- Replay:
jticache keyed toexprejects replayed ACTs and ECTs. - Algorithm downgrade: JWT/VC verification restricted to an explicit allowlist (
ES256,ES384,EdDSA);alg=noneand HS* rejected at parse. - DID spoofing:
did:webresolution requires TLS 1.3, pinned trust roots, and DNSSEC-or-content-hash-TOFU with rotation audit. - Policy bypass: framework enforces PEP before handler body; no opt-out; middleware cannot skip.
- TOCTOU: policy and handler read from the same graph snapshot for one dispatch; policy decision is cached for that dispatch's lifetime.
- Biscuit Datalog DoS: attenuation depth <= 5, rule count <= 16, verification wall-clock <= 1 ms, fact count <= 1000, nesting depth <= 128.
- Cost DoS: envelopes enforced pre-handler; streaming capabilities report in-flight cost and are aborted on overrun.
- FFI panics: every PyO3 entry point wraps kernel calls in
catch_unwind; a panic becomes aTrailsError, never an interpreter abort.
Secrets handling¶
- Env var first, pluggable secret providers (Vault, AWS Secrets Manager, 1Password Connect).
- Never in config files committed to Git.
- Key rotation bounds: biscuit/ACT signing-key overlap period default 24 h, maximum 72 h.
- Signing keys held only by the
Signertrait impl (§3.2.4b); no other crate reads key material directly.
Audit¶
- PROV-O graph = queryable audit log;
prov:named graph is write-protected (kernel only). - Policy decision log append-only, signed, hash-chained.
- Capability invocation log OTLP-exported.
- Consent receipts external (user-owned wallet).
3.9 Testing affordances (summary)¶
See 06-test-plan.md for full test strategy. Highlights:
- Stdlib-only
trails.testing:isolated_kernel,mock_llm,capture_events,fresh_context— importable always, no optional deps. - Shape-pinned assertions:
assert_shape(response, Patient)— tolerates field reordering, extra predicates, different IRIs of correct type. - Agent simulator:
trails sim run --model mocklaunches a cheap local agent that invokes random capabilities with plausible inputs. - Golden provenance: capture PROV-O subgraph per capability, diff against baseline on change.
- Policy tests: Cedar policies have dedicated
.cedar-testformat; framework runs them in CI.
3.10 Invariants¶
These hold across the kernel and are enforced at trait level (type, RAII guard, or structural check) wherever feasible. Test bands in 06-test-plan.md reference these numbers.
- Single-shape contract. Every capability has exactly one input shape and
one output shape (may be
Unit). Unions are not supported at the trait boundary; apps that need them expose a disambiguating capability pair. - Lifecycle ordering. Shape validation precedes policy check; policy check
precedes handler invocation; provenance is recorded atomically with handler
completion. Middleware
@before/@after/@on_error/@aroundruns around this spine without skip power (§3.2.4d). prov:graph write-protection. Theprov:named graph is write-protected; onlytrails-provmay write to it.GraphStorerefuses principal writes whose target isprov:at the trait boundary (defence in depth, Cedar policy additionally denies).- Audit on deny. Every capability invocation emits at least one PROV-O
activity, even on deny — the
prov:deniedrecord is emitted unconditionally by the dispatch coordinator outside the dispatch rollback (§3.2.4, §3.2.4a). - Cost-envelope symmetry. A cost envelope must be closed iff it was
opened;
Dispatchholds theEnvelopeas aDrop-safe guard that reclaims reserved budget on panic. - Policy-decision snapshot. Policy decision is cached for the duration of a single dispatch. Policy and handler share one read-snapshot of the graph (TOCTOU mitigation).
- ACT before policy. ACT verification precedes policy evaluation; Cedar
policies may reference
VerifiedAct.permitted_actionsbut never bypass ACT claims. - ECT key selection. ECT signing uses the key identified by the
configured writer's assurance level (
L1-> unsigned;L2-> default L2 key;L3-> regulated-deployment key + audit-ledger anchor). TheSignerimpl (§3.2.4b) resolves the key ref from theProvenanceWriter, not from the descriptor. - L1 export confinement. L1 ECT records never leave the originating trust
domain without explicit operator opt-in;
allow_export_across_domain: falseis the default per named graph (ADR-0009 update (b)). - FFI panic containment. FFI panics in the kernel do not crash the
Python process; every PyO3 entry point translates a Rust
panic!into aTrailsErrorviacatch_unwind. - Strongest-available-type matching. Cedar, SHACL, and PROV-O inspect each entity and act on the richest typing present (RDF class > SHACL shape > JSON-Schema type > label). No tier flag on the call site (ADR-0021, ADR-0022).
3.11 Error taxonomy (Rust)¶
TrailsError is the shared error type across kernel crates. It lives in a
trails-types (or equivalent shared) crate so that every trait returns
Result<T, TrailsError> and the FFI edge (§3.8, §3.10 #10) maps variants to a
Python trails.TrailsError with structured context.
#[derive(Debug, thiserror::Error)]
pub enum TrailsError {
#[error("validation: {constraint} at {path} on {focus}")]
Validation { focus: IriBuf, path: IriBuf, constraint: IriBuf, value: Option<String>, message: String },
#[error("authentication: {reason}")]
Authentication { reason: String, jti: Option<String> },
#[error("authorization: {decision} by {policy}")]
Authorization { decision: String, policy: String, diagnostics: Vec<String> },
#[error("precondition: {kind} failed ({scope:?})")]
Precondition { kind: String, scope: Option<String> },
#[error("budget exceeded: {kind} over {limit}")]
BudgetExceeded { kind: String, limit: String, observed: String },
#[error("handler error: {source}")]
Handler { #[source] source: Box<dyn std::error::Error + Send + Sync> },
#[error("backend: {backend}: {message}")]
Backend { backend: String, iri: Option<IriBuf>, message: String },
#[error("signing: {operation} with {key_ref}")]
Signing { operation: String, key_ref: String, message: String },
#[error("replay: jti {jti} already seen")]
Replay { jti: String, audience: String },
#[error("panic: {message}")]
Panic { message: String, frame: Option<String> },
#[error("internal: {message}")]
Internal { message: String },
}
Each variant carries structured context (field, constraint, iri, jti, key_ref,
etc.) so the FFI edge can surface it to Python as typed attributes on the
exception (see §3.8 and the 02-architecture.md FFI section for the full
Python exception hierarchy mapping).