Skip to content

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:

  • GraphStore has sync AND async variants. Two traits: SyncGraphStore (blocking) and AsyncGraphStore (async fn methods). The Oxigraph adapter implements SyncGraphStore natively (RocksDB is blocking) and wraps itself in spawn_blocking to satisfy AsyncGraphStore. Qlever and Fuseki adapters are async-native (HTTP) and implement AsyncGraphStore directly.
  • 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-runtimes to bridge Python asyncio to the Tokio runtime that drives AsyncGraphStore and the coordinator. @capability-decorated Python functions may be declared either async def or plain def; the framework normalizes both to async internally and awaits sync handlers inside spawn_blocking.
  • Implication for adapters. New graph backends SHOULD implement AsyncGraphStore directly when they are I/O-bound; embedded/blocking backends MAY implement only SyncGraphStore and rely on the kernel's spawn_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-capsCapability 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: jti cache keyed to exp rejects replayed ACTs and ECTs.
  • Algorithm downgrade: JWT/VC verification restricted to an explicit allowlist (ES256, ES384, EdDSA); alg=none and HS* rejected at parse.
  • DID spoofing: did:web resolution 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 a TrailsError, 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 Signer trait 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 mock launches 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-test format; 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.

  1. 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.
  2. Lifecycle ordering. Shape validation precedes policy check; policy check precedes handler invocation; provenance is recorded atomically with handler completion. Middleware @before / @after / @on_error / @around runs around this spine without skip power (§3.2.4d).
  3. prov: graph write-protection. The prov: named graph is write-protected; only trails-prov may write to it. GraphStore refuses principal writes whose target is prov: at the trait boundary (defence in depth, Cedar policy additionally denies).
  4. Audit on deny. Every capability invocation emits at least one PROV-O activity, even on deny — the prov:denied record is emitted unconditionally by the dispatch coordinator outside the dispatch rollback (§3.2.4, §3.2.4a).
  5. Cost-envelope symmetry. A cost envelope must be closed iff it was opened; Dispatch holds the Envelope as a Drop-safe guard that reclaims reserved budget on panic.
  6. 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).
  7. ACT before policy. ACT verification precedes policy evaluation; Cedar policies may reference VerifiedAct.permitted_actions but never bypass ACT claims.
  8. 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). The Signer impl (§3.2.4b) resolves the key ref from the ProvenanceWriter, not from the descriptor.
  9. L1 export confinement. L1 ECT records never leave the originating trust domain without explicit operator opt-in; allow_export_across_domain: false is the default per named graph (ADR-0009 update (b)).
  10. FFI panic containment. FFI panics in the kernel do not crash the Python process; every PyO3 entry point translates a Rust panic! into a TrailsError via catch_unwind.
  11. 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).