Synced verbatim from
fornax-coreat build time — edit it there, not here.
ADR 0004: AgentAdapter — the provider-adapter boundary and normalization contract
Status: Accepted Date: 2026-08-30 Jira: FORNX-156 (child of FORNX-138)
Context
FORNX-155 formalized what a provider integration can observe
(RuntimeCapabilities/SignalClass/SignalAvailability). It did not define
how a provider integration observes it, or what shape core code may assume
every provider integration has. Before this ticket, fornax-adapter-claude
and fornax-adapter-codex were bin-only crates: main.rs mixed transport
plumbing (stdin reads, UDS writes, file tailing) with translation logic
(translate/translate_line) as private free functions. Nothing outside
either binary could call that logic, so there was no way to prove — short of
reading both files side by side — that the two adapters actually agreed on
what a "conforming adapter" does. This ADR records that contract so it
doesn't have to be re-derived, and so a third provider adapter has a trait to
implement rather than a binary to reverse-engineer.
Decision
The AgentAdapter trait
pub trait AgentAdapter: CapabilityProbe {
fn provider(&self) -> Provider;
fn adapter_version(&self) -> &'static str;
fn normalize(&mut self, session_hint: &str, native: &serde_json::Value) -> NormalizationOutcome;
}
Defined in crates/fornax-types/src/adapter.rs. A supertrait of
CapabilityProbe (FORNX-155), not a duplicate of it — an adapter's
capability declaration and its normalization logic are separate concerns
implemented by the same type. This lets core code that only needs
capabilities (e.g. a verifier's is_observable gate) keep depending on
CapabilityProbe alone, unchanged by this ticket.
normalize takes &mut self because a transport may need cross-call state
(Codex's custom_tool_call/custom_tool_call_output pairing by call_id —
FORNX-55); session_hint lets a caller supply what it knows so far (a
long-lived tailing loop's best-known session id) without forcing every
adapter to derive its own — an adapter should prefer a session id it can
read out of the native payload itself when one is present, falling back to
the hint otherwise. Neither source is authoritative in general; which one is
available is itself a transport-specific fact (see
docs/research/adapter-capability-matrix.md).
normalize never returns a Result: a malformed or unrecognized native
payload is expected input, not an exceptional condition (see "Error
semantics" below).
Lifecycle
There is no explicit session-start/session-end method. Claude Code hooks are
stateless per-invocation processes with no guaranteed single "session start"
moment; Codex's rollout-tail is a long-lived process that reads a session's
lifecycle out of the transcript itself. Session boundaries are expressed as
ordinary EventKind::SessionStart/SessionEnd events flowing through
normalize, which is what lets one trait fit both transports without
forcing either into the other's shape.
CapabilityProbe::probe() (FORNX-155) is documented as safe to call once per
adapter process, not once per session — Claude's adapter announces on
every event (there is no reliable single moment to announce once); Codex's
announces once per connection, gated on a caps_sent flag in main.rs. Both
are conforming: the daemon's (session_id, provider) upsert, not a
call-once contract on probe(), is what makes repeated announcements
idempotent.
Unknown-event policy: NormalizationOutcome
pub enum NormalizationOutcome {
Messages(Vec<IngestMessage>),
Ignored { reason: &'static str },
Unrecognized { discriminator: String },
}
Two structurally different reasons an adapter might not translate a native payload into a canonical message are kept distinct:
Ignored— the shape is recognized, and this adapter deliberately emits no canonical message for it (Codex'ssession_metabookkeeping line; the invocation half of acustom_tool_call/_outputpair, still awaiting its match).reasonis a short, static string, never user data.Unrecognized— the shape was not matched by anything this adapter knows about (a future provider event, a schema change).discriminatorcarries only the shape's own type tag (Claude'shook_event_name; Codex'spayload.type) — never the payload itself.
Chosen policy: log + skip. An unrecognized native payload is never
persisted or forwarded raw, and never crashes the session observing it.
Log+preserve (forwarding the raw payload for later interpretation) was
considered and rejected: an unrecognized shape is by definition un-vetted
provider-native JSON, and forwarding it would be exactly the uncontrolled
"provider-native payload leakage into domain/storage" this ticket's
acceptance criteria forbid. A safe, versioned envelope for carrying
forward-compatible provider payloads is FORNX-158's job (the "extension
envelope"); Unrecognized::discriminator is deliberately the smallest
possible signal — a type tag, not a payload — so that future envelope has
something to key off without this contract pre-building it.
Adapter/runtime version metadata
adapter_version() returns this adapter implementation's version
(env!("CARGO_PKG_VERSION")), independent of the provider runtime's
version (which, where knowable, belongs in a CapabilitySignal::detail
string, e.g. "as of v2.1.238" — that's a fact about the provider, not the
adapter). It is attached to every capability declaration via the reserved
notes["adapter_version"] key, alongside notes["session_id"]
(FORNX-155), and to Evidence.provenance strings (e.g.
claude_code:0.0.1:PostToolUse:Bash#tool_response).
This rides on the existing RuntimeCapabilities.notes map and
Evidence.provenance string rather than a new field on AgentEvent,
deliberately: fornax-store::insert_event persists AgentEvent into a
fixed SQLite column schema
(crates/fornax-store/src/lib.rs::insert_event), and fornax-cli's
export-spool must stay byte-for-byte wire-compatible with
fornax-cloud's closed RuntimeCapabilities shape for the capabilities
envelope (LegacyCapabilitiesWire) — see fornax-types/src/capabilities.rs.
Adding a column or a spool-envelope field for adapter version is a
schema/wire change with a cost this ticket did not need to pay; notes was
already the reserved, machine-consumed transport for exactly this kind of
per-session provenance metadata.
Allowed core dependencies
An AgentAdapter implementation may depend on fornax-types and
general-purpose libraries (serde, tokio, uuid, chrono). It must not
depend on fornax-verify or fornax-store — claim extraction beyond a
cheap, intentionally-duplicated pre-filter (see
fornax-adapter-claude::fornax_verify_claims_tests_passed, which does not
import fornax-verify) and persistence are core's job. Conversely, core
crates (fornax-daemon, fornax-verify, fornax-store) may depend on
fornax_types::AgentAdapter (the trait) but must never depend on a concrete
adapter crate. That dependency direction exists only in
crates/fornax-adapter-conformance, a test-only harness crate — see
"Conformance harness" below.
One named exception (FORNX-302): an adapter crate may additionally
depend on fornax-vcs, and only fornax-vcs, for git-native
filesystem/commit evidence. FORNX-91's original design for this evidence
class was blocked by two constraints that still hold: shelling out to git
is forbidden workspace-wide by
crates/fornax-daemon/tests/adversarial_daemon_input.rs:: subprocess_surface_is_still_zero_in_production_code (FORNX-238), and an
in-process git library cannot live directly inside an adapter crate without
widening what "thin" means for every adapter, not just the one that needs
git access. fornax-vcs resolves this the same way fornax-verify/
fornax-store are kept out of adapters generally: the git dependency
(gix, pure-Rust, no subprocess, no FFI to a system git) lives in its own
single-purpose crate with a small synchronous query surface
(fornax_vcs::working_tree_status), and adapters call into it rather than
embedding the dependency themselves. This exception is scoped to
fornax-vcs by name — it does not open adapter dependencies to Fornax
crates generally, and a future adapter need must not be assumed to qualify
for the same treatment without its own explicit ADR amendment.
Error semantics
normalize never propagates an error. A malformed or unrecognized native
payload (a hook payload from a newer CLI version, a corrupt line in a tailed
file) is normal, expected input, not an exceptional condition, and must
never tear down an adapter's connection or the session it is observing (D2:
observation must never be what breaks the user's actual coding session).
Genuine I/O failures (stdin unreadable, a rollout file disappearing) are
caught at the transport layer (main.rs, outside this trait) and handled by
best-effort skip/retry — never a panic. The conformance suite asserts this
directly: normalizing_never_panics feeds each adapter a synthetic,
never-seen shape and a completely empty payload, and requires a
NormalizationOutcome back, not a panic.
Core does not branch on provider name
Auditing fornax-daemon, fornax-store, and fornax-verify for
provider-specific parsing (field-name literals like hook_event_name,
transcript_path, exec_command_end, custom_tool_call,
last_agent_message) found none — those strings appear only in
doc-comments explaining why a canonical field exists, or inside the
adapter crates themselves. Core code consumes only AgentEvent/Claim/
Evidence/RuntimeCapabilities, entirely through the canonical shapes this
crate already defined (FORNX-24/FORNX-155). This ADR's contribution is
formalizing the boundary those types already implied, not migrating parsing
that had leaked across it. One pre-existing, out-of-scope gap was found and
left as-is (fornax-daemon::default_unknown_caps hardcodes Provider::Codex
as a placeholder when no adapter has announced yet) — see that function's
doc comment for why the two candidate fixes are each worse than the bug;
tracked as a FORNX-138 follow-up.
Conformance harness
crates/fornax-adapter-conformance is a test-only crate: its src/lib.rs
harness functions are written against AgentAdapter alone, with zero
dependency on either concrete adapter; crates/fornax-adapter-claude and
crates/fornax-adapter-codex are [dev-dependencies] used only by
tests/conformance.rs. This keeps the "core never depends on a concrete
adapter" rule intact even for the crate whose entire purpose is exercising
both concrete adapters side by side.
Assertions are over properties, not message sequences: the two real
adapters differ deliberately in when they announce capabilities (every
event vs. once per connection — both conforming, see "Lifecycle" above), so
a sequence-shaped assertion (msgs[0] == Capabilities, msgs.len() == N)
would be true for one adapter and false for the other by construction, not
because either is broken. The properties checked:
provider()andprobe().providernever disagree.- Every
AgentEvent/RuntimeCapabilitiesemitted carries the adapter's own declaredprovider(). - Every emitted
IngestMessageround-trips through its ownserde_jsonwire shape (the actual contractfornax-daemon::handle_connectionparses against). normalizenever panics on a synthetic unrecognized shape or a completely empty payload; anUnrecognizedoutcome always carries a non-emptydiscriminator.
Consequences
- A third provider adapter implements
AgentAdapter+CapabilityProbeand is automatically exercisable by the conformance suite by adding it as a[dev-dependency]and a fixture set intests/conformance.rs— seedocs/contributing/adding-an-adapter.md. fornax-adapter-claude/fornax-adapter-codexare nowlibcrates (ClaudeAdapter/CodexAdapter) with thinmain.rsbinaries, matching D5 ("adapters are thin") more literally than before: the binaries now contain only stdin/socket/file-tail plumbing, zero translation logic.- No extension-envelope schema was built (FORNX-158's job);
Unrecognized'sdiscriminatoris the only surface this ticket adds that a future envelope might consume.