Skip to content

ISNAD Architecture — Complete Guide

Note (2026-08): parts of this guide predate recent changes. The registry is now keyed by (narrator, domain, role) (issue #3), the default policy enforces an integrity ladder (issue #30), and an UNGRADED narrator now caps at ḍaʿīf by default (lenient_unknown=True restores the old ḥasan ceiling). See the README and the code for current behaviour.

For anyone who wants to understand, extend, or contribute to the ISNAD framework. Start here.


  1. The Big Picture: Four Independent Loops
  2. Module Map: What Lives Where
  3. Loop 1: The Chain (isnād)
  4. Loop 2: The Registry (rijāl)
  5. Loop 3: Chain Grading (weakest-link)
  6. Corroboration (mutābaʿāt)
  7. Loop 4: Content Criticism (matn)
  8. The Decision Matrix
  9. Grade Freshness & Time Decay
  10. Narrator Identity & Versioning
  11. Persistence Layer
  12. API Layer
  13. CLI
  14. Trace Capture (LangChain)
  15. Trace Schema v0.1
  16. Chain Viewer
  17. LangChain Integration (older tracer + decorator)
  18. The Full End-to-End Flow
  19. Where to Start Contributing

ISNAD is not one pipeline. It is four independent loops that intersect at the decision matrix:

┌──────────────────────┐ ┌──────────────────────┐
│ LOOP 1: CHAIN │ │ LOOP 2: REGISTRY │
│ (isnād) │ │ (rijāl) │
│ │ │ │
│ Every claim carries │ │ Every transmitter │
│ its full path: │ │ has a graded record │
│ source → scraper │ │ per (narrator, │
│ → model → output │ │ domain) │
└──────────┬───────────┘ └───────────┬──────────┘
│ │
│ grades_for_chain() │
└──────────────┬───────────────┘
│
┌────────▼─────────┐
│ LOOP 3: GRADE │
│ (weakest-link) │
│ │
│ Walk chain │
│ link-by-link, │
│ floor = min() │
│ refined by │
│ transform type │
└────────┬─────────┘
│
┌─────────────┼──────────────┐
│ │ │
┌──────▼──────┐ ┌───▼────┐ ┌──────▼──────────┐
│ CORROB. │ │ CHAIN │ │ LOOP 4: MATN │
│ (mutābaʿāt)│ │ GRADE │ │ (content crit) │
│ │ │ │ │ │
│ Independent│ │ ṣaḥīḥ │ │ consistent / │
│ chains │ │ ḥasan │ │ contradiction │
│ upgrade │ │ ḍaʿīf │ │ / unverifiable │
│ the grade │ │ ḍaʿīf │ │ │
│ │ │ jiddan │ │ │
│ │ │ mawḍūʿ │ │ │
└──────┬──────┘ └───┬────┘ └──────┬──────────┘
│ │ │
└─────────────┼─────────────┘
│
┌────────▼────────┐
│ DECISION MATRIX │
│ 5×3 router │
│ │
│ chain × content │
│ → serve/review │
│ /quarantine │
└─────────────────┘

Every loop is pluggable via a Python Protocol. Swap any strategy without touching the others.


src/isnad/
├── __init__.py Public API re-exports
├── types.py All enums, Protocols, ordinal types
├── models.py Pydantic DTOs + SQLAlchemy ORM models
├── matn.py DeterministicRuleCritic (simple stub)
├── quick.py One-shot grading convenience wrapper
├── scan.py Batch / directory scan entry point
│
├── core/ ── The engine ──
│ ├── chain.py Chain + ChainLinkSpec construction
│ ├── registry.py Narrator store, jarḥ–taʿdīl, freshness
│ ├── grading.py Weakest-link chain grade computation
│ ├── corroboration.py Independent-chain upgrade + madār detection
│ ├── decision.py 5×3 matrix: chain × content → action
│ ├── identity.py alias@version resolution
│ ├── volatility.py Grade TTL / stale window / expiry
│ ├── policies.py Policy registry + swappable-policy protocols
│ ├── chain_grounding.py Chain-scoped corpus + grounding policy
│ ├── content_madar.py Content-level shared-error fingerprinting
│ ├── co_failure.py Co-failure / shared-error detection
│ └── fidelity.py Per-link transformation-fidelity verdicts
│
├── audit/ ── Audit evidence layer ──
│ ├── schema.py AuditRecord + JSON Schema
│ ├── canonical.py RFC 8785 canonicalization
│ ├── exporter.py build + emit tamper-detecting records
│ ├── chainlog.py linear hash-chain (prev_hash) integrity
│ ├── merkle_log.py Merkle batch log (parallel agents, CT-style)
│ └── sign.py Detached-signature audit signing
│
├── critics/ ── Content criticism ──
│ ├── base.py ContentCritic Protocol
│ ├── embedding.py TF-IDF cosine similarity critic
│ ├── nli.py HybridCritic (MiniLM + DeBERTa NLI)
│ ├── llm.py LLM-backed critic (provider-agnostic)
│ ├── affirmation_gate.py Affirmation gate (expert-flag gating)
│ ├── ensemble.py Critic ensembling
│ ├── recompute.py Recompute / refresh verdicts
│ ├── routing.py Critic routing / selection
│ └── eval.py Evaluation harness for critics
│
├── storage/ ── Persistence ──
│ ├── base.py RegistryPersistence Protocol
│ └── sqlalchemy.py SQLAlchemy session management
│
├── api/ ── REST API ──
│ ├── app.py FastAPI app factory
│ ├── auth.py API key auth
│ ├── dependencies.py DI / state management
│ └── endpoints/
│ ├── claims.py Claim grading + version drift
│ ├── narrators.py Registry CRUD
│ ├── review.py Human review queue
│ └── health.py Health + Prometheus /metrics
│
├── cli/ ── CLI ──
│ └── main.py isnad serve | seed | export | verify | verify-chain | verify-merkle
│
├── trace/ ── Trace schema ──
│ ├── __init__.py Public API
│ └── schema.py TraceV01, TransmitterNode, Grade, etc.
│
├── integrations/
│ ├── langchain/ ── LangChain integration ──
│ │ ├── callback.py IsnadCallbackHandler (tree-based, sync+async)
│ │ ├── tracer.py IsnadTracer (older, flat-list, report())
│ │ ├── helpers.py seed_registry(), CriticAdapter
│ │ └── decorator.py @isnad_track decorator
│ ├── crewai.py ── CrewAI integration ──
│ ├── langgraph.py ── LangGraph integration ──
│ ├── llamaindex.py ── LlamaIndex integration ──
│ ├── mcp.py ── MCP server (glama.json) ──
│ ├── otel/ ── OpenTelemetry integration ──
│ └── liveverify/ ── Live Verify integration ──
│ ├── client.py verify: seal lookup (consumer)
│ ├── issuer.py create sealed verdicts (issuer)
│ └── adapter.py register_sealed_source — seal → high-trust narrator
bench/ ── ISNAD-Bench eval suite ──
└── (README, mapping, export, narrator-grade agreement, ikhtilat)
viewer/
└── index.html Self-contained chain viewer (3 fixtures)
fixtures/
├── 1-clean-chain.json Ṣaḥīḥ chain, verified independent
├── 2-weak-extraction.json Ḍaʿīf chain, verified origin
├── 3-false-corroboration.json 5 transmitters, 1 source (madār)
└── isnad_trace_v0.1.schema.json Auto-emitted JSON Schema
examples/
├── isnad_langchain_demo.py Runnable demo (no API keys)
├── langchain_demo.py Older LangChain demo
└── worked_example.py Paper's worked example
docs/
├── trace-schema.md Schema spec with PROV mapping
├── ARCHITECTURE.drawio Architecture diagram (3 tabs)
└── concept_to_code.md Concept → module mapping
tests/ ~790 tests across ~70 files

File: core/chain.py

Every claim carries its full ordered transmission path.

ChainLinkSpec — one transformation step:

Field Type Meaning
narrator_id str Who transmitted (e.g. "model:gpt-4o")
step int Zero-indexed position in chain
version str Model version, "unknown" if unresolved
transform_type TransformType DESTRUCTIVE / GENERATIVE / PASS_THROUGH
trace_id str Ops trace identifier
domain str Domain tag for per-domain grading
confidence float? Optional numeric metadata (NOT the grade)

Chain — ordered list of links:

  • is_complete — checks step continuity. {0,1,2} = complete, {0,2} = gap = munqaṭiʿ.
  • chain_status — COMPLETE or MUNQATI.
  • narrator_ids — ordered list of narrator identifiers.

Completeness is epistemic. A chain with a gap is munqaṭiʿ and automatically capped at ḍaʿīf regardless of narrator quality. This comes directly from hadith methodology — “we don’t know what happened in that gap.”

How it connects:

  • grades_for_chain(registry, chain) — looks up each link’s grade in the registry
  • store_claim(session, ...) — persists to rijal_claims + chain_links tables
  • make_claim_id(text) — deterministic SHA-256 claim_id for deduplication
  • normalize_claim_text(text) — lowercase, strip, collapse whitespace

Files: core/registry.py (store: Narrator, Registry, RegistryDB), core/policies.py (grading arithmetic: transition policies), core/volatility.py (freshness)

The computational equivalent of the classical rijāl compendium — a living, evidence-driven registry of transmitter reliability.

1. Domain- and role-conditioned grading. The key is (narrator_id, domain, role) — never just narrator_id. A model precise on physics may be unreliable on medicine, and the same model can extract faithfully yet over-reach when synthesizing (issue #3). Classical scholars did this too.

2. Two axes per narrator. adalah (ʿadālah — integrity/manipulation- resistance) and dabt (ḍabṭ — precision/error-rate) are stored separately:

  • AdalahGrade: HIGH / ACCEPTABLE / SUSPECT / COMPROMISED / UNASSESSED
  • DabtGrade: HIGH / ACCEPTABLE / LOW / UNASSESSED
  • NarratorGrade: RELIABLE / ACCEPTABLE / WEAK / REJECTED / UNGRADED

The NarratorGrade is a composite that combines both axes at lookup time, but the raw axes are preserved for diagnostic use.

3. The jarḥ–taʿdīl state machine. Narrator grades evolve through named evidence types, not formulas:

Evidence type What triggers it
EVAL_HARNESS Per-narrator evaluation harness result
POST_HOC_AUDIT Audit of served claims
CORROBORATION_OUTCOME Corroboration/contradiction with other chains
HUMAN_REVIEW Human reviewer verdict
VERSION_BUMP Model version change → reset to UNGRADED
BOOTSTRAP_SEED Initial seed grade from benchmarks

Each event is logged immutably. The TransitionPolicy (pluggable Protocol) decides whether the grade changes.

4. Three transition policies (all in core/policies.py):

  • ThresholdTransitionPolicy — sliding window + edge trigger; 3 jarḥ = downgrade, 5 taʿdīl = upgrade
  • BayesianTransitionPolicy (default) — Beta(α, β) per narrator; posterior mean → grade
  • CalibratedThresholdPolicy — thresholds learned from calibration data

All three threshold policies share threshold_transition, which also encodes the axis split (issue #9 follow-up): integrity (ʿadālah) jarḥ is permanent and never ages out; precision (ḍabṭ) jarḥ is windowed and recoverable.

5. REJECTED is sticky. Only explicit human review can restore from REJECTED. This is active containment, not a passive label.

6. The integrity ladder (issue #30). The default policy enforces a strikes-per-tier ladder on the integrity axis: an integrity (ʿadālah) strike is permanent and cannot be lifted by good precision — precision cannot buy back compromised integrity.

7. Period-sliced grades (issue #43). get_grade_as_of(narrator, domain, as_of) re-derives a narrator’s grade at any past instant from the append-only evidence log — the ikhtilāṭ (decline) remedy: a narrator who was sound and then declined is dated, not discarded.

Narrator(
narrator_id: str,
domain_tag: str,
narrator_type: NarratorType, # SOURCE | SCRAPER | MODEL | HUMAN
grade: NarratorGrade,
adalah_grade: AdalahGrade,
dabt_grade: DabtGrade,
known_error_rate: float?, # NULL = uncalibrated
model_version: str?,
model_family: str?, # for madār detection
upstream_source: str?, # for shared-ancestry detection
is_active: bool,
graded_at: datetime?, # freshness clock start
valid_until: datetime?, # freshness clock end
evidence_log: list[dict],
)
Method What it does
register(narrator_id, domain, ...) Create or return existing narrator
get(narrator_id, domain) Look up by composite key
effective_grade(narrator_id, domain, now) Time-decayed grade with freshness status
get_grade(narrator_id, domain) Effective grade, UNGRADED if unknown
get_grade_for_link(narrator_id, domain, version) Resolve alias@version first
get_metadata(narrator_id, domain) model_family, upstream_source for correlation detection
record_evidence(...) Log evidence, re-evaluate grade via TransitionPolicy
bump_version(...) Model version bump → reset to UNGRADED
quarantine(...) Set REJECTED + COMPROMISED, deactivate
flag_contradiction(...) Independent-chain contradiction → jarḥ evidence
record_survival(...) Claim survived independent (endorsed) verification → taʿdīl; tazkiyah-guarded, claim-scoped dedup (issue #25)
evidence_provenance(...) Report whether a grade is prior-derived or observation-backed (issue #6)
renew_grade(...) Extend freshness window (corroboration proxy)

Wraps Registry with SQLAlchemy: load() reads from DB, flush() writes back. Implements RegistryPersistence Protocol — swap for Redis, DynamoDB.


File: core/grading.py

Combines per-link narrator grades into a single chain grade. The rule: weakest link caps trust, refined by transform type.

floor = SAHIH (start at best possible)
for each link in chain:
if link is DESTRUCTIVE (extraction, chunking):
floor = min(floor, link_grade)
// Permanent floor. Nothing downstream recovers lost info.
elif link is GENERATIVE (synthesis model):
if corroborated AND grade >= ACCEPTABLE:
floor = link_grade
// Can REPAIR upstream damage (raise floor)
// OR introduce corruption (lower floor)
else:
floor = min(floor, link_grade)
else (PASS_THROUGH):
floor = min(floor, link_grade)
if chain is incomplete (munqaṭiʿ):
return DAIF
if any narrator has COMPROMISED integrity:
return MAWDU
if any narrator is REJECTED (SUSPECT integrity):
return DAIF_JIDDAN

Ungraded narrators. An UNGRADED narrator caps the chain at ḍaʿīf by default — the classical majhūl treatment (you cannot vouch for what you do not know). Pass lenient_unknown=True to cap at ḥasan instead (epistemic humility: refuse ṣaḥīḥ, do not punish the absence of a grade).

The destructive/generative distinction comes from classical hadith:

  • riwāya bi-l-lafẓ (verbatim transmission) — can only lose information
  • riwāya bi-l-maʿnā (transmission by meaning) — transmitter’s competence enters the chain

A scraper that drops tables (destructive) can only lose information. A synthesis model with broad pre-training (generative) might repair upstream noise — but only when corroboration supports the repair. The algorithm gives generative links a conditional repair capability gated on corroboration, while destructive links are permanent floors.

GradingStrategy Protocol. Swap RefinedWeakestLink for any implementation.


File: core/corroboration.py

When multiple independent chains assert the same claim, trust can be upgraded. This is mutābaʿāt from hadith science.

Independence detection (SharedLineageDetector)

Section titled “Independence detection (SharedLineageDetector)”
Shared narrator IDs? → score = 0.0 (hard correlation)
Shared retrieved-document hashes? → score = 0.0 (hard correlation — the madār case)
Both chains carry lineage, no shared → score = 1.0 (independent, earned)
signal (family / upstream source)
Shared model family? → penalty = 0.4 per shared family
Shared upstream source? → penalty = 0.3 per shared source
Either chain has NO lineage metadata → UNKNOWN_LINEAGE_SCORE = 0.5
(below the gate — independence
must be demonstrated, not assumed)
Threshold: score ≥ 0.8 → considered independent

Independence must be shown, not assumed (issue #54, PR #83). When either chain carries no lineage metadata, independence cannot be demonstrated, so the score is the below-gate UNKNOWN_LINEAGE_SCORE and the chains do not corroborate. Populate model_family / upstream_source on register() so distinctness can actually be observed.

The madār problem: Naive set-disjointness of narrator IDs is explicitly wrong. Two chains with no shared narrators can still be correlated — same base model family, same upstream source, one chain reading the other’s output. The detector catches this via shared upstream sources.

Corroboration upgrade (CappedCorroborationPolicy)

Section titled “Corroboration upgrade (CappedCorroborationPolicy)”
  • Never reaches SAHIH via corroboration alone (capped at HASAN)
  • At most one tier upgrade
  • Minimum-grade gate: at least one corroborating chain must be HASAN+
  • Information-theoretic: combined error = ∏ p_i (multiplicative reduction)
  • Effective weight: log-reduction / log(p_hasan). Must reach 2.0 for upgrade.
  • Tawātur discount (#54): every chain carries a shared blind-spot prior (shared_blind_spot_prior = 0.20 by default; witness-type-aware for the shāhid/mutābaʿa distinction) — even a topology-independent chain is not a full witness, so its weight is discounted.
  • Effective witnesses: effective_witnesses = Σ (independence_score × (1 − blind-spot prior)); caps the effective count below the nominal count.
  • Lineage-aware Kish discount (3.0.5): phi_shared_lineage (default 0.0, opt-in) discounts shared-lineage corroborators by 1/(1+mφ); the serving path reads it from ISNAD_PHI_SHARED_LINEAGE (3.0.6). Default-off — φ is an LLM-domain proxy.

Operational engine: finds corroborating chains by exact text match or pre-matched via evaluate_direct(). Runs content-level madār detection (detect_content_madar → shared_error_detected), so two chains echoing the same wrong claim are not counted as independent. Validated on Wikipedia + physics textbooks (707 claim pairs, 100% fire rate, 9/9 negative controls — the v3 harness adds a C9 shared-document-hash control, closing #127).

Content-madār (shared-error fingerprinting)

Section titled “Content-madār (shared-error fingerprinting)”

File: core/content_madar.py (#54 — the detectable half of chain independence)

The N_eff / tawātur discount prices in the unobservable shared-failure prior; detect_content_madar handles the observable half: two nominally-independent chains repeating the same error is a fingerprint of a common upstream (the classical madār), not independent confirmation. The discriminator is error identity, not answer identity — agreeing on a correct statement is expected, agreeing on a wrong statement is suspicious. Deterministic and dependency-free (numbers + units, named entities, dates, citation tokens, lexical shingles); it reports “evidence consistent with a shared error”, never proof — the flag only withholds corroboration, it never serves or upgrades.


Files: critics/base.py, critics/embedding.py, critics/nli.py, critics/llm.py

Evaluates whether a claim contradicts the existing corpus — independent of chain quality. This is naqd al-matn from hadith science.

class ContentCritic(Protocol):
def evaluate(
self,
claim_text: str,
normalized_claim: str,
corpus_claims: list[str],
domain: str,
) -> ContentVerdict: ...

Returns CONSISTENT / CONTRADICTION / UNVERIFIABLE.

Critic Mechanism Quality Requires
DeterministicRuleCritic Pattern matching Stub (UNVERIFIABLE on real text) Nothing
EmbeddingCritic TF-IDF cosine similarity Catches obvious contradictions Nothing (pure-Python TF-IDF)
HybridCritic MiniLM retrieval → DeBERTa NLI Good semantic coverage sentence-transformers
LocalNLICritic DeBERTa cross-encoder Best offline quality sentence-transformers
LLMCritic LLM-prompted judgment (provider-agnostic: OpenRouter/OpenAI/DeepSeek/Anthropic/Gemini/…) Highest quality API key

Chain grading and content criticism are fully decoupled. They never read each other’s internals. They combine only at the decision matrix.

Files: core/chain_grounding.py, wired into POST /v1/claims (api/endpoints/claims.py)

The provenance-aware half of “is it grounded?”. The content critic answers “is this claim’s information in the retrieved rows?” against one merged corpus — but that merged pile has lost track of which link fetched which row. A claim can look grounded because its supporting row is somewhere in the pile, even when that row was fetched by a different branch that never fed this claim.

  • chain_scoped_corpus(chain) — the primitive: rows retrieved by links on this claim’s own chain (deterministic chain topology).
  • ChainScopedGroundingPolicy / evaluate_chain_grounding — the policy: reports a grounding gap (grounded_off_chain_only) when the claim is CONSISTENT off-chain but UNVERIFIABLE on-chain. A gap, not proven contamination — deliberately no route into the SERVE/REVIEW/QUARANTINE matrix yet.

See docs/chain-scoped-grounding.md.


File: core/decision.py

The 5×3 router: chain_grade × content_verdict → action.

CONSISTENT CONTRADICTION UNVERIFIABLE
SAHIH SERVE (cache) REVIEW (contradiction (taʿāruḍ) — highest-value) SERVE_WITH_CAVEAT
HASAN SERVE_WITH_CAVEAT REVIEW (hold; do not serve) REVIEW
DAIF REVIEW (seek corrob.) QUARANTINE REVIEW
DAIF_JIDDAN QUARANTINE QUARANTINE QUARANTINE
MAWDU REJECT_AND_QUARANTINE REJECT_AND_QUARANTINE REJECT_AND_QUARANTINE

The third column is not a formality. A critic that cannot evaluate a claim returns UNVERIFIABLE rather than defaulting to CONSISTENT — the classical tawaqquf (suspension of judgment). Under a weak critic, most claims are UNVERIFIABLE, which is why content criticism (not chain grading) is the binding constraint on serving coverage.

Three key defaults:

  1. Contradictions go to humans by default (LLMs are unreliable at reconciling competing evidence).
  2. The ṣaḥīḥ × contradiction cell is the system’s most informative signal — either a trusted source changed the world’s state, or the corpus has a latent defect.
  3. The mawḍūʿ tier is active containment, not passive labeling. A source attempting prompt injection gets quarantined, not “low confidence.”

Files: core/volatility.py, core/registry.py (effective_grade)

A narrator grade is a truth-statement about a window of time, not a permanent attribute.

graded_at ──────── stale_start ──────── valid_until ────────>
│ │ │
├── FRESH ──────────┤── STALE ─────────────┤── EXPIRED ──
│ grade as stored │ downgraded 1 tier │ UNGRADED
│ │ needs_recheck │ needs_recheck
  • FRESH: within TTL — grade used as-is
  • STALE: in grace window — downgraded one tier, flagged needs_recheck
  • EXPIRED: past best-before — reverts to UNGRADED
  • REJECTED never decays (active containment)
RELIABLE → ACCEPTABLE → WEAK → UNGRADED

renew_grade() extends the window when independent chains keep agreeing — corroboration is a proxy freshness signal. flag_contradiction() is event-driven invalidation: immediately logs jarḥ evidence.


File: core/identity.py

Grades are keyed by alias@version. An endpoint alias (“gpt-4o”) keeps its name while the model behind it changes — a grade attached to the alias silently survives the swap. Resolving the version fixes this.

resolve_narrator_id("model:gpt-4o", "gpt-4o-2024-08-06")
→ "model:gpt-4o@gpt-4o-2024-08-06"

Non-resolved tags: latest, dev, canary, unknown, "" are treated as aliases, not versions. They silently drift — the registry doesn’t key on them.

Version bump: bump_version() resets the narrator to UNGRADED. A model version bump is a new narrator, not inherited reputation.


Files: storage/base.py, storage/sqlalchemy.py, models.py

Table Purpose Key columns
rijal_claims One row per claim claim_id, claim_text, narrator_chain (JSONB), chain_grade
chain_links Normalized link table claim_id (FK), step, narrator_id, version, transform_type
narrator_registry One row per (narrator, domain, role) narrator_id, domain_tag, role, grade, model_family, upstream_source
narrator_evidence Append-only jarḥ–taʿdīl log narrator_id, domain_tag (FK), evidence_type, action
review_queue Claims awaiting human adjudication claim_id, chain_grade, content_verdict, matrix_action
  • PostgreSQL — production
  • SQLite — development/testing (default, no server needed)
class RegistryPersistence(Protocol):
def load(self) -> None: ...
def flush(self) -> None: ...
def get_grade(self, narrator_id, domain_tag) -> NarratorGrade: ...
def get_metadata(self, narrator_id, domain_tag) -> dict: ...

Swap for Redis, DynamoDB, etc.


Files: api/app.py, api/endpoints/*.py, api/auth.py, api/dependencies.py

FastAPI application with three endpoint groups:

Group Prefix Purpose
Health /v1/health, /metrics Liveness check + Prometheus metrics
Claims /v1/claims Submit claim for grading, list claims, review queue
Narrators /v1/narrators Register narrators, query grades, get evidence log

Accepts a chain of link specs. Runs the full pipeline:

  1. Build chain from link specs
  2. Look up grades from registry
  3. Detect version drift (versioned link has no grade but sibling alias does)
  4. Compute chain grade (weakest-link)
  5. Check corroboration
  6. Run content criticism
  7. Route through decision matrix
  8. Return verdict

get_registry() returns a cached RegistryDB instance. Override via app.dependency_overrides for testing. API key auth via auth.py.


File: cli/main.py

Ten commands:

Terminal window
isnad serve # Start API server (uvicorn)
isnad seed # Seed narrators from ISNAD_SEED_CONFIG env var
isnad export --claim <id> --format json # Emit a tamper-detecting AuditRecord
isnad verify --record <path> # Recompute a record's hash
isnad verify-chain --chain <path> # Walk a tamper-detecting hash chain
isnad verify-merkle --log <path> # Verify a Merkle batch log (parallel agents)
isnad ingest --otlp <path> # Grade an OpenTelemetry GenAI trace
isnad bench # Run the benchmark harness
isnad mcp # Serve the registry as an MCP server (grade_claim)
isnad scan --narrators a,b --domain d # Scan narrators against the default registry

Environment variables: ISNAD_HOST, ISNAD_PORT, ISNAD_DATABASE_URL, ISNAD_SEED_CONFIG.


File: integrations/langchain/callback.py

Automatically instruments a LangChain pipeline to capture an isnad_trace v0.1 JSON document.

from isnad.integrations.langchain import IsnadCallbackHandler, seed_registry
reg = seed_registry({"source:my-docs": "reliable", "model:gpt-4o": "acceptable"})
handler = IsnadCallbackHandler(registry=reg, domain="physics")
chain.invoke("What is F=ma?", config={"callbacks": [handler]})
trace = handler.to_trace() # isnad_trace v0.1 JSON
  1. Implements LangChain’s BaseCallbackHandler
  2. LangChain passes run_id and parent_run_id to every lifecycle method — the tree is free
  3. on_chain_start → creates a transmitter node
  4. on_retriever_end → records retrieved documents as DocumentRef (source + doc_id + content hash, NOT full content)
  5. on_llm_start → captures model version from ls_model_name metadata; records prompt hash
  6. on_llm_end → extracts output claim from response
  7. on_chain_end → finalizes; to_trace() produces the JSON

_build_chain() walks from root (node with no parent) to leaves, assigning steps in transmission order. Nodes are ordered by position in the tree, not by timestamp.

_detect_shared_ancestry() checks four signals:

  1. Shared narrator IDs
  2. Overlapping document content hashes
  3. Shared upstream sources
  4. Shared model families

Returns shared_ancestry_detected if any found.

  • Every callback wrapped in try/except — never breaks the user’s pipeline
  • Content redacted by default (only hashes, not full text)
  • Full content capture is opt-in (capture_full_content=True)

AsyncIsnadCallbackHandler implements AsyncCallbackHandler — delegates to the sync handler.


File: trace/schema.py, spec: docs/trace-schema.md

Versioned JSON contract between capture and rendering. Aligned with W3C PROV-DM and PROV-AGENT (arXiv 2508.02866).

Model Role Key fields
TraceV01 Root document claim_text, chain, corroborating_chains, chain_integrity, origin_strength, independence, contradictions, binding_constraint
TransmitterNode One chain step node_id, parent_ids, role, narrator_id, model_version, input_documents, output_claim, grade
Grade Per-narrator score chain_integrity, adalah, dabt, origin_strength, model_version, model_family, upstream_source
DocumentRef Input provenance source, doc_id, content_hash
CorroborationVerdict Independence status verified / unverified / shared_ancestry_detected / assumed
ContradictionFlag Conflicting claims claim_a, chain_a_node_ids, claim_b, chain_b_node_ids
  • chain_integrity — how soundly was the claim transmitted? (ṣaḥīḥ / ḥasan / ḍaʿīf / ḍaʿīf jiddan / mawḍūʿ)
  • origin_strength — how trustworthy is the SOURCE? (verified/attested/reputable/unknown/suspect/compromised)

A ḍaʿīf chain from a verified origin must be distinguishable from a ṣaḥīḥ chain from an unknown origin.

W3C PROV isnad_trace v0.1
prov:Entity DocumentRef
prov:Activity TransmitterNode
prov:Agent (narrator_id, role)

File: viewer/index.html

Self-contained HTML component. Open in any browser — no server needed. Three hand-built fixtures demonstrate the framework’s key signals.

  • Default state: Collapsed. One line: claim + band + binding constraint.
  • Expanded: Origin → chain nodes → corroborating chains → diagnosis → validated/indicative table.
  • Weakest link gets vermilion left-border with “BINDING CONSTRAINT” label.
  • shared_ancestry_detected renders as a warning, not consensus.
  • Fixture 3 (false corroboration) is the default view.
  • No numeric confidence (never 87.3) — ordinal bands only
  • No colour-alone encoding — badges always have text
  • prefers-reduced-motion respected
  • Keyboard focus visible
  • Every view has “What’s Validated vs. Indicative”

LangChain Integration (older tracer + decorator)

Section titled “LangChain Integration (older tracer + decorator)”

Files: integrations/langchain/tracer.py, helpers.py, decorator.py

Predecessor to IsnadCallbackHandler. Produces a flat list of links (not a tree from run_id). Has a built-in report() method for human-readable output. Still available for backward compatibility.

reg = seed_registry({
"source:my-docs": "reliable",
"model:gpt-4o": "acceptable",
})

Builds a Registry from a simple dict. Warm-start is required for practical coverage — seeding is now evidence-backed (Registry.seed) and coverage is critic-bound: DeterministicRuleCritic stub 75%, affirming critics 0% (affirmation gate).

Decorator for simple functions not using the full callback machinery:

@isnad_track(registry=reg, narrator_id="my-model")
def answer_question(query: str) -> str:
return llm.invoke(query)

Wraps any callable as a ContentCritic. Includes a reference LLM-backed example (provider-agnostic — Anthropic by default, any OpenAI-compatible endpoint via base_url).


1. Pipeline runs (LangChain, manual, PROV-AGENT)
│
2. Callback handler captures run_id/parent_run_id tree
│
3. to_trace() → isnad_trace v0.1 JSON
│
4. Registry looks up narrator grades per (narrator, domain, role)
(time-decayed via effective_grade)
│
5. grade_chain() computes weakest-link with transform-type refinement
│
6. CorroborationEngine checks for independent corroborating chains
(madār detection via SharedLineageDetector)
│
7. ContentCritic evaluates the claim against the corpus
(independent of chain quality)
│
8. Decision matrix routes: serve / review / quarantine
│
9. Viewer renders the trace: collapsed → expanded → diagnosis
│
10. Evidence feeds back into the registry (jarḥ–taʿdīl loop)
(grade transitions, freshness renewal, version bumps)

Every step is pluggable. Every parameter is a Protocol. The framework makes no claims it can’t defend.


  1. Read types.py — understand the ordinal types, enums, and Protocols
  2. Run the demo: python examples/isnad_langchain_demo.py
  3. Open the viewer: open viewer/index.html in a browser, explore all 3 fixtures
  4. Read the paper: arXiv 2607.24117 — especially §4 (framework) and §8 (validation)
  • Implement an alternative ContentCritic (sentence-transformers, CrewAI)
  • Write a seed-grade bootstrapper from published benchmark data
  • Extend semantic corroboration to multi-source corpora
  • Add a new fixture demonstrating a specific failure mode
  • Swap TransitionPolicy for a new grading approach (beyond Bayesian/Threshold)
  • Implement a new CorrelationDetector (embedding-based blind spot detection)
  • Add cross-run corroboration to the callback handler
  • Build a live viewer that fetches traces from a running API
  • Grades are ordinal, never numeric. No 87.3%. Use named bands.
  • Chain integrity and origin strength are separate axes. Never collapse them.
  • Independence is a first-class enum, never a silent boolean.
  • Be honest about limits. If a mechanism is unvalidated, the UI and docs say so.
  • Everything is pluggable. Every strategy is a Protocol — swap without touching the rest.