Skip to content

Events

reyn emits a structured event for every state change. The full event log is JSONL, written to .reyn/events/<run_id>.jsonl and replayable with reyn events <log_file>.

Kind vocabulary

The type field is drawn from a closed set. Every kind reyn emits is listed below, and reyn emits nothing else — so a subscriber can enumerate the complete set of types it may receive, and a handler written for a kind not on this list will never fire.

The source of truth is AUDIT_EVENT_KINDS in src/reyn/core/events/event_schema.py; this list is checked against it in CI, and that declaration is in turn checked against the emitting code — in both directions, so neither a kind without a declaration nor a declaration without a producer can land. Adding a kind is a public-interface change, and it shows up as a diff on this page.

Two things this list is deliberately not:

  • Not the op values. read_file, write_file, grep and their siblings are values of the op field inside the shared tool_executed kind, not kinds of their own. They are a different axis and are not part of this vocabulary.
  • Not a field contract. Which payload fields a given kind must carry is a separate registry (EVENT_AUDIT_REQUIREMENTS, same module) covering a subset of these kinds; the per-kind sections below document the payloads in prose.
agent_delta
agent_message_refused
agent_message_sent
agent_request_received
agent_response_committed
agent_response_received
asyncio_unhandled_exception
behavior_anomaly_judged
body_summary_hard_truncated
budget_caps_updated
budget_reset
bus_subscriber_dropped
call_parent_registration_collided
canonical_degraded
canonical_fallback_used
chain_peer_discarded
chain_timeout
chain_timeout_extended
chat_started
chat_stopped
chat_turn_completed_inline
client_attached
client_detached
client_seized
compact_now_spill_capability_absent
compact_op_completed
compact_op_failed
compact_op_requested
compact_op_unavailable
compaction_batch_cap_below_head_tail_budget
compaction_check
compaction_completed
compaction_episode_ended
compaction_failed
compaction_floor_lowered
compaction_schema_invalid
compaction_shrink_recovered
compaction_started
compaction_wire_bytes_measured
composer_dropped
composer_fired
config_reload_rejected
config_reloaded
control_ir_failed
control_ir_skipped
control_post_bounded_timeout_cut
cron_fired
direct_alias_call_salvaged
display_frame_unknown_kind
embed_attempts
embed_cancelled
embed_secret_redacted
embedding_index_build_complete
embedding_index_build_error
embedding_index_build_progress
embedding_index_build_started
exec_threat_blocked
exec_threat_match
exec_threat_scan_skipped
exec_threat_scanned
exec_tool_axis_denied
file_changed
file_read_media_denied
file_read_media_write_unavailable
force_close_triggered
history_hydration_stopped_reading_early_unsafe
history_oversized_content_spilled
history_oversized_content_truncated
hook_changed
hook_drain_task_died
hook_event_emitted
hook_push_fired
hook_push_rejected_oversized
hook_shell_executed
hooks_layer_rejected
inbox_cancel
index_dropped
index_update_cost_warning
index_updated
ingress_bridge_dropped
intervention_announced
intervention_answer_submitted
intervention_denied
intervention_routed
limit_denied
llm_call_retry
llm_call_retry_exhausted
llm_called
llm_request
llm_request_error
llm_response_received
mcp_called
mcp_cancelled
mcp_client_close_leaked
mcp_completed
mcp_elicitation_answered
mcp_elicitation_auto_declined
mcp_elicitation_requested
mcp_elicitation_timed_out
mcp_failed
mcp_hook_subscribe_not_applied
mcp_initialized
mcp_install_cancelled
mcp_install_probe_failed
mcp_install_threat_blocked
mcp_install_threat_match
mcp_media_denied
mcp_media_write_unavailable
mcp_progress
mcp_prompt_get
mcp_prompt_get_cancelled
mcp_prompt_get_completed
mcp_prompt_get_failed
mcp_prompt_list_changed
mcp_prompts_listed
mcp_reconnect_failed
mcp_resource_read
mcp_resource_read_cancelled
mcp_resource_read_completed
mcp_resource_read_failed
mcp_resource_subscribe
mcp_resource_subscribe_cancelled
mcp_resource_subscribe_failed
mcp_resource_subscribed
mcp_resource_templates_listed
mcp_resource_unsubscribe
mcp_resource_unsubscribe_cancelled
mcp_resource_unsubscribe_failed
mcp_resource_unsubscribed
mcp_resource_updated
mcp_resources_listed
mcp_server_install_skipped
mcp_server_installed
mcp_server_removed
mcp_tool_list_changed
mcp_tool_probe_degraded
mcp_tools_listed
memory_deleted
memory_saved
model_budget_fallback
model_cost_block
model_cost_warn
network_ssl_verify_disabled
new_msg_exceeds_budget
oauth_login_completed
oauth_login_started
offloaded_content_unavailable
payload_reduced
peer_reply_failed_surfaced
pending_intervention_claimed
pending_intervention_discarded
permission_approval_granted
permission_approval_revoked
permission_approvals_cleared
permission_denied
permission_granted
pipeline_install_skipped
pipeline_install_threat_blocked
pipeline_install_threat_match
pipeline_installed
pipeline_load_failed
pipeline_run_attached
pipeline_step_completed
pipeline_step_started
plan_step_llm_memoized
plugin_install_completed
plugin_install_copied
plugin_install_reconciled
plugin_install_registered
plugin_install_started
plugin_install_token_vocabulary_mismatch
plugin_uninstall_completed
plugin_uninstall_registry_dropped
plugin_uninstall_started
presentation_install_blocked
presentation_installed
presentation_load_failed
presented
process_footprint
process_footprint_unavailable
process_marker_reaped
process_memory_breakdown
process_memory_forensics
project_context_changed
project_context_unreadable
pump_exception_swallowed
recovery_summary_persisted
repo_ingest_files_skipped
resource_cap_exceeds_budget_trigger
router_context_overflow_detected
router_context_overflow_unrecovered
router_empty_response_detected
router_empty_response_retry_injected
router_loop_terminated_by_exception
router_represent_round
router_retry_exhausted
routing_decided
safety_limit_checkpoint
sandbox_axis_unenforced
sandbox_policy_narrowed
sandbox_policy_not_applied
sandboxed_exec_cancelled
sandboxed_exec_completed
sandboxed_exec_started
secret_cleared
secret_rotated
secret_set
semantic_search_complete
semantic_search_embed_failed
semantic_search_started
session_completed
session_halted
session_memory_backpressure
session_memory_backpressure_cleared
session_memory_exit
session_restored
session_run_task_finished
session_run_task_rediscovered_dead
session_started
skill_body_loaded
skill_body_threat_blocked
skill_body_threat_match
skill_install_skipped
skill_install_threat_blocked
skill_install_threat_match
skill_installed
skill_invoke_body_loaded
skill_invoke_collision
spill_candidate_population_exhausted
stall_trace_armed
stall_trace_disarmed
state_change_notified
summary_resummarize_failed
summary_resummarized
task_settle_undelivered
threat_block
threat_scan_match
token_refresh_failed
token_refreshed
tool_call_cap_exceeded
tool_call_deduped
tool_called
tool_cycle_kept_whole_over_budget
tool_executed
tool_failed
tool_result_offloaded
tool_result_write_unavailable
tool_returned
turn_cancelled
turn_completed
turn_kept_whole_over_budget
turn_settled
turn_started
turn_stopped_memory
untrusted_narrowing_engaged
untrusted_narrowing_lifted
user_answered_intervention
user_intervention_received
user_intervention_requested
user_message_received
user_submitted
visibility_changed
web_fetch_completed
web_fetch_failed
web_fetch_media_denied
web_fetch_media_write_unavailable
web_fetch_ssrf_blocked
web_fetch_started
web_fetch_too_large
web_fetch_too_many_redirects
web_search_completed
web_search_failed
web_search_started
webhook_received
workspace_updated

Event envelope

Every event has:

{
  "type": "<event_kind>",
  "timestamp": "2026-04-30T10:00:00.123456+00:00",
  "data": {
    ... // kind-specific payload; may include agent_id / run_id when the
        // emitting EventLog was configured with them (see below), plus
        // audit_seq / emitter on every event from a session-path EventLog
        // (see "Audit sequence" below)
  }
}

An AUDIT-event's body field is data, read e["data"][...], not e["payload"]. For a llm_response_received event, e["data"]["finish_reason"] / e["data"]["call_id"] are where those fields live; for tool_called, e["data"]["caller_kind"] / e["data"]["caller_id"] / e["data"]["tool"] / e["data"]["args"]. e.get("payload", {}) returns {} for EVERY audit-event, silently — every field reads as absent, not because it wasn't emitted, but because the read itself targeted a key that was never on THIS vocabulary. payload is a real key, but on the WAL-event's own vocabulary (.reyn/state/wal.jsonl — see Time-Travel § WAL vs audit-event separation, and CLAUDE.md's "'event' is three distinct things"): agent_snapshot.py's inbox_put replay reads event.get("payload", {}) for real. Mixing the two vocabularies returns empty in EITHER direction — an audit-event read as e["payload"], or (the mirror mistake) a WAL-event read as e["data"].

Agent ID field (all events)

Every event emitted from a session whose agent_id is configured (in reyn.yaml) automatically carries an agent_id field in its payload. The default value is reyn/<hostname>. This enables RBAC and multi-agent audit trails per SOC2 / ISO 27001 / METI v1.1 requirements.

See Concepts: multi-agent — "Agent ID propagation" for details.

Audit sequence (audit_seq / emitter, all events)

Every event carries emitter (a string identifying one execution of an EventLog — not the session's stable identity, a fresh token minted at construction time, one per real process run) and, when that EventLog tracks sequencing (the default), a monotonically increasing audit_seq starting at 1.

audit_seq is NOT the WAL's seq. The WAL's seq is a recovery coordinate (where replay resumes reconstructing in-memory state); audit_seq is purely an audit-continuity witness — a subscriber uses it to detect a DROPPED event ((emitter, audit_seq) pairs with a gap in the number) without needing in-order delivery. The two are deliberately different names for two different concepts (this repo has hit the "same name, two meanings" defect class more than once — see CLAUDE.md).

Monotonic per emitter, never across emitters. Reconstructing global ordering across two different emitters (two sessions, or two runs of the same session) is not this field's job — that would require cross-process coordination at the point of emission, which the audit band deliberately does not impose (a synchronization point on the execution path, for audit's sake, is the opposite of what the band exists to be). A reader groups events by emitter first, then checks each group's own audit_seq run for gaps.

A new process execution is a new emitter, never a resumed count. If a session's EventLog reused a stable identity (e.g. the session_id alone) across a restart, a fresh process would re-emit audit_seq 1..N under the SAME emitter a prior process already used — a reader could no longer tell "event 5 is missing" from "this is a new run's own event 5". A fresh EventLog instance is constructed exactly once per real process execution and never reloaded across a restart, so minting emitter once at construction (rather than deriving it from any longer-lived identity) gives per-execution uniqueness by construction, with no counter to persist or reconcile across a restart.

Neither field is caller-settable per emit call (unlike agent_id/ run_id above, which follow a caller-wins convention) — EventLog.emit always overwrites both, even if a caller's own data happens to include same-named keys. Numbering the events is emit's own job, done in exactly one place; letting a caller influence it would open a path to skip or forge a number, which would defeat the gap-detection contract 3 exists for.

.reyn/events/direct/cli/ is out of gap-detection scope. A one-off CLI event (emit_cli_event, no active session) carries emitter: "cli" but no audit_seq at all — a single event from a single process invocation is not a series a gap can be detected in, so a meaningless audit_seq: 1 every time is omitted rather than emitted.

LLM and context

