ADR-0002 twinflow implements the agent seam rather than adopting Pydantic AI¶
Status¶
accepted, 2026-08-13.
Context¶
Requirement ARCH-5 asks for an agent framework that is thin and inspectable,
with a local model option. Two technology decisions answer it. Decision D9
selects Pydantic AI 2.27.0, for one interface across Anthropic, OpenAI, Gemini,
and Ollama, and for a structured-output guarantee with validation-retry.
Decision D10 pairs it with Ollama and constrained decoding, so the demo runs
with no API key and the accuracy stack does not weaken when the key is absent.
Decision D15 sets the inbound license policy: permissive only, and CI fails the
build outside the allowlist in CONTRIBUTING.md. That allowlist carries two
MPL-2.0 rows. Development only is accepted, because a test dependency is never
shipped. Shipped at run time is refused, and the stated reason is that the
file-level condition would travel to a user who installs a twinflow package.
Release gate VAL-GATE-SEC-001 names the same allowlist.
Resolving the framework against the Python Package Index on 2026-08-13 gives
the numbers this decision turns on. pydantic-ai 2.27.0 resolves to 100
distributions. The narrower pydantic-ai-slim still resolves to 20. Both trees
contain certifi, which arrives through httpx as a run-time dependency, and
certifi declares License: MPL-2.0 in its own package metadata. The full tree
also carries Unlicense, CNRI-Python, MIT-0, and MPL-2.0 AND MIT, and
none of those four has an allowlist row at all.
So the allowlist refuses the framework as resolved. The refusal is on a certificate authority bundle rather than on anything the agent layer uses, which is the awkward part of this record and the reason the argument against it below is worth reading.
One fact makes writing the local path affordable. Ollama listens on the loopback
interface, and a request to loopback verifies no certificate, so it needs no
certificate authority bundle and therefore no certifi. Ollama's /api/chat
accepts a format parameter holding a JSON Schema and constrains the sampler to
it at decode time, which is the constrained decoding D10 asks for. The Python
standard library posts that request without a third-party distribution.
Decision¶
twinflow implements ARCH-5 itself, behind one named seam. twinflow.agent.tools
declares StructuredOutputAdapter, a single-method protocol that turns an
untrusted emission into a validated pydantic model or raises.
Two implementations fill it. PydanticStructuredOutput carries decision D9's
validate-then-retry loop on pydantic alone. OllamaStructuredOutput lives in
twinflow.agent.local_model. It posts the model's JSON Schema as the format
parameter of Ollama's /api/chat, using urllib.request from the Python 3.12
standard library, then validates the answer against the same model. No agent
framework and no HTTP client distribution enters the run-time tree.
The second validation is deliberate duplication. The decode-time constraint is a
promise made by a server this project does not control, and a build whose grammar
compiler drops pattern returns a document that is shaped correctly and still
wrong. There is no code path that omits the schema from a request, and no path
that answers a schema failure by asking again without the constraint.
Alternatives considered¶
| Alternative | Why it lost |
|---|---|
Adopt pydantic-ai 2.27.0 as decision D9 names it |
100 distributions, four SPDX ids with no allowlist row, and certifi under MPL-2.0 shipped at run time, which the allowlist refuses |
Adopt the narrower pydantic-ai-slim |
20 distributions rather than 100, and it still reaches certifi through httpx at run time, so the refused row still applies |
| Widen the allowlist to accept MPL-2.0 at run time | It is a policy change the allowlist owner takes on evidence, not one a work package takes to unblock itself, and gate VAL-GATE-SEC-001 would then pass a class nobody re-examined |
Vendor Pydantic AI and remove httpx |
The provider clients are httpx, so removing it removes the multi-provider switch that was the reason to adopt the framework, and it adds a fork to maintain |
Use urllib3 for the local request instead of the standard library |
It has no required dependencies, so it would pass the allowlist. It buys nothing on loopback that urllib.request does not already do, and every distribution is a surface to watch |
Ask the local model for JSON in the prompt, with no format |
That is a request rather than a guarantee, so the structured-output layer of the accuracy stack would weaken exactly when the API key is absent, which decision D10 forbids |
| Adopt LangChain or LangGraph instead | Decision D9 already rejected it on inspectability, and its dependency tree is larger, so the license question returns in a worse form |
| Ship the hosted path only, and no local model | Decision D10 requires a demo that runs with no API key and no cloud account |
Consequences¶
What this buys. The run-time license allowlist stays as written, and gate
VAL-GATE-SEC-001 passes on a tree that contains no MPL-2.0 distribution. The
whole local model path is one module a reader can inspect end to end, which is
closer to ARCH-5's "thin and inspectable" than 100 distributions would be.
The local path adds zero distributions, so pip install twinflow-agent still
brings pydantic and two workspace leaves. The seam is named and tested, so
adopting a framework later is a constructor argument rather than a rewrite.
What it costs, and this is the part that earns the record its place. The
multi-provider switch is not shipped. Decision D9 wanted one interface across
Anthropic, OpenAI, Gemini, and Ollama with a one-line model change, and what
exists is one local implementation behind a seam. Adding a hosted provider means
writing that client here, and a hosted provider does verify a certificate, so
the certifi question returns for that path alone. Everything a framework
maintains is now this project's to maintain: retries, timeouts, streaming, the
tool-call protocol, message history, and each provider's deviations from its own
documentation. That is a real recurring cost against a saved dependency.
The obligation this creates. UrllibJsonTransport is a real network adapter, and
scripts/checks/nondeterminism-gate.sh names the kernel package as the one
legitimate home for a real adapter. It sits in twinflow-agent because that gate
matches text rather than behavior and does not see urllib.request, not because
the rule does not apply. Moving it to the kernel package, and adding
urllib.request.urlopen to the gate's banned list at the same time, is work this
record books rather than leaves implicit. Until then the shape is held by design:
the transport is injected and never constructed implicitly, so a simulation run
drives a recording.
The strongest argument against this decision. Most serious Python projects ship
certifi unmodified. MPL-2.0 section 3.3 permits distributing a Larger Work
under other terms, provided the covered files stay under the MPL. The bundle
would ship unmodified, so the source-availability condition of section 3.2 is
discharged by pointing at the upstream distribution. Nothing about a twinflow
package would become file-level copyleft. On that reading the allowlist row is
stricter than the license requires, and this record spends a real capability,
plus a maintenance stream, to buy a compliance margin that may be unnecessary.
The reply is not that the reading is wrong. It is that the allowlist is a written policy with a release gate behind it, and a work package is not where a license policy is reinterpreted to unblock itself. The reading deserves to be settled on the record by the allowlist owner, which is what the revisit condition names.
Either of two changes reopens this decision. The first is an allowlist decision
on unmodified certificate authority trust stores, taken deliberately and written
into CONTRIBUTING.md, which would remove the only objection to decision D9. The
second is Pydantic AI dropping httpx from its run-time dependencies, or
offering an extra that does, which would remove the objection without any policy
change. Neither had happened on the date of this record, and this record is
superseded rather than edited when one does.
Validation¶
The license allowlist gate is the mechanical check. It fails the build on a
distribution outside the allowlist, so a later commit that adds Pydantic AI
without also changing the policy fails rather than lands quietly. Release gate
VAL-GATE-SEC-001 names the same allowlist.
Three tests in packages/twinflow-agent/tests/test_local_model.py hold the parts
a license gate cannot see. test_the_local_path_costs_no_third_party_dependency
parses the module and asserts that every import resolves to the standard library
or to pydantic. An httpx import would fail there rather than pass unnoticed.
test_no_attempt_falls_back_to_an_unconstrained_call asserts that the schema is
present in every request including retries.
test_a_missing_daemon_is_not_retried_and_is_never_swallowed asserts that a
connection failure leaves the loop immediately.
One thing nothing holds. The nondeterminism gate does not currently see
urllib.request, so the placement of UrllibJsonTransport outside the kernel
package is held by review rather than by a check. That is stated here because an
unenforced decision drifts, and the fix is booked in the consequences above.