/blog · the architecture argument for the evidence pipeline

ARCHITECTURE · PILLAR · FUNCTIONS OVER GRAPHS
PUBLISHED 2026-05-08 · REVISED 2026-08-06 · ~10-MIN READ · WARRANT ENGINEERING

no agentic framework. just functions.

warrant runs zero LangGraph, zero AutoGen, zero CrewAI. four stages, one model, one request handler. the architectural choice that lets us cite sub-clauses with discipline.

PIPELINE
4 stages· Classify · Extract · Assess · Map
Four named, typed stage calls — stages 1 and 2 concurrent, 3 and 4 dependent. Single-agent-output. Bounded depth.
DEPS
zero LangGraph· zero AutoGen · zero CrewAI
Native Anthropic API + tool use. The pipeline is the orchestrator.
ORCHESTRATOR
4 stage calls· one handler
One request handler, four awaited stage calls. Named arguments, one boundary model, one schema. The call site is the control-flow graph.
W
PIPELINE · 4 STAGES · 1 MODEL · 20 REGIMES MAPPED
claude-opus-5 on all four stages over the Anthropic SDK · a Pydantic boundary model on stage 4 · JSON Schema on the package · the production architecture as of 2026-08-06.

The compliance-for-AI category is full of products built on agent orchestration frameworks. LangGraph for the graph. AutoGen for the multi-agent chat. CrewAI for the role-based crew. semantic-kernel for the planner. Each one promises composition. Each one, in our experience, delivers coupling. Three things you do not need leak in alongside the part you wanted: hidden control flow, hidden retry semantics, and hidden token spend.

Warrant's pipeline is four named Python stage functions calling the Anthropic API. Each takes named arguments, returns a documented envelope, and writes one structured log line per model call. A junior engineer reads four awaited calls in one request handler and understands the system. The auditor's question, which exact stage produced this citation, has a one-line answer in the trace.

This post is the architectural argument for that choice. Not a takedown of any specific framework. A defence of explicit functions and typed edges, in the bounded-depth setting where evidence is the deliverable.

01 · ACCIDENTAL COMPLEXITY

The framework as accidental complexity.

Three-stage agentic pipeline · LangGraph version vs plain Python · same output, different surface area · illustrative, not Warrant's code

Take a three-stage pipeline. Classify a trace, extract actions from it, assess each action against a policy. The same task, written two ways. The graph version is roughly fifty lines. The plain version is roughly twenty-five. Both produce identical output on a fixed trace.

python · graph executor version
from langgraph.graph import StateGraph, END
from langchain_anthropic import ChatAnthropic
from typing import TypedDict, List

class State(TypedDict):
    trace: dict
    classification: dict
    actions: List[dict]
    findings: List[dict]

llm = ChatAnthropic(model="claude-opus-5")

def classify_node(state):
    msg = llm.invoke([{"role": "user", "content": f"classify: {state['trace']}"}])
    return {"classification": msg.content}

def extract_node(state):
    msg = llm.invoke([{"role": "user", "content": f"extract actions from: {state['trace']}"}])
    return {"actions": msg.content}

def assess_node(state):
    msg = llm.invoke([{"role": "user", "content": f"assess: {state['actions']}"}])
    return {"findings": msg.content}

graph = StateGraph(State)
graph.add_node("classify", classify_node)
graph.add_node("extract", extract_node)
graph.add_node("assess", assess_node)
graph.set_entry_point("classify")
graph.add_edge("classify", "extract")
graph.add_edge("extract", "assess")
graph.add_edge("assess", END)

app = graph.compile()
result = app.invoke({"trace": trace})
# where did the retry happen? what was the wall-clock per node?
# the executor knows. you have to read its source to know.

The graph compiles. The output is correct. When extract returns malformed JSON, the executor retries with some backoff schedule and surfaces the result. When assess hits a rate limit, the same executor decides what to do. When you ask how many tokens did this trace cost, the answer lives behind a callback handler you have to register.

