Raxol.Harness.Projection.BlockBuilder (Raxol v2.6.1)

View Source

Folds one turn's family: :loop events into %Block{}s.

Two-pass per turn:

  1. fold_items/1 -- a single streaming pass over the turn's events (already id-recovered by Raxol.Harness.Projection.Recovery) that groups events by item_id (an "item-group"), detects orphan item_completed records (no matching item_started, §4.1), drops late item_deltas arriving after their item sealed (N-SEAL), and accumulates the live tail for items that never completed.
  2. build_blocks/2 -- walks the completed item-groups in order with 1-item lookahead: a well-formed tool_use immediately followed by a well-formed tool_result merges into ONE :tool_call block (Block has no separate :tool_result kind -- the merge is how a tool round-trip becomes a single renderable unit). Everything else becomes its own block.

approval_requested events carry their own complete payload (no item lifecycle) and are folded as singleton groups, always well-formed, never eligible for the tool-call merge.

The duration fix (STATE note)

Block.from_events/3's own duration_from_timestamps/1 finds the FIRST item_started and FIRST item_completed in whatever event list it's given -- correct for a single item, wrong for a merged tool_use+tool_result pair (it reports the tool_use's own start/complete span, not the full call-to-result span). This module owns the fix: every :tool_call block's outcome.duration_ms is recomputed here from the min/max timestamp across ALL of its source events, overriding Block's narrower calculation post-construction.

Recovered-block provenance

Block has no provenance field (frozen struct, T4's write-set). Orphan/opaque-kind recovered blocks are flagged via two extra keys merged into block.content -- :recovered (boolean) and :recovered_reasons (a list, since orphan-ness and unknown-kind are independent and a block can be both at once) -- which is safe: every Block.render/2 content pattern match binds only the specific keys it needs, so extra keys are inert. Both keys come from Raxol.Harness.Projection.Recovery.recovery_meta_keys/0, the same source Projection.transcript_identity/1 strips back out.

Live-tail keys are per-turn, not global

build_tail/2 keys the live tail by {turn_id, item_id}, not raw item_id. Item ids are only guaranteed unique WITHIN a turn; a producer reusing "i1" in a later turn (while an earlier turn's "i1" is still unsealed) would otherwise collide when Raxol.Harness.Projection.project_turn/3 merges each turn's tail into the session-wide accumulator, silently dropping the earlier turn's live entry.

Live-tail delta buffer is bounded

An item that never completes (item_started with no matching item_completed) accumulates its item_delta chunks forever unless capped. fold_event(:item_delta, ...) keeps a sliding window of the most recent @max_tail_delta_chunks chunks per item, diagnosing :delta_buffer_capped once per item the first time a chunk is dropped (not on every subsequent delta).

Completion evidence (the honesty row)

After a turn's blocks are built, build_turn/3 scans the turn's raw events for the LAST turn_completed whose payload final is true (string or atom key -- the wire is string-keyed JSON, live producer events are atom-keyed, see Raxol.Agent.Contract.gated_done_payload/4, read-only ground truth in packages/raxol_agent). A non-final turn (no such event) is untouched -- byte-identical output, no :completion key anywhere.

When a final turn_completed IS found and the turn produced at least one block, content.completion is merged into the turn's LAST block only (the one a "done" claim attaches to):

  • refs missing, not a list, or an empty list -> %{evidence: :none} -- the design creed's mandatory absence row (never blank, never a checkmark).
  • otherwise -> %{evidence: entries, total: n, type_counts: counts}, entries capped at @max_completion_entries (Block.render/2's completion row shows "+N more" for the rest), n the FULL ref count, and counts a [%{type:, count:}] list (descending count, ties broken by first-appearance order among refs) covering EVERY ref, not just the capped entries -- the summary line's breakdown must be accurate even when most refs are never individually rendered. Maps, not {type, count} tuples: this content rides through Jason.encode!/1 at bless time, which has no Encoder for tuples.

Refs are SESSION-scoped journal offsets, not turn-scoped -- and why

Per the frozen offset law (a ref is a journal event id, and ids are session-wide), resolution is against the WHOLE session's id-recovered event stream, not just the owning turn's own events. Projection. project/2 builds one id -> event index over the id-recovered stream (before family partition or turn bucketing) and threads it into every turn's build_turn/3 call; this module never re-derives it and never assumes a ref lives in the events it was handed.

This is a DISPLAY-side decision, not a re-implementation of the producer's evidence gate: the honest producer (Raxol.Agent.Contract.gated_done_payload/4 / the evidence gate it consults) only ever emits gate-accepted, SAME-turn refs -- the gate is the sole acceptance authority. A direct call back into the gate is impossible (raxol_agent depends on main raxol, never the reverse), but that only rules out CALLING it -- WHICH of its predicates to re-derive locally is a decision, made on predicate stability:

  • Closed predicates are mirrored. Index existence (a rejected missing_ref renders "unresolvable evidence ref"), item_type (a rejected not_evidence is disclosed by the type breakdown -- renders "1 message", never "1 tool result"), and turn_id equality (a rejected foreign_turn renders marked cross_turn: true, see below) are decidable from frozen wire facts and can never diverge from the gate's own reading of them.
  • Open predicates are knowingly NOT mirrored -- see "Knowingly unmarked" below.

Session-scope resolution here is a deliberate display-side SUPERSET of what an honest producer emits -- a claim citing a ref the gate would have rejected as foreign-turn is SHOWN, marked, rather than hidden or silently resolved as if unremarkable. type carries the "what kind" signal, cross_turn the orthogonal "which turn" signal.

Knowingly unmarked: stale and mutation-echo

A same-turn ref the gate would reject as stale_evidence (the cited result predates a later mutation in the claiming turn) or as mutation_echo (the last mutation's own result echo, verifying nothing) renders as ordinary, UNMARKED evidence. This is a conscious decision, pinned by tests, not an oversight. Both predicates consume the gate's mutation predicate, which is an OPEN predicate: today it is fail-safe ("every completed tool_use is a mutation ... and not classified_effect_free?", where classified_effect_free?/1 is constantly false), but that private function is an explicit, designed refinement seam -- a structural effect classification is planned to remove effect-free tools from the mutation set. Mirroring today's everything-mutates reading into this renderer would freeze it here: once the seam is filled, gate-ACCEPTED evidence (accepted precisely because the intervening tools were classified effect-free) would render with false stale/echo marks -- the display would accuse honest evidence, which burns trust in the marks that ARE reliable. The closed-predicate marks above never have that failure mode.

Bound on the residual: the current gate is fail-closed on journals without effect classification, and the producer attaches refs ONLY on gate-accept -- so today's honest wire never carries refs at all, and an unmarked stale/echo render is reachable only through a tampered or synthetic journal. Even there, the closed-predicate marks and the projection's damaged flag still catch most tampering shapes; what remains unmarked is exactly a same-turn, evidence-class, index-resolvable ref whose only defect is ordering relative to mutations -- a defect only the gate's evolving mutation predicate can judge without false accusations.

Cross-turn disclosure

Every resolved entry, and a session-wide tally alongside type_counts, compares the resolved event's OWN turn_id against the CLAIMING turn's turn_id (the final turn_completed event's own turn_id -- not a turn a same-turn :tool_call block happens to belong to):

  • a mismatch adds cross_turn: true to that entry's map (put_present style -- a same-turn entry's map shape is UNCHANGED, so an all-same-turn completion churns nothing that wasn't already there before this fix).
  • cross_turn_count (tallied over ALL refs, not just the capped entries -- the identical "every ref, not just the shown ones" discipline type_counts already follows) is added to the completion map ONLY when it is greater than zero, for the same no-churn reason.

Per-ref resolution never raises (defensive, bounded, always returns %{ref:, type:, label:}, plus cross_turn: true when applicable):

  • ref missing from the session index -> type: :unresolvable, label: "unresolvable evidence ref" (literal, rendered verbatim -- an unresolvable ref is information, never silently dropped, and it still counts toward the summary line's breakdown). Never marked cross-turn: there is no resolved event to compare a turn_id against, and cross-turn is a "which turn did this citation come from" signal, not a synonym for "unresolvable".
  • otherwise -> type is the resolved event's item_type when it is an item_completed (:tool_result, :message, ...), else :unknown. label prefers a same-turn :tool_call block's own content.name (nicer, already-extracted display name) when the ref lands in THIS turn's own blocks -- naturally absent for a cross-turn ref, since a different turn's own built blocks were never searched -- falling back to the raw event's own "name" payload field; that name is joined with the resolved event's content's first non-blank line via " — " when both are present, either alone when only one is, and the type name itself when neither resolves to anything displayable.

Every resolved label is sanitized (control-byte strip, mirroring Raxol.Harness.Surface.ViewText.sanitize/1's byte-wise technique verbatim, but with NO \t exception -- a completion label is a single inline cell, never a multi-line body) and clamped to 32 display columns (Raxol.UI.TextLayout.truncate/3, never String.length/ String.slice -- see that module's own width-safety contract) before it ever reaches a block's content: labels originate from the LLM/agent side and are untrusted exactly like any other tool-call/tool-result payload this module already folds.

Known conflation (fail-open wire)

The producer emits final: true with NO refs key for at least THREE indistinguishable states: gate-rejected evidence, a "done" citing nothing at all, and a trivial tool-free turn with nothing to cite. The wire carries no rejection marker distinguishing them, so the absence row (%{evidence: :none}, "no evidence provided") is INTENTIONALLY unconditional -- per the ratified design creed, absence renders "no evidence provided" explicitly regardless of WHICH of the three states produced it. Disambiguating them needs a producer-side wire change (tracked cross-lane, not this module's concern); the zero-tool-turn policy question is deliberately parked in the producer's gate. Do not weaken the absence row trying to guess which of the three states it was.

Staleness under compaction

Refs are stable, counter-derived journal ids -- a reattach at a later offset does not shift them. But an event compacted or dropped upstream (before this projection ever sees it) leaves the session index without that key, and the ref renders "unresolvable evidence ref" exactly as if the ref had never existed at all -- this module cannot tell the two apart BY ITSELF. A consumer CAN, though, one level up: an interior drop produces a forward id gap, which sets the whole projection's damaged: true flag (Raxol.Harness.Projection.Recovery.filter_ids/1). Unresolvable-in-a-damaged-stream (a possible compaction victim) is therefore distinguishable from unresolvable-in-an-intact-stream (the ref never existed) by checking projection.damaged, not by anything this module's own output carries.

A final turn_completed with ZERO blocks in the turn (nothing to attach honesty to) synthesizes no block -- turn_completed stays structural/never block-producing (the frozen P-TIER-01 tier law) -- and instead diagnoses :final_completion_without_blocks via Recovery.emit/2, same as every other recovered condition this module reports.

Summary

Functions

Folds one turn's events into {blocks, tail, diagnostics}. fold_defaults maps a Block.kind() to its initial fold state. session_index is the whole session's id -> event map (built by Raxol.Harness.Projection.project/2 over the id-recovered stream, BEFORE turn bucketing) -- completion-evidence refs are session-scoped journal offsets, not turn-scoped, so resolution reaches beyond this turn's own events (see the moduledoc's "Refs are SESSION-scoped"). Defaults to %{} so a ref genuinely absent from the session resolves as unresolvable rather than raising.

Functions

build_turn(events, fold_defaults, session_index \\ %{})

Folds one turn's events into {blocks, tail, diagnostics}. fold_defaults maps a Block.kind() to its initial fold state. session_index is the whole session's id -> event map (built by Raxol.Harness.Projection.project/2 over the id-recovered stream, BEFORE turn bucketing) -- completion-evidence refs are session-scoped journal offsets, not turn-scoped, so resolution reaches beyond this turn's own events (see the moduledoc's "Refs are SESSION-scoped"). Defaults to %{} so a ref genuinely absent from the session resolves as unresolvable rather than raising.