The typed event contract between the agent core and any surface (CLI, TUI,
LiveView, remote): the event envelope (Raxol.Agent.Contract.Event), its
JSON line encoder, and the decoder for the evidence marker a closing
turn_completed carries.
Every observable step of an agent run is one Event. This module defines
the shape and encodes it; it neither produces nor publishes events. The
package README ("The event contract") describes the shape.
v0 vocabulary (family :loop only)
:turn_started— a prompt was accepted; payload%{prompt}:item_started— an item opened; payload%{item_id, item_type}:item_delta— streaming text chunk; payload%{item_id, chunk}; the one ephemeral event (live render only, never for replay):item_completed— a finished item; payload%{item_id, item_type: :message | :reasoning | :tool_use | :tool_result, ...}.item_ids are turn-scoped ("i1","i2", …) and distinct per item.:turn_completed— a turn boundary; payload%{usage, iteration, final}(final: truecloses the run):error— fault; payload%{reason}
The contract only grows: a field or a type is added, never removed, renamed or made required.
Evidence tri-state wire marker
The payload of a turn_completed{final: true} may carry one optional
discriminator plus one optional detail:
evidence: :accepted | :rejected | :absent— grow-only enum (atom in-memory, JSON string on the wire, same convention as every other enum on this surface).:accepted— stamped alongsiderefs, the accepted-refs carrier.:absent— the turn offered no refs at all.:rejected— refs were offered and the producer's done gate refused them.
evidence_rejected(present only whenevidence: :rejected) —%{"refs" => offered_refs, "reason" => reason_name, "ref" => offset_or_nil}.reason_nameis one of"missing_ref","not_evidence","foreign_turn","stale_evidence","mutation_echo".
A record without the marker cannot tell a rejected done from a never-offered
one: both are %{usage, final: true} with no refs. evidence_status/1
therefore decodes the field with a grandfather rule: an evidence key
present is authoritative; a legacy record (key absent) with refs
present grandfathers to :accepted (the refs carrier never lied); a
legacy record with neither key present is genuinely :unknown — it must
NEVER be guessed as :absent, which would launder a historical rejection
into a false "never offered" claim.
The marker is in journals written at schema_version 1.1.0 and later
(Raxol.Agent.Journal.FileStore.Writer stamps the version). The v1.0.0
golden corpus has none: an old journal's records are never rewritten in
place, only decoded through the grandfather rule above.
Trust boundary — producer-stamped, journal-trusted
evidence_status/1 is a decode, not a re-derivation. It reports the
enum the producer stamped; it does NOT re-run the producer's done gate over
the journal at read time. The soundness of the marker therefore rests on two
explicit assumptions, stated here so a reader wiring it to a surface knows
exactly what it does and does not defend against:
Producer honesty is stamped, not asserted. The producer writes the marker from the same gate verdict that decides the done, so producer and decider cannot disagree at write time. This module does not check that.
The reader trusts the journal. A hand-forged or replayed line that stamps
evidence: :accepted— or a legacy-shaped forged line that omits the marker but carries arefslist, which the grandfather arm blesses:accepted— will decode as proven-done. This is the identical threat model asrefsthemselves and every other durable field: a journal that can be forged can fabricate tool results, refs, and verdicts directly. The marker adds no trust surface the journal did not already assume (append-only, single-writer integrity). It is sound only on a trusted, append-only journal.
What the decoder does defend: an evidence value it cannot understand —
a forged string, a future enum value, a garbage type — fails safe to
:unknown, never :accepted (see normalize_evidence/1). A forged
non-legacy line thus cannot launder a bogus marker into acceptance.
The decoder does not re-derive the verdict. Re-running the gate at decode time would (a) require the whole journal + turn + refs, coupling a pure single-record decode to journal state it does not carry, and (b) re-evaluate at the display surface the open gate predicates (staleness relative to later mutations), which must NOT be frozen or recomputed there. A hostile-journal re-check is out of scope for a producer-stamped durable marker.
Summary
Functions
Encode an event as a JSON line (newline-terminated). There is no decoder: v0 is emit-only.
Decode a turn_completed{final: true} payload's evidence status.
Coerce a payload map into a JSON-encodable one.
Functions
@spec encode_line(Raxol.Agent.Contract.Event.t()) :: iodata()
Encode an event as a JSON line (newline-terminated). There is no decoder: v0 is emit-only.
@spec evidence_status(map()) :: :accepted | :rejected | :absent | :unknown
Decode a turn_completed{final: true} payload's evidence status.
Tolerant of both atom- and string-keyed payloads and both atom- and string-valued enums (live in-memory events vs. journal-replayed JSON).
Implements the grandfather rule for records written before this marker
existed (see moduledoc): an evidence key present is authoritative and
wins outright. A legacy record (key absent) with refs present
grandfathers to :accepted — the refs carrier never lied about
acceptance. A legacy record with neither key present is genuinely
:unknown: without the marker, a rejected done and a never-offered done
are wire-identical, so there is no way to tell them apart. This case must
NEVER decode as :absent — that would launder a
historical rejection into a false "never offered" claim.
Totality: the decoder never crashes. An evidence value it does not
recognize — a future grow-only enum value, a forged string, a garbage type —
degrades to :unknown (fail-safe, never :accepted); see
normalize_evidence/1. The decode is quaternary — :accepted | :rejected | :absent | :unknown — even though the wire enum a producer stamps is
tri-state; :unknown is the decode-only bucket for legacy/unrecognized
records, so a consumer's case over this must handle four outcomes.
Trust: this reports the stamped enum, it does not re-derive the verdict from the journal — see the "Trust boundary" section in the moduledoc for what that does and does not defend.
Coerce a payload map into a JSON-encodable one.
Same boundary sanitization encode_line/1 applies, exposed for the durable
journal sink: an event whose payload carries non-encodable terms (message
tuples, structs, pids) must not crash Jason.encode! when appended. Anything
Jason can't take becomes inspect/1 text.