python · plain functions version
from anthropic import Anthropic

client = Anthropic()

def classify(trace: dict) -> dict:
    r = client.messages.create(
        model="claude-opus-5", max_tokens=2048,
        messages=[{"role": "user", "content": f"classify: {trace}"}])
    return {"text": r.content[0].text, "usage": r.usage.model_dump()}

def extract(trace: dict) -> dict:
    r = client.messages.create(
        model="claude-opus-5", max_tokens=2048,
        messages=[{"role": "user", "content": f"extract actions: {trace}"}])
    return {"text": r.content[0].text, "usage": r.usage.model_dump()}

def assess(actions: dict) -> dict:
    r = client.messages.create(
        model="claude-opus-5", max_tokens=2048,
        messages=[{"role": "user", "content": f"assess: {actions}"}])
    return {"text": r.content[0].text, "usage": r.usage.model_dump()}

classification = classify(trace)
actions = extract(trace)
findings = assess(actions)
# every retry is yours. every token count is in the return value.
# the call site is the control flow.

Same output. Half the lines. The retry semantics are explicit, because there are none yet. When you decide to add them, you wrap one function call in a four-line retry loop with the policy you actually want. Token spend is in the return value, not in a callback. The control flow is the call site. The auditor question which model produced which output has a one-line answer.

This is what accidental complexity means. The graph executor solves the general problem of arbitrary graph traversal, and the price of that generality is paid in every node, every run.

02 · THE FOUR STAGES

The four stages that ship the artefact.

