Raxol.Agent.Contract (Raxol Journal v2.8.0)

Copy Markdown View Source

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: true closes 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 alongside refs, 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 when evidence: :rejected) — %{"refs" => offered_refs, "reason" => reason_name, "ref" => offset_or_nil}. reason_name is 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:

  1. 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.

  2. 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 a refs list, which the grandfather arm blesses :accepted — will decode as proven-done. This is the identical threat model as refs themselves 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

encode_line(event)

@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.

evidence_status(payload)

@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.

sanitize_payload(payload)

@spec sanitize_payload(map()) :: map()

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.