Chapter 12 -- WoT Integration and Memory Security¶
Your Trails agent can do useful things. But can other agents find it? Can it protect the knowledge it accumulates? This chapter covers two systems that answer those questions: the Web of Things (WoT) integration layer that makes your agent discoverable by machines, and the memory security stack that prevents agents from spoofing, poisoning, or leaking shared knowledge.
Both systems are additive. An app that never publishes a WoT Thing Description works fine. An app that never uses the memory gateway still stores facts. But when you need discoverability and trust, both are ready -- and they compose naturally.
Learning objectives¶
After reading this chapter, you will be able to:
- Model your agent as a WoT Thing with
AgentCard - Serialize capabilities as W3C Thing Descriptions
- Expose a
/.well-known/wotdiscovery endpoint - Use the
trails wot describeCLI to inspect your agent's TD - Stand up a
MemoryGatewaythat enforces identity and confidence - Configure trust levels, data classifications, and read policies
- Detect tampering with provenance hash chains
- Control what facts cross federation boundaries
Why WoT?¶
The W3C Web of Things (WoT) standard defines a machine-readable format for describing what a device -- or in our case, an agent -- can do. A Thing Description (TD) lists actions, their inputs, their outputs, and security requirements. Any WoT-aware consumer (another agent, an IoT gateway, a discovery service) can parse the TD and know how to invoke your agent without reading your source code.
Trails maps each @capability to a td:ActionAffordance. The mapping
is mechanical and happens at serialization time -- your capability code
does not change. WoT is a projection of the same metadata that MCP uses,
not a replacement (ADR-0015a).
AgentCard: modeling your agent as a WoT Thing¶
AgentCard is the central type. It collects all registered capabilities,
converts them to WoT actions, and serializes the result as a W3C TD 1.1
JSON-LD document.
Building from the registry¶
The simplest path: let AgentCard scan the live capability registry.
from trails import capability
from trails.wot import AgentCard
@capability(id="notes.create", description="Create a note")
def create_note(ctx, title: str, body: str) -> dict:
note = Note(title=title, body=body)
ctx.kg.add(note)
return {"id": note.id}
@capability(id="notes.search", description="Search notes", idempotent=True)
def search_notes(ctx, q: str) -> list:
return Note.where(title__icontains=q).fetch(ctx)
# Build the card -- picks up both capabilities automatically
card = AgentCard.from_registry(
base_iri="https://notes.example/agents/main",
title="Notes Agent",
description="A simple note-taking agent",
)
print(card)
# <AgentCard id='https://notes.example/agents/main' actions=2>
No tool list, no manual registration. Every @capability in the
process is included.
With MCP endpoint¶
When your agent speaks MCP, pass the endpoint to get td:Form entries
on each action. Consumers see exactly how to invoke capabilities over
the MCP transport:
card = AgentCard.from_registry(
base_iri="https://notes.example/agents/main",
mcp_endpoint="mcp://localhost:8080",
)
td = card.to_td()
# Each action now has:
# "forms": [{"href": "mcp://localhost:8080/tools/call",
# "op": "invokeaction", ...}]
Direct construction (for tests)¶
When you need a card without a running registry -- unit tests, offline documentation -- construct it directly:
card = AgentCard(
agent_id="did:web:example.org:agents:test",
title="Test Agent",
actions={
"greet": {
"description": "Say hello",
"idempotent": True,
"input": {"type": "object"},
},
},
)
Thing Descriptions: serialization¶
AgentCard offers two serialization methods.
to_td() -- W3C TD 1.1¶
Returns a dict conforming to the TD 1.1 structure. This is what
/.well-known/wot serves and what WoT consumers expect:
{
"@context": [
"https://www.w3.org/2022/wot/td/v1.1",
{
"trails": "https://trails.dev/ont/core#",
"prov": "http://www.w3.org/ns/prov#",
"sh": "http://www.w3.org/ns/shacl#"
}
],
"@type": ["trails:AgentCard", "Thing"],
"id": "https://notes.example/agents/main",
"title": "Notes Agent",
"securityDefinitions": {
"biscuit_sc": {"scheme": "bearer", "format": "biscuit"}
},
"security": "biscuit_sc",
"actions": {
"notes.create": {
"description": "Create a note",
"idempotent": false
},
"notes.search": {
"description": "Search notes",
"idempotent": true
}
}
}
to_jsonld() -- full JSON-LD with extensions¶
A superset of to_td() that includes extra metadata you attach to
card.extra. Use this when you need to advertise custom endpoints:
card.extra["trails:endpoint"] = "https://notes.example/mcp"
doc = card.to_jsonld()
# doc["trails:endpoint"] == "https://notes.example/mcp"
Capability-to-action mapping¶
Each Trails field maps to a WoT or namespaced field:
| Trails field | WoT field | Notes |
|---|---|---|
id |
action key | Dict key, not a TD field |
description |
td:description |
Direct |
input_shape |
td:input (sh:node) |
SHACL IRI preserved |
idempotent |
td:idempotent |
Boolean, TD 1.1 native |
side_effects |
td:safe + trails:sideEffect |
Non-empty = safe: false |
cost_estimate |
trails:costEstimate |
Trails extension |
policy_required |
trails:policyRequired |
Trails extension |
reasoning |
trails:reasoningMode |
none / rdfs / owl-rl |
Fields that have no WoT equivalent ride as trails: namespaced
extensions. The TD stays valid; WoT consumers that do not understand
trails: simply ignore the extensions.
Discovery: the /.well-known/wot endpoint¶
The WoT Discovery module exposes two HTTP routes conforming to W3C WoT Discovery:
| Method | Path | Content-Type | Returns |
|---|---|---|---|
GET |
/.well-known/wot |
application/td+json |
Host TD (directory) |
GET |
/agents/{agent_id}/td |
application/td+json |
Individual agent TD |
Mounting the router¶
from fastapi import FastAPI
from trails.wot_discovery import create_wot_router
app = FastAPI()
app.include_router(create_wot_router())
# Now:
# GET /.well-known/wot -> host TD with links to all capabilities
# GET /agents/notes.create/td -> individual TD for notes.create
How the host TD works¶
The host TD's links[] array enumerates all registered capabilities.
Each link points to the individual agent TD endpoint:
from trails.wot_discovery import build_host_td
td = build_host_td()
# td["links"] == [
# {"rel": "item", "href": "trails://myapp/agents/notes.create",
# "type": "application/td+json"},
# {"rel": "item", "href": "trails://myapp/agents/notes.search",
# "type": "application/td+json"},
# ]
The base IRI is resolved from trails.toml ([project].base_iri),
falling back to trails://<project.name>/, then trails://local.
Security definitions¶
Security is auto-detected:
- If
TRAILS_HTTP_TOKENis set or[http].auth_tokenis configured, the TD usesbearer_sc(HTTP bearer). - Otherwise,
nosec_sc(no security).
CLI: trails wot describe¶
Inspect your agent's TD from the command line without starting a server:
# Print the host TD
trails wot describe
# Print a specific agent's TD
trails wot describe --agent notes.create
# Override base IRI
trails wot describe --base-iri https://myapp.example
# Compact JSON (pipe-friendly)
trails wot describe --compact
# Filter with jq
trails wot describe | jq '.actions | keys'
# List all registered agents
trails wot list
Both commands auto-discover capabilities from the current project directory before generating output.
Example: publishing your agent to the WoT ecosystem¶
A complete working setup -- capabilities, AgentCard, discovery endpoint:
from fastapi import FastAPI
from trails import capability
from trails.wot import AgentCard
from trails.wot_discovery import create_wot_router
@capability(id="evidence.search",
description="Search evidence by topic")
def search(ctx, topic: str) -> dict:
hits = ctx.kg.match(labels=["Evidence"],
where={"topic": topic})
return {"hits": hits}
@capability(id="evidence.summarize",
description="Summarize evidence",
idempotent=True)
def summarize(ctx, evidence_id: str) -> dict:
return {"summary": "..."}
# Build and inspect the card
card = AgentCard.from_registry(
base_iri="https://evidence.example/agents/main",
title="Evidence Agent",
description="Search and summarize clinical evidence",
mcp_endpoint="https://evidence.example/mcp",
)
import json
print(json.dumps(card.to_td(), indent=2))
# Mount the discovery endpoint
app = FastAPI()
app.include_router(create_wot_router())
# Run: uvicorn app:app --port 8000
# Then: curl http://localhost:8000/.well-known/wot
A remote agent or IoT gateway can now fetch
http://localhost:8000/.well-known/wot, parse the TD, discover
evidence.search and evidence.summarize, read their input schemas,
and invoke them via the MCP transport.
Why memory security matters¶
Agent memory is a high-value target. A compromised or misbehaving agent can:
- Spoof identities -- claim another agent's DID to gain trust.
- Inflate confidence -- set
confidence: 1.0to dominate recall. - Poison shared knowledge -- write false facts that other agents consume.
- Leak confidential data -- export restricted facts to untrusted federation peers.
- Hide taint -- inferences based on retracted facts remain trusted.
The memory security layer prevents these attacks at the framework level,
transparently to agents using trails.invoke().
MemoryGateway: the trusted intermediary¶
All memory operations pass through MemoryGateway. The gateway sits
between the agent and the MemoryStore and enforces six invariants:
from trails.memory_security import MemoryGateway, MemorySession
session = MemorySession(
principal_did="did:key:alice",
trust_level="authenticated",
)
gw = MemoryGateway(memory_store, session)
# Learn -- gateway injects identity, caps confidence, chains provenance
iri = gw.learn(
"Deploy key rotates weekly",
confidence_hint=0.9,
topic="ops",
source="observed in CI config",
ttl_seconds=86400, # expires in 24 hours
)
# Recall -- delegates to store, adds audit entry
facts = gw.recall("deploy key rotation", topic="ops")
# Correct -- enforces authorization for cross-agent corrections
gw.correct(iri, "Deploy key rotates daily", reason="updated schedule")
# Forget -- soft delete with full audit trail
gw.forget(iri, reason="no longer relevant")
What the gateway enforces on every operation:
- Identity injection.
agent_did,timestamp, and provenance are set from the authenticatedMemorySession. Agents cannot override these fields. - Confidence calibration.
confidence_hintis capped by the agent's trust level. - Correction authorization. Cross-agent corrections require explicit Cedar policy.
- Provenance hash chain. Every operation appends a SHA-256-chained record for tamper evidence.
- Audit logging. Every operation is logged with agent DID, action, timestamp, and detail.
- TTL tracking. Facts with
ttl_secondsare tracked for expiry-based garbage collection.
Identity enforcement: no spoofing¶
MemorySession carries the agent's DID (extracted from Biscuit token
or MCP handshake) and trust level. The gateway reads these -- agents
cannot set them:
from trails.memory_security import MemorySession
session = MemorySession(
principal_did="did:key:z6Mk...",
trust_level="established",
roles=["analyst"],
)
session.trust_level_float # 0.9
session.is_anonymous # False
Anonymous sessions (did:key:anonymous or empty DID) receive the
lowest trust multiplier (0.3). The gateway reads the session's
principal_did for every learn, correct, and forget call --
there is no parameter that lets the agent override it.
Confidence calibration: trust but verify¶
An agent says "I am 95% confident." The gateway says "your trust level allows at most 70%." The lower value wins.
from trails.memory_security import calibrate_confidence, TrustLevel
# AUTHENTICATED agent capped at 0.7
calibrate_confidence(hint=0.95, agent_did="did:key:alice",
trust_level=TrustLevel.AUTHENTICATED)
# -> 0.7
# ESTABLISHED agent with high correction rate
calibrate_confidence(hint=0.8, agent_did="did:key:bob",
trust_level=TrustLevel.ESTABLISHED,
correction_rate=0.4)
# -> 0.54 (0.9 * max(0.5, 1.0 - 0.4) = 0.54)
Trust levels¶
| Level | Multiplier | When assigned |
|---|---|---|
ANONYMOUS |
0.3 | No DID or did:key:anonymous |
AUTHENTICATED |
0.7 | DID-bearing agent (default) |
ESTABLISHED |
0.9 | Agent with track record |
HUMAN |
1.0 | Human operator |
SYSTEM |
1.0 | Framework-internal operations |
The formula: effective = min(hint, trust_level * max(0.5, 1.0 -
correction_rate)). An agent whose facts are frequently corrected sees
its effective trust decay toward 50% of its base level. This is
automatic -- no manual intervention needed.
Provenance chains: tamper detection¶
Every memory operation appends to a SHA-256 hash chain. Each record's
self_hash covers the record's identity fields plus the prev_hash
of the preceding record:
from trails.memory_security import ProvenanceChain, verify_chain
chain = ProvenanceChain()
# Records are appended automatically by the gateway.
# Manual use for testing:
rec = chain.append(
fact_iri="urn:trails:memory:fact:1",
agent_did="did:key:alice",
action="memory.learn",
timestamp="2026-04-19T10:00:00+00:00",
)
print(rec.self_hash) # sha256:a1b2c3...
print(rec.prev_hash) # sha256:000...000 (genesis)
# Verify the entire chain
valid, broken = chain.verify()
assert valid
assert broken == []
# Or use the convenience wrapper
assert verify_chain(chain)
The gateway exposes verify_provenance() for on-demand verification:
valid, broken_indices = gw.verify_provenance()
if not valid:
print(f"Chain broken at indices: {broken_indices}")
# A CHAIN_BREAK audit event is logged automatically
If anyone tampers with a record (changes content, modifies a timestamp,
reorders entries), the hash chain breaks and verify() reports the
exact indices.
Trust levels: LOCAL, PEER, PUBLIC¶
trails.memory_trust.TrustLevel classifies where a fact originated.
This is a different concept from the agent trust levels in
memory_security -- those measure who wrote the fact, these measure
where it came from:
from trails.memory_trust import TrustLevel
TrustLevel.LOCAL > TrustLevel.PEER # True
TrustLevel.PEER > TrustLevel.PUBLIC # True
| Level | Meaning |
|---|---|
LOCAL |
Produced by agents on this instance |
PEER |
Received from a federated trusted peer |
PUBLIC |
From untrusted or external sources |
Data classification: OPEN through RESTRICTED¶
DataClassification controls which trust levels may read a fact:
| Classification | Who can read | Sensitivity |
|---|---|---|
OPEN |
Any agent (LOCAL, PEER, PUBLIC) | Lowest |
INTERNAL |
LOCAL and PEER agents | |
CONFIDENTIAL |
LOCAL agents only | |
RESTRICTED |
Named agents only (explicit grant) | Highest |
from trails.memory_trust import DataClassification, FactTrustMetadata
meta = FactTrustMetadata(
trust_level=TrustLevel.LOCAL,
classification=DataClassification.CONFIDENTIAL,
)
Topic-scoped read policies¶
TopicScopedReadPolicy restricts which agents can recall facts based
on topic and classification:
from trails.memory_trust import TopicScopedReadPolicy, DataClassification
policy = TopicScopedReadPolicy(
topic_rules={
"financials": DataClassification.CONFIDENTIAL,
"public-docs": DataClassification.OPEN,
},
default_classification=DataClassification.INTERNAL,
agent_clearances={
"did:key:auditor": {
DataClassification.CONFIDENTIAL,
DataClassification.INTERNAL,
},
},
)
# Check access
policy.can_read("did:key:auditor", "financials") # True (explicit clearance)
policy.can_read("did:key:analyst", "financials") # False (no clearance)
policy.can_read("did:key:analyst", "public-docs") # True (OPEN)
# Filter a fact list -- drops facts the agent cannot see
visible = policy.filter_facts(all_facts, agent_did="did:key:analyst")
Cedar policies can be attached for fine-grained control. When provided, Cedar evaluation takes precedence over the default matrix:
policy = TopicScopedReadPolicy(
cedar_policies=[{
"effect": "permit",
"principal": "did:key:analyst",
"action": "memory.recall",
"resource": {"type": "Trails::Memory::Fact", "topic": "financials"},
}],
)
Contamination tracking¶
When a source fact is retracted or found unreliable, all downstream
inferences built on it must be flagged. ContaminationTracker does
this automatically by following supports links in the knowledge graph:
from trails.memory_trust import ContaminationTracker
tracker = ContaminationTracker(store=ctx.kg._store)
# Mark a fact as tainted
tracker.mark_tainted(
"urn:trails:memory:fact:42",
reason="Source paper retracted",
)
# Taint status
tracker.is_tainted("urn:trails:memory:fact:42") # True
# All downstream facts linked via "supports" are transitively tainted
chain = tracker.taint_chain("urn:trails:memory:fact:42")
# ["urn:trails:memory:fact:43", "urn:trails:memory:fact:44"]
Taint propagation is recursive: if fact A supports fact B, and fact B supports fact C, tainting A taints both B and C.
Federation trust gates¶
FederationTrustGate controls which facts cross federation boundaries.
Export filtering¶
Facts are excluded from export if: - Their classification exceeds the peer's allowed level. - They are tainted. - Their confidence is below the configured floor. - They originated from the requesting peer (no echo).
from trails.memory_trust import FederationTrustGate, TrustLevel, DataClassification
gate = FederationTrustGate(
local_did="did:key:my-instance",
peer_trust_levels={
"did:key:partner": TrustLevel.PEER,
"did:key:public-api": TrustLevel.PUBLIC,
},
export_max_classification=DataClassification.INTERNAL,
confidence_floor=0.3,
)
exportable = gate.filter_for_export(
facts=all_facts,
peer_trust_level=TrustLevel.PEER,
fact_metadata=metadata_map,
)
Import validation¶
All imported facts receive trust metadata based on the source peer:
annotated = gate.validate_import(incoming_facts, source_peer="did:key:partner")
for fact, meta in annotated:
print(f"Trust: {meta.trust_level.value}, tainted: {meta.tainted}")
| Source trust | Confidence multiplier | Auto-tainted |
|---|---|---|
| LOCAL | 1.0 | No |
| PEER | 0.7 | No |
| PUBLIC | 0.3 | Yes |
PUBLIC facts are automatically marked as tainted on import. The consuming agent can still use them, but downstream inferences inherit the taint.
Putting it together: WoT + secure memory¶
Here is a complete example combining WoT discoverability with secured multi-agent memory. Two agents share a memory store through gateways with different trust levels. The whole system is discoverable via WoT:
from fastapi import FastAPI
from trails import capability
from trails.wot import AgentCard
from trails.wot_discovery import create_wot_router
from trails.memory_security import (
MemoryGateway,
MemorySession,
MemoryAuditLog,
ProvenanceChain,
)
from trails.memory_trust import (
TopicScopedReadPolicy,
DataClassification,
FederationTrustGate,
TrustLevel,
)
# -- Shared infrastructure --
audit_log = MemoryAuditLog()
chain = ProvenanceChain()
# -- Agent A: senior analyst (established trust) --
session_a = MemorySession(
principal_did="did:key:analyst-a",
trust_level="established",
)
gw_a = MemoryGateway(
memory_store, session_a,
audit_log=audit_log,
provenance_chain=chain,
)
# -- Agent B: junior agent (authenticated trust) --
session_b = MemorySession(
principal_did="did:key:junior-b",
trust_level="authenticated",
)
gw_b = MemoryGateway(
memory_store, session_b,
audit_log=audit_log,
provenance_chain=chain,
)
# -- Capabilities --
@capability(id="research.learn",
description="Store a research finding in memory")
def learn_finding(ctx, content: str, topic: str,
confidence: float = 0.8) -> dict:
iri = gw_a.learn(content, confidence_hint=confidence,
topic=topic, source="research-agent")
return {"iri": iri}
@capability(id="research.recall",
description="Recall findings on a topic",
idempotent=True)
def recall_findings(ctx, topic: str) -> list:
return gw_a.recall(topic=topic)
# -- Read policy --
read_policy = TopicScopedReadPolicy(
topic_rules={"clinical": DataClassification.CONFIDENTIAL},
agent_clearances={
"did:key:analyst-a": {
DataClassification.CONFIDENTIAL,
DataClassification.INTERNAL,
},
},
)
# -- Federation gate --
gate = FederationTrustGate(
local_did="did:key:my-instance",
export_max_classification=DataClassification.INTERNAL,
confidence_floor=0.5,
)
# -- WoT discovery --
card = AgentCard.from_registry(
base_iri="https://research.example/agents/main",
title="Research Agent",
description="Store and recall research findings with trust controls",
mcp_endpoint="https://research.example/mcp",
)
app = FastAPI()
app.include_router(create_wot_router())
# -- Use it --
# Agent A stores a high-confidence fact
iri = gw_a.learn(
"Patient cohort shows 15% improvement",
confidence_hint=0.95,
topic="clinical",
source="RCT results 2026-Q1",
ttl_seconds=7776000, # 90 days
)
# Effective confidence: min(0.95, 0.9) = 0.9 (ESTABLISHED cap)
# Agent B tries to store with inflated confidence
iri_b = gw_b.learn(
"Secondary analysis confirms trend",
confidence_hint=0.99,
topic="clinical",
)
# Effective confidence: min(0.99, 0.7) = 0.7 (AUTHENTICATED cap)
# Verify provenance integrity
valid, broken = chain.verify()
assert valid
# Check audit trail
recent = audit_log.query(action="memory.learn", limit=10)
print(f"{len(recent)} recent learn operations")
The agent is now:
- Discoverable via GET /.well-known/wot
- Secure via gateway-enforced identity and confidence
- Auditable via provenance chain and audit log
- Federation-ready via trust gates and data classification
What's next¶
This chapter introduced the WoT and memory security systems. For the full API reference and advanced configuration:
- WoT Integration guide -- complete API reference, federation peer TD exchange
- Memory Security guide -- full reference for all security and trust types
- ADR-0015 -- WoT AgentCard alignment design
- ADR-0016 -- WoT Discovery endpoint design
- ADR-0052 -- Memory security gateway design
- ADR-0053 -- Memory trust boundaries design
- MCP guide -- the MCP transport that WoT actions link to
- Federation guide -- peer-to-peer data exchange where trust gates apply