Synced verbatim from
fornax-coreat build time — edit it there, not here.
ADR 0005: Typed canonical evidence + versioned provider-extension envelope
Status: Accepted Date: 2026-08-30 Jira: FORNX-158 (child of FORNX-138)
Context
FORNX-155/156/157 gave core a versioned capability taxonomy, a thin adapter boundary, and a structured sensor/provenance contract. None of them answered a question that comes up the moment a second or third provider adapter wants to report something genuinely provider-specific: where does new provider-only evidence live? Two extremes were both available and both wrong:
- Force it into a new canonical
EvidenceKindimmediately. A canonical field is a cross-provider commitment (core code, verifiers, and storage all get to assume it means the same thing regardless of provider) — that commitment shouldn't be made on the strength of one provider's one signal. - Let it flow through as untyped
serde_json::Valueeverywhere, with no version, no provider tag, no classification. This is the "generic schemaless event lake" this ticket's AC explicitly forbids: nothing distinguishes "provisional, provider-specific, low-confidence" data from the canonical fields core code already trusts.
This ADR defines the middle path: canonical fields stay strongly typed and validated; new provider-specific evidence gets a versioned, classified envelope that is explicitly a secondary, opt-in path.
Decision
Canonical evidence stays typed
Evidence::payload remains serde_json::Value on the wire — every producer
already builds one directly (fornax-adapter-claude/fornax-adapter-codex's
ExitCode sensors) and changing that field's storage type was not necessary
to satisfy the AC. What was missing was a way to check that a given
(EvidenceKind, payload) pair actually matches its canonical shape, so
"canonical fields remain strongly typed and validated" is a checkable claim
and not just an aspiration:
pub struct ExitCodePayload { pub command: serde_json::Value, pub exit_code: i64, pub heuristic: bool }
pub struct ToolResultPayload { pub summary: String }
pub struct FileDiffPayload { pub path: String, pub diff: String }
pub struct ProcessObservationPayload { pub description: String }
pub struct TranscriptExcerptPayload { pub text: String }
pub fn validate_canonical_payload(kind: EvidenceKind, payload: &serde_json::Value) -> Result<(), String>;
One typed struct per EvidenceKind variant, all #[serde(deny_unknown_fields)]
— unlike the extension envelope (below), a canonical shape is closed: an
unrecognized field on a canonical payload is a validation failure, not
something to tolerate. Only ExitCodePayload has a real producer today; the
rest are typed ahead of a producer existing, mirroring FORNX-157's
ReasoningSummarySensor worked example (typing a shape before any provider
exposes it, so a future sensor has a target from day one).
The extension envelope
pub struct ExtensionEnvelope {
pub schema_version: u32,
pub provider: Provider,
pub adapter_version: String,
pub content_class: ContentClass,
pub fields: serde_json::Value,
pub unknown: serde_json::Map<String, serde_json::Value>, // #[serde(flatten)]
}
Attached as Evidence::extension: Option<ExtensionEnvelope> — one new
optional field on the existing type, not a change to any unrelated canonical
model (AC 1). None is the common case.
Field choices:
schema_version— see "Version compatibility" below.provider/adapter_version— which adapter produced this, so a consumer can tell "Claude Code'sadapter_version0.3.0" from "Codex's 0.1.0" without depending oncontent_classalone to disambiguate.content_class— a coarse category (ToolTelemetry,ProviderDiagnostic,ExperimentalSignal,RawProviderMetadata,Unrecognized(String)catch-all), so a consumer can filter/route without parsingfields.fields— the actual provider-specific content, deliberately untyped. This is the only schemaless field anywhere in the canonical/extension split, and only because that is the envelope's entire purpose.unknown— see "Unknown-field tolerance" below.
Version compatibility
pub const SUPPORTED_EXTENSION_SCHEMA_VERSIONS: &[u32] = &[1, 2];
pub const EXTENSION_SCHEMA_VERSION: u32 = 2; // current default for new envelopes
A plain allow-list of supported integers, not a packed major/minor scheme.
Rejected the packed-decimal alternative (major * 1000 + minor) because a
minor counter has no job left once unknown fields within a version are
already tolerated-and-preserved: "an additive change within a version" is
already exactly "same schema_version, extra unknown keys" (see below). A
packed scheme would only add a rollover rule to document for no expressive
gain.
"Truly incompatible" = not a member of SUPPORTED_EXTENSION_SCHEMA_VERSIONS.
Deserialization goes through ExtensionEnvelopeWire + a fallible
TryFrom (#[serde(try_from = "ExtensionEnvelopeWire")], mirroring
RuntimeCapabilitiesWire's pattern but fallible instead of always-succeeding)
so an incompatible version fails loudly and specifically — a distinct
error naming both the offending version and the supported set — before an
ExtensionEnvelope value is ever constructed. This is deliberate contrast
with the rest of the extension surface: an unrecognized field is
forward-compatible noise (see below); an unrecognized version means this
binary cannot vouch for the payload's own invariants, and silently accepting
it risks misinterpreting data rather than merely missing an optional detail.
A version is retired from the supported set only per the deprecation policy
below, not simply because a newer default version exists.
Blast radius of an incompatible row (FORNX-289, resolved). A single
evidence row stamped with an unsupported schema_version fails loudly at
the row level, per above — but it does not take down the whole session's
evidence read. fornax-store::evidence_for_session returns an
EvidenceReadOutcome { evidence, failed }: every row that deserializes
successfully still comes back in evidence, and each row that doesn't is
named by id in failed rather than silently dropped or aborting the whole
query, per ADR 0001's "observation must never break the session" invariant.
This is deliberately scoped to this session-wide query — a direct
single-row read or an explicit version check elsewhere still fails loudly
per the "truly incompatible" rule above; only the session-wide aggregate
read is hardened to isolate one bad row's failure from the rest.
Unknown-field tolerance
ExtensionEnvelope carries #[serde(flatten)] unknown: serde_json::Map<...>.
Any top-level JSON key present on the wire but not named by
schema_version/provider/adapter_version/content_class/fields lands
in unknown on deserialize, and is re-emitted verbatim (via the same
flatten) on serialize. This is preserve-and-ignore, not
delete-on-read: a binary reading a newer envelope keeps what it doesn't
understand and can hand it back unmodified (e.g. through fornax-cli's
export-spool, which spools Evidence as-is — see
fornax-cli/src/main.rs::evidence_envelope_carries_extension_data_through_export).
content_class's own Unrecognized(String) catch-all follows the same
FORNX-155 precedent one level down, for the tag itself.
Reused, not reinvented: this is the same "explicit catch-all variant, carry
the original string/data forward" shape as
SignalAvailability::Unrecognized/SignalClass::Unrecognized
(crates/fornax-types/src/capabilities.rs) and TrustClass::Unrecognized
(crates/fornax-types/src/sensor.rs).
Boundary with NormalizationOutcome::Unrecognized
AgentAdapter::normalize's NormalizationOutcome::Unrecognized (FORNX-156)
carries only a discriminator type tag, never the native payload body — see
crates/fornax-types/src/adapter.rs's "Unknown-event policy", which already
anticipated this ticket and explicitly deferred to it. The extension
envelope does not change that. An ExtensionEnvelope is built only for a
native shape a sensor recognizes and deliberately chooses to carry
provider-specifically (a real, working EvidenceSensor implementation
decided this data is worth keeping, just not worth a canonical field yet) —
never as a generic laundering path for a shape normalize didn't recognize
at all. Conflating the two would reintroduce exactly the "uncontrolled
provider-native payload leakage into domain/storage" FORNX-156 forbids.
No dispatch on envelope content
fields/unknown are inert JSON data. Nothing in fornax-types,
fornax-store, or fornax-daemon executes code, loads a plugin, or branches
control flow based on their contents beyond ordinary
serialize/deserialize/persist. This satisfies the "no arbitrary code/plugin
execution" non-goal directly — there is no interpreter for fields to have
been given one.
Persistence
crates/fornax-store/migrations/0005_evidence_extension.sql adds one
nullable extension TEXT column to the existing evidence table, following
0003/0004's additive-migration precedent exactly: no new table, extension IS NULL reads back as Evidence::extension == None, and there is no legacy
data to reconstruct (same reasoning as 0004's source column).
Promotion criteria: extension field → canonical field
A provider-extension field (identified by its content_class + a key under
fields) should be promoted to a canonical EvidenceKind/typed payload
struct when all of the following hold:
- Cross-provider: at least two distinct
Providervalues have produced the same semantic field (same meaning, not just the same JSON key name) through the extension envelope. A field only one provider will ever have is provider-specific by nature and belongs in the envelope indefinitely. - Stable shape: the field's type and meaning have not changed across at
least one full
schema_versionbump's worth of usage (i.e. it survived a version transition without needing to change shape). A field still changing shape between providers/versions is not ready to be frozen into a typed struct. - Consumed by core logic: something in
fornax-verify/fornax-daemon(a verifier, a status computation) wants to depend on the field's presence/type directly, not just pass it through. If nothing downstream of collection ever readsfields["x"], there is no pressure to type it. - No canonical field already covers it: promotion introduces a new
EvidenceKindvariant or a new field on an existing canonical payload struct, not a duplicate of somethingExitCodePayload/etc. already express.
When all four hold: add the typed payload struct (or extend an existing
one) in fornax-types, add it to validate_canonical_payload, migrate
producing sensors to emit the canonical shape (in payload, not
extension), and leave extension empty for that data going forward.
Existing persisted rows with the data still under extension are not
backfilled — extension: None alongside a populated payload and
extension: Some(...) for pre-promotion rows are both valid states a
consumer must tolerate, exactly as Evidence::source == None already
tolerates pre-FORNX-157 rows.
Deprecation / migration rules
- A
schema_versionis added toSUPPORTED_EXTENSION_SCHEMA_VERSIONSwhen a change to the envelope's shape is not additive-safe under the unknown-field-tolerance mechanism above (e.g. a field's type or meaning changes under the same name — pure additions never require a new version). EXTENSION_SCHEMA_VERSION(the default new envelopes are stamped with) is bumped to the new version at the same time.- An old version is dropped from
SUPPORTED_EXTENSION_SCHEMA_VERSIONSonly after every producer (both adapters, and any future adapter) has been migrated to the new version and no unmigrated historical data is expected to still be read (in v0.0.x, with no long-term archival requirement yet, this means: dropped in the same release that removes the last producer of the old shape). Dropping a version is what actually makes reads of it fail with the "incompatible" error — until dropped, an old supported version keeps parsing successfully forever, which is the compatibility guarantee this ADR's required tests pin down for two such versions. - Fixtures for at least two live entries in
SUPPORTED_EXTENSION_SCHEMA_VERSIONSare kept incrates/fornax-types/src/extension.rs's test module (historical_v1_envelope_fixture_still_reads_correctly,historical_v2_envelope_fixture_reads_correctly_and_is_the_current_default) for as long as both versions remain supported, so a regression that silently breaks an old-but-supported version's parsing is caught in CI rather than discovered against real persisted data.
Non-goals (explicit, FORNX-158 AC)
- No generic schemaless event lake.
fields/unknownare the escape hatch, not the default path — canonical typed payloads (validate_canonical_payload) remain how broadly-shared evidence is represented. A code reviewer should be able to point at any givenEvidencerow and say whether it went through the canonical or extension path, and canonical must remain the common case. - No arbitrary code/plugin execution. See "No dispatch on envelope content" above.