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

View Source

Cross-cutting recovery mechanics shared by Raxol.Harness.Projection: the global id-monotonic filter (N-ADV-02/03/forward-gap), family partitioning (loop vs meta vs untyped, N-DORM-04), turn bucketing in first-seen order (N-ADV-05), and the single diagnostic-emission seam every recovered condition goes through.

Every recovery emits [:raxol, :harness, :projection, :recovered] telemetry with %{reason, event_id} -- the same event Raxol.UI.Components.Harness.Block uses for its own internal rescues. Recovery here is never silent: see Raxol.Harness.Projection's moduledoc for the recovery policy table and the two identity keys this feeds.

Summary

Functions

Groups events by turn_id, preserving FIRST-SEEN turn order (not raw id/arrival order) so interleaved turns render grouped, never cross-bled (N-ADV-05). Each turn's own event list keeps its original relative order.

Emits one recovery diagnostic: fires the telemetry event and returns the diagnostic map so callers can also accumulate it in t.diagnostics.

Global id-monotonic recovery pass, applied across BOTH :loop and :meta events (a single session-wide journal offset, protocol §3)

Whether this turn's event list never saw a turn_started event.

Splits already id-recovered events into {loop_events, meta_events, diagnostics}. :meta events are legal interleaving (no diagnostic, P-DET-06). Anything with neither family: :loop nor family: :meta (missing, nil, or unrecognised) is the FI-12-mirrored untyped record: excluded from both, diagnosed (N-DORM-04).

The block.content keys that carry recovery provenance metadata.

Types

diagnostic()

@type diagnostic() :: %{reason: atom(), event_id: term()}

Functions

bucket_by_turn(events)

@spec bucket_by_turn([map()]) :: [{term(), [map()]}]

Groups events by turn_id, preserving FIRST-SEEN turn order (not raw id/arrival order) so interleaved turns render grouped, never cross-bled (N-ADV-05). Each turn's own event list keeps its original relative order.

emit(reason, event_id)

@spec emit(atom(), term()) :: diagnostic()

Emits one recovery diagnostic: fires the telemetry event and returns the diagnostic map so callers can also accumulate it in t.diagnostics.

filter_ids(events)

@spec filter_ids([map()]) :: {[map()], [diagnostic()], boolean()}

Global id-monotonic recovery pass, applied across BOTH :loop and :meta events (a single session-wide journal offset, protocol §3):

  • a duplicate id (already accepted) is dropped -- idempotent, second application is a no-op (N-ADV-03).
  • an out-of-order id (lower than the highest accepted so far, and not itself a duplicate) is dropped loud (N-ADV-02).
  • a forward gap -- an id strictly more than one past the highest accepted so far (e.g. ids 1, 2, 5 with 3 and 4 never arriving) -- is accepted (soft-render: the survivor blocks are not withheld) but diagnosed loud AND marks the returned damaged? flag true (hard-mark). Mirrors Raxol.Agent.Journal's own interior-loss contract (status/1 -> :damaged): the journal fails closed upstream on real corruption, so a gap reaching this filter means dense-id events were dropped somewhere between journal and here. This projection's job is diagnose + mark, not withhold, so a downstream consumer (T18) can refuse a gapped tail as canonical without losing the events that DID survive.

The very first id accepted in a given filter_ids/1 call is exempt from both the out-of-order and forward-gap checks -- there is no "highest accepted so far" yet, and a call may legitimately start mid-stream (a suffix replay from a durable block boundary, offset-based reattach, etc.) where the first id is not 0/1.

Events without an integer id (the untyped-record generator case) bypass this check entirely -- they have no ordering semantics to violate -- and are handled by partition_families/1 instead.

missing_turn_started?(events)

@spec missing_turn_started?([map()]) :: boolean()

Whether this turn's event list never saw a turn_started event.

partition_families(events)

@spec partition_families([map()]) :: {[map()], [map()], [diagnostic()]}

Splits already id-recovered events into {loop_events, meta_events, diagnostics}. :meta events are legal interleaving (no diagnostic, P-DET-06). Anything with neither family: :loop nor family: :meta (missing, nil, or unrecognised) is the FI-12-mirrored untyped record: excluded from both, diagnosed (N-DORM-04).

A family: :loop record whose :type is not an atom (e.g. a string "item_started" surviving from a malformed/legacy producer) would otherwise reach BlockBuilder.fold_event/3's catch-all clause and be dropped with NO diagnostic -- the same silent-discard failure mode this module exists to prevent. Guarded here instead: a non-atom :type on an otherwise-loop record is treated as untyped too.

recovery_meta_keys()

@spec recovery_meta_keys() :: [atom()]

The block.content keys that carry recovery provenance metadata.