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.
The framework as accidental complexity.
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.
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.
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.
The four stages that ship the artefact.
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.
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.
# 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.
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.
# 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.
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.
One model. Four stages.
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.
Frameworks promise composition. They deliver coupling.
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.
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.
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.
The call site is your control-flow graph.
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.
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.
Where frameworks earn their complexity.
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.
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.
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.
The regulator does not care about your DAG.
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.
Questions an architect asks first.
Read the source directly.
- EU AI Act, Regulation (EU) 2024/1689, CELEX 32024R1689 — Article 12, automatic recording of events over the system's lifetime →
- Anthropic API reference, the surface the pipeline calls directly →
- Pydantic, typed boundaries between stages →
- Chris Richardson, "Microservice Architecture pattern" →
- Martin Fowler, "Event Sourcing" →
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.