The legal agent that gave wrong advice

A contract management agent is asked: "What are the current terms of MSA-7 and what governing law applies?"

The facts in the system look like this:

The agent queries its vector store. Semantic search returns the original MSA, the amendment, and both governing law records — all ranked roughly equally by embedding similarity to the query. The LLM receives all of them. It produces an answer that hedges between $2.5M and $3.2M and references Delaware law, which was superseded eight months ago.

The failure

The agent didn't hallucinate. It faithfully summarised what it was given. The problem is what it was given — contradictory facts with no signal about which version is current.

Plain RAG Ashnode
Contract value $2.5M and $3.2M returned together — LLM hedges between both Only $3.2M (Amendment 1). Original superseded and hidden.
Governing law Delaware and California both returned with no conflict signal Only California. Delaware superseded in November 2025.
Litigation hold May or may not appear depending on embedding similarity rank Always included — semantically relevant to the query
Contradictions Passed silently to the LLM Detected and flagged in the packet before the LLM sees them
Audit trail None — no record of what was retrieved or when store_revision + policy_version stamped on every packet

This is not an edge case. It is the default behavior of plain vector RAG applied to facts that evolve over time.

In legal, compliance, healthcare, and finance — exactly the domains where agents are being deployed — the cost of this failure is not a wrong chatbot response. It's liability.

Why RAG doesn't know what "current" means

Vector search was designed for static document retrieval. You have a corpus, you embed it, you find the nearest neighbours to a query. It does that job well.

What it was never designed to do:

These aren't missing features you can prompt-engineer around. They're structural properties of the retrieval architecture. You cannot fix a supersession problem with a better system prompt.

Your Documents
v1 · v2 · v3 — all versions, all timestamps
↓ embed everything equally
Vector Store
no concept of current · no supersession · similarity rank only
↓ returns all versions ranked by similarity — no conflict signal
LLM / Agent
receives contradictory facts · cannot determine what is current · no audit trail
Plain RAG returns all versions of a fact equally — no concept of current, no contradiction signal, no audit trail.

What an epistemic memory layer does differently

An epistemic memory layer sits between your data pipeline and your LLM. Its job is not storage or retrieval — it's the retrieval contract.

Every query returns a bounded, inspectable context packet with four guarantees that plain RAG cannot provide:

This is Ashnode. It runs locally, CPU-only, with ~3ms p95 recall latency. No external dependencies. No API keys required for the default setup.

How Ashnode works

Your Data Pipeline
ingest(claim, source, claim_key)
Memory Layer
ASHNODE
SQLite store HNSW index Background brain
supersession · contradiction detection · freshness decay
↓ recall(query, policy) → ContextPacket
LLM / Agent
current facts only · contradictions flagged · full provenance · deterministic
Ashnode sits between your data pipeline and your LLM. Ingest claims in, get bounded context packets out.

There are three components:

1. The store and index

Claims are stored in SQLite (WAL mode, durable across restarts) and indexed in an HNSW graph for approximate nearest-neighbour search. HNSW gives O(log N) query time — latency grows at 0.12x for a 100x corpus increase, keeping p95 recall around 3ms in production.

2. The background brain

A daemon thread that runs NLI (Natural Language Inference) contradiction detection asynchronously after every ingest. It scans each new claim against its nearest neighbours using a cross-encoder model, stores contradiction records when found, and never blocks the ingest path. Your agent sees contradiction flags on the next recall after detection — typically within milliseconds on modern hardware.

3. The recall contract

recall(query, policy) returns a structured context packet. The policy controls how many items to return (k), how many contradictions to surface (c), decay half-life (tau), and whether superseded history is included. Every packet carries a store_revision — a monotonic write counter that pins the exact memory state that produced it.

The legal agent, rebuilt with Ashnode

Let's walk through the same contract lifecycle, this time with Ashnode as the memory layer.

Ingestion

Each fact is stored with a claim_key — a stable, human-readable identifier for that specific claim (e.g. "contract.MSA-7.governing_law"). When a newer version of the same fact arrives, Ashnode automatically supersedes the old one. Your agent always gets the current truth, never the full revision history unless you explicitly ask for it.

from ashnode import Ashnode

memory = Ashnode(db_path="legal-memory.db")