Kind Key payload
llm_called model (+ chain_id when the call belongs to a delegation chain)
llm_response_received prompt_tokens, completion_tokens, cached_tokens, cache_creation_tokens, cost_usd, usage_source (+ chain_id). call_id/finish_reason (#4691 Phase 1 ①) are the litellm ModelResponse's own id/choices[0].finish_reason, measured off the response and stamped ONLY here (not llm_called, which fires before the response exists) — the call-granularity key the TUI outbox meta and the flowview tree (#4691 Phase B) use to tell which litellm call a row belongs to and whether it was a tool round (finish_reason == "tool_calls") or the turn's own reply ("stop"). None when genuinely absent off the response — never a minted placeholder, and this includes litellm's own "" id: the stamp point sits at _once's exit, common to both the sync and streaming call paths — streaming reconstructs a ModelResponse via litellm's stream_chunk_builder, and .id is present there too (lead-coder measurement, #4722) — but litellm's ChunkProcessor._get_chunk_id returns "", not None, when no stream chunk carried an id, and ModelResponse.__init__ only mints a fresh id when the field is None, so a genuinely-absent streamed id survives onto the response as "". Reyn's own stamp collapses that (and any other falsy id) to None before emitting, so this field is never "" on the event — a missing id reads as absence, not as a shared empty key multiple unrelated calls would otherwise collide on.
embedding_index_build_complete source_id, chunk_count, total_tokens, cost_usd, embedding_model (a disk-adopt/no-fresh-build completion carries total_tokens/cost_usd as null — no embed call happened that run, not a cost of zero)
repo_ingest_files_skipped #4431 — the repo-knowledge (knowledge_repo_doc/knowledge_repo_src) background build excluded one or more files for exceeding _REPO_INGEST_MAX_BYTES (256 KB, src/reyn/data/index/knowledge_ingest.py). Emitted once per build, only when the count is nonzero — a routine build with nothing skipped emits no event at all. kind ("doc"/"src"), skipped_count, reason (currently always "over_size_cap")
resource_cap_exceeds_budget_trigger #4381 PR-1 — resolve_effective_trigger_and_budgets's own invariant check (src/reyn/runtime/services/router_history_buffer.py): the per-result resource boundary (control_ir_inline_cap, bytes, converted to tokens via context_builder.INLINE_CAP_BYTES_PER_TOKEN) exceeds the budget boundary (effective_trigger, the model's context window). Detection only — no value is clamped. Warn-once per (model, phase), not per call (this SSoT runs on every trigger resolution). model, phase ("" when none), resource_bound_bytes, resource_bound_tokens, effective_trigger
model_budget_fallback get_max_input_tokens (src/reyn/llm/model_budget.py) could not resolve a real context-window figure for model and used the conservative 128,000-token default instead. Warn-once per model string (process-shared). #4680②: reason distinguishes WHY — "not_ready" (litellm has not finished importing in this process yet; TEMPORARY, self-corrects once litellm is ready — see this same call site's own log for the correction, which is log-only, not a second event) vs "uncataloged" (litellm IS loaded but has no catalog entry, or no positive max_input_tokens, for this exact model string; PERMANENT for this process unless llm.models.<tier>.max_input_tokens is configured, #4689, or litellm's own catalog is updated). Before #4680②, both states fired this SAME event with no way to tell them apart. model, fallback_tokens, reason, phase (None when none), run_id

usage_source says where the token counts came from: provider (the provider reported them) or estimated (the provider's stream carried no usage, so LiteLLM filled the counts locally with its own tokenizer). unknown means the origin was not stated. An estimated figure is recorded and enforced exactly like a reported one — the field exists so a cost audit can tell them apart, and so the turns billed on an estimate can be found afterwards:

reyn events .reyn/events --filter llm_response_received   # then grep/jq for "estimated"

See reference/config/budget.md — "Token-count provenance".

Control IR

Each Control IR op kind emits its own event:

Kind When
read_file, write_file, edit_file, delete_file, glob_files, grep, regenerate_index file op variants — all via tool_executed with op=<sub_op>
sandboxed_exec_started, sandboxed_exec_completed, sandboxed_exec_cancelled sandboxed_exec op — started: argv, argv0_resolved, plan, backend, timeout_seconds, network, deny_subprocess; completed: argv, argv0_resolved, plan, backend, returncode, stdout_len, stderr_len, truncated, denial_class; cancelled (#1470, fires INSTEAD of completed when a Ctrl-C cancel_event interrupts an in-flight run): argv, backend, returncode, stdout_len, stderr_len (no plan, the same existing asymmetry argv0_resolved already has on this event). argv0_resolved (#2820) is the absolute path actually executed: a version-manager shim (~/.pyenv/shims/python3) is resolved to its real binary by reading the manager's on-disk layout (part A — filesystem-only, no subprocess) so the sandbox runs the real binary directly instead of the shim, whose launch-fork() would die under (deny process-fork); equals the plain PATH resolution for a non-shim command, or the unchanged argv[0] when resolution is unavailable (fail-open). plan (#5838 段5) is null for an argv-request run (argv0_resolved alone already names what ran) or, for a cmd-request run (#5838 段4 — a shell command line, parsed for policy and executed via sh -c on the ORIGINAL string, never a reconstruction), a list of {"argv0": <original token>, "resolved": <resolved absolute path>} — one entry per parsed segment, in order — the ONLY place a chained/piped cmd's actual per-segment binaries are named, since argv0_resolved for a cmd-request run is always /bin/sh. The pair (rather than resolved alone) means a reader never has to re-parse cmd to learn a segment's original token, and still shows whether/what a version-manager shim resolved to (e.g. {"argv0": "python", "resolved": "/opt/.pyenv/versions/3.12/bin/python"}). resolved is sourced from security.exec_plan_policy.check_exec_plan_policy's own return value (that function already resolves each segment's argv[0] for its own tool-axis check) — never a second resolution, so the binary POLICY approved and the binary the trace names cannot diverge (the same discipline #5991's own env_path/cwd fix established). started's network/timeout_seconds/deny_subprocess are the ACTUAL enforced policy values (#1339: the operator-or-default policy always wins over the op's own request fields) — network in particular (#5825①) reflects the GRANTED value when the op requested it (op.network: true) and the request was approved via require_network, not the raw request itself. denial_class is "fork_denied" when the sandbox blocked fork() at a PATH launcher/shim (pyenv/asdf/mise/npx/uvx) under (deny process-fork), "network_denied" when it blocked outbound connect() because this exec ran CLOSED — never requested network (#5244①; an ASKED-and-refused request instead raises PermissionError before the process spawns, via require_network, and never reaches this classifier), else null — an environment/config condition, not a tool failure
mcp_called, mcp_completed, mcp_failed, mcp_cancelled MCP tool ops — mcp_cancelled (#2813) fires instead of mcp_completed/mcp_failed when a Ctrl-C cancel_event interrupts an in-flight call before it completes
mcp_media_denied #4946 — an MCP tool response carried an image whose byte size the multi-modal gate (require_media_load, same shared gate as file_read_media_denied/web_fetch_media_denied) rejected under multimodal.on_oversize=deny (or an ask prompt the operator declined). Gated PER IMAGE, not per call — an MCP tool can return several images in one response, and one oversized image is dropped (replaced with a text denial note) without discarding the rest of the result. server, tool, size_bytes, mime_type
file_read_media_write_unavailable, mcp_media_write_unavailable, web_fetch_media_write_unavailable #5653 — MediaStore.save_media raised MediaStoreWriteUnavailable (the project-wide storage.max_bytes pre-check, _evict_cross_session_over_cap, refused this write) — a DIFFERENT gate than *_media_denied above (size cap vs. storage cap). Each site degrades to the SAME inline-base64 shape it already uses for "no MediaStore configured" (never a raw path-ref); mcp's own version fires PER IMAGE inside its batch loop (mirrors mcp_media_denied's own per-image gating) and keeps the image (unlike mcp_media_denied, which drops it) rather than aborting the rest of the result. size_bytes, mime_type, plus path/url/server+tool per site matching its own *_media_denied sibling's own identifying fields.
mcp_server_installed mcp_install op — name, key names only (no values)
mcp_install_cancelled, mcp_prompt_get_cancelled, mcp_resource_read_cancelled, mcp_resource_subscribe_cancelled, mcp_resource_unsubscribe_cancelled #2813 — a Ctrl-C cancel_event interrupted the corresponding op (install probe / get-prompt / read-resource / subscribe / unsubscribe) before it completed; the op returns status:"cancelled" and nothing is committed
plugin_install_started, plugin_install_copied, plugin_install_registered, plugin_install_completed plugin_install op's own main-flow milestones — started: name, source_kind; copied: name, plugin_root; registered: name, registered (the per-capability-kind registration result); completed: name
mcp_server_install_skipped plugin_install op (#4580) — fires once per declared MCP server the op's probe-then-commit loop drops (never a silent continue): server_id, server_name, reason ("probe_failed" or "permission_denied"), source ("plugin_install:<plugin_name>"). The op's own return value also carries a skipped list alongside registered — this event is the audit-trail counterpart, not a duplicate of it.
pipeline_install_skipped, skill_install_skipped plugin_install op (#4590) — fires once per declared pipeline/skill whose own sub-install call (pipeline_install/skill_install) returned a non-"installed" status instead of raising (a bad name, a threat-scan block, a missing DSL file, ...): plugin_id, path (the pipeline DSL file / skill directory), reason (the sub-install's own status value — e.g. "error", "blocked"), error (the sub-install's own error message, "" if absent). Unlike mcp's probe-then-commit (which skips BEFORE ever calling the sub-install), a pipeline/skill sub-install always runs; the skip is read from its return value, not a separate pre-check.
skill_body_threat_match, skill_body_threat_blocked load_skill op (#4699) — the body-scan chokepoint every skill body crosses regardless of how its entry was registered (skill_install's own skill_install_threat_match/skill_install_threat_blocked above cover only the frontmatter description and only the skill_install op, so a hand-written skills.yaml entry never reaches those). match fires once per pattern hit (pattern_id, severity, scope); blocked fires when a hit reaches block_severity (pattern_id, severity, path) — the load returns status: "blocked" with content: "", so the body never reaches the model's context.
plugin_install_token_vocabulary_mismatch plugin_install op (#4610) — fires once per finding in _expand_plugin_files's own stale_token_warnings list (mcp.json's args/env/cwd still containing ${REYN_PLUGIN_ROOT}/${REYN_PROJECT_DIR}/${REYN_SKILL_DIR}, or a pipelines/*.yaml/skills/*/SKILL.md file still containing ${PLUGIN_ROOT}/${PLUGIN_DATA} — the OTHER file's token vocabulary, never expanded where it was found so it survives literally with no error otherwise): name, warning (the same human-readable string the op's own stale_token_warnings return-value list carries — this event is the durable audit-trail counterpart, so a plugin installed once and never re-inspected still has a record). mcp.json's command/url fields never trigger this — the Agent Plugins 1.0 spec deliberately never expands tokens there, so a literal token surviving in either field is correct, not a mismatch. Report-only: never blocks the install, never rewrites the plugin's files.
plugin_install_reconciled Startup crash-recovery, not the main install flow — a partial install left behind by a prior crash is rolled back before any op runs; name, action ("rolled_back" today, the only value emitted)
web_search_started, web_search_completed, web_search_failed web_search ops — started: query, backend; completed: adds result_count; failed: adds error
web_fetch_started, web_fetch_completed, web_fetch_failed web_fetch ops — started: url; completed: url, status_code, content_length, extractor; failed: url, status ("timeout" or "error"), error
semantic_search_embed_failed semantic_search op (FP-0057 Phase 2a; renamed from recall_embed_failed) — emitted when a model group's embed call fails; query, model, error
index_dropped index_drop op — source, chunks_dropped: int
index_update_cost_warning index_update op (FP-0057 Phase 2a) — the to-embed batch exceeds embedding.cost_warn_threshold; source, chunk_count, estimated_tokens, threshold
index_updated index_update op — source, added: int, updated: int, removed: int, skipped: int
control_ir_skipped, control_ir_failed dispatch failures (control_ir_skipped reasons include handler_not_implemented)
permission_denied When an op is denied by the resolver
permission_approval_revoked, permission_approvals_cleared #5065 — a management operation on the SAVED-approvals ledger (.reyn/approvals.jsonl), e.g. the /api/permissions REST router. Distinct from permission_granted/permission_denied above (those are in-run decisions, hence run_id/actor/phase; these happen outside any run, so they carry neither): permission_approval_revoked — key, surface; permission_approvals_cleared — count, surface.
permission_approval_granted #5236 — the grant half of the pair above: an operator answering an interactive permission prompt with the persist-and-allow choice (PermissionResolver._persist, the SINGLE choke point every such choice funnels through — _prompt's ALWAYS branch, _prompt_file_access's JUST_PATH/RECURSIVE branches). Before this, only the undo (revoke/clear) was observable; the grant itself left no trail. surface="permission_prompt" (not "web"/"cli", unlike the revoke pair above) — a grant can be answered from ANY surface presenting the prompt (TUI, an SSE-delivered web intervention, A2A, MCP elicitation), and _persist itself never learns which one; claiming a specific surface would be a fabricated field.

MCP

Unlike the Control IR mcp_* events above (tied to a tool-call op), these fire asynchronously from the MCP connection/receive-loop, independent of any op dispatch:

Kind Trigger Key payload
mcp_initialized Emitted on every (re)connect, once the server's handshake completes (initialize or, since #3698 PR-2's Client(mode="auto"), discover). server, negotiated_version, capabilities, subscription_adapter (the selected SubscriptionAdapter class name — LegacySubscriptionAdapter or ListenSubscriptionAdapter, see Concepts: MCP — the audit-visible witness of which delivery mechanism this connection actually uses)
mcp_resource_updated A subscribed resource's server-pushed resources/updated notification, or a synthetic resync fired per re-subscribed URI after a transport-death reconnect. Also wired into the hook dispatcher as an external-event hook-point — see Concepts: hooks. server, uri, resync (true for a reconnect resync, false for a real push)
mcp_reconnect_failed #5280: a reconnect attempt (MCPConnectionService._reconnect, either _HeldConnection._heal's reactive path or _reconnect_from_lost_subscription's proactive one) itself failed to reopen — the server subprocess is genuinely gone, not just a transient transport death. Fired instead of mcp_initialized (which only fires on success) specifically so Session.mcp_subscription_state()'s reactive cache (#5276/#5279) has a signal to invalidate on, since the server has already dropped out of held_servers() by this point. server
mcp_client_close_leaked #5357: an MCPClient's transport was torn down because its dedicated owner task was cancelled (nothing referenced the client any more) WITHOUT close()/__aexit__ ever being called — the "reference just gets dropped" shape that used to crash the event loop with RuntimeError: Attempted to exit cancel scope in a different task than it was entered in (the loop's own async-generator finalizer closing the transport in NO task at all). initialize()/close() now pin the open and the eventual close to one dedicated task regardless of caller, so this no longer crashes — but it is still a genuine leak (nobody asked for the teardown), so it is reported. Never fires on a normal close() call (the noise guard). server, transport
mcp_elicitation_requested A server issues an elicitation/create structured-input request. server, field_keys (the requested schema's property names only — never values)
mcp_elicitation_answered The request resolves to accept or decline (human choice, or a decline from auto_decline config). server, field_keys, action ("accept" | "decline")
mcp_elicitation_timed_out No answer arrived before elicitation_timeout_seconds. server, field_keys
mcp_elicitation_auto_declined Declined without prompting — reason distinguishes a server configured elicitation: auto_decline from a headless context (no live intervention listener). server, field_keys, reason ("server_configured" | "headless")
mcp_hook_subscribe_not_applied #5167: a session-start mcp_resource_updated hook whose auto-subscribe attempt could not be honored (permission denied, an ambiguous/glob uri that names no concrete resource, an unconfigured server, or a subscribe-level failure). Never silent — see Concepts: hooks § mcp_resource_updated. hook, server, uri (may be None when the matcher never named one), reason

None of these events include the human's typed answer or any field value — only the requested schema's property names, matching the sensitive-field handling described in Concepts: MCP § Elicitation.

External events

The 4 points a hook's on: can subscribe to as an external-event source — see Concepts: hooks § External-event points. mcp_resource_updated (above, MCP section) is the one of the 4 that has always emitted an arrival audit event; #4605 closed the same gap for the remaining 3 — each now emits its own arrival event REGARDLESS of whether a hook is configured to consume it, so the signal's arrival is reconstructable from .reyn/events even when nothing was listening.

Kind Trigger Key payload
file_changed A watched path (fs_watch.paths) reports a create/modify/delete via the watchdog observer, after debounce (fs_watch.debounce_seconds) and symlink-path rewrite. See Concepts: hooks § file_changed. path (rewritten to the operator-configured prefix), event_type ("created" | "modified" | "deleted")
cron_fired A scheduled cron job fires, on the job's own resolved cron:<job_name> session, right after session resolution. See Concepts: hooks § cron_fired. job_name, to (operator-authored config, never end-user-supplied)
webhook_received An inbound webhook (slack/line/generic plugin) is routed to its resolved per-sender session, right after session resolution. See Concepts: hooks § webhook_received. transport, sender — NEVER the raw inbound body/text, which may carry tokens/PII (same discipline as hook_push_fired's "never the message body")

All three are best-effort: a sink fault logs and is swallowed, never blocking the job's inbox delivery / the webhook's HTTP response / the watcher's drain loop.

mcp_resource_updated and file_changed share one in-process delivery mechanism, reyn.hooks.ingress._BoundedEventBridge — a bounded queue that drops the newest event on overflow rather than blocking the producer or growing unboundedly. Before #5515 that drop was logger.warning-only, unlike the structurally identical HookBus subscriber-queue drop (bus_subscriber_dropped, #2886). ingress_bridge_dropped closes that gap for this bridge, on the same first-drop/every-100th cadence: metadata-only (source — the bridge's adapter name; point — the bare hook point; drop_count — cumulative, never reset), never the dropped event's own payload. cron_fired and webhook_received share a separate bridge (_SessionFireBridge, reyn.hooks.external_fire) with the same overflow shape and now fires the SAME kind on its own drop, keyed apart by source:

source Bridge
McpIngressAdapter reyn.hooks.ingress._BoundedEventBridge instance backing mcp_resource_updated
FsIngressAdapter reyn.hooks.ingress._BoundedEventBridge instance backing file_changed
_SessionFireBridge reyn.hooks.external_fire._SessionFireBridge — shared by cron_fired/webhook_received

Credentials and OAuth

Kind Trigger Key payload
token_refreshed Emitted by reyn.secrets.get_valid_token(key) after a successful OAuth refresh against the provider's token endpoint (RFC 6749 §6). key: str — OAuth token key (same as the ~/.reyn/oauth_tokens.json entry); expires_at: str — ISO-8601 timestamp of the new access token's expiry.
token_refresh_failed Emitted by get_valid_token when the token endpoint returns a non-2xx response or the response payload is malformed. Raises OAuthRefreshError. key: str; error: str — short error description (HTTP status + provider error code if available).

Notes: - token_refresh_failed pairs with token_refreshed — exactly one is emitted per get_valid_token call that performs a network refresh.

See also: Concepts: secret handling — OAuth lifecycle and credential scoping; Concepts: permission model — per-skill credential scoping.

Action catalog routing

Kind Trigger Key payload
routing_decided Emitted at the router's single dispatch chokepoint (RouterLoop._dispatch_resolved) whenever a catalog action is dispatched — via the invoke_action wrapper, an ARS-salvaged direct call, or (#3455) the flat bare-name dispatch path used when tool_use.universal_wrappers_enabled: false (moved from action_retrieval:, #4552 PR-3+4) is set in reyn.yaml. action_name: str; source: str — "invoke_action" | "ars_direct"; outcome: str — "success" | "error"; chain_id: str — request chain identifier for cross-call correlation.

Notes: enables auditing catalog-action routing regardless of which entry surface the model used (#3455: previously gated on the invoke_action wrapper surface, so the universal_wrappers_enabled: false opt-out configuration never emitted this event at all). Cross-correlate with chain_id across the action's downstream events.

User interaction

#4666 item ③ — the content field on 6 kinds below is its OWN opt-in (audit_events.user_input_include_text, default false, separate from ①'s agent_delta_include_text AND ②'s completed_response_include_text below): off, the durable record drops that kind's one content field (named per row below) but keeps everything else; on, restores it. LocalEventBackend.declare_gaps() names this gap only while off. ⚠️ Default-behavior change, 2026-08-21 (#4666): before this field existed, these kinds' content was always durably recorded, unconditionally. Unchanged either way: live subscriber delivery (TUI/AG-UI/peer broadcast — every one of these kinds is delivered live before this backend-level knob is consulted) and history.jsonl. ⚠️ Known gap NOT closed by this knob: ask_user's question/answer also reach the audit log unconditionally via tool_called.args/ tool_returned.result (dispatch_tool, a separate emit path this knob does not touch) — do not read the rows below as exhaustive coverage of "user input reaches the audit log".

Kind When Key payload
user_submitted A user typed a message and it entered the inbox (submit_user_text). text (#4666 ③ opt-in, see above), chain_id, msg_id, seq, meta
user_message_received A new user turn enters the runtime. Carries chain_id (the uuid minted by submit_user_text and propagated through any agent-to-agent messages this turn produces). text (#4666 ③ opt-in, see above), chain_id, media_block_count
user_intervention_requested An ask_user op asked its question (core/op_runtime/ask_user.py). Fires unconditionally, always. #4666② — question/suggestions/options are in ②'s scope, gated by audit_events.completed_response_include_text — because the question is text the MODEL directed at the user, the same content type ②'s other kind (agent_response_committed) covers (owner ruling: one knob per content TYPE). This is NOT the same knob the row below uses: that row's answer is a DIFFERENT content type (text the USER directed at the model), so it is necessarily gated by ③'s own, separate knob — not because "②/③ must share a knob for one exchange" (an earlier, corrected framing), but because the owner's per-content-type rule puts question and answer in different types by construction. intervention_id is always kept; the free-text fields are dropped from the DURABLE record by LocalEventBackend.write() while ②'s knob (default false) is off. This is ②'s SECOND redaction site, not covered by agent_response_committed's own choke-point coverage: ask_user never reaches Session._put_outbox (it routes through a separate intervention_bus protocol path instead). Live TUI/AG-UI delivery, and any opt-in OTEL subscriber, are UNAFFECTED by this flag. Toggling ② without ③ (or vice versa) durably records one side of an exchange but not the other — a deliberate per-operator choice under "one knob per content type", not a defect. The SAME question text ALSO reaches tool_called.args["question"] (a wholly separate, generic emit path, dispatch_tool) — governed by this SAME ②'s knob via a per-tool content-field declaration (#4666 item ③b, reyn.core.dispatch.content_declarations), closing the dispatcher-level leak a PR review found. question, intervention_id, suggestions, options (the three free-text fields present only when completed_response_include_text=true, or on a log written before #4666② shipped)
intervention_announced #5729. Fired from InterventionHandler.announce — the ONE choke point ALL 6 intervention_bus.request() callers share (ask_user/permissions/limit_handler/mcp_install/elicitation/hooks-shell_runner), unlike user_intervention_requested above which only ask_user.py emits directly (verified against interfaces/repl/renderer.py's own comment). Exists so a subscriber that needs "an intervention was just enqueued, whichever kind" (the #5729 registry status fan-out powering the TUI agent tab's iv_waiting glyph) can read it via subscribe_audit_events alone, without also subscribing to the session's outbox — announce ALSO puts a kind="intervention" message there for display, but subscribing to that channel for this purpose was tried and reverted: it starts OutboxHub's drain task eagerly for every session, a measured regression that silently began consuming session.outbox before any real UI ever attached. No content field — kind is the intervention's own type discriminator ("ask_user" / "permission.*" / etc.), never the prompt text. intervention_id, kind
user_intervention_received An ask_user op got its answer — the user→model half of the exchange the row above documents, a DIFFERENT content type from the question, gated by ③'s OWN knob (user_input_include_text), not ②'s. The SAME answer text ALSO reaches tool_returned.result["answer"] (a wholly separate, generic emit path, dispatch_tool) — governed by this SAME ③'s knob via a per-tool content-field declaration (#4666 item ③b, reyn.core.dispatch.content_declarations), closing the dispatcher-level leak a PR review found. answer (#4666 ③ opt-in, see above), run_id, actor, intervention_id, refused
intervention_answer_submitted An intervention answer was broadcast to every connected surface (TUI free-text / choice / A2A peer / AG-UI HITL share this one funnel). text (#4666 ③ opt-in, see above; the matched choice's label, or the raw free-text answer), intervention_id, meta
user_answered_intervention An intervention was answered, recorded alongside the history append. answer_text (#4666 ③ opt-in, see above), intervention_id, kind, run_id, actor, choice_id
router_retry_exhausted The per-turn router-invocation cap (check_and_increment_router_cap) was exceeded. user_message (#4666 ③ opt-in, see above; truncated to 200 chars at the emit site regardless of this flag), count, cap, last_reason
chat_started, chat_stopped Chat session lifecycle —
turn_cancelled A user turn was cancelled mid-router-loop (e.g. /cancel or a new submission supersedes the running turn). chain_id
turn_stopped_memory #5851 PR-3 ③' — the turn-mid memory mini-ladder's own terminal step: Session._check_turn_mid_memory_ladder, polled at the SAME router-loop iteration boundary as the cancel checkpoint, found the process footprint still over process_memory.max_bytes after both ①' (an immediate compaction fold, excluding the in-flight turn's own messages) and ②' (PR-1's shared cache-release step) failed to bring it back under cap — so the turn ends at THIS boundary, never mid-tool-call. Deliberately a DIFFERENT kind from turn_cancelled: an operator's own cancel and this safety net must never be conflated (a reader needs to be able to tell "the user stopped it" from "the ladder stopped it"). The exit is otherwise shaped exactly like a cooperative cancel — history and WAL stay consistent, and the next turn starts normally. chain_id, footprint_bytes, cap_bytes
history_oversized_content_spilled #6042 — Session._enforce_per_message_content_cap, checked at _append_history's own convergence point ("every role funnels through" — its own docstring), found msg.content over history_resident.per_message_max_bytes and fully preserved it by spilling to a file via the SAME seam MediaStore.save_tool_result already uses for tool results (#5896) — the durable history.jsonl row's content becomes a short pointer text, never the original bytes. Real-machine grounding: #6089 (architect) measured a 462 MB single role=tool entry in a live history.jsonl, past the existing (non-airtight) tool-result spill gate. Role-agnostic by design (lead-coder ruling) — assistant-authored content is in scope too, even though max_tokens makes it unlikely to actually reach this cap in practice. role, seq, original_bytes, cap_bytes, path
history_oversized_content_truncated #6042 — the FALLBACK sibling of history_oversized_content_spilled, fired when no MediaStore is configured (or the spill write itself failed): the content is truncated, but never silently — the durable row's own meta carries content_truncated/content_truncated_original_bytes, and msg.content itself becomes a preview of the first bytes (never a bare boolean marker with nothing else — lead-coder ruling: "何が失われたか" must stay at least partially readable). role, seq, original_bytes, cap_bytes, preview_bytes

Session and turn lifecycle

The boundary events every trigger passes through, whatever surface submitted it. Required fields are declared in src/reyn/core/events/event_schema.py.

Kind When Key payload
session_halted #2259/#2280 (remedies added #5939 PR-2) — a session has FAIL-STOPPED: it no longer accepts operations. Emitted by Session._latch_halt, the ONE chokepoint every halt reason goes through — durability_failure (a durable write persistently failed), shutdown_requested (a clean shutdown sentinel), cancelled (run() was cancelled, e.g. Ctrl-C), and process_memory (the steady-state memory ladder's own ③, #5939 PR-2). First reason wins — at most one halt notice per session. remedies is MANDATORY (_latch_halt's own signature has no default and raises ValueError on an empty tuple) — a halt that does not tell the operator what to do next cannot even be constructed. reason, remedies (non-empty list of concrete recovery actions)
session_memory_backpressure / _cleared #5939 PR-2 ① — the memory ladder's own recoverable step: this session's process_footprint is over process_memory.max_bytes, so new turns are refused (a turn already running still completes). Latched — one emit per transition into backpressure, never per-tick (#5901's own latch ruling, reused). _cleared fires on the next turn-end sample that drops back under cap — a reader can always tell whether backpressure alone was enough, from the audit trail. session_memory_backpressure: bytes, cap_bytes, metric. _cleared: no kind-specific fields.
session_memory_exit #5939 PR-2 ④ — this process is exiting: the ladder reached ③ (session_halted{reason="process_memory"}) and a compaction + cache-release pass (PR-1's process_memory_forensics) still left the footprint over cap. Owner ruling: process exit is the DEFAULT terminal step (not opt-in) once reached. Broadcasts the SAME halt (reason + remedies) to every co-resident session in this process first, via AgentRegistry.all_sessions() when a registry is attached. broadcast_path ("none" | "registry") and session_count together disambiguate a lone session with no registry attached (embedded/test — broadcast_path="none") from a registry genuinely holding only 1 live session (broadcast_path="registry", session_count=1) — the same number, two different facts, kept apart rather than collapsed. footprint, cap_bytes, metric, session_count, broadcast_path, delivered, remedies
session_started, session_completed The session's resource scope opens / closes (alongside chat_started / chat_stopped). agent_name
session_run_task_finished #5694 stage 2 (architect ruling) — AgentRegistry's own (name, sid) background session.run() task ended: normally, by raising, or by being cancelled. Before this event existed, all 4 of the registry's asyncio.create_task(<session>.run()) call sites (ensure_running, ensure_session_running, attach, attach_session) had 0 add_done_callback registrations between them — a session dying was consumed by nothing more specific than asyncio's own generic, (name, sid)-blind "Task exception was never retrieved" handler (asyncio_unhandled_exception, a DIFFERENT event kind, still fires independently if this one's own emit somehow fails). Emitted from AgentRegistry._on_session_run_task_done, via self._project_root (the registry's own resolved project root — NOT emit_cli_event's Path.cwd()-derived one, which that function's own docstring names as wrong for a long-lived server process). Diagnostic only — does not itself decide whether to restart anything; the 4 call sites' own restart-guard (now folded into AgentRegistry._ensure_session_run, see below) is unchanged in its own decision logic. name, sid, status ("completed" | "exception" | "cancelled", mutually exclusive), exception_type/exception_message (present only when status="exception")
session_run_task_rediscovered_dead #5694 stage 2 disposition (architect ruling, the arc's own final open item — "処分") — a request-driven caller (ensure_running/ensure_session_running/attach/attach_session, via AgentRegistry._ensure_session_run, the same method that also folds the 4 sites' own restart-guard into one place) found a PRIOR (name, sid) run-task already done() and is replacing it. This restart itself is NOT new — it already happened, silently, before this event existed (the ruling's own framing: "問いは『再起動するか』ではありません。もう しています。問いは『それが記録されているか』"). Fires only on a genuine rediscovery (a task existed for key AND was done) — never on a first boot (key absent), which is not a rediscovery of anything. Deliberately no new automatic-restart policy of any kind rides along (no retry count, no backoff, no crash-loop detection, no push notification — the ruling's own explicit "落とすもの(全部)" list); the read side is reyn doctor and fleet visibility (reyn-broker#31 / #5709's own series). The event's own timestamp is an upper bound on when the task actually died, never the death time itself — nothing polls for this; it is only ever noticed the next time some real request needs this (name, sid) again (same property process_marker_reaped's own observed_at already has). Out of scope (explicitly, a re-open trigger only if measured): a session that is genuinely still WAITING (done() is False, its own loop still runs) is not a death this event's condition can ever fire for. name, sid
turn_started A trigger has been consumed from the inbox and is about to be dispatched. kind — the inbox message kind that triggered this turn, so a subscriber can tell human triggers from automated ones without parsing the payload. The vocabulary is CLOSED and enumerated in TurnOrigin (src/reyn/runtime/turn_origin.py) — read the member values there rather than a list here, which is how "task_ready" outlived the task system in this row. #3595 made kind == "user" TRUE rather than approximate: a pipeline agent step's prompt (step 1), a webhook / MCP / A2A / cron push (step 1b), and the attached-pipeline run nudge (S2) each used to arrive claiming it; each now carries its own member, so "user" means an operator typed the line at a first-party client and nothing else does. chain_id; seq; skipped_over (#5647 — MID-TURN injections only, absent on an ordinary turn-boundary turn_started): what this injection looked PAST in the inbox to reach the operator's message, as [{"kind", "msg_id"}] in arrival order, empty list when it looked past nothing. #3792 originally STOPPED a mid-turn peek at the first ineligible queue head, reasoning that looking further would silently reorder arrival and leave no trace; measured on reyn-self, that rule disabled the feature outright, because a broker_drain hook posts inbox items continuously and the operator's prompt was almost always queued behind one. #5647 lets the peek look past them and makes this field the trace — enumerated rather than counted, so a reader can tell WHICH work was overtaken. Arrival order itself is NOT changed: the items looked past are held in a FIFO buffer that consume_inbox drains before the queue, so turns still START in arrival order — only which message the model sees first WITHIN a turn moves. Eligibility is unchanged: only CLIENT_INPUT is ever injected (#3595).
turn_completed The router loop reached a terminal condition — router path only. chain_id
stall_trace_armed, stall_trace_disarmed #5103 ④ — bracket the #4405 REYN_STALL_TRACE diagnostic around run_turn dispatch (armed before, disarmed in the finally after). Only emitted when REYN_STALL_TRACE is set — mirrors arm/disarm's own off-by-default gate, so both are absent from an ordinary run. Joining on chain_id against turn_started and reading index order gives an append-only, public way to prove the bracket really wraps dispatch, without a private run_turn replacement asserting inside a monkeypatched closure. chain_id; seconds (armed only)
agent_delta One EMITTED content-delta batch during an LLM reply (#3288 ③b) — not one raw provider chunk since #5261/#5268: recorded_acompletion's own source-side merge (_pump_chunks/_drain_and_emit_deltas, two same-loop tasks, no threads) concatenates whatever raw chunks were ALREADY waiting in the queue into one on_content_delta call before this event is ever emitted — a fixed window/count is deliberately never used, so the merge boundary is emergent from cooperative scheduling, not a knob. raw_chunk_count/first_arrival/last_arrival (architect condition, #5261) keep "what did the provider send, and when" answerable after that merge. Live subscriber delivery fires for EVERY emitted batch, unthrottled, text always included — with one exception further downstream: #5259, the AG-UI connection's own frame drain concatenates a run of consecutive same-chain_id/round_index batches that were ALREADY waiting in that connection's queue into one frame (a SEPARATE, later merge from the source-side one above). Nothing is held to make that happen, so a connection keeping up sees one frame per emitted batch exactly as before, and two connections watching the same session can legitimately see different frame COUNTS while receiving the same text. Concatenation is the contract at both layers; the boundary between batches/frames is not. #4960 (architect ruling C): the DURABLE write side is different — LocalEventBackend coalesces to one record per audit_events.agent_delta_coalesce_fragments RAW PROVIDER CHUNKS (default 100 — before #5261, the code counted with a flat +1 per EVENT; since 1 event was always exactly 1 raw chunk back then, that count happened to equal a raw-chunk count too. #5268, when #5261's source-side merge broke that 1:1 correspondence, chose to keep counting raw chunks rather than switch to counting events; see below for how) or audit_events.agent_delta_coalesce_interval_ms milliseconds (default 2000), whichever comes first, per chain_id, plus one final record when the stream ends (success, exception, or cancellation — RouterLoop.run()'s own finally). Measured (2000-delta/60KB real streamed reply, agent_delta_include_text=true): unthrottled, agent_delta was 99.4% of the audit file's total bytes for that run — this figure predates #5261's source-side merge (it counted one event per raw chunk at measurement time) and assumes text is being written; with the #4666 default (false), a coalesced record's bytes are smaller and this percentage does not apply as measured. reyn events replay therefore sees FEWER agent_delta records than raw provider chunks actually streamed — declared via LocalEventBackend.declare_gaps() (contract 2), not a silent loss. A record produced by coalescing carries coalesced_fragment_count — #5261/#5268: this now sums each incoming event's own raw_chunk_count (defaulting to 1 for an event without one, i.e. an unmerged event IS one raw chunk), not a flat +1 per event — the field continues to mean "how many raw provider chunks does this one durable record stand in for," now correctly accounting for the source-side merge above rather than silently under-counting it. Because the coalescing threshold's UNIT was kept as raw chunks (summing raw_chunk_count rather than switching to counting events), the default number of durable records per streamed reply is UNCHANGED by #5261/#5268 — a reader should not infer that pre-merged chunks made durable coalescing coarser; it didn't, only the write side's counting arithmetic became correct again. #4666 — text on the durable record is ITS OWN opt-in (audit_events.agent_delta_include_text, default false, separate from the coalescing knobs above): off, the durable record drops text but keeps everything else; on, restores it. declare_gaps() names this gap ONLY while off — dynamic, not static, per contract 2's "declared vs never-existed" distinction. ⚠️ Default-behavior change, 2026-08-21 (#4666): before this field existed, text was always durably recorded, unconditionally. text (present only when agent_delta_include_text=true), chain_id, round_index, raw_chunk_count (#5261 — how many raw provider chunks this event's text merges, minimum 1), first_arrival/last_arrival (#5261 — ISO timestamps of the first/last merged raw chunk's arrival), coalesced_fragment_count (present only on a coalesced/durable record, absent on the live-dispatch-only events it stands in for)
agent_response_committed #4666② — opt-in, default OFF (audit_events.completed_response_include_text). Fires UNCONDITIONALLY (same shape as agent_delta above) from Session._put_outbox, the single measured choke point every model→user text commit funnels through: the terminal reply (organic or its response_format variant), mid-loop budget force-close, max_iterations wrap-up, session.py's own router_cap wrap-up, and the tool_calls-round accompanying text (persist=False — not written to history, but the user sees it, so still in ②'s scope per architect's ruling: "何が言われたか、であって、なぜ言われたか、ではない" — no organic-vs-forced classifier exists here and none is added). A future commit site needs no new emit to be covered, only one that bypasses _put_outbox entirely would leak. Excludes cancellation (a cancelled turn's result is never appended, so no kind="agent" message exists to commit) and canned/synthetic non-model text (_EMPTY_RESPONSE_MSG and similar — never routed through put_outbox with model-generated content). text is dropped from the DURABLE record by LocalEventBackend.write() while the knob is off — chain_id is always kept, so "a response was committed" remains provable without its content; live TUI/AG-UI delivery, and any opt-in OTEL subscriber, are UNAFFECTED by this flag (same disclosure as agent_delta_include_text above — this only throttles what reaches disk). ask_user's own question is ALSO in ②'s scope (the question is text the model directed at the user — the same content type this kind covers) but is covered by a SEPARATE redaction site — see user_intervention_requested in the "User interaction" section above — because it never reaches the outbox (a distinct intervention_bus protocol path). The same text ALSO duplicated into tool_called.args / tool_returned.result for tool-mediated exchanges (e.g. ask_user's question/answer verbatim) — that dispatcher-level path is now covered too (#4666 item ③b: a per-tool "this field is conversation content" declaration, reyn.core.dispatch.content_declarations, consulted by dispatch_tool — see user_intervention_requested/user_intervention_received above for the field-by-field mapping). ⚠️ Scope, measured, not extended by guess: only ask_user declares any field today; mcp_called.args was measured and excluded (an MCP tool's args are model-decided call parameters, not a dedicated question/answer channel). A tool that shows the user free text and forgets to declare it still leaks that text silently through this path — the declaration bound only catches the declared set GROWING, not a tool that should have declared and didn't. text (present only when completed_response_include_text=true, or on a log written before #4666② shipped), chain_id (may be absent for a non-user-triggered turn)
turn_settled The turn is done, for EVERY turn kind including slash / intervention short-circuits that return before the router. The reliable clear signal for a working indicator started on turn_started. kind; chain_id (may be absent for non-user triggers)
project_context_changed Turn boundary, emitted by TWO ProjectContextWatcher instances per session (src/reyn/runtime/project_context_watch.py, #3787/#4263/#5742) — tell them apart by scope ("project" / "agent", #5742: a typed field, never inferred by sniffing path's shape — the pre-#5742 form of this row's own consumer contract). Project frame (reyn.yaml/reyn.local.yaml-only project_context_path → REYN.md / AGENTS.md — #5742 flip, REYN.md now wins when both exist): detection only — the file is scanned, not fenced (#4830 — operator/agent-editable content, the same trust class as Claude Code's CLAUDE.md, backstopped by the file-write permission gate rather than a per-turn fence marker), but the live project_context a session's system prompt was built with is frozen at construction and NOT reloaded from this signal (owner ruling, #3787/#5742: this is config — read once at startup by design, not hot). Agent frame (.reyn/agents/<agent_name>/profile.yaml's own context_path, owner ruling, #3787/#5742 — REYN.md/AGENTS.md again, WITHIN the agent's own workspace_dir): DOES reload — but not because of this event. RouterHostAdapter.get_project_context (via _read_agent_instructions) resolves and reads this file fresh on every call regardless (it is already invoked live every turn, never from a memoized value), so an edit — or a context_path change that selects a different file — is reflected on the very next turn with no dependency on this watcher firing at all; this event is purely the audit-event signal that an edit was observed, for the same reason every other *_changed kind exists (band: observability). Either way: fires once per edit, never repeats until the file changes again (mtime comparison, not content hash — same content re-touched, e.g. a branch switch, can still fire once). scope ("project" | "agent", #5742), path
project_context_unreadable An operator EXPLICITLY specified a project-context or agent-context file (project_context_path in reyn.yaml, or context_path in profile.yaml) and reyn could not read it — a typo'd path, a permission change, or a deletion in the narrow resolve-then-read race (src/reyn/config/loader.py's resolve_context_text/_warn_context_unreadable, #5742). Never fired for "nothing configured" (silent/normal — PROJECT_CONTEXT_NO_CANDIDATE/PROJECT_CONTEXT_DISABLED are NOT this kind) — only for "you asked for something and it broke" (owner: "捏造しないこと" applied to runtime, not only reyn doctor, which answers the SAME question by calling the SAME resolver rather than reading this event). The session/agent still starts (degrades to empty text, same as before) — this is a WARN-class diagnostic, not a startup failure. Emitted via emit_direct_event (no live Session/EventLog exists at either call site's own point in startup, #5065's own "no Session to call through" shape) — best-effort; an emit failure here is itself logged and swallowed, never propagated. scope ("project" | "agent"), path (the CONFIGURED value — a resolved Path for the exists-but-unreadable case, or the raw configured string for the missing-explicit-pin case)
visibility_changed #5276/#5277 — Session.set_capability_visible (the /visibility command's own seam) was called for THIS session. Fires unconditionally once the forwarded CapabilityVisibility.set_capability_visible call returns without raising — this records the OPERATOR'S OWN toggle action, not whether the effective, envelope-gated visibility actually changed (toggling ON a capability the agent's envelope denies is a no-op for the live gate, per that method's own docstring, but the override is still recorded and this event still fires — charter lens 7: reconstructable from the audit-event trail). applied (architect review, same rule as hook_changed below) means "the override was written", always True here since this method never reaches the emit past its one raising case (an unknown kind); it does NOT mean "the live, envelope-gated visibility ended up matching the request" — that deeper distinction is a disclosed, NOT-yet-closed gap (see Session.set_capability_visible's own docstring). kind (tool/mcp/category/skill), name, on, applied
hook_changed #5276/#5277 — Session.set_hook_enabled (the /hook command's own seam) was called for THIS session. Fires on BOTH outcomes (architect BLOCK, #5277: a kind that only ever fires on success cannot answer "why is this protected hook still running after someone tried to stop it" — #5041/#5213 — and the meaning cannot be widened later without changing what already-logged events of this kind mean, #5261's rejected shape): a genuine change to _disabled_hooks (applied=True), or a #5230 refusal of a disable request for a startup/runtime-origin hook (applied=False, _disabled_hooks untouched — the attempt itself is the fact worth recording). name, enabled (the request), applied, origin (the hook's most-specific origin, None for an unresolved name)
hook_push_rejected_oversized #5514 §5/§8 (architect ruling, 2026-08-30) — a spillability: never hook's push exceeded its own declared spillability_max_chars. The push is REJECTED outright, not truncated (a partial frame reads as complete to the model — worse than no frame — and never forbids offload, so there is no ref to keep it lossless with the way tool_result_offloaded does); history is left byte-unchanged. Only this event plus a WARNING log line record the rejection. Scope: only that one push is dropped — the hook itself is not disabled and the turn does not fail (a separate policy, out of scope here). first_choice/last_resort hooks never reach this branch (no cap is required of them). hook_name, declared_max_chars, actual_chars

Behavioral anomaly detector

behavior_anomaly_judged (#5221) — the ONLY producer is the emit_behavior_anomaly_verdict tool (src/reyn/tools/emit_behavior_anomaly_verdict.py), called from a tool step in the src/reyn/data/pipelines/behavior_anomaly.yaml pipeline right after its judge agent step answers. NOT a "prompt-injection detector" (architect ruling, issue #5221): the judge is constrained to a CLOSED vocabulary of op-kind counts/booleans (never raw message text — see reyn.runtime.turn_behavior_tally), so it can flag that a turn's OPERATIONS looked unusual, never that its TEXT contained an injection. Launched from a turn_end hook, which fires AFTER the turn — this can only alert on / gate the NEXT turn, never prevent the one that carried the behavior (detection, not prevention).

Three states, join by chain_id against turn_settled/turn_completed above — never conflate them: (1) verdict: "suspicious" — actionable; (2) verdict: "clean" — asymmetric trust: means ONLY "the judge did not flag this turn", never "verified clean" (a judge successfully talked out of alarming is indistinguishable from a genuinely clean turn from the outside); (3) a chain_id with turn_settled but NO matching behavior_anomaly_judged at all — "the judge did not run" (below the deterministic escalation threshold, or the pipeline launch itself failed) — a DIFFERENT state from (2), not a variant of it.

Kind When Key payload
behavior_anomaly_judged The judge agent step answered and its tool step recorded the verdict. Opt-in — nothing fires until an operator registers both the pipeline (pipelines.entries) and a turn_end pipeline_launch hook naming it. verdict (clean | suspicious), chain_id, anomalous_op_count (the sensitive-op tally that triggered escalation)

Tool dispatch

Emitted by dispatch_tool (src/reyn/core/dispatch/dispatcher.py) around every call it wraps. tool_returned and tool_failed are mutually exclusive per call.

caller_kind is "router" (the chat agent main loop — every pre-#5654 caller), "operator" (#5654: a slash command driving an op directly, with no LLM tool_calls round behind it — e.g. /tasks cancel, via slash/tasks.py's own dispatch_tool call), or "pipeline" (#5865: a pipeline tool: step, dispatched by a driver-session outside any RouterLoop turn — tools/pipeline_verbs._make_tool_dispatch, which used to invoke a tool's handler directly with no audit trail at all; chain_id is always None for this caller, since there is no litellm tool_calls round to key one on). Load-bearing for who a tool_called event says did something: a slash-driven cancel reaching chains.get(id).cancel() directly instead of through dispatch_tool would silently drop this attribution — #5654's own reviewer strip.

Kind When Key payload
tool_called Before invocation, after argument validation. caller_kind, caller_id, tool, chain_id, call_id, args, args_hash
tool_returned The invocation returned a value that does NOT declare an error (see tool_failed). caller_kind, caller_id, tool, chain_id, call_id, tool_call_id, args_hash, result, result_bytes, result_sha256, result_truncated (only when actually cut), content_ref_unavailable, tool_call_id_absent_reason (only when tool_call_id is None)
tool_failed The invocation was refused, raised, or returned normally with a self-declared error (#3450 — a handler's own {"error": ...} / {"error_message": ...} / {"error_kind": ...} return, plain or one level under its own {"status": "error", "data": {...}} self-envelope, promoted to this event instead of silently wrapped as a success). same, plus error_kind (tool_excluded | permission_denied | exception | a validation reason | a handler-supplied kind | handler_error) and message — tool_excluded (#1406/#187/#5841/#5854): the call-time TOOL-axis contextual restrict denied the (invoke_action-unwrapped) effective name, checked BEFORE catalog membership so an excluded-but-uncataloged name still reads truthfully rather than as unknown_tool

call_id (#4691 Phase B ①, remainder — DispatchContext.call_id) is the litellm call (LLMToolCallResult.call_id, #4725) whose tool_calls this dispatch belongs to — the SAME key llm_response_received carries for that call (#4722). None for a dispatch whose DispatchContext never threaded one through (the router's own tool-turn dispatch always does — RouterLoop reads it off the current round's LLMToolCallResult.call_id and passes it as an explicit parameter down dispatch()/_run_execute_round()/ _dispatch_resolved(), never a stored field, so there is nothing that can go stale between rounds; an op-loop or other non-router caller that constructs its own DispatchContext may still leave it unset) — never a minted placeholder. This is the key a TUI consumer uses to attach a tool row to its parent CALL (#4691 Phase B's flowview tree); chain_id alone cannot do this because one turn's chain_id can span several litellm calls, and dispatch order is explicitly NOT used for this (owner ruling B, #4691: order holds only while every reader reconstructs it identically, is one of several independent invariants, and a single broken one goes unnoticed silently).

args_hash is a stable SHA-256 prefix over the canonical-JSON arguments — the correlation id that pairs a tool_called with its outcome across the log.

tool_call_id (#5891 (c), DispatchContext.tool_call_id) is the ONE tool call's own tc["id"] — distinct from call_id above, which identifies the whole litellm ROUND and is shared by every tool_calls entry in it, so it cannot by itself join a tool_returned event to the ONE history row a reader wants (the same tool called twice in one round shares a call_id but not a tool_call_id). This is the SAME id RouterLoop.feedback already writes onto the corresponding {role: tool, tool_call_id: ...} history row (ChatMessage.tool_call_id) — a reader joins tool_returned. tool_call_id to that row, then to its meta.content_ref, with no new storage. Always present as a key; None for a dispatch with no real litellm tool_calls entry behind it at all (a CodeAct snippet's tool() call, an /exec//tasks slash command, a pipeline tool: step) — never a minted placeholder. tool_call_id_absent_reason (naming caller_kind) is added ONLY in that None case, the same "don't let absence and omission read as the same silence" discipline content_ref_unavailable above uses.

Remote fan-out. turn_started / llm_called / tool_returned / tool_failed are the kinds the A2A and MCP progress surfaces forward to a remote peer (src/reyn/core/events/progress_lifecycle.py); see Concepts: A2A § Progress payloads and Concepts: MCP § Progress notifications. Three of the four (turn_started, tool_returned, tool_failed) are also what the AG-UI transport maps to RUN_STARTED / TOOL_CALL_END — see agui-transport.md § "Working-indicator path". Cost events (llm_called) ride the progress fan-out but not the AG-UI working-indicator set.

Agent-to-agent messaging

Kind When Key payload
agent_message_sent _send_to_agent or _send_agent_response delivered a payload kind=agent_request\|agent_response, from_agent, to_agent, depth, chain_id
agent_request_received Receiving agent pulled an agent_request from its inbox from_agent, depth, chain_id
agent_response_received Originating agent pulled an agent_response from its inbox from_agent, depth, chain_id
agent_message_refused A send was refused (e.g. exceeded safety.loop.max_agent_hops) reason, to_agent, depth, chain_id
chain_timeout A pending chain exceeded safety.timeout.chain_seconds and was force-resolved with a synthetic error response upstream chain_id, waiting_on (sorted list of agents that hadn't replied), timeout_seconds, origin_agent
task_settle_undelivered A task's settle disposition executed, but its reply target (agent, sid) is no longer resolvable (agent removed / session not loaded) — the settle still goes terminal; delivery is dropped, fail-safe, NEVER rerouted to main (proposal 0067 P9, #3978) run_id, reply_to_agent, reply_to_sid, reason

chain_id is uuid4 hex; one per top-level user submission, propagated unchanged across every hop. Cross-agent reconstruction is grep <chain_id> over each agent's events.jsonl plus history.jsonl.

Workspace

Kind When
workspace_updated Any artifact is written
tool_executed Generic tool dispatch

Tool-result canonicalization

Every tool/op result is normalized to one canonical shape before it reaches the LLM. A producer with a real mapper is shaped cleanly; one that has no mapper (declared debt, or a genuinely unregistered source) takes a lossless whole-dict fallback instead — and that fallback is made visible here rather than silent, so unmapped-producer debt is one grep away.

Kind When Key payload
canonical_fallback_used A tool/op result took the whole-dict canonical fallback: a producer with declared-but-unwritten mapper debt, a genuinely unregistered / unknown source, or a passthrough producer whose whole-dict blob exceeded the structured offload gate. source — the invoked producer id (op kind / tool name; null for a genuine unknown); reason — unregistered | canonical_todo | passthrough_oversized. Carries the source identity only — never the result body.

Memory

Kind When Key payload
memory_saved The memory tool persisted a memory file to a layer layer, slug, path
memory_deleted The memory tool deleted a memory file layer, slug, path

Compaction and context budget

These fire on chat turns as the context-budget advisor and compaction controller evaluate whether history needs summarising. Most carry a "checked but did not compact" outcome — they are high-frequency and mostly informational.

Kind When Key payload
compaction_started A compaction pass begins — emitted from CompactionEngine.compact()'s own entry (#5475: moved off CompactionController's caller so retry_loop's own internal compaction attempts are visible too, not just the controller's) — the LLM call that follows is about to spend real tokens and rewrite context. new_message_count (#5791: a wire-MESSAGE count — every role, mixed — NOT a turn count; owner-hit [⟳ compacting 3925 turns] against a 73-user-message history was this field, misnamed new_turn_count and misdisplayed as "turns"), covers_through_seq (null on the retry_loop path — wire-dict-shaped turns carry no real seq), covers_through_unavailable_reason (null when covers_through_seq is a real int; otherwise names WHY, e.g. wire_dicts_carry_no_seq — always check this field alongside covers_through_seq, a bare null there does not distinguish "no seq" from "don't know"), had_previous, input_chars (#5592, owner ruling "事後で測れる情報を残して": the EXACT char count of what was actually handed to compact() — never an estimate; before this field, the only way to answer "did the input genuinely shrink, or never arrive?" was reading history.jsonl's own file size)
compaction_completed A compaction pass finished — the rolling summary was written to history. prompt_tokens/completion_tokens/cost_usd (#4703 axis①) are the compact()-call's OWN real usage — the primary compaction LLM call only, not any summary_resummarize follow-up pass (a rare, bounded-to-1-by-default backstop; that pass's cost is a disclosed, separate gap, not folded in here). None for any of the three only when usage genuinely could not be read off the response — never coerced to 0. This is what ChatLifecycleForwarder.on_compaction_completed reads to put real money on the [↑ N messages compacted · ↑8.2k ↓340 · $0.05] conversation-face marker — before #4703 this row existed but never showed the spend. new_message_count (#5791: a message count, from CompactionController's own candidate selection — its turns-named local variable is itself a flat list of individual messages, never grouped into conversational turns; the SAME quantity as compaction_started's own new_message_count above, from a different producer — the two were never actually different quantities, only differently-misnamed instances of the one the pre-#5791 shared field name, new_turn_count, got wrong for BOTH), covers_through_seq, section_lengths, prompt_tokens, completion_tokens, cost_usd
compaction_batch_cap_below_head_tail_budget #4477 — resolve_effective_trigger_and_budgets's 4th resource/budget invariant instance (src/reyn/runtime/services/router_history_buffer.py, siblings to resource_cap_exceeds_budget_trigger below): the compaction batch read's own byte cap (history_tail_reader.COMPACTION_BATCH_MAX_BYTES, 8 MiB) is smaller than head_budget + tail_budget's own combined token footprint converted to bytes via context_builder.INLINE_CAP_BYTES_PER_TOKEN — meaning a compaction pass would produce zero candidates every time (head+tail trimming alone consumes the whole batch), permanently stalling covers_through_seq. Confirmed reachable against real, currently-cataloged litellm models (>8 MiB combined at the shipped component_weights default). Detection only — no value is clamped. Warn-once per (model, phase). model, phase ("" when none), head_budget, tail_budget, combined_bytes, compaction_batch_max_bytes
compaction_check The compaction gate ran for a turn. outcome records the decision — e.g. too_few_turns, below_min_batch, already_running, forced_sync, forced_sync_no_turns, compaction_input_gap_invariant_violated, history_load_unsafe (#5939 PR-4: force_compact_now refused because latest_summary() returned None while this session's own hydration read was truncated by its byte budget before ever finding a summary — deriving prev_cover=0 here could re-select messages a not-yet-loaded summary already covers, a genuine double-compaction defect, not a routine "nothing to fold") (#5528: pre_frame_overflow retired along with the estimate-based proactive pre-frame guard that emitted it — force_compact_now() is reached only reactively now, never from that outcome) (#4472: candidates are read directly from the durable history.jsonl, never residency-gated, so this should never fire under normal operation — a defensive invariant, not a routine branch; a hit means something else narrowed the durable read out from under compaction, reopening #4470's silent-coverage-claim defect through a new path). outcome="forced_sync" also carries batch_truncated (#4472: True when the durable read's own per-call byte cap capped this pass short of the full uncovered range — the pass still only claims coverage of what it actually examined; a large backlog needs multiple passes, each with batch_truncated=True until the final one). Some outcomes also carry turns, head, tail. outcome="forced_sync" additionally carries the #5888 measurement set — selection ("shortfall" for the reactive ladder, "operator" for an explicit /compact), eligible_count, protected_head_tokens/protected_tail_tokens, and middle_room_tokens/middle_used_tokens — the same numbers ForceCompactResult returns to the caller, from the same selection pass, so a later reader can tell "nothing was eligible" (eligible_count == 0) from "everything eligible was protected" (eligible_count > 0, candidate_count == 0) without re-deriving either from a token estimate of their own. outcome, plus outcome-specific fields
compaction_episode_ended #5885 — the instant Session.is_compacting returns to False: emitted by CompactionController.force_compact_now right AFTER its flag reset (path="controller"), and by RouterLoopDriver._recovery_episode_scope when the shrink ladder's recovery depth returns to 0 (path="ladder"). compaction_completed/compaction_failed are emitted INSIDE the episode (before the flag reset) and, since audit-event subscriber dispatch is deferred to the event log's consumer task, project "compacting" or "settled" depending on scheduling; this is the one event emitted AFTER the reset, so a projection it triggers can only read "settled". It is a member of AgentRegistry's status-listener kinds, so an AG-UI connection gets a STATE_DELTA that settles the remote shrink-flow entry even when no display frame follows — /compact over AG-UI replies client-side, so the server would otherwise have nothing to send. path ("controller" | "ladder"), failed (controller path only), episode (ladder path only)
compaction_failed A compaction attempt raised. error, failure_class (#5828, owner-hit reyn-self brown: "overflow" | "fatal" | "retryable" — classify_llm_failure's own 3-member vocabulary, called as a non-flow-altering probe inside CompactionEngine.compact()'s own except so the render layer (ChatLifecycleForwarder.on_compaction_failed) can distinguish "this attempt is shrink-ladder-eligible, may still recover within the same episode" ("overflow") from "this exception bypasses the ladder entirely, the turn is ending" ("fatal"/"retryable") — every occurrence used to render identically as [✗ compaction failed: ...], which is exactly what the owner read as "broken" twice in a row before asking how it would be addressed. "overflow" does NOT itself promise the ladder will recover — see that method's own #5828 comment for why that further distinction is not knowable at this emission site; compaction_shrink_recovered/router_context_overflow_unrecovered (unchanged) carry it instead. The 2 non-LLM compaction_controller.py sites — chunk-building before any LLM call, and post-processing after compact() already succeeded — pass the literal "fatal", never a probe: neither is ever LLM/overflow-shaped)
recovery_summary_persisted #5578 (architect ruling) — a SUCCESSFUL overflow-recovery's own internal fold (retry_loop's ChatSummary, engine.py) is durably persisted to history.jsonl and the watermark advanced, WITHOUT a new compaction LLM call — the fold already happened, this only records it (deliberately NOT force_compact_now, which would spend a second LLM call re-folding already-folded content). outcome: "persisted" (the common case — a real advance happened), "already_covered" (idempotent no-op — the durable watermark already covers this seq, e.g. a repeat call within the same turn), "no_covers_through_seq" (no-op — the caller could not derive a real seq for what was folded, #5498's own guard against retry_loop's structurally-0 covers_through_seq reaching history). outcome, covers_through_seq, prev_cover (only on already_covered), section_lengths (only on persisted)
compaction_floor_lowered #5531 PR-2 (owner acceptance: "下限を割ったことが見える" — visible with the shipped config, not just inferable from a shrunk wire). retry_loop's own reservation-based halving ladder (SP/new_msg/summary reserved, room — the remainder — apportioned between head/tail) lowered head/tail's own minimums below what component_weights configured, once T_max itself gets halved. Fires once per halving pass that actually lowers the floor — both the byte-limit (HTTP 413) and, since PR-2, a plain token-overflow path reach this (previously silent, and even then only on the byte path). t_max_override (the halved candidate this pass derived the new minimums from), head_min_tokens, tail_min_tokens (the NEW, lowered minimums), configured_head_budget, configured_tail_budget (the UNCHANGED, entry-time component_weights-derived values this pass just went below), saw_byte_limit
compaction_schema_invalid #4883 — the compaction LLM response was syntactically valid JSON but had an empty/missing topic_arc (whose emptiness cannot be told apart from a dead response — see _validate_chat_summary_fields's own docstring). Fires once per invalid attempt, including the LAST one right before compact() raises (CompactionConfig.max_schema_reprompt_attempts bounds the re-prompt budget) — so a persistent failure across the whole budget shows as N of these, then a compaction_failed, not a silent gap. new_turn_seqs plays no part in this at all: an empty/wrong echo of that field could never trigger it even at #4951-A (reyn stopped reading the echo), and #4951-B went further — the key is removed from the schema/prompt entirely, so reyn no longer asks the LLM for it either. attempt (0-based), max_attempts, errors (the missing-field messages)
compaction_shrink_recovered (#3783 stage 2) retry_loop's bounded shrink ladder caught a context-overflow (compaction-call or main-call) and is about to shrink and retry. Fires once per recovered iteration, not once per turn — a turn that shrinks 3 times emits 3 of these. cause (the caught exception's class name), iteration (0-based ladder position), consecutive (how many times in a row cause has recovered without a different cause or a success in between), t_max_override (#4885: None on an ordinary token-based recovery; the LOCAL, retry_loop-scoped T_max this specific iteration is using once an HTTP 413 — a request-BODY-BYTE limit — triggers binary-search-halving of the in-turn token ceiling, so the temporarily-lowered window is visible in the SAME audit trail rather than running silently smaller), raw_middle_remaining/raw_middle_total (#5592, owner ruling: the absolute unspilled-candidate count out of the original total this retry_loop call started with — "5/2469", never a bar or percentage, which would hide the exact magnitude that was the missing observation in the real incident this field exists to prevent recurring; single producer for this count, #5588's own levers_left reads the same expression)
compact_now_spill_capability_absent #5717 (lead-coder review of #5712/PR #5716) — Session._compact_now_for_op (/compact and the compact op — reachable from ANY ExecutionDriver, chat or pipeline, ctx.compact_now is wired unconditionally) found no _spill_batch_for_retry on the attached driver (e.g. PipelineExecutorDriver, which carries no RouterHistoryBuffer at all). Fires once per such compaction attempt, BEFORE the shrink-and-retry loop starts — the shared shrink_pool_after_overflow function never even calls spill_fn in this case (spill_capability_present=False), so a later MID_FLOOR UnrecoveredError on this SAME attempt carries spill_was_offered=False too — the two are the SAME underlying fact recorded through two different channels (this event at the session-wiring layer, that field on the eventual raise), never a "tried, found nothing" false positive. driver_type (the attached driver's class name)
spill_candidate_population_exhausted #5531 §9.6 (owner acceptance, 2026-08-30) — the injected spill_fn (RouterLoopDriver) scanned a non-empty spill population and found no usable candidate. "Candidate 0" is ambiguous by itself: a genuinely all-NEVER population (correct — nothing left to spill) and a population-construction path silently broken (a real bug: e.g. every candidate missing its spillability key entirely) produce the SAME zero-candidate observation with no other signal. This event's own counts answer which: if first_choice_count + last_resort_count + never_count == population, the population was built correctly and is legitimately exhausted; if the sum falls short, the construction path itself is broken. Fires once per spill attempt that finds nothing (i.e. once per retry_loop iteration that reaches rung② instead of continuing on rung①). #5592 (owner ruling, correcting #9.6): the scanned population is the slice THIS attempt is actually sending (raw_middle[:_attempt_len] for a compact()-origin overflow) — coincides with raw_middle entirely on the first attempt, and is the offered slice only once rung② has halved at least once. population (candidates scanned this call), first_choice_count, last_resort_count, never_count
offloaded_content_unavailable #5438 (architect ruling — "compute, don't store") — a spilled entry's own backing file is confirmed missing on read-back (history_content_resolve.resolve's own "lost" kind, checked FRESH every serialise, never a persisted "lost" ledger). reason is derived at THIS read, never stored: never_persisted when the entry's own LOST_REASON_META_KEY already carries it (the write-time cap refused the offload outright — #5364 §1.5); otherwise gc for a SPILLED entry (eviction, media_store._evict_history_content_over_cap, is reyn's only deleter of an already-persisted spilled ref — a file missing for any other reason still reads as gc, disclosed, not claimed exhaustive) and external for an UN-SPILLED one (#5896 stage ①: no eviction pass selects an un-spilled body's file — MediaStore.is_unspilled_file — so only something outside reyn's own GC can have removed it; this one also fires once at parse time, from Session._parse_history_line's hydration of a return-time row). Fires at most once per distinct ref per build_history()/decompose_history_for_retry() call (deduped within that one read, never a process-global cache — a still-missing file is reported again on the NEXT read). ref (the missing offload path), reason (gc | never_persisted | external), ref_sha256 (a stable, ref-STRING-derived identifier — NOT a hash of the lost content itself, which by definition is not available to hash, hence the name — so an operator can correlate repeated reads of the SAME missing file)
compaction_wire_bytes_measured #4944① — retry_loop measured the wire-JSON byte size of a main_call request (estimate_wire_bytes: SP + head + summary + tail + new_msg, the byte-axis sibling of the token estimate already computed here). Fires on BOTH outcomes, not success only (a turn whose every attempt 413s — the real-machine shape #4944 was opened for — would otherwise emit this event zero times, leaving no diagnostic trail at all): accepted=True on a successful call (the real limit is >= this value, a lower bound) and accepted=False when the call raised an HTTP 413 specifically (the real limit is < this value, an upper bound) — the two bracket where the limit actually sits. The accepted=False emission is skipped for a compact()-originated 413 (a different, unmeasured payload — raw_middle + section_caps, not head/summary/tail/new_msg). #5316: the total is now also split into its 5 components so a reader can tell WHICH one dominated (the "history-dominant / one-huge-payload / oversized-new-message" diagnostic #5316 exists for), and a byte-limit terminal UnrecoveredError's own message now reads this run's own accepted/rejected pair back into a "gateway rejects at roughly N bytes (last accepted: M bytes)" clause — no new measurement, a read-back of what this event already emitted. wire_bytes (the measured total; KNOWN under-count — excludes tools-schema bytes and provider-specific wrapper overhead, see estimate_wire_bytes's own docstring), accepted (True/False), sp_bytes/head_bytes/summary_bytes/tail_bytes/new_msg_bytes (#5316, the 5-component breakdown — byte counts only, never content)
compact_op_unavailable The compact Control IR op was dispatched in a context where no compaction engine is wired. run_id, phase
summary_resummarize_failed Re-summarising an existing summary (nested compaction) raised. error
budget_reset The chat budget gateway reset its per-window accounting. before (prior accumulated value)
payload_reduced #5296 PR-2 / #5364 §1.6 — RouterLoopDriver's same-turn recovery from an UnrecoveredError, MODE-INDEPENDENT since §1.6 (a BYTE-limited HTTP 413 cause OR a non-byte, token-axis terminal cause — the emit site checks neither saw_byte_limit nor any other cause-specific flag, only whether either progress axis below advanced): the 413-reactive spill (RouterHistoryBuffer.spill_turn_content, tool-result turns offloaded to MediaStore; #5612 — DURABLE since then: a role="spill_record" entry is appended to history.jsonl once per successfully-spilled turn, superseding the pre-#5612 session-lived, non-durable projection overlay that used to exist here, never advances the compaction watermark either way; #5364 §1.2's write-time offload cap is a SEPARATE mechanism that persists ChatMessage.meta[SPILLED_META_KEY] on a NEW entry at append time — see router_loop.py's own call site — a different code path reusing the same vocabulary, not what this event's spill refers to) and, only if spill made no progress, durable compaction (ContextBudgetAdvisor's existing force_compact_now path) reduced the wire payload below what it was before the attempt, and the turn is being re-sent with the SAME chain_id — user_submitted/turn_started/the WAL append are NOT re-fired (this retry loop lives entirely inside run_turn, below where those fire). #5364 §1.6 removed the saw_byte_limit-only gate this line used to cite: a non-byte-limited (token-axis) terminal cause now reaches this SAME path too and fires this SAME event — see _run_with_shrink_and_byte_reduction's own docstring for the two independent progress axes (spill / compaction-watermark advance) that now decide retry regardless of which axis terminated the call. chain_id, attempt (1-based, within this turn)
budget_caps_updated #5067 — PATCH /api/budget/caps (budget.py's REST router) changed one or more of the live BudgetTracker's hard caps. In-process only (not persisted to reyn.yaml), reachable via project_root (no live Session, hence no run_id/actor/phase — same shape as #5065's permission_approval_revoked/permission_approvals_cleared, the OTHER band pairing: cost-budget x audit-events). Because this change is not persisted anywhere else, this event is the ONLY record either value ever existed. changes (a {field_name: {"from": old_hard_limit, "to": new_hard_limit}} mapping of ONLY the fields the request actually set), surface

Safety limits

See Concepts: safety framework for the intervention flow and force-close wrap-up.

Kind When Key payload
limit_denied A safety limit was denied (no extension granted) and the OS is about to attempt the force-close wrap-up. kind (max_iterations | router_cap), chain_id, plus limit (router iterations) or count/cap (router cap)
untrusted_narrowing_engaged, untrusted_narrowing_lifted #1909/#3501 opt-in (safety.threat_scan.capability_narrowing != off): untrusted external content (an external_source-tagged history entry) enters/leaves the active, uncompacted context, most-restrictively narrowing tool visibility while it is live. Session._ephemeral_contextual_for_turn — the default turn rung, the common case an operator opting in at all is most likely running — emits both kinds, exactly once per genuine state flip, never per read (a status-panel poll on an unchanged state produces no event); previously silent at both transitions (#5282). The top iteration rung (RouterLoop's own _intra_turn_contextual_for_turn_fn branch) separately emits untrusted_narrowing_engaged only — it has no lift event yet, out of #5282's scope. provenance (external_source); the iteration rung's engaged also carries chain_id, iteration

Process memory

5851 stage (a) — observation only, no halt (a later stage adds

process_memory.enforce; see docs/reference/config/reyn-yaml.md's process_memory block). ProcessMemoryGuard (reyn.runtime. process_memory, ONE instance per process, threaded to every Session the same route history_resident_config uses) reads the process's real measured footprint — darwin: phys_footprint (libproc.proc_pid_ rusage, the same figure Activity Monitor's "Memory" column and footprint(1) report; SHRINKS on free, unlike ru_maxrss); linux: rss (/proc/self/statm's resident pages × page size). No fork, no subprocess (ps would itself spawn a process — exactly the harm to avoid when memory is already tight), ~39µs per read.

Kind When Key payload
history_hydration_stopped_reading_early_unsafe #5939 PR-4 — Session.load_history()'s own fast path (read_history_tail_with_byte_budget) stopped reading further lines because cumulative bytes reached history_resident.max_bytes (architect ruling, #5851 comment 11: reuses that existing cap live during the read rather than adding a new one), BEFORE ever finding a summary message. Named distinctly from PR-2's own session_memory_backpressure ("refuse new turns") — this is a different act ("stop reading, open with what's loaded") that happens to share the ladder's "①" position in the design docs only, never in code/event names (lead-coder review — the same name must not stand for two different facts). The ONE consumer of the resulting session-level fact is force_compact_now's own history_load_unsafe refusal (compaction_check, above) — every other reader of Session.history (display, context for the next LLM call) is unaffected; the partial lines already loaded are real, usable content. lines_loaded, max_bytes
process_footprint Two record points (of the ruling's 4 observation points — the other two, run_one_iteration's process-edge and the router-loop's in-turn iteration head, are DECISION points for a halt check that does not exist until a later stage; nothing to record there yet in stage (a)): Session.load_history()'s own finally (every load_history() caller — registry_bootstrap.py/chat.py/web/deps.py/mcp.py/dogfood.py — funnels through this ONE method, so the emit lives there rather than at 5 separate call sites), and _run_router_loop's finally, next to the turn_end hook dispatch — which fires once per _run_router_loop INVOCATION, not once per operator-submitted turn: that method has more than one caller, including agent-to-agent message injection, and the dispatch is unconditional in that same finally on every one of them (see Concepts: hooks § Lifecycle points, which states the rule and what to filter on instead). A session emitting more turn-end process_footprint readings than it had conversational turns is this cadence, not a duplicate-emit bug. metric rides with every bytes value — a phys_footprint reading and an rss reading are not comparable numbers, and this repo's own verification-hazards discipline is "an observation does not name its own referent" without help. bytes, metric ("phys_footprint" | "rss"), cap_bytes (None = observe-only), enforce, chain_id (turn-end emit only)
process_footprint_unavailable This platform has no reader (process_memory_metric_name() is None — today, anything but darwin/linux). Fires at MOST ONCE PER PROCESS (ProcessMemoryGuard is the one shared instance every session's factory_config carries, so the "announced once" latch is process-scoped, not per-session) — a value is never fabricated to fill the gap. platform (sys.platform)
process_memory_breakdown reyn doctor-memory (#5959, owner-hit — "leak 容疑を process の外から判定できない"; vmmap/#5957 cannot tell an allocator-held free region from a live Python reference). Two populations, one snapshot: type_breakdown/largest_objects are ⓐ discovery — every live object gc.get_objects() can see, grouped/ranked, no curated list; bounding_census is ⓑ completeness — every config field carrying Axis.BOUNDING, DERIVED from walk_config_schema() (never a second hand-written list — adding a Axis.BOUNDING field makes it appear here automatically), paired with its declared cap and current measured value (None = unmeasured, distinct from a real 0). sys.getsizeof is shallow (a container's own size, not its contents') — estimate_note carries that disclosure into the event itself, not just the CLI's prose. type_breakdown, largest_objects, bounding_census, estimate_note
process_memory_forensics #5939 / #5851 stage (b) — the memory ladder's step ② (cache release, process_memory_release.py), called from every ladder context (steady-state, in-turn, hydration — same function each time). Reports what it dropped and whether the footprint moved, as the byproduct measurement of ② itself, never a separate walk — this is the owner's own "RAM消費要因の候補を数値と共に" requirement. dropped is a list of {name, entries_before, entries_after}, one per reconstructable module-level cache released (the token-estimate cache, the artifact-ref table cache, the derived-config cache — NOT the sandbox derivation cache, excluded: its presence-implies-outstanding-checkout invariant makes it unsafe to drop mid-flight, see the module's own docstring). top_history_rows (#5851 D7) is now a real list of {tool, bytes, ts} dicts — the biggest resident history rows (by ChatMessage.resident_bytes() plus, for an un-spilled content-ref row, its real reachable body size — #5973's own correction, reused here so this never shows a misleadingly-small shell for exactly the rows that are actually large) across every session passed to ② via its own sessions argument, capped at process_memory_release._TOP_HISTORY_ROWS_LIMIT. host still rides as None (PR-2's own field, out of scope for this module — the KEY exists now so no reader has to special-case an older shape once it lands). footprint_before, footprint_after, metric, dropped, host, top_history_rows

Process registry

.reyn/processes/<pid>.json (#5226) is a machine-wide, per-PID liveness marker — not per-project like every other event source in this doc. reyn.runtime.process_registry.live_processes reaps any marker whose PID is confirmed dead as a side effect of reading — its own contract ("who is alive right now"), unchanged. #5709: reyn doctor and a broker health poll both read via the NON-destructive sibling, reyn.runtime.process_registry.read_process_markers, instead — reaping is not part of a passive display's own contract, and whichever of two readers ran live_processes first would otherwise destroy the evidence (last_loop_beat_at, below) the second reader came to see.

Kind Trigger Key payload
process_marker_reaped #5358: a confirmed-dead PID's marker is about to be reaped (unlinked) — the marker was the ONLY record that PID ever ran, since the process itself never got to say it was stopping (a graceful exit's own atexit handler would have already removed it). Emitted from the reading CALLER's process, but under the DEAD process's OWN project (reyn_root resolved by walking up from the marker's own cwd field, never the caller's cwd — a reyn doctor run from project A must not misattribute a reap for a process that ran in project B). Best-effort: an emit failure (no .reyn/ reachable from the marker's cwd, or any other error) logs a warning and never blocks the reap itself. Detection, not diagnosis — carries no information about WHY the process stopped (crash / SIGKILL / power loss / OOM are indistinguishable from here). #5709: marker now also carries last_loop_beat_at (absent/None if the process's own turn loop never armed the beat driver, e.g. it died before Session.run() ever started) — together with observed_at this is the ONE place [last_loop_beat_at, observed_at] — the tightened death window #5709 exists to produce — is readable, and it lands in the dead session's own .reyn/events, the same place a post-mortem reader already looks. marker (the dead process's own full recorded content: pid/ppid/cwd/subcommand/started_at/sessions (#5714 — a collection of hosted-session identity entries, replacing the pre-#5714 singular agent_name/broker_session_id pair)/last_loop_beat_at), observed_at (an UPPER BOUND on the stop time — the real stop happened sometime between the marker's last_loop_beat_at (or started_at, if the beat never armed) and this timestamp, never asserted as the exact moment)

Textual chat pump

TextualChatApp._pump_frames (src/reyn/interfaces/inline/textual_chat/app.py) wraps each of 4 per-message-kind handlers (/copy sentinel, /rewind sentinel, __open_artifact__ sentinel, _ingest_frame) in its own try/except Exception that routes through _record_pump_swallow — the pump must survive one bad frame, since a single unhandled exception there would end the whole chat loop. #5732 (architect ruling, real-machine incident #5731 — _coalesce_pipeline_step raised AttributeError on EVERY "status"-kind frame, shipped, undetected: tests stayed green and nothing surfaced it to a human): keeping the catch is correct — the defect was that a swallowed exception reached nowhere but one logger.exception line, with no operator-visible signal.

#6224 correction: those 4 are not the ONLY except Exception blocks in _pump_frames — of its own ~20, roughly 16 (every EVENT-frame handler, the turn-end sweep family, the queue-view seed, the chrome-refresh guards) logged via logger.exception alone, with no counter bump and no audit-event, AT THE TIME #6224 landed. #6224 also added a call site OUTSIDE _pump_frames entirely (an Artifacts-row selection handler, on_option_list_option_selected — a message handler, not a pump frame, that previously left ZERO on-screen trace on failure) that routes through the SAME _record_pump_swallow/PumpSwallowStats.

#6234 (architect ruling, issuecomment-5772189312) closed that gap: of _pump_frames's 22 except blocks (2 raise after logging and are deliberately excluded — a re-raised exception is not "swallowed"), all 20 remaining now route through _record_pump_swallow, plus the one call site outside the pump — 21 total. Doing so required widening the dedup key from (kind, exception type) to (site, exception type) (see the row below) — most of the newly-routed sites carry no frame at all, so a shared placeholder kind would have collapsed them into one dedup bucket. So reyn.interfaces.inline.textual_chat.app.PumpSwallowStats.count (folded into the status line via chrome.status_line_text's pump_swallow_count param — "frame pump: N swallowed — see log · ", only when N > 0, never a transient toast, wording widened #6224 once a non-draw call site started feeding the same counter) is now the complete read of every _pump_frames except Exception block that does not itself re-raise (21 call sites as of #6234), not merely a partial one.

Kind Trigger Key payload
pump_exception_swallowed One of the (now 21, across and outside the pump — see above) _record_pump_swallow call sites caught an exception. Fires only on the FIRST time a given (site, exception type) pair is seen this process (PumpSwallowStats.record) — a broken call site fails on every frame, so a durable record per OCCURRENCE would flood .reyn/events with thousands of rows for one defect; the bounded count above already carries the complete tally (of the sites that route through it). #6234 (architect ruling, issuecomment-5772189312): the key used to be a pair of frame_kind and exception type, but most call sites have no frame at all (turn-end cleanup, the two chrome-refresh guards, the queue-view seed, every EVENT-frame handler) — keying on a shared placeholder for all of them would have collapsed distinct call sites into one dedup bucket. The key is now (site, exception type), where site is a static literal naming the call site itself (AST-checked for pairwise distinctness, scripts/check_pump_swallow_site_uniqueness.py) and is present on every call site, unlike frame_kind (see the payload column). Never carries the exception's own message or traceback — logger.exception (unchanged, already called at every one of those sites) already covers the free-text half; this event carries only the structured facts a post-mortem reader queries by. Best-effort (emit_cli_event, matching install_asyncio_exception_handler's own posture for reyn chat — a single-invocation, single-cwd CLI entrypoint, unlike a long-lived multi-project server): an emit failure here is logged and swallowed, never propagated into the pump. site (the static call-site literal — one of the 21 named in TextualChatApp._record_pump_swallow's own docstring: the 3 CLIENT-consumed sentinel handlers, _ingest_frame, __open_inline_artifact__, one per EVENT-frame etype branch, one per turn-end cleanup step, and one per frame-unrelated guard), exception_type (type(exc).__name__), frame_kind (the pump message's own kind — __copy_last_reply__ | __rewind_list__ | __open_artifact__ | the _ingest_frame frame's kind | __open_inline_artifact__; absent — the key is not present in the payload at all, never ""/"unknown" — at every call site with no frame: the turn-end cleanup steps, the two chrome-refresh guards, the queue-view seed, and every EVENT-frame handler)

Call-parent registration

TextualChatApp._call_parents (app.py, #4691 Phase B) nests a round's tool_call_* rows under their own litellm-response agent row, keyed by call_id — litellm's own response id, a THIRD PARTY's identifier (_response_call_id, src/reyn/llm/llm.py). Neither OpenAI's nor litellm's own documentation states a uniqueness SCOPE for that field (#6198 investigation, 0 observed instances as of this writing). Registration's own write (_register_call_parent) is an unconditional overwrite — unchanged by this event's own addition (#6198's own ruling: detection only, never a behavior change) — so a reused call_id would otherwise silently re-point the registry at a different round's own row with no operator-visible signal. The SAME bounded-by-key shape pump_exception_swallowed above established (one event per first occurrence, never per occurrence — a provider that reused one id across many rounds must not flood .reyn/events).

Kind Trigger Key payload
call_parent_registration_collided _register_call_parent saw a call_id already present in _call_parents — a SECOND kind="agent" row registering under an id already claimed by an earlier one. Fires only on the FIRST time a given call_id collides this process (_call_parent_collisions_seen, process-lifetime — not per-session, since this is a diagnostic about whether the defect has EVER been observed). The overwrite itself is UNCHANGED: _call_parents[call_id] still ends up pointing at the LATEST entry. Best-effort (emit_cli_event, the same choice/posture pump_exception_swallowed made): an emit failure here is logged and swallowed, never propagated into registration. call_id (the colliding value)

Unknown display-frame kind

display_frame_unknown_kind (#6230 stage 2) is the witness that a display frame's kind fell outside the closed display vocabulary (reyn.runtime.outbox.DISPLAY_KINDS) when a renderer received it — reyn.runtime.outbox.is_unknown_kind is the check (re-imported by reyn.interfaces.repl.renderer for its existing callers), wired into both TextualChatApp._ingest_frame (the inline TUI's pump) and stream_client.py's _render_display_message (the plain --cui output loop). This exists so the SAME stage's own legible-degrade fix (an unrecognized kind now renders a readable line instead of raising, or showing nothing) cannot also make a routing defect invisible: before this stage, an unrecognized kind either raised (caught and logged, but with no structured record of WHICH kind or WHERE) or, for the remote/web path, was silently dropped — either way, an operator investigating a report like "I saw a garbled row" had no durable trail to start from (the concrete gap #6234's own dead-ended investigation demonstrated: a record naming the exception TYPE but not a POSITION).

The SAME bounded-by-key shape pump_exception_swallowed/ call_parent_registration_collided above established (one event per FIRST occurrence of a given kind, never per occurrence — a mis-routed producer emitting the same wrong kind on every frame must not flood .reyn/events), via a SIBLING stats class (reyn.interfaces.repl.renderer.UnknownKindStats) rather than a reuse of PumpSwallowStats — that class's own key is (site, exception type) (#6234), and this event has no exception at all (the whole point of the stage: an unrecognized kind renders instead of raising).

Kind Trigger Key payload
display_frame_unknown_kind A renderer (_ingest_frame / _render_display_message) received a kind outside DISPLAY_KINDS. Fires only on the FIRST time a given kind is seen this process (UnknownKindStats.record). Best-effort (emit_cli_event, the same choice/posture pump_exception_swallowed made): an emit failure here is logged and swallowed, never propagated into rendering. frame_kind (the unrecognized kind itself), ui_surface ("textual_chat" | "plain_cui" — which renderer caught it; named ui_surface, not surface, because emit_cli_event already stamps its own surface="cli" on every event it emits, and a same-named payload key collides), transport_kind (type(transport).__name__ — the nearest fact this layer has for "where it arrived from"; a remote PEER or client BUILD identifier would answer that more precisely but neither is obtainable at this layer, disclosed rather than fabricated), detected_at (file:line of the call site, captured dynamically via inspect.stack() so it can never drift from the code that actually classified the kind — the POSITION #6234's own dead-ended investigation lacked)

Remote control-POST bounded timeout cut

reyn.interfaces.repl.remote_client (reyn chat --connect <url>) splits every client→server control POST's own read timeout into two classes by payload["type"]: BOUNDED_PAYLOAD_TYPES (_CONTROL_TIMEOUT_S, #5894 ①-1) vs. LONG_RUNNING_PAYLOAD_TYPES (read=None, #6083 ⑵-a) — see protocol.py's own module comment for how that classification is derived (a HUMAN trace into each ptype's awaited callee, #6244 widened to also cover the shared PROLOGUE awaits every branch after it runs through).

That trace can only prove a ptype does not await an operation with genuinely unbounded duration. It CANNOT prove _CONTROL_TIMEOUT_S is enough headroom for the bounded operation a ptype DOES await — a payload correctly classified BOUNDED at review time can still turn out too slow in practice (a loaded server, a slow link), and that misclassification is invisible to any static check: it can only be observed at the moment a real POST actually gets cut, which reads exactly like a dead connection to everything downstream unless something durably marks it.

The SAME bounded-by-key shape pump_exception_swallowed above established (one event per FIRST occurrence, never per occurrence — a persistently slow network cutting the SAME ptype on every retry must not flood .reyn/events), via a SIBLING stats class (remote_client.ControlTimeoutCutStats) keyed by payload["type"] alone — the exception is always httpx.ReadTimeout by construction here (the only except-clause branch that calls record()), so unlike PumpSwallowStats's (site, exception type) pair (#6234) a second axis would name nothing new. The key domain itself is BOUNDED_PAYLOAD_TYPES — a small, FIXED frozenset — so the stats' own counts dict can never grow past that set's size, a stronger bound than a dedup key alone gives its siblings above.

Kind Trigger Key payload
control_post_bounded_timeout_cut remote_client.post_control caught httpx.ReadTimeout for a payload["type"] in BOUNDED_PAYLOAD_TYPES — a LONG_RUNNING_PAYLOAD_TYPES payload reads read=None and structurally cannot raise this, so the membership check is what keeps this from ever firing for that class. Fires only on the FIRST time a given payload_type is cut this process (ControlTimeoutCutStats.record, module-level remote_client._CONTROL_TIMEOUT_CUT_STATS) — the always-complete per-ptype count stays available via ControlTimeoutCutStats.counts, readable from a test or a debugger; there is no operator-facing surface that reads it (no status line, no log line, no command) — the audit-event itself, landing in .reyn/events, is the ONLY operator-facing side of this mechanism today. Never carries the exception's own message or traceback — post_control's existing logger.warning call (unchanged) already covers the free-text half; this event carries only the structured facts a post-mortem reader queries by. Best-effort (emit_cli_event, the same choice/posture pump_exception_swallowed made): an emit failure here is logged and swallowed, never propagated into the POST path. payload_type (the ptype that was cut), timeout_seconds (the read timeout that was actually in force — _CONTROL_TIMEOUT_S, or a test-injected override)

Replay

reyn events .reyn/events/<run_id>.jsonl

Replays the log to the console with the same formatting as a live run. The LLM is not re-invoked — replay is purely for inspection.

Why everything is an event

Two consequences fall out of "every state change emits":

  • Replayability. A saved log is a complete record of execution. Future checkpoint/resume designs (see roadmap) build on this.
  • Observability with no bolt-on. No separate logger, tracer, or telemetry hook — the same channel powers debug output, replay, and (eventually) eval analytics.

See also