api/pipelines/*.py · the production analysis path · zero hidden state, zero hidden retries

The Warrant analysis path is exactly four stage functions. Each takes named arguments, returns a documented envelope, writes one structured log line per model call. The orchestrator is one request handler — the attest handler in api/main.py — with four awaited stage calls in it.

01 · CLASSIFY
stage1_classify
trace → classification. Domain, jurisdictions, regimes, risk tier. claude-opus-5.
02 · EXTRACT
stage2_extract_actions
trace → actions envelope. Tool-use schema, structured JSON. claude-opus-5. Runs concurrently with stage 1 — it does not read the classification.
03 · ASSESS
stage3_assess_authorization
actions, classification → authorization findings. Within purpose, preconditions met, oversight, reversibility. claude-opus-5.
04 · MAP
stage4_map_obligations
actions, authorizations, classification, corpus → obligations envelope. Sub-clause citations across the mapped regimes. claude-opus-5.

The stage boundaries are written down in two places, and it is worth being exact about which does what. The wire-level contract for the package is JSON Schema — api/spec/warrant-v1-evidence.schema.json, the schema of record. Inside the analysis path, one stage carries a Pydantic boundary model: stage 4 parses the model's response through Stage4Output.model_validate_json before any downstream consumer sees it. Stages 1 to 3 hand plain dicts and lists across the seam and check their own shape in code. pyright reads the signatures, in just ci and in CI.

python · api/spec/warrant-v1-evidence.schema.json · the nine roots of the package, as at 2026-08-06
# additionalProperties: false at the root. seven of the nine are required.
classification       # stage 1: domain, jurisdictions, regimes, risk_tier, confidence
actions              # stage 2: array of rows — action_id, actor, action, subject
authorizations       # stage 3: array of per-action rows, each carrying action_id
obligations          # stage 4: an OBJECT keyed by action_id, not a flat list
coverage_by_regime   # stage 4: coverage status per regime id
deferred_regimes     # optional, added in v0.2
risk_tier
refusal_reason       # optional
trace_metadata

Two things in that list do work that a reader coming from a graph framework will not expect. obligations is an object keyed by action_id, so an obligation row is never orphaned from the action that produced it. And classification.regimes together with coverage_by_regime carry the regulator mapping, which is why the seam between stage 1 and stage 4 has to be legible rather than hidden inside an executor's state object.

The schema is narrower than the pipeline. Stage 2's working envelope carries more per action than four fields — the prompt asks for timestamps and for the input and output values an action depended on — but actions[] in the package is closed at action_id, actor, action, subject, with additionalProperties: false. What a graph framework would hold as an ever-widening state object is here two shapes with an explicit narrowing between them, and the narrowing is written down.

python · api/pipelines/stage4_schema.py · the one boundary model in the analysis path
from pydantic import BaseModel, ConfigDict, Field
from typing import Literal

from api.spec.sub_clause_enum import SubClauseId

ComplianceStatus = Literal["satisfied", "gap", "uncertain", "unvalidated"]
CoverageStatusLiteral = Literal[
    "evaluated", "not_evaluated_no_obligations_in_corpus", "partially_evaluated",
]


class ObligationRowModel(BaseModel):
    # one obligation row authored by the model for a single action
    model_config = ConfigDict(extra="forbid", frozen=True)

    id: SubClauseId          # StrEnum built from the corpus at import time
    compliance: ComplianceStatus
    confidence: float = Field(ge=0.0, le=1.0)
    evidence: str = Field(min_length=1, max_length=2000)
    unvalidated: bool = False


class Stage4Output(BaseModel):
    # the full stage 4 envelope returned by the model
    model_config = ConfigDict(extra="forbid", frozen=True)

    obligations_by_action: dict[str, list[ObligationRowModel]]
    coverage_by_regime: dict[str, CoverageStatusLiteral]
    refusal_reason: str | None = None

extra="forbid" and a sub-clause enum built from the corpus at import time are the two lines that matter. A citation the corpus cannot emit does not parse, so it never reaches a package. That check is a model boundary, not a type annotation — the distinction is the subject of section 05.

Pure-ish is the right word. The Anthropic API call is a side effect, and so is the structured log line. Both are explicit and visible. No hidden state machine, no callback handler buried in a base class. The function does what it says.

python · the stage sequence in api/main.py · elided, the surrounding handler is longer
# stages 1 and 2 are independent of each other, so they run together
classification, extract_envelope = await asyncio.gather(
    stage1_classify(client, trace_json),
    stage2_extract_actions(client, trace_json),
)
actions = extract_envelope["actions"]

# stages 3 and 4 are dependent, so they run in order
authorizations = await stage3_assess_authorization(client, actions, classification)
obligations_envelope = await stage4_map_obligations(
    client, actions, authorizations, classification, REGULATIONS
)

Four stage calls. Nothing hidden. The auditor question show me the exact code path that produced this citation resolves to one handler, four stage names, one dependency order. The European framing in Article 12(1) of the AI Act, on automatic recording of events over the lifetime of the system, leaves no room for an answer like well, the executor decided. Every stage logs. Every stage names itself.

"High-risk AI systems shall technically allow for the automatic recording of events (logs) over the lifetime of the system." EU AI Act · Article 12(1) · Regulation (EU) 2024/1689, enacted text as at 24 July 2026

Article 12 sits in Chapter III, Section 2 of the AI Act, and it is not yet in application. It applies from 2 December 2027 for Annex III high-risk systems and 2 August 2028 (subject to Article 2(13)) for Annex I, under Article 113, third paragraph, point (c) as replaced by Article 1(40)(b) of Regulation (EU) 2026/1744, in force 27 July 2026. The architecture question is live now. The obligation is not. Building for it after the date is building a retrofit.

The word lifetime is the part that dictates the architecture. A system whose internal control flow is opaque cannot guarantee that every event is recorded. A system whose control flow is the call site can.

03 · ONE MODEL, FOUR STAGES

One model. Four stages.

STAGE_MODELS in api/spec/pipeline_config.py · one dict, four entries · as at 2026-08-06

All four stages run claude-opus-5. That was ruled on 2026-07-31, and the reason it had to be ruled is the more interesting part of the story.

Before the ruling, three places in this repository each answered the model question differently. A documentation table said one thing for stages 2 and 4. The code ran a single earlier model on all four. The eval harness pinned a third id for stage 4. Three sources, three answers, and an evaluation that was scoring a model the pipeline was not running — an assurance that did not transfer to production verdicts. Nothing about that failure needed a graph executor to happen. It needed only more than one place where the answer lived.

Stage Model Output of record
01 · stage1_classify claude-opus-5 Domain, jurisdictions, regimes, risk tier
02 · stage2_extract_actions claude-opus-5 action_id, actor, action, subject — the package's actions[] row
03 · stage3_assess_authorization claude-opus-5 Per-action authorization judgement and justification
04 · stage4_map_obligations claude-opus-5 Per-action obligations, sub-clause citation, status

The routing is not a feature flag and it is not a constant per role. It is one dict keyed by stage, STAGE_MODELS in api/spec/pipeline_config.py, and nothing else in the codebase is allowed to name a model id. That dict is hashed into pipeline_config_sha256, and that digest sits inside the signed bytes of every evidence package. The model is part of the attestation, not an implementation detail. A reader holding a package can tell which model produced the verdict without asking us.

Which is also why a cheaper model on the structured stages is not a free saving. Changing a stage's model changes the digest on every package produced afterwards, so it is a provenance event that has to be argued from this pipeline's own evaluation numbers rather than from a price sheet. Per-stage token usage and wall latency are recorded — api/pipelines/stage_metrics.py — but they surface only on a gated debug payload, and no per-trace cost figure for this configuration has been published. This post does not carry one, because there is not one to carry.

04 · COMPOSITION VS COUPLING

Frameworks promise composition. They deliver coupling.

The model upgrade test · two changelogs versus one dict entry · the maintenance asymmetry

Every orchestration framework promises composition. Drop in a new node, swap a model, change a tool, the graph stays intact. The reality of running these through model upgrades, in production, is that the surface they expose is wider than the surface you wanted, and the wider surface couples you to their release cadence.

When Anthropic ships a new model, the upgrade in the four-stage pipeline is one dict entry in api/spec/pipeline_config.py. Change the entry, run the eval, diff per stage, decide. Because the model ids are hashed into the config digest that sits inside the signed bytes, the swap is a provenance event recorded on every package produced after it, not an invisible edit — which is a stronger property than a one-line diff, and it costs the same.

When the framework upgrades, you read its CHANGELOG. Release notes for the executor, schema migration for the state object, deprecation list for tool decorators, breaking changes for callbacks. Maybe you rewrite three nodes because the state interface changed. Maybe a retry default flipped, your bill silently doubles for a week, and you find out from the cost dashboard. The model upgrade and the framework upgrade are independent axes of breakage. You signed up for both.

Architecture is a series of decisions about which boundaries to draw, where, and what passes across them. The boundary between the analysis pipeline and the model API has one decision in it, the model id. The boundary between the analysis pipeline and an orchestration framework has hundreds of decisions in it, embedded in that framework's API surface, most of which were not made by you.

DO

Couple to the model API directly. Couple to your own type definitions.

Two surfaces. Both stable. Vendor controls one, you control the other. Upgrading either is a single-axis decision.

DON'T

Couple to a graph executor that wraps the model API.

Three surfaces. One yours, two vendor. The vendor framework tracks model changes on its own schedule, and you absorb every drift event downstream of both.

05 · THE STAGE SEAM

The call site is your control-flow graph.

pyright over the stage seam · one Pydantic boundary model · what each one actually catches

An orchestration framework shows you a graph in a notebook. Nodes connected by arrows, the visualisation as the documentation. The picture is appealing and the run-time semantics are not always what it says.

The call site carries the graph. stage1_classify takes the client and the trace and returns a classification. stage3_assess_authorization takes the client, actions and that classification. stage4_map_obligations takes the client, actions, authorizations, classification and the corpus — five arguments, in that order, and the signature says so. Change a stage signature and the editor lights up red at every call site at once. That is a real property, and it is worth stating precisely what it does and does not cover.

python · api/pipelines/map_obligations.py · the stage-4 signature as written, as at 2026-08-06
async def stage4_map_obligations(
    client,
    actions: list[dict],
    authorizations: list[dict],
    classification: dict,
    regulations: dict,
) -> dict:
    # five positional arguments, in that order. pyright checks the
    # arity and the list-versus-dict positions on every push.
    ...

# what pyright does NOT catch: actions and authorizations are both
# list[dict], so swapping those two positions type-checks cleanly.
envelope = await stage4_map_obligations(
    client, authorizations, actions, classification, REGULATIONS)

That is the honest boundary of the claim. A type checker enforces what the annotations say, and list[dict] says very little. What the annotations do buy is arity, the list-versus-dict positions, and a red editor at every call site the moment a signature moves — which is the property that matters when four stages have to stay in a fixed order.

The shape checks live one layer in, and they are runtime checks, not compiler checks. Stage 4's response is parsed through Stage4Output.model_validate_json with extra="forbid" and a sub-clause enum built from the corpus, so an obligation row naming a clause the corpus cannot emit raises a ValidationError rather than reaching a package. Stage 2 raises if its output is neither a list nor a dict carrying actions. Stage 3 raises if its output is not a list. The finished package is then checked against the JSON Schema of record.

Nothing here needs a graph executor, and none of it would be easier with one. The point of the section is not that a compiler replaces a runtime; it is that the checks are enumerable. Four signatures, one boundary model, two shape guards, one schema. A reader can list them. A reader cannot list what a framework's executor does between two nodes without reading the executor.

06 · WHEN A FRAMEWORK HELPS

Where frameworks earn their complexity.

The honest trade-off · open-ended planning, large tool ecosystems, human-in-loop interventions

The argument is not that orchestration frameworks are wrong. They are right for a different problem.

Three properties make a framework's overhead pay off. Unbounded planning depth, where the agent decides at run time how many steps to take and which tools to call. Large tool ecosystems, where the integration cost of fifty tools dwarfs the cost of the orchestration layer. Many human-in-loop interventions, where pausing the graph, presenting state to a human, and resuming on input is the dominant runtime concern.

FRAMEWORK FITS

Open-ended research agents. Unbounded planning depth, tool selection at run time, dynamic graph shape. The graph executor earns its complexity.

Tool-rich operator agents. Fifty plus tools, the integration manifest is the work, the orchestration overhead is small in comparison.

Human-in-loop workflows. The pause-and-resume pattern, with state inspection and manual approval, is the right thing to factor out.

FRAMEWORK MISFIT

Bounded depth. Warrant is always four stages. The depth is in the prompt and the type, not in the run-time graph.

Bounded tools. Anthropic API for the model. A versioned JSON corpus passed in as an argument. Two surfaces, neither of which benefits from a tool registry.

Zero human interventions per trace. A record mapped to a specific EU AI Act obligation is the deliverable. There is no human approval gate inside the run.

The shape of the problem decides the shape of the architecture. An open-ended research agent and a four-stage evidence pipeline are not the same product, and they should not share the same scaffolding. The framework earns its keep where the run-time graph is the thing being modelled. It is overhead where the run-time graph is fixed, the tools are two, and the deliverable is a single record that is independently verifiable without contacting Warrant.

07 · EVIDENCE OVER ARCHITECTURE

The regulator does not care about your DAG.

Architecture serves the artefact · the artefact is what gets cited in court · the rest is implementation detail

An auditor reading a Warrant evidence package never sees the four-stage pipeline. They see a record mapped to a specific EU AI Act obligation, with a sub-clause citation, independently verifiable without contacting Warrant. The architecture is invisible. That is the point.

The reason the architecture has to be small, named, and explicit is that the artefact has to be defensible. Show me the citation, show me the action that triggered it, show me the model that produced the assessment, show me the timestamp. Each question resolves to one row in one log table. The architecture choice is the precondition for that resolvability.

A graph executor with hidden retries can produce the same artefact, on a good day. On a bad day, the executor retried four times under load, your token bill quadrupled silently, and the response was the latest of the four runs. The artefact looks identical. The integrity story is not.

The rule, written down inside the engineering team in one line, is architecture serves the artefact. Every decision in the pipeline is justified against that rule. The four stages exist because four named stages map cleanly to how a regulator reasons about an AI system. The model id lives in one dict because it is part of the attestation, and a claim about which model produced a verdict has to be checkable by whoever holds the package. The typed boundaries exist because the artefact citations have to be checked at edit time, not at the auditor's desk. The stage sequence is four calls because every one of them is a call the auditor can read.

Nothing else is in the system. There is no graph object, no executor, no callback handler, no state machine, no retry decorator with a hidden policy. Four stages, one model, one request handler. The architecture decision is the citation discipline.

08 · FAQ

Questions an architect asks first.

FAQ · the questions an architect asks first
Do you use any LangChain modules?

No. Zero LangChain, zero LangGraph, zero CrewAI, zero AutoGen, zero semantic-kernel. The pipeline is four Python stage functions calling the Anthropic SDK directly. Pydantic gates the stage-4 response through Stage4Output; the finished package is checked against api/spec/warrant-v1-evidence.schema.json. Postgres for receipts, not for the analysis path. That is the entire dependency surface for the analysis path.

What about retrieval. Surely that is a framework.

There is no retrieval step, and that is the more useful answer. The regulatory corpus is a single versioned JSON artefact, api/data/regulations.json, loaded in process at boot and passed into stage 4 as an argument. No embedding call, no vector index, no query at analysis time. Its SHA-256 is pinned and checked at boot, so a corpus change is a provenance event rather than a silent one. There is no orchestration layer, no chain object, no retriever class holding state.

Does this scale to multi-agent workflows?

Not the goal. Warrant's pipeline is single-agent-output. One trace produces one evidence record, independently verifiable without contacting Warrant. The four stages are not agents talking to each other, they are functions composing into a deterministic transformation. Multi-agent orchestration is a different product category. If you need open-ended planning or N agents debating in a loop, an orchestration layer earns its complexity. The bounded-depth case does not.

How do you test the pipeline end-to-end?

A hand-curated regression set, not a generated one. Seven labelled cases in eval/regression_set.jsonl, twenty stage-3 and twenty stage-4 golden cases under tests/fast-flow/eval-harness, and six adversarial traces. There are no golden outputs for stages 1 and 2 yet. A nightly workflow runs the adversarial set on a 06:00 UTC cron and reports per-stage deltas, so a regression in stage three does not look like a regression in stage four. The set is small on purpose: every trace is hand-built, because fabricated test data would be the same defect class as a fabricated citation.

What if Anthropic releases a new model?

The model id changes in one place: STAGE_MODELS in api/spec/pipeline_config.py, a dict keyed by stage. Update the entry, run the eval, compare per-stage deltas, decide. No framework upgrade, no CHANGELOG to read, no graph executor to test for behaviour drift. The dict is hashed into the config digest that sits inside the signed bytes, so the swap is recorded on every package produced after it. That is the entire upgrade workflow.

Can I use this style with LangChain or the OpenAI SDK?

Yes. The architecture choice is independent of vendor. The point is explicit functions, typed edges, no hidden control flow. You can write a four-function pipeline against the OpenAI SDK, against Bedrock, against the LangChain core primitives, against any HTTP client. The argument is not anti-LangChain. The argument is anti-hidden-graph. If your problem is bounded depth, write functions. If it is not, do not write functions.

09 · READ THE SOURCE

Read the source directly.

SEE THE PIPELINE RUN

Drop a trace. Watch the four steps.

The fastest way to read the architecture is to run a sample trace through the live demo. Four steps on a rail — trace, record, authority, obligations — ending in an evidence package you can check without contacting us. The demo does not print model ids or per-stage cost; those are recorded, and they travel with the package rather than the page.