# March 2025 — original MSA
memory.ingest(
    "MSA-7 contract value is $2.5M. Governing law: Delaware.",
    source="contracts/msa-7-original.pdf",
    claim_key="contract.MSA-7.value",
)
memory.ingest(
    "MSA-7 governing law is Delaware.",
    source="contracts/msa-7-original.pdf",
    claim_key="contract.MSA-7.governing_law",
)

# July 2025 — amendment supersedes value
memory.ingest(
    "MSA-7 contract value revised to $3.2M per Amendment 1.",
    source="contracts/msa-7-amendment-1.pdf",
    claim_key="contract.MSA-7.value",  # same key — auto-supersedes the original
)

# November 2025 — governing law change
memory.ingest(
    "MSA-7 governing law changed to California via side letter dated Nov 2025.",
    source="contracts/msa-7-side-letter-nov25.pdf",
    claim_key="contract.MSA-7.governing_law",  # supersedes Delaware
)

# January 2026 — litigation hold
memory.ingest(
    "Litigation hold placed on all MSA-7 documents effective Jan 2026.",
    source="legal/litigation-hold-jan26.pdf",
    claim_key="contract.MSA-7.litigation_hold",
)

The claim_key parameter is the key design decision. When two claims share the same key, the newer one automatically supersedes the older one. The old version is preserved in the store for audit purposes but hidden from default recall. No manual ID tracking. No delete operations.

Recall

packet = memory.recall("MSA-7 current terms, governing law, and any active holds")

# What the agent receives:
for item in packet.items:
    fi = packet.freshness[item.item_id]
    print(f"[{item.source}] {item.content}")
    print(f"  age={fi.age_seconds/86400:.0f} days  superseded={fi.is_superseded}")

# Check for any surfaced contradictions
if packet.contradictions:
    print("Contradictions detected — review before proceeding")

# Always know if context was capped
if packet.completeness.any_truncated():
    print("Context was capped by policy — consider raising k")

The agent receives exactly three items: the current contract value ($3.2M), the current governing law (California), and the active litigation hold.

The $2.5M original and the Delaware governing law record are in the store — available for audit — but not returned by default recall. The LLM cannot see them unless you explicitly request superseded history.

The result

The agent produces a correct, unambiguous answer: MSA-7 is currently valued at $3.2M, governed by California law, with an active litigation hold from January 2026. Zero contradictions passed to the LLM. Full audit trail available if needed.

Audit query — when you need the history

from ashnode.models import Policy
from ashnode import register_policy

audit_policy = Policy(
    policy_version="audit-v1",
    k=50,
    include_superseded=True,  # show the full history
)
register_policy(audit_policy)

history = memory.recall(
    "MSA-7 full contract history",
    policy_version="audit-v1",
)
# Returns all versions — original, amendment, side letter — with provenance

The same store serves both queries. Default recall returns only current state. Audit recall returns the full chain. The policy controls which view you get — and the policy version is stamped on every packet so you always know which view produced which output.

What's in every context packet

Field What it gives your agent
items Top-k claims, semantically ranked and freshness-weighted. Superseded versions excluded by default.
contradictions Detected tensions between retrieved items, keyed by item_id. Flagged before they reach the LLM.
freshness Per-item: age in seconds, decay factor (e^−age/tau), supersession status, and the item that superseded it.
completeness Explicit flags for each cap: items_truncated, contradictions_truncated. No silent gaps.
store_revision Monotonic write counter. Same revision + same query + same policy = identical packet. Enables replay.
policy_version Pins which policy produced this packet. Every decision is auditable to its exact retrieval configuration.

Performance

Ashnode is designed to run in production without infrastructure overhead:

Who should use this

Ashnode is the right layer if your agent meets any of these conditions:

If your agent is stateless — each conversation starts fresh with no persistent memory — you don't need this. Ashnode is infrastructure for agents that accumulate knowledge over time and need that knowledge to stay correct.

Getting started

pip install git+https://github.com/itachi-hue/ashnode.git
from ashnode import Ashnode

memory = Ashnode(db_path="agent.db")

memory.ingest("Your first claim.", source="your-source")
packet = memory.recall("your query")

for item in packet.items:
    print(item.content)
    print(packet.freshness[item.item_id])

That's the full integration. NLI contradiction detection runs automatically in the background. SQLite persists everything across restarts. No configuration required to get started.


Try Ashnode with your agent

Early access is available to teams building long-lived agents in production. Full technical documentation and benchmark results shared after access is granted.

Request Access →