Skip to main content

car_eventlog/
lib.rs

1//! Event log with JSONL persistence for Common Agent Runtime.
2//!
3//! Append-only event log. Every runtime operation is recorded here.
4//! Supports optional JSONL journal persistence for replay and audit.
5
6pub mod harness_adapt;
7pub mod harness_metrics;
8pub mod observability;
9pub mod tool_receipts;
10
11pub use observability::{
12    evaluate_alerts, summarize, summarize_log, Alert, AlertKind, AlertThresholds, MetricsSummary,
13};
14
15use car_secrets::{
16    atomic_replace_private_file, create_private_file, open_private_append, revalidate_private_file,
17    revalidate_private_path,
18};
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use std::collections::{HashMap, HashSet, VecDeque};
23use std::fs;
24use std::future::Future;
25use std::io::{BufRead, BufReader, BufWriter, Read, Seek, SeekFrom, Write};
26use std::path::{Path, PathBuf};
27use std::pin::Pin;
28use std::sync::{mpsc, Arc, Condvar, Mutex, Weak};
29use std::task::{Context, Poll, Waker};
30use std::thread;
31use std::time::{Duration, Instant};
32use uuid::Uuid;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct EventLogStats {
37    pub events: usize,
38    pub spans: usize,
39    pub approx_event_bytes: usize,
40    pub approx_span_bytes: usize,
41}
42
43/// Event kinds matching the Python EventKind enum.
44#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum EventKind {
47    /// The authenticated `runs.start` bracket reached its durable boundary.
48    RunStarted,
49    /// A body-free authenticated run cancellation request became durable.
50    RunCancellationRequested,
51    /// A deterministic run cancellation receipt became durable.
52    RunCancellationResult,
53    ProposalReceived,
54    /// A proposal reached its deterministic terminal result. Emitted by the
55    /// CAR server after the runtime has emitted every action transition.
56    ProposalCompleted,
57    ActionValidated,
58    ActionRejected,
59    ActionExecuting,
60    ActionSucceeded,
61    ActionFailed,
62    ActionSkipped,
63    ActionRetrying,
64    ActionDeduplicated,
65    PolicyViolation,
66    StateChanged,
67    StateSnapshot,
68    /// Proposal-level aggregate of observed state mutations that survived the
69    /// transaction boundary. Distinct from provisional per-action
70    /// `state_changed` rows and from declared expected effects.
71    StateCommitted,
72    StateRollback,
73    // Skill lifecycle events (SkillRL-inspired)
74    SkillDistilled,
75    SkillEvolved,
76    SkillDeprecated,
77    EvolutionTriggered,
78    /// A provisional skill candidate passed the validation gate and was promoted
79    /// to Active, superseding its incumbent (SkillOpt-inspired — see
80    /// `docs/solutions/gated-skill-optimization.md`).
81    CandidatePromoted,
82    /// A provisional skill candidate failed the validation gate and was rejected
83    /// (recorded in the rejected-edit buffer so it isn't regenerated).
84    CandidateRejected,
85    // Memory consolidation ("dream") events
86    Consolidated,
87    // Proactive memory intervention events (arXiv 2607.08716-inspired):
88    // Phase 1 maintenance records compact bank edits derived from recent
89    // trajectory telemetry; Phase 2 records whether the selector injected a
90    // grounded reminder or explicitly remained silent.
91    ProactiveMemoryMaintained,
92    ProactiveMemoryIntervention,
93    // Replanning events
94    ReplanAttempted,
95    ReplanProposalReceived,
96    ReplanRejected,
97    ReplanExhausted,
98    // Voice turn telemetry — emitted by car-engine's voice_turn dispatch
99    // and the orchestrator. `data` carries `turn_id` (u64) plus
100    // event-specific fields like `text_len`, `error`, `timeout_ms`.
101    VoiceFastTurnStarted,
102    VoiceFastTurnEnded,
103    VoiceSidecarResolved,
104    VoiceSidecarFailed,
105    VoiceSidecarTimedOut,
106    VoiceTurnCancelled,
107    VoiceBridgePlayed,
108    // Foreman merge-verify gate (verified-parallel-coding-orchestrator).
109    // Emitted by car-multi's foreman gate when a farmed-out worktree is
110    // verified before integration. `data` carries `subtask`, `changed_symbols`,
111    // `containment_violations`, `semantic_conflicts`, and `build_test`. This is
112    // the audit trail that makes the gate policy-aware rather than a bare merge.
113    GateAccepted,
114    GateRejected,
115    // The inference chain served a call on a model other than the preferred
116    // candidate, mid-run. `data` carries `from`, `to` and `reason` (one of
117    // `credential_rejected` / `credential_absent` / `rate_limited` /
118    // `timed_out` / `failed`).
119    //
120    // car#1333 recorded WHO wrote each turn (`models_served`); this records
121    // WHY the backbone changed, which is a different fact. Without it a run
122    // that degraded mid-session had nothing on disk explaining a surprising
123    // result, and mining would attribute it to the code under test rather than
124    // to the model swap (car#1351).
125    //
126    // One row per HOP of a fallback chain. The live `model_fallback` event is
127    // latched once per phase so the stream does not narrate every routing
128    // decision; the journal wants each distinct transition, and not the same
129    // one re-stated on all fifty turns of a run whose credential stayed dead.
130    // Repeats are therefore collapsed CONSECUTIVELY, not globally — a lane
131    // that fails, recovers, and fails again later is a new episode, and
132    // suppressing it would leave a journal that cannot tell "changed once,
133    // early" from "flapped all run".
134    //
135    // What it does NOT record: a candidate the ROUTER never offered. Rate-limit
136    // exclusion and the circuit breaker filter the candidate list before the
137    // dispatch loop runs, so a lane dropped that way produces no skip and no
138    // row. Read an absence of rows as "no candidate was attempted and passed
139    // over", not as "the backbone did not change".
140    ModelFallback,
141    // Per-execution caller / tenant scope (Parslee-ai/car#187 phase 3).
142    // Emitted by Runtime::execute_scoped* once per proposal when the
143    // RuntimeScope carries any identity. `data` carries `caller_id`,
144    // `tenant_id`, and `claims` — exact set depends on what the
145    // dispatcher forwarded. Audit / log analysis correlates actions
146    // back to the caller / tenant that triggered them.
147    SessionScope,
148    // Permission-tier gate decisions (survey "Code as Agent Harness"
149    // §3.4.3, §5.2.5 — the harness as safety governor). Emitted by
150    // car-engine's TierPermissionHandler when the permission gate
151    // evaluates an action. `data` carries `gate_decision` (allow /
152    // needs_approval / deny), `required_tier`, `granted_tier`, and (for
153    // escalation/deny) `fingerprint` + `reason`. The audit trail that
154    // makes permission tiers inspectable rather than implicit.
155    //
156    // `data` also carries `reversibility` (reversible / compensable /
157    // irreversible) on every variant — the SECOND, independent axis, from
158    // car_policy::classify_reversibility. The tier answers "who may
159    // authorize this?" and says nothing about whether the effect can be
160    // undone: a `git push` and a charged card are both full_access /
161    // needs_approval and have different rollback contracts. The gate does
162    // not act on this field; it is recorded so an audit can tell those two
163    // rows apart without re-deriving the classification later.
164    PermissionDecision,
165    // A durable human-in-the-loop approval/rejection was recorded
166    // (§5.2.5 — "approvals should be auditable state transitions").
167    // `data` carries `fingerprint`, `approval` (approved / rejected),
168    // `required_tier`, `reviewer`, `reason`, and optional `evidence`.
169    // The auditable counterpart to the ApprovalLedger's durable record.
170    ApprovalRecorded,
171    // Deep-telemetry breadcrumbs (survey §3.5.1 — deep telemetry as the
172    // optimization substrate; "decision-tree traces show where the agent
173    // repeatedly chooses unproductive paths"). A BranchDecision records a
174    // fork the harness took and why; `data` carries `branch` (the chosen
175    // path), `reason`, and any decision-specific context. The substrate an
176    // Evolution Agent (§3.5.2) replays to find where the loop wastes work.
177    BranchDecision,
178    // An alternative the harness considered and discarded — a failed
179    // attempt superseded by a retry/replan, a candidate not selected.
180    // `data` carries `alternative` (what was rejected) and `reason`.
181    // Without this, telemetry shows only the path taken, not the paths
182    // pruned, which is exactly what failure-mode diagnosis needs.
183    AlternativeRejected,
184    // An inference call's token/cost telemetry (§3.5.1). Carries the
185    // standardized metric keys (`tokens_in`, `tokens_out`, `cost_usd`) via
186    // `append_metered`. A dedicated kind so model cost feeds
187    // `metrics_totals` without inflating action-success counts.
188    InferenceMetered,
189    // A transactional conflict the harness detected before executing a
190    // proposal against the versioned shared state (survey §4.3/§5.2.4).
191    // Emitted by the executor's pre-execution transaction check. `data`
192    // carries `kind` (write_write / read_write / stale_assumption), `key`,
193    // `actions`, `explanation`, and `resolution`. Under strict mode the
194    // proposal is rejected; under warn mode it is only recorded.
195    TransactionConflict,
196    // A proposal-admission gate decision (EPIC A / task A1 — the
197    // executor's pre-execution safety seam). Emitted once per registered
198    // `AdmissionGate` that runs during proposal admission. `data` carries
199    // `gate` (the gate name, e.g. information_flow / concurrency / policy),
200    // `decision` (allow / reject / needs_approval), and — when the gate
201    // objects — `reason`, `blocked` (the offending action ids), and an
202    // optional `fingerprint` for approval escalations. The audit trail
203    // that makes the verified safety checks inspectable as live
204    // enforcement rather than dormant library functions.
205    AdmissionGateDecision,
206    // A tool-use hallucination caught by cross-checking the model's claims
207    // against the runtime's own execution receipts (EPIC A / A6 — arXiv
208    // 2603.10060). `data` carries `count` and `hallucinations` (each with
209    // kind/tool/explanation). Deterministic and zero-inference: the runtime
210    // ran the tools, so it holds unforgeable ground truth.
211    ToolReceiptHallucination,
212    // Deterministic goal-loop verifier pass. Emitted after the runtime gathers
213    // ground truth and `car-verify` evaluates a goal condition. `data` carries
214    // `iteration`, `met`, `grounded`, `reason`, and `model_id`/`model_tier`
215    // (local|cloud|unknown) so `/goal` outcomes — and which model tier produced
216    // an ungrounded completion — are queryable from the same append-only
217    // journal as the tool receipts they depend on.
218    GoalEvaluated,
219    // Terminal decision of an assistant/coder turn loop. `data` carries
220    // `decision` ("empty_tool_calls" | "max_turns" | "stalled"), `stop_reason`,
221    // `was_truncated`, and `turns`. The default (goal-less) loop declares success
222    // the instant the model emits no tool calls, with no truncation/outcome
223    // check — so this makes "why did the loop stop" (a clean finish vs a
224    // truncated, turn-capped, or stalled one) queryable from the journal, on the
225    // ungrounded default path that emits no `GoalEvaluated`.
226    TurnCompleted,
227    /// The active `runs.start` bracket reached one terminal state. This is
228    /// distinct from `ProposalCompleted`: one run can contain many proposals.
229    RunCompleted,
230}
231
232/// Status of a trace span.
233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
234#[serde(rename_all = "snake_case")]
235pub enum SpanStatus {
236    Ok,
237    Error,
238    Unset,
239}
240
241/// A trace span representing a unit of work.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct Span {
244    pub trace_id: String,
245    pub span_id: String,
246    pub parent_span_id: Option<String>,
247    pub name: String,
248    pub start_time: DateTime<Utc>,
249    pub end_time: Option<DateTime<Utc>>,
250    pub status: SpanStatus,
251    pub attributes: HashMap<String, Value>,
252}
253
254/// Standardized `Event.data` keys for cross-cutting telemetry metrics, so
255/// every emit site records them under the same name and aggregation can
256/// rely on it (survey §3.5.1: deep telemetry "records the decision process
257/// in greater detail: token usage and cost, model/tool latency …").
258pub mod metric_keys {
259    /// Wall-clock duration of the unit of work, milliseconds (f64).
260    pub const DURATION_MS: &str = "duration_ms";
261    /// Input/prompt tokens consumed (u64).
262    pub const TOKENS_IN: &str = "tokens_in";
263    /// Output/completion tokens produced (u64).
264    pub const TOKENS_OUT: &str = "tokens_out";
265    /// Estimated cost in USD (f64).
266    pub const COST_USD: &str = "cost_usd";
267}
268
269/// Cross-cutting telemetry metrics attachable to any event. All optional —
270/// a tool call has latency but no tokens; an inference has all four. Merged
271/// into `Event.data` under [`metric_keys`] by [`EventLog::append_metered`],
272/// and read back via the `Event` accessors, so downstream aggregation
273/// (harness-level metrics, the Evolution Agent) has a uniform source.
274#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
275pub struct Metrics {
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub duration_ms: Option<f64>,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub tokens_in: Option<u64>,
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub tokens_out: Option<u64>,
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub cost_usd: Option<f64>,
284}
285
286impl Metrics {
287    /// Latency-only metrics (the common tool/action case).
288    pub fn latency(duration_ms: f64) -> Self {
289        Self {
290            duration_ms: Some(duration_ms),
291            ..Default::default()
292        }
293    }
294
295    /// Token + cost metrics for an inference call.
296    pub fn inference(tokens_in: u64, tokens_out: u64, cost_usd: Option<f64>) -> Self {
297        Self {
298            duration_ms: None,
299            tokens_in: Some(tokens_in),
300            tokens_out: Some(tokens_out),
301            cost_usd,
302        }
303    }
304
305    pub fn with_duration(mut self, duration_ms: f64) -> Self {
306        self.duration_ms = Some(duration_ms);
307        self
308    }
309
310    /// Merge these metrics into an event `data` map under [`metric_keys`].
311    fn merge_into(&self, data: &mut HashMap<String, Value>) {
312        if let Some(d) = self.duration_ms {
313            data.insert(metric_keys::DURATION_MS.into(), Value::from(d));
314        }
315        if let Some(t) = self.tokens_in {
316            data.insert(metric_keys::TOKENS_IN.into(), Value::from(t));
317        }
318        if let Some(t) = self.tokens_out {
319            data.insert(metric_keys::TOKENS_OUT.into(), Value::from(t));
320        }
321        if let Some(c) = self.cost_usd {
322            data.insert(metric_keys::COST_USD.into(), Value::from(c));
323        }
324    }
325}
326
327/// A single event in the log.
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329pub struct Event {
330    pub kind: EventKind,
331    /// Authenticated active-run identity stamped by CAR at append time.
332    /// Historical journals omit this field and replay as `None`.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub run_id: Option<String>,
335    /// WebSocket client identity that opened `run_id` via `runs.start`.
336    /// Never reconstructed from the journal filename during replay.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub client_id: Option<String>,
339    /// CAR-minted policy-session identity used for this proposal, when the
340    /// caller selected a live `session.policy.open` session. Unvalidated
341    /// caller labels are never copied here.
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub policy_session_id: Option<String>,
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub action_id: Option<String>,
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub proposal_id: Option<String>,
348    #[serde(default)]
349    pub data: HashMap<String, Value>,
350    #[serde(default = "Utc::now")]
351    pub timestamp: DateTime<Utc>,
352    /// Hash of the previous event in the chain (EPIC A / A9 tamper-
353    /// evidence). `None` when hash chaining is disabled (the default) —
354    /// the field is skipped in serialization, so logs without chaining are
355    /// byte-identical to before this was added.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub prev_hash: Option<String>,
358    /// This event's own content hash, computed over its fields plus
359    /// `prev_hash`. Present only when hash chaining is enabled.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub hash: Option<String>,
362}
363
364impl Event {
365    /// Wall-clock duration recorded on this event, if any.
366    pub fn duration_ms(&self) -> Option<f64> {
367        self.data
368            .get(metric_keys::DURATION_MS)
369            .and_then(Value::as_f64)
370    }
371
372    /// Input tokens recorded on this event, if any.
373    pub fn tokens_in(&self) -> Option<u64> {
374        self.data
375            .get(metric_keys::TOKENS_IN)
376            .and_then(Value::as_u64)
377    }
378
379    /// Output tokens recorded on this event, if any.
380    pub fn tokens_out(&self) -> Option<u64> {
381        self.data
382            .get(metric_keys::TOKENS_OUT)
383            .and_then(Value::as_u64)
384    }
385
386    /// Estimated cost (USD) recorded on this event, if any.
387    pub fn cost_usd(&self) -> Option<f64> {
388        self.data.get(metric_keys::COST_USD).and_then(Value::as_f64)
389    }
390
391    /// All metrics carried on this event, gathered into a [`Metrics`].
392    pub fn metrics(&self) -> Metrics {
393        Metrics {
394            duration_ms: self.duration_ms(),
395            tokens_in: self.tokens_in(),
396            tokens_out: self.tokens_out(),
397            cost_usd: self.cost_usd(),
398        }
399    }
400}
401
402/// Summed telemetry metrics across a set of events — the trajectory-level
403/// totals harness-level evaluation (§5.2.1) and the Evolution Agent
404/// (§3.5.2) reason over. `tokens` is the sum of in + out.
405#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
406pub struct MetricsTotals {
407    pub duration_ms: f64,
408    pub tokens_in: u64,
409    pub tokens_out: u64,
410    pub tokens: u64,
411    pub cost_usd: f64,
412    /// Number of events that carried at least one metric.
413    pub metered_events: usize,
414}
415
416/// Sum the telemetry metrics across a slice of events. The single
417/// implementation behind both [`EventLog::metrics_totals`] and the
418/// harness-metrics computation, so the two can never drift on the metric
419/// contract (neo review: avoid a duplicated copy).
420pub fn metrics_totals_of(events: &[Event]) -> MetricsTotals {
421    let mut totals = MetricsTotals::default();
422    for ev in events {
423        let m = ev.metrics();
424        let mut metered = false;
425        if let Some(d) = m.duration_ms {
426            totals.duration_ms += d;
427            metered = true;
428        }
429        if let Some(t) = m.tokens_in {
430            totals.tokens_in = totals.tokens_in.saturating_add(t);
431            metered = true;
432        }
433        if let Some(t) = m.tokens_out {
434            totals.tokens_out = totals.tokens_out.saturating_add(t);
435            metered = true;
436        }
437        if let Some(c) = m.cost_usd {
438            totals.cost_usd += c;
439            metered = true;
440        }
441        if metered {
442            totals.metered_events += 1;
443        }
444    }
445    totals.tokens = totals.tokens_in.saturating_add(totals.tokens_out);
446    totals
447}
448
449/// Per-agent cost/token attribution (EPIC G / G3). Folded from
450/// `InferenceMetered` events that carry an `agent` field in `data`.
451#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
452pub struct AgentCost {
453    pub agent: String,
454    /// Number of metered inference events attributed to this agent.
455    pub calls: u64,
456    pub tokens_in: u64,
457    pub tokens_out: u64,
458    pub cost_usd: f64,
459}
460
461/// Attribute token/cost totals per agent by folding `InferenceMetered` events
462/// grouped by their `data["agent"]` field (EPIC G / G3). Events with no `agent`
463/// field are grouped under `"unknown"`. Ordered by agent name (BTreeMap) so the
464/// report is deterministic. This is how a multi-agent run reports cost per agent
465/// (e.g. Researcher $2, Coordinator $0.5) and, joined with the `tools`/`workflow`
466/// provenance fields the emit sites stamp, how a tool call is traceable to its
467/// agent.
468pub fn cost_by_agent_of(events: &[Event]) -> Vec<AgentCost> {
469    use std::collections::BTreeMap;
470    let mut map: BTreeMap<String, AgentCost> = BTreeMap::new();
471    for e in events {
472        if e.kind != EventKind::InferenceMetered {
473            continue;
474        }
475        let agent = e
476            .data
477            .get("agent")
478            .and_then(|v| v.as_str())
479            .unwrap_or("unknown")
480            .to_string();
481        let entry = map.entry(agent.clone()).or_insert_with(|| AgentCost {
482            agent,
483            ..Default::default()
484        });
485        entry.calls += 1;
486        entry.tokens_in = entry.tokens_in.saturating_add(e.tokens_in().unwrap_or(0));
487        entry.tokens_out = entry.tokens_out.saturating_add(e.tokens_out().unwrap_or(0));
488        entry.cost_usd += e.cost_usd().unwrap_or(0.0);
489    }
490    map.into_values().collect()
491}
492
493/// Background JSONL journal writer. `EventLog::append` hands a serialized event
494/// line to this over a channel; a dedicated thread owns the file and does the
495/// actual write. So `append` never does file I/O while a caller holds the log
496/// mutex — the head-of-line blocking that bites when many concurrent tasks
497/// (e.g. Foreman gate verifications running under one shared, journaled session
498/// log) each re-opened and wrote the file under the lock.
499///
500/// Best-effort, like the journal it replaces: an open/write failure drops the
501/// line (the in-memory event vec is unaffected) — but unlike the old silent
502/// journal, the hard failures (can't spawn the thread, can't open the file) are
503/// surfaced via `tracing::warn!`, since this carries the gate audit trail and a
504/// silently-broken audit log is worse than a noisy one.
505///
506/// The channel is unbounded so a burst never blocks the hot path. This relies on
507/// an envelope: low per-session journal volume and a writer that keeps up, so the
508/// backlog stays small. It is not a *new* unbounded-growth risk — the in-memory
509/// `events` vec already grows without bound under the same pathological
510/// hot-loop-`append` workload, so the channel is not the first thing to OOM.
511enum JournalMessage {
512    Async(String),
513    Critical {
514        line: String,
515        known_existing: bool,
516        ack: JournalAcknowledgement,
517    },
518    #[cfg(test)]
519    Shutdown,
520}
521
522/// Hard upper bound for one asynchronous critical-journal acknowledgement.
523/// Callers may choose a shorter deadline, but never create a timer entry that
524/// lives longer than this process-wide contract.
525pub const MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT: Duration = Duration::from_secs(30);
526
527const DEFAULT_CRITICAL_ACKNOWLEDGEMENT_CAPACITY: usize = 64;
528
529/// Deterministic failure seam for durable-journal tests. Production callers
530/// use the default empty queue; embedders may inject one failure at an exact
531/// write/flush/fsync boundary without replacing the filesystem.
532#[derive(Debug, Clone, Copy, PartialEq, Eq)]
533pub enum JournalFailurePoint {
534    AsyncWrite,
535    Write,
536    Flush,
537    Fsync,
538    /// Accept and durably write a critical row, but retain its acknowledgement.
539    /// This models the ambiguous boundary where a caller cannot know whether
540    /// the writer completed before its acknowledgement deadline.
541    HoldAcknowledgement,
542}
543
544#[derive(Debug, Clone, Default)]
545pub struct JournalFailureInjector {
546    failures: Arc<Mutex<VecDeque<JournalFailurePoint>>>,
547    held_acknowledgements: Arc<Mutex<Vec<JournalAcknowledgement>>>,
548}
549
550impl JournalFailureInjector {
551    pub fn fail_next(&self, point: JournalFailurePoint) {
552        self.failures
553            .lock()
554            .expect("journal failure injector mutex poisoned")
555            .push_back(point);
556    }
557
558    fn take(&self, point: JournalFailurePoint) -> bool {
559        let mut failures = self
560            .failures
561            .lock()
562            .expect("journal failure injector mutex poisoned");
563        if failures.front() == Some(&point) {
564            failures.pop_front();
565            true
566        } else {
567            false
568        }
569    }
570
571    fn hold_acknowledgement(&self, ack: JournalAcknowledgement) {
572        self.held_acknowledgements
573            .lock()
574            .expect("journal held-acknowledgement mutex poisoned")
575            .push(ack);
576    }
577
578    /// Number of acknowledgements retained by the explicit
579    /// [`JournalFailurePoint::HoldAcknowledgement`] test seam.
580    #[doc(hidden)]
581    pub fn held_acknowledgement_count(&self) -> usize {
582        self.held_acknowledgements
583            .lock()
584            .expect("journal held-acknowledgement mutex poisoned")
585            .len()
586    }
587
588    /// Release acknowledgements retained by the explicit stalled-writer test
589    /// seam. Production code never arms that seam.
590    #[doc(hidden)]
591    pub fn release_held_acknowledgements(&self) {
592        let acknowledgements: Vec<_> = self
593            .held_acknowledgements
594            .lock()
595            .expect("journal held-acknowledgement mutex poisoned")
596            .drain(..)
597            .collect();
598        for acknowledgement in acknowledgements {
599            acknowledgement.send(Ok(()));
600        }
601    }
602}
603
604#[derive(Debug)]
605enum CriticalPreAcceptanceError {
606    WriterUnavailable,
607    WriterStopped,
608    CoordinatorUnavailable(String),
609    CapacityExhausted { capacity: usize },
610    InvalidAcknowledgementTimeout { requested: Duration },
611}
612
613impl std::fmt::Display for CriticalPreAcceptanceError {
614    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
615        match self {
616            Self::WriterUnavailable => write!(formatter, "journal writer thread is unavailable"),
617            Self::WriterStopped => {
618                write!(
619                    formatter,
620                    "journal writer stopped before accepting critical append"
621                )
622            }
623            Self::CoordinatorUnavailable(reason) => write!(
624                formatter,
625                "critical acknowledgement coordinator is unavailable: {reason}"
626            ),
627            Self::CapacityExhausted { capacity } => write!(
628                formatter,
629                "critical acknowledgement capacity is exhausted ({capacity} in flight)"
630            ),
631            Self::InvalidAcknowledgementTimeout { requested } => write!(
632                formatter,
633                "critical acknowledgement timeout must be between 1ns and {}ms, got {}ms",
634                MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT.as_millis(),
635                requested.as_millis()
636            ),
637        }
638    }
639}
640
641#[derive(Debug)]
642enum CriticalPostAcceptanceError {
643    DurabilityFailure(String),
644    AcknowledgementTimedOut { timeout: Duration },
645    CoordinatorStopped,
646}
647
648impl std::fmt::Display for CriticalPostAcceptanceError {
649    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
650        match self {
651            Self::DurabilityFailure(reason) => write!(formatter, "{reason}"),
652            Self::AcknowledgementTimedOut { timeout } => write!(
653                formatter,
654                "journal writer did not acknowledge within {}ms",
655                timeout.as_millis()
656            ),
657            Self::CoordinatorStopped => {
658                write!(
659                    formatter,
660                    "acknowledgement coordinator stopped after enqueue"
661                )
662            }
663        }
664    }
665}
666
667struct AsyncAcknowledgementState {
668    terminal: bool,
669    result: Option<Result<(), CriticalPostAcceptanceError>>,
670    waker: Option<Waker>,
671}
672
673struct AsyncAcknowledgementEntry {
674    id: u64,
675    deadline: Instant,
676    timeout: Duration,
677    manager: Weak<AsyncAcknowledgementManagerInner>,
678    state: Mutex<AsyncAcknowledgementState>,
679}
680
681impl AsyncAcknowledgementEntry {
682    fn complete(&self, result: Result<(), CriticalPostAcceptanceError>) {
683        self.complete_deciding(|| result);
684    }
685
686    fn complete_writer(&self, result: std::io::Result<()>) {
687        self.complete_deciding(|| {
688            if Instant::now() >= self.deadline {
689                Err(CriticalPostAcceptanceError::AcknowledgementTimedOut {
690                    timeout: self.timeout,
691                })
692            } else {
693                result.map_err(|error| {
694                    CriticalPostAcceptanceError::DurabilityFailure(error.to_string())
695                })
696            }
697        });
698    }
699
700    fn complete_deciding(&self, decide: impl FnOnce() -> Result<(), CriticalPostAcceptanceError>) {
701        let waker = {
702            let mut state = self
703                .state
704                .lock()
705                .expect("journal async-acknowledgement mutex poisoned");
706            if state.terminal {
707                return;
708            }
709            state.terminal = true;
710            state.result = Some(decide());
711            state.waker.take()
712        };
713        if let Some(manager) = self.manager.upgrade() {
714            manager.remove(self.id);
715        }
716        if let Some(waker) = waker {
717            waker.wake();
718        }
719    }
720
721    fn cancel_preacceptance(&self) {
722        {
723            let mut state = self
724                .state
725                .lock()
726                .expect("journal async-acknowledgement mutex poisoned");
727            if state.terminal {
728                return;
729            }
730            state.terminal = true;
731        }
732        if let Some(manager) = self.manager.upgrade() {
733            manager.remove(self.id);
734        }
735    }
736}
737
738struct AsyncAcknowledgement {
739    entry: Arc<AsyncAcknowledgementEntry>,
740}
741
742impl Future for AsyncAcknowledgement {
743    type Output = Result<(), CriticalPostAcceptanceError>;
744
745    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
746        let mut state = self
747            .entry
748            .state
749            .lock()
750            .expect("journal async-acknowledgement mutex poisoned");
751        match state.result.take() {
752            Some(result) => Poll::Ready(result),
753            None => {
754                state.waker = Some(context.waker().clone());
755                Poll::Pending
756            }
757        }
758    }
759}
760
761struct AsyncAcknowledgementManagerState {
762    entries: HashMap<u64, Arc<AsyncAcknowledgementEntry>>,
763    next_id: u64,
764    shutting_down: bool,
765}
766
767struct AsyncAcknowledgementManagerInner {
768    capacity: usize,
769    state: Mutex<AsyncAcknowledgementManagerState>,
770    changed: Condvar,
771    #[cfg(test)]
772    expiry_barrier: Mutex<Option<AsyncAcknowledgementExpiryBarrier>>,
773}
774
775#[cfg(test)]
776#[derive(Clone)]
777struct AsyncAcknowledgementExpiryBarrier {
778    removed: Arc<std::sync::Barrier>,
779    release: Arc<std::sync::Barrier>,
780}
781
782#[cfg(test)]
783impl AsyncAcknowledgementExpiryBarrier {
784    fn new() -> Self {
785        Self {
786            removed: Arc::new(std::sync::Barrier::new(2)),
787            release: Arc::new(std::sync::Barrier::new(2)),
788        }
789    }
790
791    fn pause_after_removal(&self) {
792        self.removed.wait();
793        self.release.wait();
794    }
795
796    fn wait_until_removed(&self) {
797        self.removed.wait();
798    }
799
800    fn allow_timeout_completion(&self) {
801        self.release.wait();
802    }
803}
804
805impl AsyncAcknowledgementManagerInner {
806    fn remove(&self, id: u64) {
807        let removed = self
808            .state
809            .lock()
810            .expect("journal acknowledgement-manager mutex poisoned")
811            .entries
812            .remove(&id)
813            .is_some();
814        if removed {
815            self.changed.notify_all();
816        }
817    }
818}
819
820struct AsyncAcknowledgementManager {
821    inner: Arc<AsyncAcknowledgementManagerInner>,
822    worker: Mutex<Option<thread::JoinHandle<()>>>,
823}
824
825impl AsyncAcknowledgementManager {
826    fn new(capacity: usize) -> Self {
827        Self {
828            inner: Arc::new(AsyncAcknowledgementManagerInner {
829                capacity,
830                state: Mutex::new(AsyncAcknowledgementManagerState {
831                    entries: HashMap::new(),
832                    next_id: 0,
833                    shutting_down: false,
834                }),
835                changed: Condvar::new(),
836                #[cfg(test)]
837                expiry_barrier: Mutex::new(None),
838            }),
839            worker: Mutex::new(None),
840        }
841    }
842
843    #[cfg(test)]
844    fn pause_next_expiry_after_removal(&self, barrier: AsyncAcknowledgementExpiryBarrier) {
845        *self
846            .inner
847            .expiry_barrier
848            .lock()
849            .expect("journal acknowledgement expiry-barrier mutex poisoned") = Some(barrier);
850    }
851
852    fn ensure_worker(&self) -> Result<(), CriticalPreAcceptanceError> {
853        let mut worker = self
854            .worker
855            .lock()
856            .expect("journal acknowledgement-worker mutex poisoned");
857        if worker.is_some() {
858            return Ok(());
859        }
860        let inner = self.inner.clone();
861        let handle = thread::Builder::new()
862            .name("car-eventlog-critical-ack".into())
863            .spawn(move || async_acknowledgement_timer_loop(inner))
864            .map_err(|error| {
865                CriticalPreAcceptanceError::CoordinatorUnavailable(error.to_string())
866            })?;
867        *worker = Some(handle);
868        Ok(())
869    }
870
871    fn reserve(
872        &self,
873        timeout: Duration,
874    ) -> Result<AsyncAcknowledgementReservation, CriticalPreAcceptanceError> {
875        if timeout.is_zero() || timeout > MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT {
876            return Err(CriticalPreAcceptanceError::InvalidAcknowledgementTimeout {
877                requested: timeout,
878            });
879        }
880        self.ensure_worker()?;
881        let mut state = self
882            .inner
883            .state
884            .lock()
885            .expect("journal acknowledgement-manager mutex poisoned");
886        if state.shutting_down {
887            return Err(CriticalPreAcceptanceError::CoordinatorUnavailable(
888                "coordinator is shutting down".to_string(),
889            ));
890        }
891        if state.entries.len() >= self.inner.capacity {
892            return Err(CriticalPreAcceptanceError::CapacityExhausted {
893                capacity: self.inner.capacity,
894            });
895        }
896        let id = loop {
897            let candidate = state.next_id;
898            state.next_id = state.next_id.wrapping_add(1);
899            if !state.entries.contains_key(&candidate) {
900                break candidate;
901            }
902        };
903        let entry = Arc::new(AsyncAcknowledgementEntry {
904            id,
905            deadline: Instant::now() + timeout,
906            timeout,
907            manager: Arc::downgrade(&self.inner),
908            state: Mutex::new(AsyncAcknowledgementState {
909                terminal: false,
910                result: None,
911                waker: None,
912            }),
913        });
914        state.entries.insert(id, entry.clone());
915        drop(state);
916        self.inner.changed.notify_all();
917        Ok(AsyncAcknowledgementReservation {
918            entry,
919            preaccepted: true,
920        })
921    }
922
923    fn shutdown(&self) {
924        let entries = {
925            let mut state = self
926                .inner
927                .state
928                .lock()
929                .expect("journal acknowledgement-manager mutex poisoned");
930            state.shutting_down = true;
931            let entries = state
932                .entries
933                .drain()
934                .map(|(_, entry)| entry)
935                .collect::<Vec<_>>();
936            self.inner.changed.notify_all();
937            entries
938        };
939        for entry in entries {
940            entry.complete(Err(CriticalPostAcceptanceError::CoordinatorStopped));
941        }
942        if let Some(worker) = self
943            .worker
944            .lock()
945            .expect("journal acknowledgement-worker mutex poisoned")
946            .take()
947        {
948            let _ = worker.join();
949        }
950    }
951}
952
953fn async_acknowledgement_timer_loop(inner: Arc<AsyncAcknowledgementManagerInner>) {
954    loop {
955        let expired = {
956            let mut state = inner
957                .state
958                .lock()
959                .expect("journal acknowledgement-manager mutex poisoned");
960            loop {
961                if state.shutting_down {
962                    return;
963                }
964                let now = Instant::now();
965                let expired_ids: Vec<_> = state
966                    .entries
967                    .iter()
968                    .filter_map(|(id, entry)| (entry.deadline <= now).then_some(*id))
969                    .collect();
970                if !expired_ids.is_empty() {
971                    break expired_ids
972                        .into_iter()
973                        .filter_map(|id| state.entries.remove(&id))
974                        .collect::<Vec<_>>();
975                }
976                if let Some(deadline) = state.entries.values().map(|entry| entry.deadline).min() {
977                    let wait = deadline.saturating_duration_since(now);
978                    let (next, _) = inner
979                        .changed
980                        .wait_timeout(state, wait)
981                        .expect("journal acknowledgement-manager mutex poisoned");
982                    state = next;
983                } else {
984                    state = inner
985                        .changed
986                        .wait(state)
987                        .expect("journal acknowledgement-manager mutex poisoned");
988                }
989            }
990        };
991        #[cfg(test)]
992        if !expired.is_empty() {
993            if let Some(barrier) = inner
994                .expiry_barrier
995                .lock()
996                .expect("journal acknowledgement expiry-barrier mutex poisoned")
997                .take()
998            {
999                barrier.pause_after_removal();
1000            }
1001        }
1002        for entry in expired {
1003            entry.complete(Err(CriticalPostAcceptanceError::AcknowledgementTimedOut {
1004                timeout: entry.timeout,
1005            }));
1006        }
1007    }
1008}
1009
1010struct AsyncAcknowledgementReservation {
1011    entry: Arc<AsyncAcknowledgementEntry>,
1012    preaccepted: bool,
1013}
1014
1015impl AsyncAcknowledgementReservation {
1016    fn sender(&self) -> AsyncAcknowledgementSender {
1017        AsyncAcknowledgementSender {
1018            entry: self.entry.clone(),
1019        }
1020    }
1021
1022    fn into_future(mut self) -> AsyncAcknowledgement {
1023        self.preaccepted = false;
1024        AsyncAcknowledgement {
1025            entry: self.entry.clone(),
1026        }
1027    }
1028}
1029
1030impl Drop for AsyncAcknowledgementReservation {
1031    fn drop(&mut self) {
1032        if self.preaccepted {
1033            self.entry.cancel_preacceptance();
1034        }
1035    }
1036}
1037
1038struct AsyncAcknowledgementSender {
1039    entry: Arc<AsyncAcknowledgementEntry>,
1040}
1041
1042impl AsyncAcknowledgementSender {
1043    fn send(&self, result: std::io::Result<()>) {
1044        self.entry.complete_writer(result);
1045    }
1046}
1047
1048enum JournalAcknowledgement {
1049    Sync(mpsc::SyncSender<std::io::Result<()>>),
1050    Async(AsyncAcknowledgementSender),
1051}
1052
1053impl JournalAcknowledgement {
1054    fn send(&self, result: std::io::Result<()>) {
1055        match self {
1056            Self::Sync(sender) => {
1057                let _ = sender.send(result);
1058            }
1059            Self::Async(sender) => sender.send(result),
1060        }
1061    }
1062}
1063
1064impl std::fmt::Debug for JournalAcknowledgement {
1065    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1066        match self {
1067            Self::Sync(_) => formatter.write_str("JournalAcknowledgement::Sync"),
1068            Self::Async(_) => formatter.write_str("JournalAcknowledgement::Async"),
1069        }
1070    }
1071}
1072
1073/// Failure from a bounded critical append. A durability-unknown result retains
1074/// the exact serialized row and is safe to retry with the same lifecycle
1075/// identity and data.
1076#[derive(Debug, Clone, PartialEq, Eq)]
1077pub enum CriticalAppendError {
1078    /// The append was rejected before an acknowledgement wait could begin.
1079    Rejected { reason: String },
1080    /// The writer accepted the request, but durable completion was not known
1081    /// before the acknowledgement bound. The exact row remains pending.
1082    DurabilityUnknown { reason: String },
1083}
1084
1085impl CriticalAppendError {
1086    pub fn is_retry_safe(&self) -> bool {
1087        matches!(self, Self::DurabilityUnknown { .. })
1088    }
1089}
1090
1091impl std::fmt::Display for CriticalAppendError {
1092    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1093        match self {
1094            Self::Rejected { reason } => {
1095                write!(formatter, "critical journal append rejected: {reason}")
1096            }
1097            Self::DurabilityUnknown { reason } => write!(
1098                formatter,
1099                "critical journal durability is unknown; retry the exact event safely: {reason}"
1100            ),
1101        }
1102    }
1103}
1104
1105impl std::error::Error for CriticalAppendError {}
1106
1107struct JournalWriter {
1108    /// `None` only if the writer thread could not be spawned (journaling then
1109    /// silently disabled — still best-effort).
1110    tx: Option<mpsc::Sender<JournalMessage>>,
1111    handle: Option<thread::JoinHandle<()>>,
1112    acknowledgements: AsyncAcknowledgementManager,
1113}
1114
1115impl JournalWriter {
1116    fn spawn(path: PathBuf) -> Self {
1117        Self::spawn_with_injectors(path, JournalFailureInjector::default(), None)
1118    }
1119
1120    fn spawn_with_injector(path: PathBuf, failures: JournalFailureInjector) -> Self {
1121        Self::spawn_with_injectors(path, failures, None)
1122    }
1123
1124    fn spawn_with_private_path_injector(
1125        path: PathBuf,
1126        failures: car_secrets::PrivatePathDurabilityFailureInjector,
1127    ) -> Self {
1128        Self::spawn_with_injectors(path, JournalFailureInjector::default(), Some(failures))
1129    }
1130
1131    fn spawn_with_injectors(
1132        path: PathBuf,
1133        failures: JournalFailureInjector,
1134        private_path_failures: Option<car_secrets::PrivatePathDurabilityFailureInjector>,
1135    ) -> Self {
1136        Self::spawn_with_injectors_and_ack_capacity(
1137            path,
1138            failures,
1139            private_path_failures,
1140            DEFAULT_CRITICAL_ACKNOWLEDGEMENT_CAPACITY,
1141        )
1142    }
1143
1144    fn spawn_with_injectors_and_ack_capacity(
1145        path: PathBuf,
1146        failures: JournalFailureInjector,
1147        private_path_failures: Option<car_secrets::PrivatePathDurabilityFailureInjector>,
1148        acknowledgement_capacity: usize,
1149    ) -> Self {
1150        let (tx, rx) = mpsc::channel::<JournalMessage>();
1151        let acknowledgements = AsyncAcknowledgementManager::new(acknowledgement_capacity);
1152        match thread::Builder::new()
1153            .name("car-eventlog-journal".into())
1154            .spawn(move || journal_loop(path, rx, failures, private_path_failures))
1155        {
1156            Ok(handle) => Self {
1157                tx: Some(tx),
1158                handle: Some(handle),
1159                acknowledgements,
1160            },
1161            // Drop tx (rx dies with it); journaling becomes a no-op.
1162            Err(e) => {
1163                tracing::warn!(error = %e, "car-eventlog: failed to spawn journal writer thread — journaling disabled for this log");
1164                Self {
1165                    tx: None,
1166                    handle: None,
1167                    acknowledgements,
1168                }
1169            }
1170        }
1171    }
1172
1173    fn send(&self, line: String) {
1174        if let Some(tx) = &self.tx {
1175            // Best-effort: if the writer thread has gone, drop the line.
1176            let _ = tx.send(JournalMessage::Async(line));
1177        }
1178    }
1179
1180    fn enqueue_critical_sync(
1181        &self,
1182        line: String,
1183        known_existing: bool,
1184    ) -> Result<mpsc::Receiver<std::io::Result<()>>, CriticalPreAcceptanceError> {
1185        let tx = self
1186            .tx
1187            .as_ref()
1188            .ok_or(CriticalPreAcceptanceError::WriterUnavailable)?;
1189        let (ack_tx, ack_rx) = mpsc::sync_channel(0);
1190        tx.send(JournalMessage::Critical {
1191            line,
1192            known_existing,
1193            ack: JournalAcknowledgement::Sync(ack_tx),
1194        })
1195        .map_err(|_| CriticalPreAcceptanceError::WriterStopped)?;
1196        Ok(ack_rx)
1197    }
1198
1199    fn reserve_async_acknowledgement(
1200        &self,
1201        acknowledgement_timeout: Duration,
1202    ) -> Result<AsyncAcknowledgementReservation, CriticalPreAcceptanceError> {
1203        if self.tx.is_none() {
1204            return Err(CriticalPreAcceptanceError::WriterUnavailable);
1205        }
1206        self.acknowledgements.reserve(acknowledgement_timeout)
1207    }
1208
1209    fn enqueue_critical_async(
1210        &self,
1211        line: String,
1212        known_existing: bool,
1213        reservation: AsyncAcknowledgementReservation,
1214    ) -> Result<AsyncAcknowledgement, CriticalPreAcceptanceError> {
1215        let tx = self
1216            .tx
1217            .as_ref()
1218            .ok_or(CriticalPreAcceptanceError::WriterUnavailable)?;
1219        tx.send(JournalMessage::Critical {
1220            line,
1221            known_existing,
1222            ack: JournalAcknowledgement::Async(reservation.sender()),
1223        })
1224        .map_err(|_| CriticalPreAcceptanceError::WriterStopped)?;
1225        Ok(reservation.into_future())
1226    }
1227
1228    #[cfg(test)]
1229    fn remove_sender_for_test(&mut self) {
1230        self.tx.take();
1231        if let Some(handle) = self.handle.take() {
1232            let _ = handle.join();
1233        }
1234    }
1235
1236    #[cfg(test)]
1237    fn stop_receiver_for_test(&mut self) {
1238        if let Some(tx) = &self.tx {
1239            let _ = tx.send(JournalMessage::Shutdown);
1240        }
1241        if let Some(handle) = self.handle.take() {
1242            let _ = handle.join();
1243        }
1244    }
1245}
1246
1247impl Drop for JournalWriter {
1248    fn drop(&mut self) {
1249        self.acknowledgements.shutdown();
1250        // Close the channel so the writer drains its backlog, flushes, and
1251        // exits; join so buffered lines are durable by the time the log is gone.
1252        self.tx.take();
1253        if let Some(handle) = self.handle.take() {
1254            let _ = handle.join();
1255        }
1256    }
1257}
1258
1259/// The journal thread's body: own the file, write each line, flush when the
1260/// channel goes momentarily idle (batches bursts, keeps durability prompt).
1261///
1262/// The file is opened **lazily on the first line to write**, not at thread
1263/// start. A session that never appends an event — health checks, heartbeats,
1264/// `agents.list` polls, and every other no-op connection — then leaves no
1265/// journal behind. Opening eagerly created a 0-byte `<client_id>.jsonl` per
1266/// connection that accumulated without bound (177K empties observed on a
1267/// long-lived daemon). Sessions that DO log are unaffected: the file is created
1268/// on their first event exactly as before.
1269fn journal_loop(
1270    path: PathBuf,
1271    rx: mpsc::Receiver<JournalMessage>,
1272    failures: JournalFailureInjector,
1273    private_path_failures: Option<car_secrets::PrivatePathDurabilityFailureInjector>,
1274) {
1275    let mut writer: Option<std::fs::File> = None;
1276    let mut written_critical_lines = HashSet::new();
1277    let mut blocked_critical: Option<String> = None;
1278    // Rows whose first asynchronous write failed remain ahead of the next
1279    // critical boundary. Rows received after a failed critical boundary stay
1280    // behind that exact row. Keeping the queues separate preserves the
1281    // producer order across retries.
1282    let mut failed_async = VecDeque::new();
1283    let mut after_blocked_critical = VecDeque::new();
1284    while let Ok(message) = rx.recv() {
1285        let existed_before_open = path.exists();
1286        let open = || match private_path_failures.as_ref() {
1287            Some(failures) => {
1288                car_secrets::open_private_append_with_failure_injector(&path, failures)
1289            }
1290            None => open_private_append(&path),
1291        };
1292        match message {
1293            #[cfg(test)]
1294            JournalMessage::Shutdown => break,
1295            JournalMessage::Async(line) => {
1296                if blocked_critical.is_some() {
1297                    after_blocked_critical.push_back(line);
1298                    continue;
1299                }
1300                if !failed_async.is_empty() {
1301                    failed_async.push_back(line);
1302                    continue;
1303                }
1304                if writer.is_none() {
1305                    writer = open().ok();
1306                }
1307                let result = match writer.as_mut() {
1308                    Some(file) => append_journal_line(
1309                        &path,
1310                        file,
1311                        &line,
1312                        false,
1313                        false,
1314                        existed_before_open,
1315                        &failures,
1316                        &mut written_critical_lines,
1317                    ),
1318                    None => Err(std::io::Error::other("cannot open journal file")),
1319                };
1320                if let Err(error) = result {
1321                    failed_async.push_back(line);
1322                    tracing::warn!(path = %path.display(), %error, "car-eventlog: asynchronous journal append failed and is awaiting ordered retry");
1323                }
1324                continue;
1325            }
1326            JournalMessage::Critical {
1327                line,
1328                known_existing,
1329                ack,
1330            } => {
1331                if blocked_critical
1332                    .as_deref()
1333                    .is_some_and(|pending| pending != line)
1334                {
1335                    ack.send(Err(std::io::Error::new(
1336                        std::io::ErrorKind::WouldBlock,
1337                        "another critical journal row is awaiting durability",
1338                    )));
1339                    continue;
1340                }
1341                if writer.is_none() {
1342                    writer = open().ok();
1343                }
1344
1345                // A critical acknowledgement is an ordered durability
1346                // barrier. Repair every earlier asynchronous row first; if
1347                // any row is still unwritable, reserve this exact critical
1348                // line and fail closed instead of creating an audit gap.
1349                while let Some(pending) = failed_async.front() {
1350                    let replay = match writer.as_mut() {
1351                        Some(file) => append_journal_line(
1352                            &path,
1353                            file,
1354                            pending,
1355                            false,
1356                            false,
1357                            existed_before_open,
1358                            &failures,
1359                            &mut written_critical_lines,
1360                        ),
1361                        None => Err(std::io::Error::other("cannot open journal file")),
1362                    };
1363                    match replay {
1364                        Ok(()) => {
1365                            failed_async.pop_front();
1366                        }
1367                        Err(error) => {
1368                            blocked_critical = Some(line.clone());
1369                            ack.send(Err(std::io::Error::new(
1370                                error.kind(),
1371                                format!("prior asynchronous journal row is not durable: {error}"),
1372                            )));
1373                            break;
1374                        }
1375                    }
1376                }
1377                if !failed_async.is_empty() {
1378                    continue;
1379                }
1380                let result = match writer.as_mut() {
1381                    Some(file) => append_journal_line(
1382                        &path,
1383                        file,
1384                        &line,
1385                        true,
1386                        known_existing,
1387                        existed_before_open,
1388                        &failures,
1389                        &mut written_critical_lines,
1390                    ),
1391                    None => Err(std::io::Error::other("cannot open journal file")),
1392                };
1393                match result {
1394                    Ok(()) => {
1395                        blocked_critical = None;
1396                        while let Some(queued) = after_blocked_critical.pop_front() {
1397                            if let Some(file) = writer.as_mut() {
1398                                if let Err(error) = append_journal_line(
1399                                    &path,
1400                                    file,
1401                                    &queued,
1402                                    false,
1403                                    false,
1404                                    true,
1405                                    &failures,
1406                                    &mut written_critical_lines,
1407                                ) {
1408                                    failed_async.push_back(queued);
1409                                    failed_async.append(&mut after_blocked_critical);
1410                                    tracing::warn!(path = %path.display(), %error, "car-eventlog: queued asynchronous append failed after critical recovery and is awaiting ordered retry");
1411                                    break;
1412                                }
1413                            }
1414                        }
1415                        if failures.take(JournalFailurePoint::HoldAcknowledgement) {
1416                            failures.hold_acknowledgement(ack);
1417                        } else {
1418                            ack.send(Ok(()));
1419                        }
1420                    }
1421                    Err(error) => {
1422                        blocked_critical = Some(line);
1423                        ack.send(Err(error));
1424                    }
1425                }
1426                continue;
1427            }
1428        };
1429    }
1430    if let Some(mut writer) = writer {
1431        let _ = writer.flush();
1432    }
1433}
1434
1435fn append_journal_line(
1436    path: &Path,
1437    file: &mut std::fs::File,
1438    line: &str,
1439    critical: bool,
1440    known_existing: bool,
1441    existed_before_open: bool,
1442    failures: &JournalFailureInjector,
1443    written_critical_lines: &mut HashSet<String>,
1444) -> std::io::Result<()> {
1445    revalidate_private_path(path, file)?;
1446    let already_written = known_existing || written_critical_lines.contains(line);
1447    if !already_written {
1448        if (!critical && failures.take(JournalFailurePoint::AsyncWrite))
1449            || (critical && failures.take(JournalFailurePoint::Write))
1450        {
1451            return Err(std::io::Error::from_raw_os_error(28)); // ENOSPC
1452        }
1453        let mut bytes = Vec::with_capacity(line.len() + 2);
1454        let len = file.seek(SeekFrom::End(0))?;
1455        if len > 0 {
1456            file.seek(SeekFrom::End(-1))?;
1457            let mut tail = [0u8; 1];
1458            file.read_exact(&mut tail)?;
1459            if tail[0] != b'\n' {
1460                bytes.push(b'\n');
1461            }
1462        }
1463        bytes.extend_from_slice(line.as_bytes());
1464        bytes.push(b'\n');
1465        if let Err(error) = file.write_all(&bytes) {
1466            // `write_all` may have written a prefix before returning an
1467            // error. Restore the exact pre-row boundary so retry cannot leave
1468            // a truncated JSON fragment or duplicate suffix in the journal.
1469            let _ = file.set_len(len);
1470            let _ = file.seek(SeekFrom::End(0));
1471            return Err(error);
1472        }
1473        if critical {
1474            written_critical_lines.insert(line.to_string());
1475        }
1476    }
1477    if critical && failures.take(JournalFailurePoint::Flush) {
1478        return Err(std::io::Error::other("injected journal flush failure"));
1479    }
1480    file.flush()?;
1481    if critical {
1482        if failures.take(JournalFailurePoint::Fsync) {
1483            return Err(std::io::Error::other("injected journal fsync failure"));
1484        }
1485        file.sync_all()?;
1486        if !existed_before_open {
1487            sync_journal_parent(path)?;
1488        }
1489    }
1490    revalidate_private_path(path, file)
1491}
1492
1493#[cfg(not(target_os = "windows"))]
1494fn sync_journal_parent(path: &Path) -> std::io::Result<()> {
1495    if let Some(parent) = path.parent() {
1496        std::fs::File::open(parent)?.sync_all()?;
1497    }
1498    Ok(())
1499}
1500
1501#[cfg(target_os = "windows")]
1502fn sync_journal_parent(_path: &Path) -> std::io::Result<()> {
1503    // Windows has no portable directory-fsync primitive: FlushFileBuffers on
1504    // a directory handle returns ERROR_ACCESS_DENIED on supported NTFS
1505    // runners. open_private_append already completes the platform-specific
1506    // parent metadata boundary before returning the newly-created file.
1507    Ok(())
1508}
1509
1510/// Append-only event log with optional JSONL journal.
1511pub struct EventLog {
1512    events: Vec<Event>,
1513    spans: Vec<Span>,
1514    journal: Option<JournalWriter>,
1515    /// When true, each appended event is hash-chained to its predecessor
1516    /// (EPIC A / A9). Off by default — enabling it is opt-in so existing
1517    /// JSONL output stays byte-identical for consumers that don't need
1518    /// tamper-evidence.
1519    hash_chaining: bool,
1520    /// The hash of the most recently appended event, threaded into the
1521    /// next event's `prev_hash`. The genesis link uses the empty string.
1522    last_hash: Option<String>,
1523    /// Auto-retention policy (EPIC G / G2). When set, `append` caps the
1524    /// in-memory event count at `max_events` (dropping oldest) so the log
1525    /// can't grow unbounded. Age-based trimming is applied by
1526    /// `enforce_retention`. `None` = keep everything (unchanged default).
1527    retention: Option<RetentionPolicy>,
1528    /// Path of the JSONL journal, kept so retention trims can compact
1529    /// (rewrite) the file — the background [`JournalWriter`] only appends.
1530    journal_path: Option<PathBuf>,
1531    /// Approximate number of event lines currently in the journal file:
1532    /// incremented per journaled append, seeded from the parsed event count
1533    /// on [`EventLog::load`], reset to the retained count after a
1534    /// compaction. Drives the compaction throttle.
1535    journal_lines: usize,
1536    /// Total events ever dropped from the in-memory log (retention trims,
1537    /// manual truncation, `clear`). Monotonic. Lets consumers that project
1538    /// over `events()` — e.g. the tool-receipt verifier (A6) — know the
1539    /// retained window is incomplete instead of mistaking an evicted event
1540    /// for one that never happened.
1541    trimmed_events: u64,
1542    /// Monotonic cumulative cost (USD) across every event ever appended
1543    /// (EPIC G / G1). Updated at append time and **never** decremented by
1544    /// retention trims, truncation, or `clear`, so a cumulative budget check
1545    /// can't slide backward when old events are evicted. Seeded from the
1546    /// journal on [`EventLog::load`].
1547    cumulative_cost_usd: f64,
1548    /// Live producer binding for CAR-owned journal appends. This is runtime
1549    /// state, not replay state: loading historical rows never fabricates an
1550    /// active run from whatever happens to be at the journal tail.
1551    active_binding: Option<EventBinding>,
1552    /// Exact serialized critical events whose first durability attempt failed.
1553    /// A retry reuses the same timestamp/bytes and asks the writer to finish
1554    /// flush+fsync instead of minting a conflicting duplicate terminal.
1555    critical_pending: HashSet<String>,
1556}
1557
1558#[derive(Debug, Clone, PartialEq, Eq)]
1559struct EventBinding {
1560    run_id: String,
1561    client_id: String,
1562    policy_session_id: Option<String>,
1563}
1564
1565struct PreparedCriticalAppend {
1566    existing_index: Option<usize>,
1567    event: Option<Event>,
1568    line: String,
1569    known_existing: bool,
1570}
1571
1572/// Journal-compaction throttle floor (G2): a retention trim only triggers a
1573/// journal rewrite once the journal holds at least this many more lines than
1574/// the retained set (and the rewrite would shrink it by ≥25% — see
1575/// [`EventLog::maybe_compact_journal`]). Keeps frequent small trims from
1576/// rewriting the file on every append.
1577const JOURNAL_COMPACT_MIN_EXCESS: usize = 1024;
1578
1579/// Compute the content hash of an event for the tamper-evidence chain.
1580///
1581/// Hashes `prev_hash` plus a canonical rendering of the event's content
1582/// (kind, action/proposal ids, sorted `data`, timestamp). The top-level
1583/// `data` map is sorted by key so the digest is stable across a
1584/// serialize/deserialize round-trip (serde_json already emits nested object
1585/// keys in sorted order). Any after-the-fact edit to a chained event — or an
1586/// interior deletion/reordering — breaks the chain from that point on. The
1587/// chain has no anchored head hash, so truncation at either end (dropping a
1588/// prefix or a suffix of the log wholesale) is NOT detectable; see
1589/// [`EventLog::verify_chain`] for the precise guarantee.
1590fn event_digest(
1591    prev_hash: &str,
1592    kind: &EventKind,
1593    run_id: Option<&str>,
1594    client_id: Option<&str>,
1595    policy_session_id: Option<&str>,
1596    action_id: Option<&str>,
1597    proposal_id: Option<&str>,
1598    data: &HashMap<String, Value>,
1599    timestamp: &DateTime<Utc>,
1600) -> String {
1601    use sha2::{Digest, Sha256};
1602    let mut sorted: Vec<(&String, &Value)> = data.iter().collect();
1603    sorted.sort_by(|a, b| a.0.cmp(b.0));
1604    let data_canon: String = sorted
1605        .iter()
1606        .map(|(k, v)| format!("{k}={}", v))
1607        .collect::<Vec<_>>()
1608        .join("\u{1f}");
1609    let kind_str = serde_json::to_string(kind).unwrap_or_default();
1610    let mut hasher = Sha256::new();
1611    hasher.update(prev_hash.as_bytes());
1612    hasher.update(b"\x1e");
1613    hasher.update(kind_str.as_bytes());
1614    // Preserve historical hashes byte-for-byte when every binding field is
1615    // absent. Bound v0.51 events add one domain-separated identity segment.
1616    if run_id.is_some() || client_id.is_some() || policy_session_id.is_some() {
1617        hasher.update(b"\x1d");
1618        hasher.update(run_id.unwrap_or("").as_bytes());
1619        hasher.update(b"\x1f");
1620        hasher.update(client_id.unwrap_or("").as_bytes());
1621        hasher.update(b"\x1f");
1622        hasher.update(policy_session_id.unwrap_or("").as_bytes());
1623    }
1624    hasher.update(b"\x1e");
1625    hasher.update(action_id.unwrap_or("").as_bytes());
1626    hasher.update(b"\x1e");
1627    hasher.update(proposal_id.unwrap_or("").as_bytes());
1628    hasher.update(b"\x1e");
1629    hasher.update(data_canon.as_bytes());
1630    hasher.update(b"\x1e");
1631    hasher.update(timestamp.to_rfc3339().as_bytes());
1632    let digest = hasher.finalize();
1633    digest.iter().map(|b| format!("{b:02x}")).collect()
1634}
1635
1636/// Retention policy for an [`EventLog`] (EPIC G / G2). Bounds the log by
1637/// **size** (`max_events`, enforced automatically on append — oldest dropped)
1638/// and by **age** (`max_age_secs`, applied by [`EventLog::enforce_retention`]).
1639/// Both `None` = keep everything.
1640#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1641pub struct RetentionPolicy {
1642    /// Cap the in-memory event count; on overflow the oldest are dropped.
1643    #[serde(default)]
1644    pub max_events: Option<usize>,
1645    /// Drop events older than this many seconds when `enforce_retention` runs.
1646    #[serde(default)]
1647    pub max_age_secs: Option<i64>,
1648}
1649
1650/// A structured audit query over the event log (EPIC G / G2). Every field is
1651/// an AND-conjoined filter; empty/`None` fields don't constrain. Answers
1652/// "who ran what tool when, and which approvals applied" by filtering the
1653/// `SessionScope` / `PermissionDecision` / `ApprovalRecorded` / action trail.
1654#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1655pub struct EventQuery {
1656    /// Restrict to these event kinds (empty = any kind).
1657    #[serde(default)]
1658    pub kinds: Vec<EventKind>,
1659    /// Exact match on `action_id`.
1660    #[serde(default)]
1661    pub action_id: Option<String>,
1662    /// Exact match on `proposal_id`.
1663    #[serde(default)]
1664    pub proposal_id: Option<String>,
1665    /// Inclusive lower time bound.
1666    #[serde(default)]
1667    pub since: Option<DateTime<Utc>>,
1668    /// Exclusive upper time bound.
1669    #[serde(default)]
1670    pub until: Option<DateTime<Utc>>,
1671    /// Match events whose `data` contains ALL these key→value pairs (compared
1672    /// as strings). Covers caller/tenant/tool/gate/decision, which live in
1673    /// `data` on the audit events.
1674    #[serde(default)]
1675    pub data_matches: std::collections::HashMap<String, String>,
1676    /// Cap the number of results (most-recent-first). `None`/0 = unlimited.
1677    #[serde(default)]
1678    pub limit: Option<usize>,
1679}
1680
1681/// Does a JSON `data` value equal the query string? Compares strings directly
1682/// and stringifies scalars so `{"count": 3}` matches `"3"`.
1683fn data_value_matches(v: &Value, want: &str) -> bool {
1684    match v {
1685        Value::String(s) => s == want,
1686        Value::Null => false,
1687        other => *other == want,
1688    }
1689}
1690
1691impl EventQuery {
1692    /// Does `e` satisfy every constraint in this query?
1693    pub fn matches(&self, e: &Event) -> bool {
1694        if !self.kinds.is_empty() && !self.kinds.contains(&e.kind) {
1695            return false;
1696        }
1697        if let Some(aid) = &self.action_id {
1698            if e.action_id.as_deref() != Some(aid.as_str()) {
1699                return false;
1700            }
1701        }
1702        if let Some(pid) = &self.proposal_id {
1703            if e.proposal_id.as_deref() != Some(pid.as_str()) {
1704                return false;
1705            }
1706        }
1707        if let Some(since) = self.since {
1708            if e.timestamp < since {
1709                return false;
1710            }
1711        }
1712        if let Some(until) = self.until {
1713            if e.timestamp >= until {
1714                return false;
1715            }
1716        }
1717        for (k, want) in &self.data_matches {
1718            match e.data.get(k) {
1719                Some(v) if data_value_matches(v, want) => {}
1720                _ => return false,
1721            }
1722        }
1723        true
1724    }
1725}
1726
1727impl EventLog {
1728    pub fn new() -> Self {
1729        Self {
1730            events: Vec::new(),
1731            spans: Vec::new(),
1732            journal: None,
1733            hash_chaining: false,
1734            last_hash: None,
1735            retention: None,
1736            journal_path: None,
1737            journal_lines: 0,
1738            trimmed_events: 0,
1739            cumulative_cost_usd: 0.0,
1740            active_binding: None,
1741            critical_pending: HashSet::new(),
1742        }
1743    }
1744
1745    pub fn with_journal(path: PathBuf) -> Self {
1746        Self {
1747            events: Vec::new(),
1748            spans: Vec::new(),
1749            journal: Some(JournalWriter::spawn(path.clone())),
1750            hash_chaining: false,
1751            last_hash: None,
1752            retention: None,
1753            journal_path: Some(path),
1754            journal_lines: 0,
1755            trimmed_events: 0,
1756            cumulative_cost_usd: 0.0,
1757            active_binding: None,
1758            critical_pending: HashSet::new(),
1759        }
1760    }
1761
1762    /// Test/embedder seam for deterministic journal write/flush/fsync faults.
1763    pub fn with_journal_failure_injector(path: PathBuf, failures: JournalFailureInjector) -> Self {
1764        let mut log = Self::with_journal(path.clone());
1765        log.journal = Some(JournalWriter::spawn_with_injector(path, failures));
1766        log
1767    }
1768
1769    #[cfg(test)]
1770    fn with_journal_failure_injector_and_ack_capacity(
1771        path: PathBuf,
1772        failures: JournalFailureInjector,
1773        acknowledgement_capacity: usize,
1774    ) -> Self {
1775        let mut log = Self::with_journal(path.clone());
1776        log.journal = Some(JournalWriter::spawn_with_injectors_and_ack_capacity(
1777            path,
1778            failures,
1779            None,
1780            acknowledgement_capacity,
1781        ));
1782        log
1783    }
1784
1785    /// Test/embedder seam for deterministic first-use directory-entry faults.
1786    pub fn with_private_path_failure_injector(
1787        path: PathBuf,
1788        failures: car_secrets::PrivatePathDurabilityFailureInjector,
1789    ) -> Self {
1790        let mut log = Self::with_journal(path.clone());
1791        log.journal = Some(JournalWriter::spawn_with_private_path_injector(
1792            path, failures,
1793        ));
1794        log
1795    }
1796
1797    /// Bind this log to one authenticated active run. Exact repeat binding is
1798    /// idempotent; a different run/client is rejected instead of silently
1799    /// re-attributing later action events.
1800    pub fn bind_run(&mut self, run_id: &str, client_id: &str) -> Result<(), String> {
1801        if run_id.is_empty() || client_id.is_empty() {
1802            return Err("active journal binding requires non-empty run_id and client_id".into());
1803        }
1804        match &self.active_binding {
1805            Some(binding) if binding.run_id == run_id && binding.client_id == client_id => Ok(()),
1806            Some(binding) => Err(format!(
1807                "journal is already bound to run_id `{}` and client_id `{}`",
1808                binding.run_id, binding.client_id
1809            )),
1810            None => {
1811                self.active_binding = Some(EventBinding {
1812                    run_id: run_id.to_string(),
1813                    client_id: client_id.to_string(),
1814                    policy_session_id: None,
1815                });
1816                Ok(())
1817            }
1818        }
1819    }
1820
1821    /// Attach a CAR-minted policy session to the currently bound proposal.
1822    pub fn bind_policy_session(&mut self, policy_session_id: &str) -> Result<(), String> {
1823        if policy_session_id.is_empty() {
1824            return Err("policy_session_id must be non-empty".into());
1825        }
1826        let binding = self
1827            .active_binding
1828            .as_mut()
1829            .ok_or_else(|| "cannot bind a policy session without an active run".to_string())?;
1830        match binding.policy_session_id.as_deref() {
1831            Some(existing) if existing != policy_session_id => Err(format!(
1832                "journal proposal is already bound to policy_session_id `{existing}`"
1833            )),
1834            _ => {
1835                binding.policy_session_id = Some(policy_session_id.to_string());
1836                Ok(())
1837            }
1838        }
1839    }
1840
1841    pub fn clear_policy_session(&mut self, policy_session_id: &str) -> Result<(), String> {
1842        let binding = self
1843            .active_binding
1844            .as_mut()
1845            .ok_or_else(|| "cannot clear a policy session without an active run".to_string())?;
1846        if binding.policy_session_id.as_deref() != Some(policy_session_id) {
1847            return Err("policy_session_id does not match the active journal binding".into());
1848        }
1849        binding.policy_session_id = None;
1850        Ok(())
1851    }
1852
1853    pub fn clear_run_binding(&mut self, run_id: &str, client_id: &str) -> Result<(), String> {
1854        let binding = self
1855            .active_binding
1856            .as_ref()
1857            .ok_or_else(|| "journal has no active run binding".to_string())?;
1858        if binding.run_id != run_id || binding.client_id != client_id {
1859            return Err("run_id/client_id does not match the active journal binding".into());
1860        }
1861        if binding.policy_session_id.is_some() {
1862            return Err(
1863                "cannot clear an active run while a proposal policy session is bound".into(),
1864            );
1865        }
1866        self.active_binding = None;
1867        Ok(())
1868    }
1869
1870    pub fn active_run_binding(&self) -> Option<(&str, &str, Option<&str>)> {
1871        self.active_binding.as_ref().map(|binding| {
1872            (
1873                binding.run_id.as_str(),
1874                binding.client_id.as_str(),
1875                binding.policy_session_id.as_deref(),
1876            )
1877        })
1878    }
1879
1880    /// Enable tamper-evident hash chaining for events appended from now on
1881    /// (EPIC A / A9). The chain continues from the last already-appended
1882    /// event's hash if one exists (re-enabling after a load), else from the
1883    /// genesis link. Returns `self` for builder-style use.
1884    pub fn with_hash_chaining(mut self) -> Self {
1885        self.enable_hash_chaining();
1886        self
1887    }
1888
1889    /// Turn on hash chaining in place. Idempotent.
1890    pub fn enable_hash_chaining(&mut self) {
1891        self.hash_chaining = true;
1892        // Continue the chain from whatever the last event already carries.
1893        if self.last_hash.is_none() {
1894            self.last_hash = self.events.last().and_then(|e| e.hash.clone());
1895        }
1896    }
1897
1898    /// Whether hash chaining is currently enabled.
1899    pub fn hash_chaining_enabled(&self) -> bool {
1900        self.hash_chaining
1901    }
1902
1903    pub fn append(
1904        &mut self,
1905        kind: EventKind,
1906        action_id: Option<&str>,
1907        proposal_id: Option<&str>,
1908        data: HashMap<String, Value>,
1909    ) -> &Event {
1910        let timestamp = Utc::now();
1911        let (prev_hash, hash) = if self.hash_chaining {
1912            let prev = self.last_hash.clone().unwrap_or_default();
1913            let binding = self.active_binding.as_ref();
1914            let h = event_digest(
1915                &prev,
1916                &kind,
1917                binding.map(|b| b.run_id.as_str()),
1918                binding.map(|b| b.client_id.as_str()),
1919                binding.and_then(|b| b.policy_session_id.as_deref()),
1920                action_id,
1921                proposal_id,
1922                &data,
1923                &timestamp,
1924            );
1925            self.last_hash = Some(h.clone());
1926            (Some(prev), Some(h))
1927        } else {
1928            (None, None)
1929        };
1930        let event = Event {
1931            kind,
1932            run_id: self.active_binding.as_ref().map(|b| b.run_id.clone()),
1933            client_id: self.active_binding.as_ref().map(|b| b.client_id.clone()),
1934            policy_session_id: self
1935                .active_binding
1936                .as_ref()
1937                .and_then(|b| b.policy_session_id.clone()),
1938            action_id: action_id.map(|s| s.to_string()),
1939            proposal_id: proposal_id.map(|s| s.to_string()),
1940            data,
1941            timestamp,
1942            prev_hash,
1943            hash,
1944        };
1945
1946        // Hand the serialized line to the background writer — no file I/O here,
1947        // so a caller holding the log mutex is never blocked on disk.
1948        if let Some(journal) = &self.journal {
1949            if let Ok(json) = serde_json::to_string(&event) {
1950                journal.send(json);
1951                self.journal_lines += 1;
1952            }
1953        }
1954
1955        // Monotonic cumulative cost (G1): fold cost in at append time so a
1956        // budget check survives retention trims of the underlying events.
1957        if let Some(c) = event.cost_usd() {
1958            self.cumulative_cost_usd += c;
1959        }
1960
1961        self.events.push(event);
1962        // Auto-retention (EPIC G / G2): cap the in-memory log at max_events so
1963        // it can't grow unbounded. Cheap — a bounded pop from the front only
1964        // when over the cap. Age-based trimming is on-demand via
1965        // enforce_retention (walking every event on each append would be O(n)).
1966        if let Some(max) = self.retention.as_ref().and_then(|p| p.max_events) {
1967            if self.events.len() > max {
1968                let removed = truncate_vec_keep_last(&mut self.events, max);
1969                self.trimmed_events += removed as u64;
1970                // The journal keeps the dropped events until the (throttled)
1971                // compaction rewrites it to the retained set.
1972                self.maybe_compact_journal();
1973            }
1974        }
1975        self.events.last().unwrap()
1976    }
1977
1978    fn prepare_critical_append(
1979        &mut self,
1980        kind: EventKind,
1981        action_id: Option<&str>,
1982        proposal_id: Option<&str>,
1983        data: HashMap<String, Value>,
1984    ) -> Result<PreparedCriticalAppend, String> {
1985        let binding = self.active_binding.as_ref().ok_or_else(|| {
1986            "critical lifecycle event requires an authenticated run binding".to_string()
1987        })?;
1988        let existing = self.events.iter().position(|event| {
1989            event.kind == kind
1990                && event.run_id.as_deref() == Some(binding.run_id.as_str())
1991                && event.client_id.as_deref() == Some(binding.client_id.as_str())
1992                && event.policy_session_id.as_deref() == binding.policy_session_id.as_deref()
1993                && event.action_id.as_deref() == action_id
1994                && event.proposal_id.as_deref() == proposal_id
1995                && event.data == data
1996        });
1997        if existing.is_none() && !self.critical_pending.is_empty() {
1998            return Err(
1999                "another critical lifecycle event is awaiting an exact durability retry".into(),
2000            );
2001        }
2002
2003        if let Some(index) = existing {
2004            let line = serde_json::to_string(&self.events[index]).map_err(|e| e.to_string())?;
2005            return Ok(PreparedCriticalAppend {
2006                existing_index: Some(index),
2007                event: None,
2008                known_existing: !self.critical_pending.contains(&line),
2009                line,
2010            });
2011        }
2012
2013        let timestamp = Utc::now();
2014        let (prev_hash, hash) = if self.hash_chaining {
2015            let prev = self.last_hash.clone().unwrap_or_default();
2016            let hash = event_digest(
2017                &prev,
2018                &kind,
2019                Some(binding.run_id.as_str()),
2020                Some(binding.client_id.as_str()),
2021                binding.policy_session_id.as_deref(),
2022                action_id,
2023                proposal_id,
2024                &data,
2025                &timestamp,
2026            );
2027            (Some(prev), Some(hash))
2028        } else {
2029            (None, None)
2030        };
2031        let event = Event {
2032            kind,
2033            run_id: Some(binding.run_id.clone()),
2034            client_id: Some(binding.client_id.clone()),
2035            policy_session_id: binding.policy_session_id.clone(),
2036            action_id: action_id.map(str::to_string),
2037            proposal_id: proposal_id.map(str::to_string),
2038            data,
2039            timestamp,
2040            prev_hash,
2041            hash,
2042        };
2043        let line = serde_json::to_string(&event).map_err(|error| error.to_string())?;
2044        Ok(PreparedCriticalAppend {
2045            existing_index: None,
2046            event: Some(event),
2047            line,
2048            known_existing: false,
2049        })
2050    }
2051
2052    fn commit_prepared_critical(&mut self, prepared: PreparedCriticalAppend) -> (usize, String) {
2053        let index = match prepared.existing_index {
2054            Some(index) => index,
2055            None => {
2056                let event = prepared
2057                    .event
2058                    .expect("new critical append must carry its prepared event");
2059                if self.hash_chaining {
2060                    self.last_hash = event.hash.clone();
2061                }
2062                self.events.push(event);
2063                self.journal_lines += 1;
2064                self.events.len() - 1
2065            }
2066        };
2067        (index, prepared.line)
2068    }
2069
2070    /// Append a lifecycle-critical event and return only after its exact JSONL
2071    /// row and all earlier queued rows have been flushed and fsynced. If a
2072    /// write/flush/fsync attempt fails, the exact event remains pending in
2073    /// memory so an identical retry finishes the same row rather than minting
2074    /// a second terminal with a new timestamp.
2075    ///
2076    /// This compatibility API performs an unbounded blocking acknowledgement
2077    /// wait and is intended only for genuinely synchronous callers. Async
2078    /// callers must use [`Self::append_critical_async`] so a stalled filesystem
2079    /// cannot occupy an executor worker indefinitely.
2080    pub fn append_critical(
2081        &mut self,
2082        kind: EventKind,
2083        action_id: Option<&str>,
2084        proposal_id: Option<&str>,
2085        data: HashMap<String, Value>,
2086    ) -> Result<&Event, String> {
2087        if self.journal.is_none() {
2088            return Err("critical lifecycle event requires an enabled journal".to_string());
2089        }
2090        let prepared = self.prepare_critical_append(kind, action_id, proposal_id, data)?;
2091        let acknowledgement = self
2092            .journal
2093            .as_ref()
2094            .expect("journal presence checked above")
2095            .enqueue_critical_sync(prepared.line.clone(), prepared.known_existing)
2096            .map_err(|error| error.to_string())?;
2097        let (index, line) = self.commit_prepared_critical(prepared);
2098        self.critical_pending.insert(line.clone());
2099        let result = acknowledgement
2100            .recv()
2101            .map_err(|_| {
2102                std::io::Error::new(
2103                    std::io::ErrorKind::BrokenPipe,
2104                    "journal writer stopped before critical acknowledgement",
2105                )
2106            })
2107            .and_then(|result| result);
2108        match result {
2109            Ok(()) => {
2110                self.critical_pending.remove(&line);
2111                Ok(&self.events[index])
2112            }
2113            Err(error) => {
2114                self.critical_pending.insert(line);
2115                Err(format!("critical journal append was not durable: {error}"))
2116            }
2117        }
2118    }
2119
2120    /// Synchronous critical append with a hard acknowledgement bound.
2121    ///
2122    /// This is the startup-thread counterpart of [`Self::append_critical_async`].
2123    /// The exact serialized row is installed in `critical_pending` before the
2124    /// bounded wait begins, so timeout cannot claim success or authorize a
2125    /// different lifecycle event. An identical later startup replay safely
2126    /// reconciles the row whether or not the writer completed before timeout.
2127    pub fn append_critical_bounded(
2128        &mut self,
2129        kind: EventKind,
2130        action_id: Option<&str>,
2131        proposal_id: Option<&str>,
2132        data: HashMap<String, Value>,
2133        acknowledgement_timeout: Duration,
2134    ) -> Result<&Event, CriticalAppendError> {
2135        if acknowledgement_timeout.is_zero()
2136            || acknowledgement_timeout > MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT
2137        {
2138            return Err(CriticalAppendError::Rejected {
2139                reason: CriticalPreAcceptanceError::InvalidAcknowledgementTimeout {
2140                    requested: acknowledgement_timeout,
2141                }
2142                .to_string(),
2143            });
2144        }
2145        let prepared = self
2146            .prepare_critical_append(kind, action_id, proposal_id, data)
2147            .map_err(|reason| CriticalAppendError::Rejected { reason })?;
2148        let acknowledgement = self
2149            .journal
2150            .as_ref()
2151            .ok_or_else(|| CriticalAppendError::Rejected {
2152                reason: "critical lifecycle event requires an enabled journal".to_string(),
2153            })?
2154            .enqueue_critical_sync(prepared.line.clone(), prepared.known_existing)
2155            .map_err(|error| CriticalAppendError::Rejected {
2156                reason: error.to_string(),
2157            })?;
2158        let (index, line) = self.commit_prepared_critical(prepared);
2159        self.critical_pending.insert(line.clone());
2160        let result = match acknowledgement.recv_timeout(acknowledgement_timeout) {
2161            Ok(result) => result.map_err(|error| error.to_string()),
2162            Err(mpsc::RecvTimeoutError::Timeout) => Err(format!(
2163                "journal writer did not acknowledge within {}ms",
2164                acknowledgement_timeout.as_millis()
2165            )),
2166            Err(mpsc::RecvTimeoutError::Disconnected) => {
2167                Err("journal writer stopped before critical acknowledgement".to_string())
2168            }
2169        };
2170        match result {
2171            Ok(()) => {
2172                self.critical_pending.remove(&line);
2173                Ok(&self.events[index])
2174            }
2175            Err(reason) => Err(CriticalAppendError::DurabilityUnknown { reason }),
2176        }
2177    }
2178
2179    /// Append a lifecycle-critical event without blocking an async executor
2180    /// worker on filesystem acknowledgement.
2181    ///
2182    /// The acknowledgement wait is bounded by `acknowledgement_timeout`. Once
2183    /// the writer accepts the message, the exact serialized row is marked
2184    /// pending before this future can yield. Timeout, cancellation by an outer
2185    /// request deadline, writer failure, and acknowledgement-coordinator shutdown
2186    /// therefore leave an identical retry safe: it reuses the original event
2187    /// timestamp/hash and the writer suppresses a duplicate row. Until that
2188    /// exact retry reconciles the pending row, a different critical event is
2189    /// rejected before enqueue.
2190    pub async fn append_critical_async(
2191        &mut self,
2192        kind: EventKind,
2193        action_id: Option<&str>,
2194        proposal_id: Option<&str>,
2195        data: HashMap<String, Value>,
2196        acknowledgement_timeout: Duration,
2197    ) -> Result<&Event, CriticalAppendError> {
2198        let reservation = self
2199            .journal
2200            .as_ref()
2201            .ok_or_else(|| CriticalAppendError::Rejected {
2202                reason: "critical lifecycle event requires an enabled journal".to_string(),
2203            })?
2204            .reserve_async_acknowledgement(acknowledgement_timeout)
2205            .map_err(|error| CriticalAppendError::Rejected {
2206                reason: error.to_string(),
2207            })?;
2208        let prepared = self
2209            .prepare_critical_append(kind, action_id, proposal_id, data)
2210            .map_err(|reason| CriticalAppendError::Rejected { reason })?;
2211        let acknowledgement = self
2212            .journal
2213            .as_ref()
2214            .expect("journal presence checked before reservation")
2215            .enqueue_critical_async(prepared.line.clone(), prepared.known_existing, reservation)
2216            .map_err(|error| CriticalAppendError::Rejected {
2217                reason: error.to_string(),
2218            })?;
2219        let (index, line) = self.commit_prepared_critical(prepared);
2220
2221        // This happens before the first `.await`, so dropping the future at an
2222        // outer Tokio timeout cannot lose the exact retry identity.
2223        self.critical_pending.insert(line.clone());
2224        match acknowledgement.await {
2225            Ok(()) => {
2226                self.critical_pending.remove(&line);
2227                Ok(&self.events[index])
2228            }
2229            Err(error) => Err(CriticalAppendError::DurabilityUnknown {
2230                reason: error.to_string(),
2231            }),
2232        }
2233    }
2234
2235    /// Verify the tamper-evidence hash chain over the currently-loaded
2236    /// events (EPIC A / A9). Walks every event that carries a `hash`,
2237    /// recomputing it from its content + the running `prev_hash` and
2238    /// checking the links join up. Returns `Ok(n)` with the number of
2239    /// chained events verified, or `Err(index)` naming the first event
2240    /// whose hash or linkage doesn't match — i.e. the point at which a
2241    /// chained event was edited, or an interior event was deleted or
2242    /// reordered.
2243    ///
2244    /// **Scope of the guarantee:** the chain detects *interior*
2245    /// edits/reorderings/deletions only. It cannot detect truncation at
2246    /// either end: there is no anchored head hash, so the first chained
2247    /// event's `prev_hash` is taken on trust (dropping a prefix goes
2248    /// unnoticed), and nothing pins the tail (dropping a suffix goes
2249    /// unnoticed). Detecting head/tail truncation requires anchoring the
2250    /// chain head (and a trusted latest-hash witness), which is out of
2251    /// scope until that anchor exists.
2252    ///
2253    /// Events without a `hash` (appended before chaining was enabled) are
2254    /// skipped, so a partially-chained log verifies its chained suffix.
2255    pub fn verify_chain(&self) -> Result<usize, usize> {
2256        let mut prev = String::new();
2257        let mut verified = 0usize;
2258        let mut chain_started = false;
2259        for (i, ev) in self.events.iter().enumerate() {
2260            let Some(stored) = &ev.hash else {
2261                // Once the chain has started, a gap is a break.
2262                if chain_started {
2263                    return Err(i);
2264                }
2265                continue;
2266            };
2267            // The recorded prev_hash must match the running hash.
2268            let recorded_prev = ev.prev_hash.clone().unwrap_or_default();
2269            if chain_started && recorded_prev != prev {
2270                return Err(i);
2271            }
2272            let recomputed = event_digest(
2273                &recorded_prev,
2274                &ev.kind,
2275                ev.run_id.as_deref(),
2276                ev.client_id.as_deref(),
2277                ev.policy_session_id.as_deref(),
2278                ev.action_id.as_deref(),
2279                ev.proposal_id.as_deref(),
2280                &ev.data,
2281                &ev.timestamp,
2282            );
2283            if &recomputed != stored {
2284                return Err(i);
2285            }
2286            prev = stored.clone();
2287            chain_started = true;
2288            verified += 1;
2289        }
2290        Ok(verified)
2291    }
2292
2293    /// Append an event with cross-cutting [`Metrics`] (duration, tokens,
2294    /// cost) merged into its `data` under [`metric_keys`]. Use this for any
2295    /// event whose latency or token cost should feed trajectory-level
2296    /// aggregation (`metrics_totals`) — the deep-telemetry substrate of
2297    /// §3.5.1. Metric keys present in both `data` and `metrics` take the
2298    /// `metrics` value (the metrics argument wins).
2299    pub fn append_metered(
2300        &mut self,
2301        kind: EventKind,
2302        action_id: Option<&str>,
2303        proposal_id: Option<&str>,
2304        mut data: HashMap<String, Value>,
2305        metrics: Metrics,
2306    ) -> &Event {
2307        metrics.merge_into(&mut data);
2308        self.append(kind, action_id, proposal_id, data)
2309    }
2310
2311    /// Sum the telemetry metrics across every event in the log — the
2312    /// trajectory-level totals (tokens, cost, wall-clock) that harness-level
2313    /// evaluation (§5.2.1) and the Evolution Agent (§3.5.2) reason over.
2314    ///
2315    /// Contract: this sums **every** event carrying a [`metric_keys`] value,
2316    /// regardless of which append path emitted it. A duration recorded once
2317    /// per action (e.g. `ActionSucceeded`) is counted once; the standardized
2318    /// keys mean there is a single value per metric per event, so there is no
2319    /// double-count as long as each unit of work meters itself once. Token
2320    /// metrics from `InferenceMetered` and latency from action events sum
2321    /// into the same totals — that is intended (total cost = model + tools).
2322    pub fn metrics_totals(&self) -> MetricsTotals {
2323        metrics_totals_of(&self.events)
2324    }
2325
2326    /// Per-agent cost/token report (EPIC G / G3) — see [`cost_by_agent_of`].
2327    pub fn cost_by_agent(&self) -> Vec<AgentCost> {
2328        cost_by_agent_of(&self.events)
2329    }
2330
2331    pub fn events(&self) -> &[Event] {
2332        &self.events
2333    }
2334
2335    pub fn len(&self) -> usize {
2336        self.events.len()
2337    }
2338
2339    pub fn span_len(&self) -> usize {
2340        self.spans.len()
2341    }
2342
2343    pub fn is_empty(&self) -> bool {
2344        self.events.is_empty()
2345    }
2346
2347    pub fn stats(&self) -> EventLogStats {
2348        EventLogStats {
2349            events: self.events.len(),
2350            spans: self.spans.len(),
2351            approx_event_bytes: approx_json_bytes(&self.events),
2352            approx_span_bytes: approx_json_bytes(&self.spans),
2353        }
2354    }
2355
2356    pub fn truncate_events_keep_last(&mut self, keep_last: usize) -> usize {
2357        let removed = truncate_vec_keep_last(&mut self.events, keep_last);
2358        self.trimmed_events += removed as u64;
2359        if removed > 0 {
2360            self.maybe_compact_journal();
2361        }
2362        removed
2363    }
2364
2365    pub fn truncate_spans_keep_last(&mut self, keep_last: usize) -> usize {
2366        truncate_vec_keep_last(&mut self.spans, keep_last)
2367    }
2368
2369    /// Drop every retained event and span, releasing their memory. The
2370    /// JSONL journal is left untouched (it is the audit trail); the
2371    /// monotonic counters (`trimmed_events`, `cumulative_cost_usd`) are
2372    /// preserved — `clear` frees memory, it doesn't reset the log's history.
2373    pub fn clear(&mut self) -> EventLogStats {
2374        let removed = self.stats();
2375        self.trimmed_events += removed.events as u64;
2376        self.events.clear();
2377        self.events.shrink_to_fit();
2378        self.spans.clear();
2379        self.spans.shrink_to_fit();
2380        removed
2381    }
2382
2383    /// Total events ever dropped from the in-memory log (retention trims,
2384    /// manual truncation, `clear`). Monotonic; `> 0` means the retained
2385    /// window is incomplete — consumers projecting over [`Self::events`]
2386    /// (e.g. the A6 tool-receipt verifier) must treat an absent event as
2387    /// possibly-evicted, not as never-happened.
2388    pub fn trimmed_events(&self) -> u64 {
2389        self.trimmed_events
2390    }
2391
2392    /// Monotonic cumulative cost (USD) across every event ever appended
2393    /// (EPIC G / G1). Unlike folding `cost_usd` over [`Self::events`] — which
2394    /// slides backward when retention trims metered events — this counter
2395    /// only grows, so it is the correct denominator for a cumulative budget
2396    /// (`AlertThresholds::max_cost_usd`). Seeded from the journal on
2397    /// [`Self::load`]; survives trims and [`Self::clear`].
2398    pub fn cumulative_cost_usd(&self) -> f64 {
2399        self.cumulative_cost_usd
2400    }
2401
2402    /// Current size of the JSONL journal file in bytes, if a journal is
2403    /// configured and stat-able. The background writer batches, so this may
2404    /// momentarily lag the last few appends.
2405    pub fn journal_size_bytes(&self) -> Option<u64> {
2406        let path = self.journal_path.as_ref()?;
2407        fs::metadata(path).ok().map(|m| m.len())
2408    }
2409
2410    /// Journal-compaction throttle (G2): rewrite only when the journal holds
2411    /// at least [`JOURNAL_COMPACT_MIN_EXCESS`] more lines than the retained
2412    /// set AND the rewrite would shrink it by ≥25%. Frequent small trims
2413    /// therefore cost nothing; each compaction rewrites at most the retained
2414    /// set and is amortized O(1) per append.
2415    fn maybe_compact_journal(&mut self) {
2416        if self.journal_path.is_none() {
2417            return;
2418        }
2419        let excess = self.journal_lines.saturating_sub(self.events.len());
2420        if excess >= JOURNAL_COMPACT_MIN_EXCESS && excess.saturating_mul(4) >= self.journal_lines {
2421            self.compact_journal();
2422        }
2423    }
2424
2425    /// Rewrite the JSONL journal to contain exactly the currently-retained
2426    /// events (G2 journal compaction — before this, retention trimmed the
2427    /// in-memory log only and the journal grew unbounded). Atomic: writes a
2428    /// sibling temp file and renames it over the journal. The background
2429    /// writer is joined first (draining its backlog and closing its handle —
2430    /// renaming under a live append-mode handle would orphan subsequent
2431    /// writes to the old inode), then respawned on the compacted file.
2432    ///
2433    /// Hash chaining (A9) survives: [`Self::verify_chain`] anchors the first
2434    /// hashed event on its *stored* `prev_hash`, so the retained tail of a
2435    /// chained log still verifies after a compact + reload. Corollary: a
2436    /// head-trim by retention is indistinguishable from compaction — tamper
2437    /// evidence covers the retained tail only.
2438    ///
2439    /// Returns `true` if the journal was rewritten. Failure is best-effort
2440    /// like the journal itself: a warning is logged, the old (uncompacted)
2441    /// journal stays in place, and appending resumes against it.
2442    pub fn compact_journal(&mut self) -> bool {
2443        let Some(path) = self.journal_path.clone() else {
2444            return false;
2445        };
2446        let compacted_lines: HashSet<String> = self
2447            .events
2448            .iter()
2449            .filter_map(|event| serde_json::to_string(event).ok())
2450            .collect();
2451        // Join the writer so pending lines are flushed and its handle closed.
2452        self.journal = None;
2453        let file_name = path
2454            .file_name()
2455            .and_then(|name| name.to_str())
2456            .unwrap_or("journal");
2457        let tmp = path.with_file_name(format!(".{file_name}.compact-{}.tmp", Uuid::new_v4()));
2458        let rewrite = (|| -> std::io::Result<()> {
2459            let file = create_private_file(&tmp)?;
2460            let mut writer = BufWriter::new(file);
2461            for ev in &self.events {
2462                let line = serde_json::to_string(ev).map_err(std::io::Error::other)?;
2463                writeln!(writer, "{line}")?;
2464            }
2465            writer.flush()?;
2466            let file = writer.into_inner().map_err(|error| error.into_error())?;
2467            file.sync_all()?;
2468            revalidate_private_file(&file)?;
2469            drop(file);
2470            atomic_replace_private_file(&tmp, &path)
2471        })();
2472        let ok = match rewrite {
2473            Ok(()) => {
2474                self.journal_lines = self.events.len();
2475                // Compaction fsynced these exact rows before replacing the
2476                // journal. Reconcile producer-side retry state before the
2477                // writer respawns so an identical critical retry is treated
2478                // as an already-written durability barrier, not appended a
2479                // second time by the fresh writer's empty identity cache.
2480                self.critical_pending
2481                    .retain(|line| !compacted_lines.contains(line));
2482                true
2483            }
2484            Err(e) => {
2485                let _ = fs::remove_file(&tmp);
2486                tracing::warn!(
2487                    path = %path.display(), error = %e,
2488                    "car-eventlog: journal compaction failed — journal keeps growing until the next successful compaction"
2489                );
2490                false
2491            }
2492        };
2493        self.journal = Some(JournalWriter::spawn(path));
2494        ok
2495    }
2496
2497    /// Run a structured audit [`EventQuery`], returning matching events
2498    /// most-recent-first, capped at `query.limit` (EPIC G / G2).
2499    pub fn query(&self, query: &EventQuery) -> Vec<&Event> {
2500        let mut out: Vec<&Event> = self.events.iter().filter(|e| query.matches(e)).collect();
2501        out.reverse(); // most recent first for audit review
2502        if let Some(limit) = query.limit.filter(|l| *l > 0) {
2503            out.truncate(limit);
2504        }
2505        out
2506    }
2507
2508    /// Install an auto-retention policy (EPIC G / G2). `max_events` is then
2509    /// enforced on every `append`; call [`Self::enforce_retention`] to also
2510    /// apply the age bound.
2511    pub fn set_retention(&mut self, policy: Option<RetentionPolicy>) {
2512        self.retention = policy;
2513    }
2514
2515    /// The active retention policy, if any.
2516    pub fn retention(&self) -> Option<&RetentionPolicy> {
2517        self.retention.as_ref()
2518    }
2519
2520    /// Apply a retention policy now: drop events older than `max_age_secs`
2521    /// and cap the count at `max_events` (keeping the most recent). Returns
2522    /// the number of events removed. Independent of the installed policy, so a
2523    /// caller can run a one-off sweep. When a journal is configured, a trim
2524    /// also triggers the throttled journal compaction (see
2525    /// [`Self::compact_journal`]) so the JSONL file tracks retention instead
2526    /// of growing unbounded.
2527    pub fn enforce_retention(&mut self, policy: &RetentionPolicy, now: DateTime<Utc>) -> usize {
2528        let before = self.events.len();
2529        if let Some(age) = policy.max_age_secs {
2530            let cutoff = now - chrono::Duration::seconds(age);
2531            self.events.retain(|e| e.timestamp >= cutoff);
2532        }
2533        if let Some(max) = policy.max_events {
2534            truncate_vec_keep_last(&mut self.events, max);
2535        }
2536        let removed = before.saturating_sub(self.events.len());
2537        self.trimmed_events += removed as u64;
2538        if removed > 0 {
2539            self.maybe_compact_journal();
2540        }
2541        removed
2542    }
2543
2544    pub fn filter(&self, kind: Option<&EventKind>, action_id: Option<&str>) -> Vec<&Event> {
2545        self.events
2546            .iter()
2547            .filter(|e| {
2548                if let Some(k) = kind {
2549                    if &e.kind != k {
2550                        return false;
2551                    }
2552                }
2553                if let Some(aid) = action_id {
2554                    if e.action_id.as_deref() != Some(aid) {
2555                        return false;
2556                    }
2557                }
2558                true
2559            })
2560            .collect()
2561    }
2562
2563    /// Begin a new trace span. Returns the generated span_id.
2564    pub fn begin_span(
2565        &mut self,
2566        name: &str,
2567        trace_id: &str,
2568        parent_span_id: Option<&str>,
2569        attributes: HashMap<String, Value>,
2570    ) -> String {
2571        let span_id = Uuid::new_v4().to_string();
2572        let span = Span {
2573            trace_id: trace_id.to_string(),
2574            span_id: span_id.clone(),
2575            parent_span_id: parent_span_id.map(|s| s.to_string()),
2576            name: name.to_string(),
2577            start_time: Utc::now(),
2578            end_time: None,
2579            status: SpanStatus::Unset,
2580            attributes,
2581        };
2582        self.spans.push(span);
2583        span_id
2584    }
2585
2586    /// End an open span by setting its status and end time.
2587    pub fn end_span(&mut self, span_id: &str, status: SpanStatus) {
2588        if let Some(span) = self.spans.iter_mut().find(|s| s.span_id == span_id) {
2589            span.end_time = Some(Utc::now());
2590            span.status = status;
2591        }
2592    }
2593
2594    /// Return all spans.
2595    pub fn spans(&self) -> Vec<Span> {
2596        self.spans.clone()
2597    }
2598
2599    /// Export traces as OTLP-compatible JSON.
2600    pub fn export_traces(&self) -> String {
2601        // Group spans by trace_id
2602        let mut traces: HashMap<&str, Vec<&Span>> = HashMap::new();
2603        for span in &self.spans {
2604            traces.entry(span.trace_id.as_str()).or_default().push(span);
2605        }
2606
2607        let resource_spans: Vec<Value> = traces.into_values().map(|spans| {
2608                let scope_spans = spans
2609                    .iter()
2610                    .map(|s| {
2611                        let mut span_obj = serde_json::json!({
2612                            "traceId": s.trace_id,
2613                            "spanId": s.span_id,
2614                            "name": s.name,
2615                            "startTimeUnixNano": s.start_time.timestamp_nanos_opt().unwrap_or(0).to_string(),
2616                            "status": {
2617                                "code": match s.status {
2618                                    SpanStatus::Ok => 1,
2619                                    SpanStatus::Error => 2,
2620                                    SpanStatus::Unset => 0,
2621                                }
2622                            },
2623                            "attributes": s.attributes.iter().map(|(k, v)| {
2624                                serde_json::json!({
2625                                    "key": k,
2626                                    "value": { "stringValue": v.to_string() }
2627                                })
2628                            }).collect::<Vec<_>>(),
2629                        });
2630
2631                        if let Some(ref parent) = s.parent_span_id {
2632                            span_obj.as_object_mut().unwrap().insert(
2633                                "parentSpanId".to_string(),
2634                                Value::from(parent.as_str()),
2635                            );
2636                        }
2637                        if let Some(end) = s.end_time {
2638                            span_obj.as_object_mut().unwrap().insert(
2639                                "endTimeUnixNano".to_string(),
2640                                Value::from(end.timestamp_nanos_opt().unwrap_or(0).to_string()),
2641                            );
2642                        }
2643
2644                        span_obj
2645                    })
2646                    .collect::<Vec<_>>();
2647
2648                serde_json::json!({
2649                    "resource": {
2650                        "attributes": [
2651                            { "key": "service.name", "value": { "stringValue": "car-runtime" } }
2652                        ]
2653                    },
2654                    "scopeSpans": [{
2655                        "scope": { "name": "car-eventlog" },
2656                        "spans": scope_spans
2657                    }]
2658                })
2659            })
2660            .collect();
2661
2662        serde_json::to_string(&serde_json::json!({
2663            "resourceSpans": resource_spans
2664        }))
2665        .unwrap_or_else(|_| "{}".to_string())
2666    }
2667
2668    /// Load an event log from a JSONL journal file.
2669    pub fn load(path: &Path) -> std::io::Result<Self> {
2670        Self::load_with_writer(path, JournalWriter::spawn(path.to_path_buf()))
2671    }
2672
2673    /// Load and validate an event log without attaching a writer or modifying
2674    /// the source journal. An unterminated final row is reported as
2675    /// [`std::io::ErrorKind::UnexpectedEof`]; callers that own live append
2676    /// recovery should use [`EventLog::load`] instead.
2677    pub fn load_read_only(path: &Path) -> std::io::Result<Self> {
2678        Self::load_from_journal(path, None, false)
2679    }
2680
2681    /// Load a journal while installing the deterministic durability-failure
2682    /// seam for subsequent appends.
2683    #[doc(hidden)]
2684    pub fn load_with_journal_failure_injector(
2685        path: &Path,
2686        failures: JournalFailureInjector,
2687    ) -> std::io::Result<Self> {
2688        Self::load_with_writer(
2689            path,
2690            JournalWriter::spawn_with_injector(path.to_path_buf(), failures),
2691        )
2692    }
2693
2694    fn load_with_writer(path: &Path, writer: JournalWriter) -> std::io::Result<Self> {
2695        Self::load_from_journal(path, Some(writer), true)
2696    }
2697
2698    fn load_from_journal(
2699        path: &Path,
2700        writer: Option<JournalWriter>,
2701        repair_torn_tail: bool,
2702    ) -> std::io::Result<Self> {
2703        let file = fs::File::open(path)?;
2704        let mut reader = BufReader::new(file);
2705        let mut events = Vec::new();
2706        let mut event_lines = Vec::new();
2707        let mut line_bytes = Vec::new();
2708        let mut line_number = 0usize;
2709        let mut torn_tail_line = None;
2710
2711        loop {
2712            line_bytes.clear();
2713            let bytes_read = reader.read_until(b'\n', &mut line_bytes)?;
2714            if bytes_read == 0 {
2715                break;
2716            }
2717            line_number += 1;
2718            let terminated = line_bytes.ends_with(b"\n");
2719            if line_bytes.iter().all(|byte| byte.is_ascii_whitespace()) {
2720                if !terminated {
2721                    torn_tail_line = Some(line_number);
2722                }
2723                continue;
2724            }
2725            match serde_json::from_slice::<Event>(&line_bytes) {
2726                Ok(event) => {
2727                    events.push(event);
2728                    event_lines.push(line_number);
2729                    if !terminated {
2730                        torn_tail_line = Some(line_number);
2731                    }
2732                }
2733                Err(_) if !terminated => {
2734                    // A process can crash after writing only a prefix of its
2735                    // final JSONL row. Nothing follows this unterminated tail,
2736                    // so discard it before any resumed append. A terminated
2737                    // bad row is authoritative corruption and fails below.
2738                    torn_tail_line = Some(line_number);
2739                }
2740                Err(error) => {
2741                    return Err(invalid_journal_data(path, line_number, error));
2742                }
2743            }
2744            if !terminated {
2745                break;
2746            }
2747        }
2748        drop(reader);
2749
2750        // If the loaded tail is chained, keep chaining ENABLED and continue
2751        // from the last hash. Restoring `last_hash` but leaving chaining off
2752        // (the pre-fix behaviour) permanently broke the chain: one unchained
2753        // append before a manual re-enable left a gap that made every future
2754        // `verify_chain` report tampering, with no repair path (review C-9b).
2755        let last_hash = events.last().and_then(|e| e.hash.clone());
2756        let hash_chaining = last_hash.is_some();
2757        // Seed the monotonic counters from what the journal preserved: the
2758        // cumulative cost restarts from the journaled spend (G1), and the
2759        // journal line count from the validated events. A recoverable torn
2760        // tail is rewritten to exactly this set before loading completes.
2761        let cumulative_cost_usd = events.iter().filter_map(Event::cost_usd).sum();
2762        let journal_lines = events.len();
2763        let loaded = Self {
2764            events,
2765            spans: Vec::new(),
2766            // Live loads journal subsequent appends back to the same file;
2767            // read-only loads intentionally carry no writer.
2768            journal: writer,
2769            hash_chaining,
2770            last_hash,
2771            retention: None,
2772            journal_path: repair_torn_tail.then(|| path.to_path_buf()),
2773            journal_lines,
2774            trimmed_events: 0,
2775            cumulative_cost_usd,
2776            active_binding: None,
2777            critical_pending: HashSet::new(),
2778        };
2779        if let Err(index) = loaded.verify_chain() {
2780            let source_line = event_lines.get(index).copied().unwrap_or(index + 1);
2781            return Err(invalid_journal_data(
2782                path,
2783                source_line,
2784                "hash chain integrity check failed",
2785            ));
2786        }
2787        if let Some(line_number) = torn_tail_line {
2788            if !repair_torn_tail {
2789                return Err(torn_journal_tail(path, line_number));
2790            }
2791            rewrite_loaded_journal(path, &loaded.events)?;
2792        }
2793        Ok(loaded)
2794    }
2795}
2796
2797fn torn_journal_tail(path: &Path, line_number: usize) -> std::io::Error {
2798    std::io::Error::new(
2799        std::io::ErrorKind::UnexpectedEof,
2800        format!(
2801            "event journal torn tail: path={} line={} reason=unterminated final record",
2802            path.display(),
2803            line_number
2804        ),
2805    )
2806}
2807
2808fn invalid_journal_data(
2809    path: &Path,
2810    line_number: usize,
2811    reason: impl std::fmt::Display,
2812) -> std::io::Error {
2813    std::io::Error::new(
2814        std::io::ErrorKind::InvalidData,
2815        format!(
2816            "event journal corruption: path={} line={} reason={reason}",
2817            path.display(),
2818            line_number
2819        ),
2820    )
2821}
2822
2823fn rewrite_loaded_journal(path: &Path, events: &[Event]) -> std::io::Result<()> {
2824    let file_name = path
2825        .file_name()
2826        .and_then(|name| name.to_str())
2827        .unwrap_or("journal");
2828    let temp = path.with_file_name(format!(".{file_name}.recover-{}.tmp", Uuid::new_v4()));
2829    let result = (|| {
2830        let file = create_private_file(&temp)?;
2831        let mut output = BufWriter::new(file);
2832        for event in events {
2833            let line = serde_json::to_string(event).map_err(std::io::Error::other)?;
2834            writeln!(output, "{line}")?;
2835        }
2836        output.flush()?;
2837        let file = output.into_inner().map_err(|error| error.into_error())?;
2838        file.sync_all()?;
2839        revalidate_private_file(&file)?;
2840        drop(file);
2841        atomic_replace_private_file(&temp, path)
2842    })();
2843    if result.is_err() {
2844        let _ = fs::remove_file(&temp);
2845    }
2846    result
2847}
2848
2849fn approx_json_bytes<T: Serialize>(value: &T) -> usize {
2850    serde_json::to_vec(value)
2851        .map(|bytes| bytes.len())
2852        .unwrap_or(0)
2853}
2854
2855fn truncate_vec_keep_last<T>(items: &mut Vec<T>, keep_last: usize) -> usize {
2856    let len = items.len();
2857    if len <= keep_last {
2858        return 0;
2859    }
2860    let removed = len - keep_last;
2861    items.drain(..removed);
2862    items.shrink_to_fit();
2863    removed
2864}
2865
2866impl Default for EventLog {
2867    fn default() -> Self {
2868        Self::new()
2869    }
2870}
2871
2872#[cfg(test)]
2873mod tests {
2874    use super::*;
2875
2876    struct ThreadWake(std::thread::Thread);
2877
2878    impl std::task::Wake for ThreadWake {
2879        fn wake(self: Arc<Self>) {
2880            self.0.unpark();
2881        }
2882
2883        fn wake_by_ref(self: &Arc<Self>) {
2884            self.0.unpark();
2885        }
2886    }
2887
2888    fn test_waker() -> std::task::Waker {
2889        std::task::Waker::from(Arc::new(ThreadWake(std::thread::current())))
2890    }
2891
2892    fn block_on_test_future<F: std::future::Future>(future: F) -> F::Output {
2893        let waker = test_waker();
2894        let mut context = std::task::Context::from_waker(&waker);
2895        let mut future = Box::pin(future);
2896        loop {
2897            match future.as_mut().poll(&mut context) {
2898                std::task::Poll::Ready(output) => return output,
2899                std::task::Poll::Pending => std::thread::park(),
2900            }
2901        }
2902    }
2903
2904    #[test]
2905    fn append_and_read() {
2906        let mut log = EventLog::new();
2907        log.append(
2908            EventKind::ProposalReceived,
2909            None,
2910            Some("p1"),
2911            [("source".to_string(), Value::from("test"))].into(),
2912        );
2913        assert_eq!(log.len(), 1);
2914        assert_eq!(log.events()[0].kind, EventKind::ProposalReceived);
2915    }
2916
2917    #[test]
2918    fn query_filters_by_kind_data_and_time() {
2919        let mut log = EventLog::new();
2920        log.append(
2921            EventKind::PermissionDecision,
2922            Some("a1"),
2923            None,
2924            [
2925                ("caller".to_string(), Value::from("alice")),
2926                ("tool".to_string(), Value::from("shell")),
2927            ]
2928            .into(),
2929        );
2930        log.append(
2931            EventKind::PermissionDecision,
2932            Some("a2"),
2933            None,
2934            [
2935                ("caller".to_string(), Value::from("bob")),
2936                ("tool".to_string(), Value::from("shell")),
2937            ]
2938            .into(),
2939        );
2940        log.append(
2941            EventKind::StateChanged,
2942            Some("a3"),
2943            None,
2944            Default::default(),
2945        );
2946
2947        // Filter by kind.
2948        let q = EventQuery {
2949            kinds: vec![EventKind::PermissionDecision],
2950            ..Default::default()
2951        };
2952        assert_eq!(log.query(&q).len(), 2);
2953
2954        // Filter by a data field (who ran the tool).
2955        let q = EventQuery {
2956            data_matches: [("caller".to_string(), "alice".to_string())].into(),
2957            ..Default::default()
2958        };
2959        let hits = log.query(&q);
2960        assert_eq!(hits.len(), 1);
2961        assert_eq!(hits[0].action_id.as_deref(), Some("a1"));
2962
2963        // Combined tool + kind.
2964        let q = EventQuery {
2965            kinds: vec![EventKind::PermissionDecision],
2966            data_matches: [("tool".to_string(), "shell".to_string())].into(),
2967            limit: Some(1),
2968            ..Default::default()
2969        };
2970        // Most-recent-first + limit → the bob decision.
2971        let hits = log.query(&q);
2972        assert_eq!(hits.len(), 1);
2973        assert_eq!(hits[0].action_id.as_deref(), Some("a2"));
2974    }
2975
2976    #[test]
2977    fn cost_by_agent_folds_metered_events() {
2978        let mut log = EventLog::new();
2979        log.append_metered(
2980            EventKind::InferenceMetered,
2981            None,
2982            None,
2983            [("agent".to_string(), Value::from("researcher"))].into(),
2984            Metrics {
2985                tokens_in: Some(100),
2986                tokens_out: Some(50),
2987                cost_usd: Some(2.0),
2988                ..Default::default()
2989            },
2990        );
2991        log.append_metered(
2992            EventKind::InferenceMetered,
2993            None,
2994            None,
2995            [("agent".to_string(), Value::from("researcher"))].into(),
2996            Metrics {
2997                tokens_in: Some(10),
2998                tokens_out: Some(5),
2999                cost_usd: Some(0.2),
3000                ..Default::default()
3001            },
3002        );
3003        log.append_metered(
3004            EventKind::InferenceMetered,
3005            None,
3006            None,
3007            [("agent".to_string(), Value::from("coordinator"))].into(),
3008            Metrics {
3009                cost_usd: Some(0.5),
3010                ..Default::default()
3011            },
3012        );
3013        let report = log.cost_by_agent();
3014        assert_eq!(report.len(), 2);
3015        // BTreeMap order: coordinator, researcher.
3016        assert_eq!(report[0].agent, "coordinator");
3017        assert_eq!(report[0].cost_usd, 0.5);
3018        assert_eq!(report[1].agent, "researcher");
3019        assert_eq!(report[1].calls, 2);
3020        assert_eq!(report[1].tokens_in, 110);
3021        assert_eq!(report[1].tokens_out, 55);
3022        assert!((report[1].cost_usd - 2.2).abs() < 1e-9);
3023    }
3024
3025    #[test]
3026    fn auto_retention_caps_event_count() {
3027        let mut log = EventLog::new();
3028        log.set_retention(Some(RetentionPolicy {
3029            max_events: Some(3),
3030            max_age_secs: None,
3031        }));
3032        for i in 0..10 {
3033            log.append(
3034                EventKind::StateChanged,
3035                Some(&format!("a{i}")),
3036                None,
3037                Default::default(),
3038            );
3039        }
3040        // Only the last 3 survive.
3041        assert_eq!(log.len(), 3);
3042        assert_eq!(log.events()[0].action_id.as_deref(), Some("a7"));
3043        assert_eq!(log.events()[2].action_id.as_deref(), Some("a9"));
3044    }
3045
3046    #[test]
3047    fn enforce_retention_drops_old_by_age() {
3048        let mut log = EventLog::new();
3049        // Two events; backdate the first well past the age bound.
3050        log.append(
3051            EventKind::StateChanged,
3052            Some("old"),
3053            None,
3054            Default::default(),
3055        );
3056        log.events[0].timestamp = Utc::now() - chrono::Duration::seconds(3600);
3057        log.append(
3058            EventKind::StateChanged,
3059            Some("fresh"),
3060            None,
3061            Default::default(),
3062        );
3063
3064        let removed = log.enforce_retention(
3065            &RetentionPolicy {
3066                max_events: None,
3067                max_age_secs: Some(60),
3068            },
3069            Utc::now(),
3070        );
3071        assert_eq!(removed, 1);
3072        assert_eq!(log.len(), 1);
3073        assert_eq!(log.events()[0].action_id.as_deref(), Some("fresh"));
3074    }
3075
3076    #[test]
3077    fn retention_trims_are_counted() {
3078        let mut log = EventLog::new();
3079        log.set_retention(Some(RetentionPolicy {
3080            max_events: Some(2),
3081            max_age_secs: None,
3082        }));
3083        for i in 0..5 {
3084            log.append(
3085                EventKind::StateChanged,
3086                Some(&format!("a{i}")),
3087                None,
3088                Default::default(),
3089            );
3090        }
3091        assert_eq!(log.trimmed_events(), 3);
3092        assert_eq!(log.truncate_events_keep_last(1), 1);
3093        assert_eq!(log.trimmed_events(), 4);
3094        log.clear();
3095        assert_eq!(log.trimmed_events(), 5);
3096    }
3097
3098    #[test]
3099    fn cumulative_cost_is_monotonic_across_trims_and_reload() {
3100        let dir = tempfile::tempdir().unwrap();
3101        let journal = dir.path().join("cost.jsonl");
3102        {
3103            let mut log = EventLog::with_journal(journal.clone());
3104            log.set_retention(Some(RetentionPolicy {
3105                max_events: Some(1),
3106                max_age_secs: None,
3107            }));
3108            for _ in 0..4 {
3109                log.append_metered(
3110                    EventKind::InferenceMetered,
3111                    None,
3112                    None,
3113                    Default::default(),
3114                    Metrics {
3115                        cost_usd: Some(2.5),
3116                        ..Default::default()
3117                    },
3118                );
3119            }
3120            // Trims dropped 3 metered events; the counter never slid back.
3121            assert_eq!(log.len(), 1);
3122            assert!((log.cumulative_cost_usd() - 10.0).abs() < 1e-9);
3123        }
3124        // Reload seeds the counter from what the journal preserved (here the
3125        // journal was never compacted, so the full spend survives).
3126        let reloaded = EventLog::load(&journal).unwrap();
3127        assert!((reloaded.cumulative_cost_usd() - 10.0).abs() < 1e-9);
3128    }
3129
3130    #[test]
3131    fn journal_compaction_rewrites_to_retained_set() {
3132        // Real journal compaction (review G2): once the excess clears the
3133        // throttle (≥ JOURNAL_COMPACT_MIN_EXCESS lines AND ≥25% shrink), the
3134        // retention trim rewrites the JSONL file to exactly the retained
3135        // events instead of letting it grow forever.
3136        let dir = tempfile::tempdir().unwrap();
3137        let journal = dir.path().join("compact.jsonl");
3138        let keep = 16usize;
3139        let total = keep + JOURNAL_COMPACT_MIN_EXCESS + 8;
3140        {
3141            let mut log = EventLog::with_journal(journal.clone());
3142            log.set_retention(Some(RetentionPolicy {
3143                max_events: Some(keep),
3144                max_age_secs: None,
3145            }));
3146            for i in 0..total {
3147                log.append(
3148                    EventKind::ActionSucceeded,
3149                    Some(&format!("a{i}")),
3150                    None,
3151                    HashMap::new(),
3152                );
3153            }
3154            assert_eq!(log.len(), keep);
3155            assert!(log.journal_size_bytes().unwrap_or(0) > 0);
3156        } // drop joins the writer → file settled.
3157
3158        // The journal holds only the retained tail, not all `total` lines.
3159        let reloaded = EventLog::load(&journal).unwrap();
3160        assert!(
3161            reloaded.len() < total,
3162            "journal must have been compacted (got {} lines)",
3163            reloaded.len()
3164        );
3165        // The newest events survived, contiguously up to the last append.
3166        assert_eq!(
3167            reloaded.events().last().unwrap().action_id.as_deref(),
3168            Some(format!("a{}", total - 1).as_str())
3169        );
3170    }
3171
3172    #[test]
3173    fn compact_journal_preserves_hash_chain_of_retained_tail() {
3174        // A9 × G2: verify_chain anchors the first hashed event on its stored
3175        // prev_hash, so a compacted journal's retained tail must still verify
3176        // after reload even though the chain's head was dropped.
3177        let dir = tempfile::tempdir().unwrap();
3178        let journal = dir.path().join("chained.jsonl");
3179        {
3180            let mut log = EventLog::with_journal(journal.clone()).with_hash_chaining();
3181            for i in 0..20 {
3182                log.append(
3183                    EventKind::ActionSucceeded,
3184                    Some(&format!("a{i}")),
3185                    None,
3186                    HashMap::new(),
3187                );
3188            }
3189            // Trim to the last 5 and force an (unthrottled) compaction.
3190            log.truncate_events_keep_last(5);
3191            assert!(log.compact_journal(), "compaction must succeed");
3192            // Appends after compaction land in the compacted file and keep
3193            // chaining from the retained tail.
3194            log.append(
3195                EventKind::ActionSucceeded,
3196                Some("post"),
3197                None,
3198                HashMap::new(),
3199            );
3200        }
3201        let reloaded = EventLog::load(&journal).unwrap();
3202        assert_eq!(reloaded.len(), 6);
3203        assert_eq!(reloaded.verify_chain(), Ok(6), "retained tail must verify");
3204        assert_eq!(reloaded.events()[0].action_id.as_deref(), Some("a15"));
3205        assert_eq!(reloaded.events()[5].action_id.as_deref(), Some("post"));
3206    }
3207
3208    #[test]
3209    fn compact_journal_without_journal_is_noop() {
3210        let mut log = EventLog::new();
3211        log.append(EventKind::StateChanged, Some("a"), None, Default::default());
3212        assert!(!log.compact_journal());
3213        assert_eq!(log.journal_size_bytes(), None);
3214    }
3215
3216    #[test]
3217    fn chaining_off_by_default_no_hashes() {
3218        let mut log = EventLog::new();
3219        log.append(
3220            EventKind::ActionSucceeded,
3221            Some("a1"),
3222            Some("p1"),
3223            HashMap::new(),
3224        );
3225        assert!(!log.hash_chaining_enabled());
3226        assert!(log.events()[0].hash.is_none());
3227        assert!(log.events()[0].prev_hash.is_none());
3228        // verify_chain over an unchained log is vacuously ok (0 verified).
3229        assert_eq!(log.verify_chain(), Ok(0));
3230    }
3231
3232    #[test]
3233    fn hash_chain_verifies_clean_log() {
3234        let mut log = EventLog::new().with_hash_chaining();
3235        for i in 0..5 {
3236            log.append(
3237                EventKind::ActionSucceeded,
3238                Some(&format!("a{i}")),
3239                Some("p"),
3240                [("i".to_string(), Value::from(i))].into(),
3241            );
3242        }
3243        // Every event hashed, links join.
3244        assert!(log.events().iter().all(|e| e.hash.is_some()));
3245        assert_eq!(log.verify_chain(), Ok(5));
3246        // First event's prev_hash is the genesis (empty) link.
3247        assert_eq!(log.events()[0].prev_hash.as_deref(), Some(""));
3248        // Each subsequent prev_hash equals the prior event's hash.
3249        for w in log.events().windows(2) {
3250            assert_eq!(w[1].prev_hash, w[0].hash);
3251        }
3252    }
3253
3254    #[test]
3255    fn tampering_with_data_breaks_chain() {
3256        let mut log = EventLog::new().with_hash_chaining();
3257        for i in 0..4 {
3258            log.append(
3259                EventKind::ActionSucceeded,
3260                Some(&format!("a{i}")),
3261                Some("p"),
3262                [("v".to_string(), Value::from(i))].into(),
3263            );
3264        }
3265        assert_eq!(log.verify_chain(), Ok(4));
3266        // Tamper with event #2's data after the fact.
3267        log.events[2].data.insert("v".to_string(), Value::from(999));
3268        // The chain breaks exactly at the edited event.
3269        assert_eq!(log.verify_chain(), Err(2));
3270    }
3271
3272    #[test]
3273    fn deleting_an_event_breaks_chain() {
3274        let mut log = EventLog::new().with_hash_chaining();
3275        for i in 0..4 {
3276            log.append(
3277                EventKind::ActionSucceeded,
3278                Some(&format!("a{i}")),
3279                Some("p"),
3280                HashMap::new(),
3281            );
3282        }
3283        // Remove the second event — the next event's prev_hash no longer
3284        // matches the running hash.
3285        log.events.remove(1);
3286        assert_eq!(log.verify_chain(), Err(1));
3287    }
3288
3289    #[test]
3290    fn chain_survives_serialize_roundtrip() {
3291        let mut log = EventLog::new().with_hash_chaining();
3292        for i in 0..3 {
3293            log.append(
3294                EventKind::PermissionDecision,
3295                Some(&format!("a{i}")),
3296                Some("p"),
3297                [
3298                    ("decision".to_string(), Value::from("allow")),
3299                    ("nested".to_string(), serde_json::json!({"z": 1, "a": 2})),
3300                ]
3301                .into(),
3302            );
3303        }
3304        // Serialize each event to JSON and back, then re-verify — the
3305        // digest must be stable across the round-trip.
3306        let lines: Vec<String> = log
3307            .events()
3308            .iter()
3309            .map(|e| serde_json::to_string(e).unwrap())
3310            .collect();
3311        let mut rebuilt = EventLog::new();
3312        for line in &lines {
3313            rebuilt.events.push(serde_json::from_str(line).unwrap());
3314        }
3315        assert_eq!(rebuilt.verify_chain(), Ok(3));
3316    }
3317
3318    #[test]
3319    fn chain_survives_journal_load_and_append() {
3320        // Regression (review C-9b): `load` used to restore `last_hash` but
3321        // hard-set `hash_chaining: false`, so the first append after a load
3322        // produced an unchained event mid-chain — a permanent, unrepairable
3323        // verify_chain failure. Loading a chained tail must keep chaining on.
3324        let dir = tempfile::tempdir().unwrap();
3325        let journal = dir.path().join("chain.jsonl");
3326        {
3327            let mut log = EventLog::with_journal(journal.clone());
3328            log.enable_hash_chaining();
3329            log.append(EventKind::ActionSucceeded, Some("a1"), None, HashMap::new());
3330            log.append(EventKind::ActionSucceeded, Some("a2"), None, HashMap::new());
3331        } // drop joins the writer thread → lines flushed.
3332
3333        {
3334            let mut log = EventLog::load(&journal).unwrap();
3335            assert!(
3336                log.hash_chaining_enabled(),
3337                "loading a chained tail re-enables chaining"
3338            );
3339            log.append(EventKind::ActionSucceeded, Some("a3"), None, HashMap::new());
3340            assert_eq!(log.verify_chain(), Ok(3), "post-load append stays chained");
3341        }
3342
3343        // And the whole thing still verifies after a second reload.
3344        let reloaded = EventLog::load(&journal).unwrap();
3345        assert_eq!(reloaded.len(), 3);
3346        assert_eq!(reloaded.verify_chain(), Ok(3));
3347
3348        // An UNCHAINED journal must not turn chaining on.
3349        let plain = dir.path().join("plain.jsonl");
3350        {
3351            let mut log = EventLog::with_journal(plain.clone());
3352            log.append(EventKind::ActionSucceeded, Some("a1"), None, HashMap::new());
3353        }
3354        let loaded = EventLog::load(&plain).unwrap();
3355        assert!(!loaded.hash_chaining_enabled(), "unchained tail stays off");
3356    }
3357
3358    #[test]
3359    fn metered_event_carries_metrics_in_data() {
3360        let mut log = EventLog::new();
3361        log.append_metered(
3362            EventKind::ActionSucceeded,
3363            Some("a1"),
3364            Some("p1"),
3365            [("tool".to_string(), Value::from("search"))].into(),
3366            Metrics::inference(120, 45, Some(0.0012)).with_duration(83.0),
3367        );
3368        let ev = &log.events()[0];
3369        // Original data preserved; metrics merged under standardized keys.
3370        assert_eq!(ev.data.get("tool").unwrap(), "search");
3371        assert_eq!(ev.duration_ms(), Some(83.0));
3372        assert_eq!(ev.tokens_in(), Some(120));
3373        assert_eq!(ev.tokens_out(), Some(45));
3374        assert_eq!(ev.cost_usd(), Some(0.0012));
3375    }
3376
3377    #[test]
3378    fn metrics_totals_sum_across_events() {
3379        let mut log = EventLog::new();
3380        log.append_metered(
3381            EventKind::ActionSucceeded,
3382            Some("a1"),
3383            None,
3384            HashMap::new(),
3385            Metrics::latency(50.0),
3386        );
3387        log.append_metered(
3388            EventKind::ActionSucceeded,
3389            Some("a2"),
3390            None,
3391            HashMap::new(),
3392            Metrics::inference(100, 20, Some(0.5)).with_duration(70.0),
3393        );
3394        // An un-metered event must not affect totals.
3395        log.append(EventKind::ProposalReceived, None, None, HashMap::new());
3396
3397        let t = log.metrics_totals();
3398        assert_eq!(t.duration_ms, 120.0);
3399        assert_eq!(t.tokens_in, 100);
3400        assert_eq!(t.tokens_out, 20);
3401        assert_eq!(t.tokens, 120);
3402        assert_eq!(t.cost_usd, 0.5);
3403        assert_eq!(t.metered_events, 2);
3404    }
3405
3406    #[test]
3407    fn metrics_totals_counts_raw_appended_duration_key() {
3408        // Contract: metrics_totals sums any event carrying a metric key,
3409        // regardless of append path. A legacy raw `append` that puts
3410        // "duration_ms" in data must still be counted (locks the contract
3411        // documented on metrics_totals).
3412        let mut log = EventLog::new();
3413        log.append(
3414            EventKind::ActionSucceeded,
3415            Some("a1"),
3416            None,
3417            [(metric_keys::DURATION_MS.to_string(), Value::from(42.0))].into(),
3418        );
3419        let t = log.metrics_totals();
3420        assert_eq!(t.duration_ms, 42.0);
3421        assert_eq!(t.metered_events, 1);
3422    }
3423
3424    #[test]
3425    fn new_telemetry_event_kinds_serialize_snake_case() {
3426        // The new kinds must round-trip as snake_case for the JSON wire.
3427        let json = serde_json::to_string(&EventKind::BranchDecision).unwrap();
3428        assert_eq!(json, "\"branch_decision\"");
3429        let json = serde_json::to_string(&EventKind::AlternativeRejected).unwrap();
3430        assert_eq!(json, "\"alternative_rejected\"");
3431        let json = serde_json::to_string(&EventKind::InferenceMetered).unwrap();
3432        assert_eq!(json, "\"inference_metered\"");
3433    }
3434
3435    #[test]
3436    fn filter_by_kind() {
3437        let mut log = EventLog::new();
3438        log.append(
3439            EventKind::ProposalReceived,
3440            None,
3441            Some("p1"),
3442            HashMap::new(),
3443        );
3444        log.append(
3445            EventKind::ActionValidated,
3446            Some("a1"),
3447            Some("p1"),
3448            HashMap::new(),
3449        );
3450        log.append(
3451            EventKind::ActionSucceeded,
3452            Some("a1"),
3453            Some("p1"),
3454            HashMap::new(),
3455        );
3456
3457        let validated = log.filter(Some(&EventKind::ActionValidated), None);
3458        assert_eq!(validated.len(), 1);
3459    }
3460
3461    #[test]
3462    fn filter_by_action_id() {
3463        let mut log = EventLog::new();
3464        log.append(EventKind::ActionValidated, Some("a1"), None, HashMap::new());
3465        log.append(EventKind::ActionValidated, Some("a2"), None, HashMap::new());
3466
3467        let a1_events = log.filter(None, Some("a1"));
3468        assert_eq!(a1_events.len(), 1);
3469    }
3470
3471    #[test]
3472    fn journal_write_and_reload() {
3473        let dir = tempfile::tempdir().unwrap();
3474        let journal = dir.path().join("events.jsonl");
3475
3476        {
3477            let mut log = EventLog::with_journal(journal.clone());
3478            log.append(
3479                EventKind::ProposalReceived,
3480                None,
3481                Some("p1"),
3482                HashMap::new(),
3483            );
3484            log.append(
3485                EventKind::ActionSucceeded,
3486                Some("a1"),
3487                Some("p1"),
3488                HashMap::new(),
3489            );
3490        }
3491
3492        assert!(journal.exists());
3493
3494        let reloaded = EventLog::load(&journal).unwrap();
3495        assert_eq!(reloaded.len(), 2);
3496        assert_eq!(reloaded.events()[0].kind, EventKind::ProposalReceived);
3497        assert_eq!(reloaded.events()[1].kind, EventKind::ActionSucceeded);
3498    }
3499
3500    #[test]
3501    fn load_rejects_newline_terminated_corrupt_middle_before_later_terminal() {
3502        let dir = tempfile::tempdir().unwrap();
3503        let journal = dir.path().join("corrupt-middle.jsonl");
3504        let started = serde_json::json!({
3505            "kind": "run_started",
3506            "run_id": "run-corrupt",
3507            "client_id": "client-1",
3508            "data": {"agent_id": "daily-continuity-newsroom"},
3509            "timestamp": "2026-08-30T09:30:00Z"
3510        });
3511        let completed = serde_json::json!({
3512            "kind": "run_completed",
3513            "run_id": "run-corrupt",
3514            "client_id": "client-1",
3515            "data": {
3516                "completion_digest": "must-not-be-trusted",
3517                "termination": {"kind": "outcome", "status": "success", "outcome": {}}
3518            },
3519            "timestamp": "2026-08-30T10:00:00Z"
3520        });
3521        fs::write(
3522            &journal,
3523            format!("{started}\n{{this-is-not-json}}\n{completed}\n"),
3524        )
3525        .unwrap();
3526
3527        let error = match EventLog::load(&journal) {
3528            Ok(_) => panic!("newline-terminated middle corruption must fail closed"),
3529            Err(error) => error,
3530        };
3531        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
3532        assert!(error.to_string().contains("line=2"), "{error}");
3533    }
3534
3535    #[test]
3536    fn load_repairs_crash_torn_final_record_before_append() {
3537        let dir = tempfile::tempdir().unwrap();
3538        let journal = dir.path().join("torn-tail.jsonl");
3539        let started = serde_json::json!({
3540            "kind": "run_started",
3541            "run_id": "run-torn",
3542            "client_id": "client-1",
3543            "data": {"agent_id": "daily-continuity-newsroom"},
3544            "timestamp": "2026-08-30T09:30:00Z"
3545        });
3546        let mut journal_file = create_private_file(&journal).unwrap();
3547        write!(journal_file, "{started}\n{{\"kind\":\"run_completed\"").unwrap();
3548        drop(journal_file);
3549
3550        {
3551            let mut loaded = EventLog::load(&journal).expect("torn final row is recoverable");
3552            assert_eq!(loaded.len(), 1);
3553            loaded.append(
3554                EventKind::ProposalReceived,
3555                None,
3556                Some("proposal-after-recovery"),
3557                HashMap::new(),
3558            );
3559        }
3560
3561        let bytes = fs::read_to_string(&journal).unwrap();
3562        assert!(!bytes.contains("{\"kind\":\"run_completed\""), "{bytes}");
3563        assert!(bytes.ends_with('\n'));
3564        let reloaded = EventLog::load(&journal).unwrap();
3565        assert_eq!(reloaded.len(), 2);
3566        assert_eq!(reloaded.events()[0].kind, EventKind::RunStarted);
3567        assert_eq!(reloaded.events()[1].kind, EventKind::ProposalReceived);
3568    }
3569
3570    #[test]
3571    fn load_read_only_rejects_a_torn_tail_without_modifying_the_journal() {
3572        let dir = tempfile::tempdir().unwrap();
3573        let journal = dir.path().join("read-only-torn-tail.jsonl");
3574        let started = serde_json::json!({
3575            "kind": "run_started",
3576            "run_id": "run-torn",
3577            "client_id": "client-1",
3578            "data": {"agent_id": "daily-continuity-newsroom"},
3579            "timestamp": "2026-08-30T09:30:00Z"
3580        });
3581        fs::write(&journal, format!("{started}\n{{\"kind\":\"run_completed\"")).unwrap();
3582        let before = fs::read(&journal).unwrap();
3583
3584        let error = match EventLog::load_read_only(&journal) {
3585            Ok(_) => panic!("read-only loading must expose a torn final row"),
3586            Err(error) => error,
3587        };
3588
3589        assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof);
3590        assert!(error.to_string().contains("event journal torn tail"));
3591        assert!(error.to_string().contains("line=2"));
3592        assert_eq!(fs::read(&journal).unwrap(), before);
3593    }
3594
3595    #[test]
3596    fn load_rejects_existing_hash_chain_tampering() {
3597        let dir = tempfile::tempdir().unwrap();
3598        let journal = dir.path().join("tampered-chain.jsonl");
3599        {
3600            let mut log = EventLog::with_journal(journal.clone()).with_hash_chaining();
3601            log.append(
3602                EventKind::RunStarted,
3603                None,
3604                None,
3605                [(
3606                    "agent_id".to_string(),
3607                    Value::from("daily-continuity-newsroom"),
3608                )]
3609                .into(),
3610            );
3611            log.append(EventKind::RunCompleted, None, None, HashMap::new());
3612        }
3613        let mut rows: Vec<Value> = fs::read_to_string(&journal)
3614            .unwrap()
3615            .lines()
3616            .map(|line| serde_json::from_str(line).unwrap())
3617            .collect();
3618        rows[0]["data"]["agent_id"] = Value::from("tampered-agent");
3619        fs::write(
3620            &journal,
3621            rows.iter()
3622                .map(Value::to_string)
3623                .collect::<Vec<_>>()
3624                .join("\n")
3625                + "\n",
3626        )
3627        .unwrap();
3628
3629        let error = match EventLog::load(&journal) {
3630            Ok(_) => panic!("hash-chain tampering must fail closed during load"),
3631            Err(error) => error,
3632        };
3633        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
3634        assert!(error.to_string().contains("hash chain"), "{error}");
3635        assert!(error.to_string().contains("line=1"), "{error}");
3636    }
3637
3638    #[cfg(unix)]
3639    fn unix_mode(path: &Path) -> u32 {
3640        use std::os::unix::fs::PermissionsExt;
3641        fs::symlink_metadata(path).unwrap().permissions().mode() & 0o777
3642    }
3643
3644    #[cfg(unix)]
3645    #[test]
3646    fn car_owned_journal_and_created_parents_are_private() {
3647        let root = tempfile::tempdir().unwrap();
3648        let parent = root.path().join("eventlogs").join("session");
3649        let journal = parent.join("events.jsonl");
3650        {
3651            let mut log = EventLog::with_journal(journal.clone());
3652            log.append(EventKind::StateChanged, Some("a1"), None, HashMap::new());
3653        }
3654
3655        assert_eq!(unix_mode(&root.path().join("eventlogs")), 0o700);
3656        assert_eq!(unix_mode(&parent), 0o700);
3657        assert_eq!(unix_mode(&journal), 0o600);
3658    }
3659
3660    #[cfg(unix)]
3661    #[test]
3662    fn append_hardens_preexisting_owned_permissive_journal() {
3663        use std::os::unix::fs::PermissionsExt;
3664
3665        let dir = tempfile::tempdir().unwrap();
3666        let journal = dir.path().join("events.jsonl");
3667        fs::write(&journal, b"").unwrap();
3668        fs::set_permissions(&journal, fs::Permissions::from_mode(0o644)).unwrap();
3669        {
3670            let mut log = EventLog::with_journal(journal.clone());
3671            log.append(EventKind::StateChanged, Some("a1"), None, HashMap::new());
3672        }
3673        assert_eq!(unix_mode(&journal), 0o600);
3674    }
3675
3676    #[cfg(unix)]
3677    #[test]
3678    fn journal_refuses_symlink_and_hardlink_destinations() {
3679        use std::os::unix::fs::symlink;
3680
3681        let dir = tempfile::tempdir().unwrap();
3682        let victim = dir.path().join("victim");
3683        fs::write(&victim, b"unchanged").unwrap();
3684
3685        for journal in [dir.path().join("symlink"), dir.path().join("hardlink")] {
3686            if journal.ends_with("symlink") {
3687                symlink(&victim, &journal).unwrap();
3688            } else {
3689                fs::hard_link(&victim, &journal).unwrap();
3690            }
3691            {
3692                let mut log = EventLog::with_journal(journal);
3693                log.append(EventKind::StateChanged, Some("a1"), None, HashMap::new());
3694            }
3695            assert_eq!(fs::read(&victim).unwrap(), b"unchanged");
3696        }
3697    }
3698
3699    #[cfg(unix)]
3700    #[test]
3701    fn journal_stops_if_the_opened_path_is_substituted() {
3702        let dir = tempfile::tempdir().unwrap();
3703        let journal = dir.path().join("events.jsonl");
3704        let moved = dir.path().join("moved.jsonl");
3705        let mut log = EventLog::with_journal(journal.clone());
3706        log.append(EventKind::StateChanged, Some("first"), None, HashMap::new());
3707
3708        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3709        while fs::metadata(&journal).map_or(true, |metadata| metadata.len() == 0) {
3710            assert!(
3711                std::time::Instant::now() < deadline,
3712                "first event was not persisted"
3713            );
3714            std::thread::yield_now();
3715        }
3716        fs::rename(&journal, &moved).unwrap();
3717        let _substitute = create_private_file(&journal).unwrap();
3718        log.append(
3719            EventKind::StateChanged,
3720            Some("second"),
3721            None,
3722            HashMap::new(),
3723        );
3724        drop(log);
3725
3726        assert_eq!(EventLog::load(&moved).unwrap().len(), 1);
3727        assert_eq!(fs::metadata(&journal).unwrap().len(), 0);
3728    }
3729
3730    #[cfg(unix)]
3731    #[test]
3732    fn compaction_preserves_private_mode_and_leaves_no_temp_name() {
3733        let dir = tempfile::tempdir().unwrap();
3734        let journal = dir.path().join("events.jsonl");
3735        let mut log = EventLog::with_journal(journal.clone());
3736        for index in 0..4 {
3737            log.append(
3738                EventKind::StateChanged,
3739                Some(&format!("a{index}")),
3740                None,
3741                HashMap::new(),
3742            );
3743        }
3744        log.truncate_events_keep_last(2);
3745        assert!(log.compact_journal());
3746        drop(log);
3747
3748        assert_eq!(unix_mode(&journal), 0o600);
3749        let names: Vec<_> = fs::read_dir(dir.path())
3750            .unwrap()
3751            .map(|entry| entry.unwrap().file_name())
3752            .collect();
3753        assert_eq!(names, vec![journal.file_name().unwrap()]);
3754    }
3755
3756    #[cfg(unix)]
3757    #[test]
3758    fn historical_world_readable_journal_can_be_loaded_without_mutation() {
3759        use std::os::unix::fs::PermissionsExt;
3760
3761        let dir = tempfile::tempdir().unwrap();
3762        let journal = dir.path().join("historical.jsonl");
3763        let event = Event {
3764            kind: EventKind::StateChanged,
3765            run_id: None,
3766            client_id: None,
3767            policy_session_id: None,
3768            action_id: Some("historical".into()),
3769            proposal_id: None,
3770            data: HashMap::new(),
3771            timestamp: Utc::now(),
3772            prev_hash: None,
3773            hash: None,
3774        };
3775        fs::write(
3776            &journal,
3777            format!("{}\n", serde_json::to_string(&event).unwrap()),
3778        )
3779        .unwrap();
3780        fs::set_permissions(&journal, fs::Permissions::from_mode(0o644)).unwrap();
3781
3782        let loaded = EventLog::load(&journal).unwrap();
3783        assert_eq!(loaded.len(), 1);
3784        drop(loaded);
3785        assert_eq!(unix_mode(&journal), 0o644);
3786    }
3787
3788    #[test]
3789    fn journal_not_created_without_appends() {
3790        // A session that never logs an event must leave no journal file behind.
3791        // Eager open created a 0-byte file per connection that accumulated
3792        // without bound; the writer now opens lazily on the first line.
3793        let dir = tempfile::tempdir().unwrap();
3794        let journal = dir.path().join("no-events.jsonl");
3795        {
3796            let _log = EventLog::with_journal(journal.clone());
3797            // No append. Drop joins the writer thread, which never opened the
3798            // file because no line was ever sent.
3799        }
3800        assert!(
3801            !journal.exists(),
3802            "journal file must not be created when nothing is appended"
3803        );
3804    }
3805
3806    #[test]
3807    fn journal_preserves_order_and_count_under_burst() {
3808        // The background writer must not lose or reorder events under a tight
3809        // append burst; drop-join guarantees the backlog is flushed before the
3810        // log is gone.
3811        let dir = tempfile::tempdir().unwrap();
3812        let journal = dir.path().join("burst.jsonl");
3813        {
3814            let mut log = EventLog::with_journal(journal.clone());
3815            for i in 0..500 {
3816                log.append(
3817                    EventKind::ActionSucceeded,
3818                    Some(&format!("a{i}")),
3819                    None,
3820                    HashMap::new(),
3821                );
3822            }
3823        } // drop joins the writer thread → all 500 lines flushed.
3824
3825        let reloaded = EventLog::load(&journal).unwrap();
3826        assert_eq!(reloaded.len(), 500, "no events lost");
3827        for (i, event) in reloaded.events().iter().enumerate() {
3828            assert_eq!(
3829                event.action_id.as_deref(),
3830                Some(format!("a{i}").as_str()),
3831                "order preserved at {i}"
3832            );
3833        }
3834    }
3835
3836    #[test]
3837    fn unopenable_journal_is_best_effort_not_fatal() {
3838        // The whole "best-effort" promise rests on this branch: a journal path
3839        // that can't be opened (here: the path IS an existing directory) must not
3840        // panic or block append — the in-memory log keeps working.
3841        let dir = tempfile::tempdir().unwrap();
3842        let journal = dir.path().join("a-directory");
3843        fs::create_dir(&journal).unwrap(); // open(append) on a dir fails
3844
3845        let mut log = EventLog::with_journal(journal);
3846        log.append(
3847            EventKind::ProposalReceived,
3848            None,
3849            Some("p1"),
3850            HashMap::new(),
3851        );
3852        log.append(EventKind::ActionSucceeded, Some("a1"), None, HashMap::new());
3853        assert_eq!(
3854            log.len(),
3855            2,
3856            "in-memory log unaffected by an unwritable journal"
3857        );
3858        // Drop must still terminate cleanly (writer thread drained and joined).
3859    }
3860
3861    #[test]
3862    fn load_then_append_preserves_existing_and_adds() {
3863        let dir = tempfile::tempdir().unwrap();
3864        let journal = dir.path().join("resume.jsonl");
3865        {
3866            let mut log = EventLog::with_journal(journal.clone());
3867            log.append(
3868                EventKind::ProposalReceived,
3869                None,
3870                Some("p1"),
3871                HashMap::new(),
3872            );
3873        }
3874        // Resume: load, append more, drop → both old and new are on disk.
3875        {
3876            let mut log = EventLog::load(&journal).unwrap();
3877            assert_eq!(log.len(), 1);
3878            log.append(EventKind::ActionSucceeded, Some("a1"), None, HashMap::new());
3879        }
3880        let reloaded = EventLog::load(&journal).unwrap();
3881        assert_eq!(reloaded.len(), 2, "append-mode preserved the loaded line");
3882        assert_eq!(reloaded.events()[0].kind, EventKind::ProposalReceived);
3883        assert_eq!(reloaded.events()[1].kind, EventKind::ActionSucceeded);
3884    }
3885
3886    #[test]
3887    fn event_kind_serializes_snake_case() {
3888        assert_eq!(
3889            serde_json::to_string(&EventKind::ProposalReceived).unwrap(),
3890            "\"proposal_received\""
3891        );
3892        assert_eq!(
3893            serde_json::to_string(&EventKind::StateSnapshot).unwrap(),
3894            "\"state_snapshot\""
3895        );
3896    }
3897
3898    #[test]
3899    fn stats_truncate_and_clear_release_retained_entries() {
3900        let mut log = EventLog::new();
3901        for idx in 0..5 {
3902            log.append(
3903                EventKind::ActionSucceeded,
3904                Some(&format!("a{idx}")),
3905                Some("p1"),
3906                [("payload".to_string(), Value::from("x".repeat(16)))].into(),
3907            );
3908            log.begin_span("action.tool_call", "trace", None, HashMap::new());
3909        }
3910
3911        let stats = log.stats();
3912        assert_eq!(stats.events, 5);
3913        assert_eq!(stats.spans, 5);
3914        assert!(stats.approx_event_bytes > 0);
3915        assert!(stats.approx_span_bytes > 0);
3916
3917        assert_eq!(log.truncate_events_keep_last(2), 3);
3918        assert_eq!(log.truncate_spans_keep_last(1), 4);
3919        assert_eq!(log.len(), 2);
3920        assert_eq!(log.span_len(), 1);
3921        assert_eq!(log.events()[0].action_id.as_deref(), Some("a3"));
3922
3923        let removed = log.clear();
3924        assert_eq!(removed.events, 2);
3925        assert_eq!(removed.spans, 1);
3926        assert_eq!(log.len(), 0);
3927        assert_eq!(log.span_len(), 0);
3928    }
3929
3930    #[test]
3931    fn span_begin_end_lifecycle() {
3932        let mut log = EventLog::new();
3933        let trace_id = "trace-1".to_string();
3934
3935        let span_id = log.begin_span(
3936            "test.operation",
3937            &trace_id,
3938            None,
3939            [("key".to_string(), Value::from("value"))].into(),
3940        );
3941
3942        let spans = log.spans();
3943        assert_eq!(spans.len(), 1);
3944        assert_eq!(spans[0].name, "test.operation");
3945        assert_eq!(spans[0].trace_id, "trace-1");
3946        assert!(spans[0].parent_span_id.is_none());
3947        assert!(spans[0].end_time.is_none());
3948        assert_eq!(spans[0].status, SpanStatus::Unset);
3949
3950        log.end_span(&span_id, SpanStatus::Ok);
3951
3952        let spans = log.spans();
3953        assert!(spans[0].end_time.is_some());
3954        assert_eq!(spans[0].status, SpanStatus::Ok);
3955    }
3956
3957    #[test]
3958    fn span_parent_child_relationship() {
3959        let mut log = EventLog::new();
3960        let trace_id = "trace-2".to_string();
3961
3962        let parent_id = log.begin_span("parent.op", &trace_id, None, HashMap::new());
3963        let child_id = log.begin_span("child.op", &trace_id, Some(&parent_id), HashMap::new());
3964
3965        let spans = log.spans();
3966        assert_eq!(spans.len(), 2);
3967
3968        let child = spans.iter().find(|s| s.span_id == child_id).unwrap();
3969        assert_eq!(child.parent_span_id.as_deref(), Some(parent_id.as_str()));
3970        assert_eq!(child.trace_id, trace_id);
3971
3972        let parent = spans.iter().find(|s| s.span_id == parent_id).unwrap();
3973        assert!(parent.parent_span_id.is_none());
3974    }
3975
3976    #[test]
3977    fn export_traces_produces_valid_json() {
3978        let mut log = EventLog::new();
3979        let trace_id = "trace-3".to_string();
3980
3981        let root = log.begin_span(
3982            "proposal.execute",
3983            &trace_id,
3984            None,
3985            [("proposal_id".to_string(), Value::from("p1"))].into(),
3986        );
3987        let child = log.begin_span(
3988            "action.tool_call",
3989            &trace_id,
3990            Some(&root),
3991            [("tool".to_string(), Value::from("read_file"))].into(),
3992        );
3993        log.end_span(&child, SpanStatus::Ok);
3994        log.end_span(&root, SpanStatus::Ok);
3995
3996        let json_str = log.export_traces();
3997        let parsed: Value =
3998            serde_json::from_str(&json_str).expect("export_traces must produce valid JSON");
3999
4000        let resource_spans = parsed["resourceSpans"].as_array().unwrap();
4001        assert_eq!(resource_spans.len(), 1);
4002
4003        let scope_spans = &resource_spans[0]["scopeSpans"][0]["spans"];
4004        let spans_arr = scope_spans.as_array().unwrap();
4005        assert_eq!(spans_arr.len(), 2);
4006
4007        // Verify OTLP structure
4008        for span in spans_arr {
4009            assert!(span.get("traceId").is_some());
4010            assert!(span.get("spanId").is_some());
4011            assert!(span.get("name").is_some());
4012            assert!(span.get("startTimeUnixNano").is_some());
4013            assert!(span.get("endTimeUnixNano").is_some());
4014            assert!(span.get("status").is_some());
4015        }
4016
4017        // Verify the child has parentSpanId
4018        let child_span = spans_arr
4019            .iter()
4020            .find(|s| s["name"] == "action.tool_call")
4021            .unwrap();
4022        assert!(child_span.get("parentSpanId").is_some());
4023    }
4024
4025    #[test]
4026    fn span_status_set_on_error() {
4027        let mut log = EventLog::new();
4028        let trace_id = "trace-4".to_string();
4029
4030        let span_id = log.begin_span("failing.op", &trace_id, None, HashMap::new());
4031        log.end_span(&span_id, SpanStatus::Error);
4032
4033        let spans = log.spans();
4034        assert_eq!(spans[0].status, SpanStatus::Error);
4035        assert!(spans[0].end_time.is_some());
4036    }
4037
4038    #[test]
4039    fn active_run_binding_stamps_every_new_event_and_rejects_conflicts() {
4040        let mut log = EventLog::new();
4041        log.bind_run("run-a", "client-a")
4042            .expect("first active run binds");
4043        log.bind_policy_session("policy-session-a")
4044            .expect("CAR-minted policy session binds inside the run");
4045
4046        log.append(
4047            EventKind::ProposalReceived,
4048            None,
4049            Some("same-proposal"),
4050            HashMap::new(),
4051        );
4052        log.append(
4053            EventKind::ActionSucceeded,
4054            Some("action-a"),
4055            Some("same-proposal"),
4056            HashMap::new(),
4057        );
4058
4059        for event in log.events() {
4060            assert_eq!(event.run_id.as_deref(), Some("run-a"));
4061            assert_eq!(event.client_id.as_deref(), Some("client-a"));
4062            assert_eq!(event.policy_session_id.as_deref(), Some("policy-session-a"));
4063        }
4064        assert!(log.bind_run("run-b", "client-a").is_err());
4065        assert!(log.bind_run("run-a", "client-b").is_err());
4066        assert!(log.clear_run_binding("run-b", "client-a").is_err());
4067        assert_eq!(
4068            log.active_run_binding(),
4069            Some(("run-a", "client-a", Some("policy-session-a")))
4070        );
4071
4072        log.clear_policy_session("policy-session-a")
4073            .expect("exact policy session clears");
4074        log.clear_run_binding("run-a", "client-a")
4075            .expect("exact active run clears");
4076        log.append(
4077            EventKind::ProposalReceived,
4078            None,
4079            Some("unbound-legacy"),
4080            HashMap::new(),
4081        );
4082        let legacy = log.events().last().unwrap();
4083        assert!(legacy.run_id.is_none());
4084        assert!(legacy.client_id.is_none());
4085        assert!(legacy.policy_session_id.is_none());
4086    }
4087
4088    #[test]
4089    fn historical_event_without_binding_fields_still_deserializes() {
4090        let historical = r#"{"kind":"proposal_received","proposal_id":"p-old","data":{},"timestamp":"2026-01-02T03:04:05Z"}"#;
4091        let event: Event = serde_json::from_str(historical).expect("historical event replays");
4092        assert!(event.run_id.is_none());
4093        assert!(event.client_id.is_none());
4094        assert!(event.policy_session_id.is_none());
4095    }
4096
4097    #[test]
4098    fn async_acknowledgement_timeout_wins_after_expiry_removal() {
4099        let manager = AsyncAcknowledgementManager::new(1);
4100        let barrier = AsyncAcknowledgementExpiryBarrier::new();
4101        manager.pause_next_expiry_after_removal(barrier.clone());
4102        let reservation = manager.reserve(Duration::from_millis(10)).unwrap();
4103        let sender = reservation.sender();
4104        let acknowledgement = reservation.into_future();
4105
4106        barrier.wait_until_removed();
4107        sender.send(Ok(()));
4108        let result = block_on_test_future(acknowledgement);
4109        barrier.allow_timeout_completion();
4110        manager.shutdown();
4111
4112        assert!(matches!(
4113            result,
4114            Err(CriticalPostAcceptanceError::AcknowledgementTimedOut { .. })
4115        ));
4116    }
4117
4118    #[test]
4119    fn async_acknowledgement_capacity_is_atomic_under_concurrent_reservation() {
4120        let manager = Arc::new(AsyncAcknowledgementManager::new(1));
4121        let barrier = Arc::new(std::sync::Barrier::new(3));
4122        let handles: Vec<_> = (0..2)
4123            .map(|_| {
4124                let manager = manager.clone();
4125                let barrier = barrier.clone();
4126                std::thread::spawn(move || {
4127                    let reservation = manager.reserve(MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT);
4128                    barrier.wait();
4129                    reservation
4130                })
4131            })
4132            .collect();
4133
4134        barrier.wait();
4135        let results: Vec<_> = handles
4136            .into_iter()
4137            .map(|handle| handle.join().unwrap())
4138            .collect();
4139        assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
4140        assert_eq!(
4141            results
4142                .iter()
4143                .filter(|result| {
4144                    matches!(
4145                        result,
4146                        Err(CriticalPreAcceptanceError::CapacityExhausted { capacity: 1 })
4147                    )
4148                })
4149                .count(),
4150            1
4151        );
4152        drop(results);
4153        manager.shutdown();
4154    }
4155
4156    #[test]
4157    fn async_acknowledgement_completed_before_future_construction_is_observed() {
4158        let manager = AsyncAcknowledgementManager::new(1);
4159        let reservation = manager.reserve(Duration::from_secs(1)).unwrap();
4160        reservation.sender().send(Ok(()));
4161        let acknowledgement = reservation.into_future();
4162
4163        assert!(block_on_test_future(acknowledgement).is_ok());
4164        manager.shutdown();
4165    }
4166
4167    #[test]
4168    fn journal_writer_drop_completes_pending_async_acknowledgement() {
4169        let dir = tempfile::tempdir().unwrap();
4170        let failures = JournalFailureInjector::default();
4171        failures.fail_next(JournalFailurePoint::HoldAcknowledgement);
4172        let writer = JournalWriter::spawn_with_injector(
4173            dir.path().join("pending-ack-shutdown.jsonl"),
4174            failures.clone(),
4175        );
4176        let reservation = writer
4177            .reserve_async_acknowledgement(MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT)
4178            .unwrap();
4179        let acknowledgement = writer
4180            .enqueue_critical_async("{}".to_string(), false, reservation)
4181            .unwrap();
4182        let deadline = Instant::now() + Duration::from_secs(2);
4183        while failures.held_acknowledgement_count() == 0 {
4184            assert!(
4185                Instant::now() < deadline,
4186                "writer never retained the pending acknowledgement"
4187            );
4188            std::thread::yield_now();
4189        }
4190
4191        drop(writer);
4192        let result = block_on_test_future(acknowledgement);
4193        assert!(matches!(
4194            result,
4195            Err(CriticalPostAcceptanceError::CoordinatorStopped)
4196        ));
4197        failures.release_held_acknowledgements();
4198    }
4199
4200    #[test]
4201    fn prepare_failure_releases_async_acknowledgement_capacity() {
4202        let dir = tempfile::tempdir().unwrap();
4203        let mut log = EventLog::with_journal_failure_injector_and_ack_capacity(
4204            dir.path().join("prepare-failure-capacity.jsonl"),
4205            JournalFailureInjector::default(),
4206            1,
4207        );
4208        let data = HashMap::from([("completion_digest".to_string(), Value::from("7".repeat(64)))]);
4209
4210        let error = block_on_test_future(log.append_critical_async(
4211            EventKind::RunCompleted,
4212            None,
4213            None,
4214            data.clone(),
4215            Duration::from_secs(1),
4216        ))
4217        .expect_err("an unbound critical event must fail during preparation");
4218        assert!(matches!(error, CriticalAppendError::Rejected { .. }));
4219
4220        log.bind_run("run-after-prepare-failure", "client-after-prepare-failure")
4221            .unwrap();
4222        block_on_test_future(log.append_critical_async(
4223            EventKind::RunCompleted,
4224            None,
4225            None,
4226            data,
4227            Duration::from_secs(1),
4228        ))
4229        .expect("the failed preparation must release the only acknowledgement slot");
4230    }
4231
4232    #[test]
4233    fn async_critical_preacceptance_failures_reject_without_fabricating_events() {
4234        let data = HashMap::from([("completion_digest".to_string(), Value::from("f".repeat(64)))]);
4235
4236        let mut no_journal = EventLog::new();
4237        no_journal
4238            .bind_run("run-no-writer", "client-no-writer")
4239            .unwrap();
4240        let error = block_on_test_future(no_journal.append_critical_async(
4241            EventKind::RunCompleted,
4242            None,
4243            None,
4244            data.clone(),
4245            Duration::from_millis(100),
4246        ))
4247        .expect_err("a missing writer must reject before acceptance");
4248        assert!(matches!(error, CriticalAppendError::Rejected { .. }));
4249        assert!(!error.is_retry_safe());
4250        assert!(no_journal.events().is_empty());
4251        assert!(no_journal.critical_pending.is_empty());
4252
4253        let dir = tempfile::tempdir().unwrap();
4254        let unavailable_path = dir.path().join("sender-missing.jsonl");
4255        let mut unavailable = EventLog::with_journal(unavailable_path.clone());
4256        unavailable
4257            .bind_run("run-sender-missing", "client-sender-missing")
4258            .unwrap();
4259        unavailable
4260            .journal
4261            .as_mut()
4262            .unwrap()
4263            .remove_sender_for_test();
4264        let error = block_on_test_future(unavailable.append_critical_async(
4265            EventKind::RunCompleted,
4266            None,
4267            None,
4268            data.clone(),
4269            Duration::from_millis(100),
4270        ))
4271        .expect_err("a missing writer sender must reject before event construction");
4272        assert!(matches!(error, CriticalAppendError::Rejected { .. }));
4273        assert!(unavailable.events().is_empty());
4274        assert!(unavailable.critical_pending.is_empty());
4275        assert!(!unavailable_path.exists());
4276
4277        let stopped_path = dir.path().join("receiver-stopped.jsonl");
4278        let mut stopped = EventLog::with_journal(stopped_path.clone());
4279        stopped
4280            .bind_run("run-receiver-stopped", "client-receiver-stopped")
4281            .unwrap();
4282        stopped.journal.as_mut().unwrap().stop_receiver_for_test();
4283        let error = block_on_test_future(stopped.append_critical_async(
4284            EventKind::RunCompleted,
4285            None,
4286            None,
4287            data,
4288            Duration::from_millis(100),
4289        ))
4290        .expect_err("a stopped writer receiver must reject a failed enqueue");
4291        assert!(matches!(error, CriticalAppendError::Rejected { .. }));
4292        assert!(stopped.events().is_empty());
4293        assert!(stopped.critical_pending.is_empty());
4294        assert!(!stopped_path.exists());
4295    }
4296
4297    #[test]
4298    fn async_critical_rejects_excessive_acknowledgement_duration_before_enqueue() {
4299        let dir = tempfile::tempdir().unwrap();
4300        let path = dir.path().join("excessive-ack-duration.jsonl");
4301        let mut log = EventLog::with_journal(path.clone());
4302        log.bind_run("run-excessive-ack", "client-excessive-ack")
4303            .unwrap();
4304
4305        let error = block_on_test_future(log.append_critical_async(
4306            EventKind::RunCompleted,
4307            None,
4308            None,
4309            HashMap::new(),
4310            MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT + Duration::from_millis(1),
4311        ))
4312        .expect_err("an excessive acknowledgement duration must reject");
4313
4314        assert!(matches!(error, CriticalAppendError::Rejected { .. }));
4315        assert!(log.events().is_empty());
4316        assert!(log.critical_pending.is_empty());
4317        assert!(!path.exists());
4318    }
4319
4320    #[test]
4321    fn async_critical_capacity_exhaustion_rejects_before_enqueue_and_exact_retry_unblocks() {
4322        let dir = tempfile::tempdir().unwrap();
4323        let path = dir.path().join("critical-ack-capacity.jsonl");
4324        let failures = JournalFailureInjector::default();
4325        failures.fail_next(JournalFailurePoint::HoldAcknowledgement);
4326        let mut log = EventLog::with_journal_failure_injector_and_ack_capacity(
4327            path.clone(),
4328            failures.clone(),
4329            1,
4330        );
4331        log.bind_run("run-capacity", "client-capacity").unwrap();
4332        let first_data =
4333            HashMap::from([("completion_digest".to_string(), Value::from("1".repeat(64)))]);
4334        let second_data =
4335            HashMap::from([("completion_digest".to_string(), Value::from("2".repeat(64)))]);
4336
4337        let mut first = Box::pin(log.append_critical_async(
4338            EventKind::RunCompleted,
4339            None,
4340            None,
4341            first_data.clone(),
4342            MAX_CRITICAL_ACKNOWLEDGEMENT_TIMEOUT,
4343        ));
4344        let waker = test_waker();
4345        let mut context = std::task::Context::from_waker(&waker);
4346        assert!(matches!(
4347            first.as_mut().poll(&mut context),
4348            std::task::Poll::Pending
4349        ));
4350        drop(first);
4351        assert_eq!(log.events().len(), 1);
4352        assert_eq!(log.critical_pending.len(), 1);
4353
4354        for attempt in 0..100 {
4355            let error = block_on_test_future(log.append_critical_async(
4356                EventKind::ProposalCompleted,
4357                None,
4358                Some("capacity-rejected"),
4359                second_data.clone(),
4360                Duration::from_millis(100),
4361            ))
4362            .expect_err("exhausted acknowledgement capacity must reject");
4363            let CriticalAppendError::Rejected { reason } = error else {
4364                panic!("attempt {attempt} was not a pre-enqueue rejection");
4365            };
4366            assert!(
4367                reason.contains("acknowledgement capacity is exhausted"),
4368                "attempt {attempt} bypassed capacity admission: {reason}"
4369            );
4370            assert_eq!(log.events().len(), 1);
4371            assert_eq!(log.critical_pending.len(), 1);
4372        }
4373
4374        let hold_deadline = std::time::Instant::now() + Duration::from_secs(2);
4375        while failures.held_acknowledgement_count() == 0 {
4376            assert!(
4377                std::time::Instant::now() < hold_deadline,
4378                "writer never reached the held acknowledgement"
4379            );
4380            std::thread::yield_now();
4381        }
4382        let pending_line = serde_json::to_string(&log.events()[0]).unwrap();
4383        assert_eq!(
4384            fs::read_to_string(&path).unwrap(),
4385            format!("{pending_line}\n")
4386        );
4387        failures.release_held_acknowledgements();
4388
4389        let original_timestamp = log.events()[0].timestamp;
4390        let retried = block_on_test_future(log.append_critical_async(
4391            EventKind::RunCompleted,
4392            None,
4393            None,
4394            first_data,
4395            Duration::from_millis(500),
4396        ))
4397        .expect("the exact cancelled row must reconcile pending state");
4398        assert_eq!(retried.timestamp, original_timestamp);
4399        assert!(log.critical_pending.is_empty());
4400
4401        block_on_test_future(log.append_critical_async(
4402            EventKind::ProposalCompleted,
4403            None,
4404            Some("capacity-rejected"),
4405            second_data,
4406            Duration::from_millis(500),
4407        ))
4408        .expect("a distinct row is allowed after exact reconciliation");
4409        drop(log);
4410
4411        let loaded = EventLog::load(&path).unwrap();
4412        assert_eq!(loaded.events().len(), 2);
4413        assert_eq!(loaded.events()[0].kind, EventKind::RunCompleted);
4414        assert_eq!(loaded.events()[1].kind, EventKind::ProposalCompleted);
4415    }
4416
4417    #[test]
4418    fn async_critical_never_acknowledged_row_remains_exactly_retryable() {
4419        let dir = tempfile::tempdir().unwrap();
4420        let path = dir.path().join("critical-never-acknowledged.jsonl");
4421        let failures = JournalFailureInjector::default();
4422        failures.fail_next(JournalFailurePoint::HoldAcknowledgement);
4423        let mut log = EventLog::with_journal_failure_injector(path.clone(), failures.clone());
4424        log.bind_run("run-never-ack", "client-never-ack").unwrap();
4425        let data = HashMap::from([("completion_digest".to_string(), Value::from("9".repeat(64)))]);
4426
4427        let error = block_on_test_future(log.append_critical_async(
4428            EventKind::RunCompleted,
4429            None,
4430            None,
4431            data.clone(),
4432            Duration::from_millis(20),
4433        ))
4434        .expect_err("the first acknowledgement is retained forever");
4435        assert!(matches!(
4436            error,
4437            CriticalAppendError::DurabilityUnknown { .. }
4438        ));
4439        let original = serde_json::to_string(&log.events()[0]).unwrap();
4440        assert!(log.critical_pending.contains(&original));
4441
4442        let retried = block_on_test_future(log.append_critical_async(
4443            EventKind::RunCompleted,
4444            None,
4445            None,
4446            data,
4447            Duration::from_millis(500),
4448        ))
4449        .expect("the exact row must reconcile without the first acknowledgement");
4450        assert_eq!(serde_json::to_string(retried).unwrap(), original);
4451        assert!(log.critical_pending.is_empty());
4452        assert_eq!(
4453            failures.held_acknowledgement_count(),
4454            1,
4455            "the original acknowledgement must remain unsent"
4456        );
4457        drop(log);
4458
4459        assert_eq!(fs::read_to_string(&path).unwrap(), format!("{original}\n"));
4460        assert_eq!(EventLog::load(&path).unwrap().events().len(), 1);
4461    }
4462
4463    #[test]
4464    fn bounded_sync_critical_timeout_is_exactly_retryable() {
4465        let dir = tempfile::tempdir().unwrap();
4466        let path = dir.path().join("critical-bounded-sync-timeout.jsonl");
4467        let failures = JournalFailureInjector::default();
4468        failures.fail_next(JournalFailurePoint::HoldAcknowledgement);
4469        let mut log = EventLog::with_journal_failure_injector(path.clone(), failures.clone());
4470        log.bind_run("run-bounded-sync", "client-bounded-sync")
4471            .unwrap();
4472        let data = HashMap::from([("completion_digest".to_string(), Value::from("7".repeat(64)))]);
4473
4474        let error = log
4475            .append_critical_bounded(
4476                EventKind::RunCompleted,
4477                None,
4478                None,
4479                data.clone(),
4480                Duration::from_millis(20),
4481            )
4482            .expect_err("the retained acknowledgement must hit the exact sync bound");
4483        assert_eq!(
4484            error,
4485            CriticalAppendError::DurabilityUnknown {
4486                reason: "journal writer did not acknowledge within 20ms".to_string()
4487            }
4488        );
4489        assert!(error.is_retry_safe());
4490        let pending = log
4491            .critical_pending
4492            .iter()
4493            .next()
4494            .expect("the exact timed-out row remains pending")
4495            .clone();
4496        assert_eq!(pending, serde_json::to_string(&log.events()[0]).unwrap());
4497
4498        let retried = log
4499            .append_critical_bounded(
4500                EventKind::RunCompleted,
4501                None,
4502                None,
4503                data,
4504                Duration::from_millis(500),
4505            )
4506            .expect("an exact retry must reconcile without the held acknowledgement");
4507        assert_eq!(serde_json::to_string(retried).unwrap(), pending);
4508        assert!(log.critical_pending.is_empty());
4509        assert_eq!(failures.held_acknowledgement_count(), 1);
4510        drop(log);
4511        assert_eq!(fs::read_to_string(&path).unwrap(), format!("{pending}\n"));
4512    }
4513
4514    #[test]
4515    fn async_critical_append_bounds_unknown_ack_and_preserves_exact_retry() {
4516        let dir = tempfile::tempdir().unwrap();
4517        let path = dir.path().join("critical-unknown-ack.jsonl");
4518        let failures = JournalFailureInjector::default();
4519        failures.fail_next(JournalFailurePoint::HoldAcknowledgement);
4520        let mut log = EventLog::with_journal_failure_injector(path.clone(), failures.clone());
4521        log.bind_run("run-unknown-ack", "client-unknown-ack")
4522            .unwrap();
4523        let data = HashMap::from([("completion_digest".to_string(), Value::from("e".repeat(64)))]);
4524
4525        let started = std::time::Instant::now();
4526        let error = block_on_test_future(log.append_critical_async(
4527            EventKind::RunCompleted,
4528            None,
4529            None,
4530            data.clone(),
4531            std::time::Duration::from_millis(40),
4532        ))
4533        .expect_err("a retained acknowledgement must become durability-unknown");
4534        assert!(
4535            started.elapsed() < std::time::Duration::from_millis(500),
4536            "the async acknowledgement wait exceeded its bounded allowance"
4537        );
4538        assert!(matches!(
4539            error,
4540            CriticalAppendError::DurabilityUnknown { .. }
4541        ));
4542        assert!(error.is_retry_safe());
4543        assert!(error
4544            .to_string()
4545            .contains("did not acknowledge within 40ms"));
4546
4547        let pending = log
4548            .critical_pending
4549            .iter()
4550            .next()
4551            .expect("the exact unacknowledged row remains pending")
4552            .clone();
4553        assert_eq!(log.critical_pending.len(), 1);
4554        assert_eq!(pending, serde_json::to_string(&log.events()[0]).unwrap());
4555        let disk_row = format!("{pending}\n");
4556        let writer_deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
4557        while fs::read_to_string(&path).unwrap_or_default() != disk_row {
4558            assert!(
4559                std::time::Instant::now() < writer_deadline,
4560                "the writer never durably accepted the held-acknowledgement row"
4561            );
4562            std::thread::yield_now();
4563        }
4564        while failures.held_acknowledgement_count() == 0 {
4565            assert!(
4566                std::time::Instant::now() < writer_deadline,
4567                "the writer never retained the late acknowledgement"
4568            );
4569            std::thread::yield_now();
4570        }
4571        assert_eq!(failures.held_acknowledgement_count(), 1);
4572        failures.release_held_acknowledgements();
4573        let original_timestamp = log.events()[0].timestamp;
4574
4575        let distinct_error = block_on_test_future(log.append_critical_async(
4576            EventKind::ProposalCompleted,
4577            None,
4578            Some("different-terminal"),
4579            HashMap::new(),
4580            Duration::from_millis(100),
4581        ))
4582        .expect_err("a different critical row must not bypass exact retry");
4583        assert!(matches!(
4584            distinct_error,
4585            CriticalAppendError::Rejected { .. }
4586        ));
4587        assert_eq!(log.events().len(), 1);
4588
4589        let retried = block_on_test_future(log.append_critical_async(
4590            EventKind::RunCompleted,
4591            None,
4592            None,
4593            data,
4594            std::time::Duration::from_secs(1),
4595        ))
4596        .expect("an identical retry must finish the retained row");
4597        assert_eq!(retried.timestamp, original_timestamp);
4598        assert!(log.critical_pending.is_empty());
4599
4600        block_on_test_future(log.append_critical_async(
4601            EventKind::ProposalCompleted,
4602            None,
4603            Some("different-terminal"),
4604            HashMap::new(),
4605            Duration::from_millis(500),
4606        ))
4607        .expect("a different row is allowed after exact retry reconciliation");
4608        drop(log);
4609
4610        let loaded = EventLog::load(&path).unwrap();
4611        assert_eq!(loaded.events().len(), 2);
4612        assert_eq!(serde_json::to_string(&loaded.events()[0]).unwrap(), pending);
4613    }
4614
4615    #[test]
4616    fn critical_append_failures_retry_same_row_and_fsync_prior_async_events() {
4617        for point in [
4618            JournalFailurePoint::Write,
4619            JournalFailurePoint::Flush,
4620            JournalFailurePoint::Fsync,
4621        ] {
4622            let dir = tempfile::tempdir().unwrap();
4623            let path = dir.path().join(format!("critical-{point:?}.jsonl"));
4624            let failures = JournalFailureInjector::default();
4625            failures.fail_next(point);
4626            let mut log = EventLog::with_journal_failure_injector(path.clone(), failures);
4627            log.bind_run("run-critical", "client-critical").unwrap();
4628            log.append(
4629                EventKind::ProposalReceived,
4630                None,
4631                Some("proposal-critical"),
4632                HashMap::new(),
4633            );
4634            let data =
4635                HashMap::from([("completion_digest".to_string(), Value::from("a".repeat(64)))]);
4636            assert!(
4637                log.append_critical(EventKind::RunCompleted, None, None, data.clone())
4638                    .is_err(),
4639                "{point:?} failure must not acknowledge"
4640            );
4641            log.append(
4642                EventKind::ActionSucceeded,
4643                Some("after-pending-terminal"),
4644                Some("proposal-critical"),
4645                HashMap::new(),
4646            );
4647            log.append_critical(EventKind::RunCompleted, None, None, data)
4648                .expect("retry finishes the exact critical row");
4649            drop(log);
4650
4651            let loaded = EventLog::load(&path).unwrap();
4652            let events = loaded.events();
4653            assert_eq!(events[0].kind, EventKind::ProposalReceived);
4654            assert_eq!(events[1].kind, EventKind::RunCompleted);
4655            assert_eq!(events[2].kind, EventKind::ActionSucceeded);
4656            assert_eq!(
4657                events
4658                    .iter()
4659                    .filter(|event| event.kind == EventKind::RunCompleted)
4660                    .count(),
4661                1,
4662                "{point:?} retry must not duplicate a terminal"
4663            );
4664        }
4665    }
4666
4667    #[test]
4668    fn compacting_a_failed_critical_row_makes_its_retry_idempotent() {
4669        let dir = tempfile::tempdir().unwrap();
4670        let path = dir.path().join("critical-compact-retry.jsonl");
4671        let failures = JournalFailureInjector::default();
4672        failures.fail_next(JournalFailurePoint::Fsync);
4673        let mut log =
4674            EventLog::with_journal_failure_injector(path.clone(), failures).with_hash_chaining();
4675        log.bind_run("run-critical-compact", "client-critical-compact")
4676            .unwrap();
4677        log.append(
4678            EventKind::ProposalReceived,
4679            None,
4680            Some("proposal-critical-compact"),
4681            HashMap::new(),
4682        );
4683        let data = HashMap::from([("completion_digest".to_string(), Value::from("c".repeat(64)))]);
4684
4685        assert!(
4686            log.append_critical(EventKind::RunCompleted, None, None, data.clone())
4687                .is_err(),
4688            "the injected fsync failure must leave the exact terminal pending"
4689        );
4690        assert!(
4691            log.compact_journal(),
4692            "compaction persists the in-memory pending terminal"
4693        );
4694        log.append_critical(EventKind::RunCompleted, None, None, data)
4695            .expect("identical retry recognizes the compacted terminal as durable");
4696        drop(log);
4697
4698        let loaded = EventLog::load(&path).unwrap();
4699        assert_eq!(
4700            loaded
4701                .events()
4702                .iter()
4703                .filter(|event| event.kind == EventKind::RunCompleted)
4704                .count(),
4705            1,
4706            "writer respawn must not duplicate the compacted terminal"
4707        );
4708        assert_eq!(loaded.events()[0].kind, EventKind::ProposalReceived);
4709        assert_eq!(loaded.events()[1].kind, EventKind::RunCompleted);
4710        assert_eq!(loaded.verify_chain(), Ok(2));
4711    }
4712
4713    #[test]
4714    fn critical_append_cannot_ack_until_failed_prior_async_row_is_replayed() {
4715        let dir = tempfile::tempdir().unwrap();
4716        let path = dir.path().join("prior-async-failure.jsonl");
4717        let failures = JournalFailureInjector::default();
4718        // The first failure drops the asynchronous attempt. The second makes
4719        // the first critical barrier prove that it cannot repair the gap yet.
4720        failures.fail_next(JournalFailurePoint::AsyncWrite);
4721        failures.fail_next(JournalFailurePoint::AsyncWrite);
4722        let mut log = EventLog::with_journal_failure_injector(path.clone(), failures);
4723        log.bind_run("run-ordered", "client-ordered").unwrap();
4724        log.append(
4725            EventKind::ActionSucceeded,
4726            Some("action-ordered"),
4727            Some("proposal-ordered"),
4728            HashMap::new(),
4729        );
4730        let data = HashMap::from([("completion_digest".to_string(), Value::from("b".repeat(64)))]);
4731
4732        assert!(
4733            log.append_critical(
4734                EventKind::ProposalCompleted,
4735                None,
4736                Some("proposal-ordered"),
4737                data.clone()
4738            )
4739            .is_err(),
4740            "a terminal must not acknowledge while an earlier row is still missing"
4741        );
4742        log.append_critical(
4743            EventKind::ProposalCompleted,
4744            None,
4745            Some("proposal-ordered"),
4746            data,
4747        )
4748        .expect("retry repairs the prior row before acknowledging the terminal");
4749        drop(log);
4750
4751        let loaded = EventLog::load(&path).unwrap();
4752        assert_eq!(loaded.events().len(), 2);
4753        assert_eq!(loaded.events()[0].kind, EventKind::ActionSucceeded);
4754        assert_eq!(loaded.events()[1].kind, EventKind::ProposalCompleted);
4755    }
4756
4757    #[test]
4758    fn first_use_parent_sync_failure_blocks_terminal_until_prior_async_replays() {
4759        let dir = tempfile::tempdir().unwrap();
4760        let path = dir.path().join("nested").join("first-use.jsonl");
4761        let failures = car_secrets::PrivatePathDurabilityFailureInjector::default();
4762        failures.fail_next(car_secrets::PrivatePathDurabilityFailurePoint::ParentDirectorySync);
4763        failures.fail_next(car_secrets::PrivatePathDurabilityFailurePoint::ParentDirectorySync);
4764        let mut log = EventLog::with_private_path_failure_injector(path.clone(), failures);
4765        log.bind_run("run-first-use", "client-first-use").unwrap();
4766        log.append(
4767            EventKind::ActionSucceeded,
4768            Some("action-first-use"),
4769            Some("proposal-first-use"),
4770            HashMap::new(),
4771        );
4772        let data = HashMap::from([("completion_digest".to_string(), Value::from("d".repeat(64)))]);
4773
4774        assert!(log
4775            .append_critical(
4776                EventKind::ProposalCompleted,
4777                None,
4778                Some("proposal-first-use"),
4779                data.clone(),
4780            )
4781            .is_err());
4782        log.append_critical(
4783            EventKind::ProposalCompleted,
4784            None,
4785            Some("proposal-first-use"),
4786            data,
4787        )
4788        .expect("retry durably replays async row then exact terminal");
4789        drop(log);
4790
4791        let loaded = EventLog::load(&path).unwrap();
4792        assert_eq!(loaded.events().len(), 2);
4793        assert_eq!(loaded.events()[0].kind, EventKind::ActionSucceeded);
4794        assert_eq!(loaded.events()[1].kind, EventKind::ProposalCompleted);
4795    }
4796}