Skip to main content

harn_vm/orchestration/
compaction_receipt.rs

1//! The canonical, versioned compaction receipt.
2//!
3//! A single transcript compaction is one lifecycle fact. Historically that fact
4//! was reconstructed independently in three places — the persisted transcript
5//! `compaction` event metadata, the live `AgentEvent::TranscriptCompacted`
6//! payload, and `RunObservabilityRecord.compaction_events` (which reparsed the
7//! transcript JSON key-by-key and dropped `reason`/`recap`). ACP consumers had
8//! no stable identity to key on and synthesized host-local UUIDs.
9//!
10//! [`CompactionReceipt`] is the one owner of that fact. It is constructed once
11//! at the lifecycle boundary (`run_compaction_lifecycle`) or, for host-script
12//! driven compaction, normalized once at the `__host_agent_record_compaction`
13//! builtin boundary. It is then:
14//!
15//!   * embedded verbatim under `metadata.receipt` on the transcript event, whose
16//!     event `id` is set to [`CompactionReceipt::receipt_id`];
17//!   * carried on `AgentEvent::TranscriptCompacted` and forwarded through ACP;
18//!   * deserialized (typed, not scraped) into `CompactionEventRecord`.
19//!
20//! So one compaction operation yields exactly one `receipt_id` across all four
21//! projections, and `reason`, `recap`, snapshot provenance, and policy survive
22//! every projection without a host synthesizing identity.
23
24use serde::{Deserialize, Serialize};
25
26use super::{CompactionSourceMeasurement, RecapMetrics};
27
28/// Current on-the-wire schema version for [`CompactionReceipt`]. Bump this when
29/// the receipt's meaning changes in a way readers must branch on; additive
30/// optional fields do not require a bump because `#[serde(default)]` fills them.
31pub const COMPACTION_RECEIPT_SCHEMA_VERSION: u32 = 1;
32
33fn default_schema_version() -> u32 {
34    COMPACTION_RECEIPT_SCHEMA_VERSION
35}
36
37/// One serializable, versioned record of a single transcript compaction. See the
38/// module docs for how it flows through every projection.
39#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(default)]
41pub struct CompactionReceipt {
42    /// Schema version. Lets readers migrate: a receipt read from an older
43    /// persisted transcript that predates a meaning change carries its original
44    /// version, and a transcript with no embedded receipt at all is migrated by
45    /// the record builder (see `compaction_events_from_transcript`).
46    #[serde(default = "default_schema_version")]
47    pub schema_version: u32,
48    /// Stable identity for this compaction operation, shared verbatim across the
49    /// transcript event id, the live event, ACP, and the observability record.
50    pub receipt_id: String,
51    /// Owning agent session, when the compaction ran against one.
52    pub session_id: Option<String>,
53    /// Owning transcript, when known. Best-effort provenance; the record's
54    /// `transcript_id` is sourced authoritatively from the transcript itself.
55    pub transcript_id: Option<String>,
56    /// Call-site surface that initiated compaction (`CompactMode::as_str`).
57    pub mode: String,
58    /// Why compaction fired (`CompactionTrigger::as_str`, or a host-supplied
59    /// reason such as `context_overflow`).
60    pub reason: String,
61    /// User-facing policy label (may be broader than the engine strategy).
62    pub strategy: String,
63    /// Engine strategy actually used after honoring any PreCompact `Modify`.
64    pub engine_strategy: String,
65    /// Normalized strategy requested at the caller boundary, before lifecycle
66    /// hooks or fallback policy changed the engine choice. `None` on legacy
67    /// receipts whose request was not measured.
68    pub requested_strategy: Option<String>,
69    /// Resolved tier-1 threshold that admitted this compaction. `Some(0)` is a
70    /// measured force-compaction threshold; `None` means the initiating path
71    /// did not report threshold provenance.
72    pub resolved_threshold_tokens: Option<usize>,
73    /// Boundary field that supplied `resolved_threshold_tokens`, for example
74    /// `token_threshold`, `compact_threshold`, or `default`.
75    pub threshold_source: Option<String>,
76    /// Resolved tier-2 hard limit, when configured.
77    pub hard_limit_tokens: Option<usize>,
78    pub archived_messages: usize,
79    pub estimated_tokens_before: usize,
80    pub estimated_tokens_after: usize,
81    /// Id of the pre-compaction snapshot asset, when one was built.
82    pub snapshot_asset_id: Option<String>,
83    pub instruction_mode: Option<String>,
84    pub instruction_source: Option<String>,
85    pub compaction_policy: Option<serde_json::Value>,
86    /// Observation-mask recap metrics; `None` for the LLM/truncate/custom
87    /// strategies, which do not spend a recap budget.
88    pub recap: Option<RecapMetrics>,
89    /// Source-window and summary byte measurement for this compaction.
90    ///
91    /// `None` means this compaction path took no measurement. That is
92    /// deliberately distinct from `Some(..)` carrying `Some(0)`, which is a
93    /// measurement that was taken and read zero.
94    pub source_measurement: Option<CompactionSourceMeasurement>,
95}
96
97/// Generate a fresh, unique compaction receipt id.
98pub fn new_compaction_receipt_id() -> String {
99    format!("compaction-{}", uuid::Uuid::now_v7())
100}
101
102impl CompactionReceipt {
103    /// Serialize the receipt for verbatim embedding under `metadata.receipt` on
104    /// the transcript event.
105    pub fn to_json(&self) -> serde_json::Value {
106        serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
107    }
108
109    /// Read a receipt embedded under `metadata.receipt` on a transcript
110    /// `compaction` event. Returns `None` when the key is absent (a legacy
111    /// transcript written before receipts existed) or malformed, so the caller
112    /// can fall back to the legacy flat-metadata migration path.
113    pub fn from_event_metadata(metadata: Option<&serde_json::Value>) -> Option<Self> {
114        let receipt = metadata?.get("receipt")?;
115        serde_json::from_value(receipt.clone()).ok()
116    }
117
118    /// Normalize a host-script compaction payload — the dict `.harn` code hands
119    /// to `agent_record_compaction` / `__host_agent_record_compaction` — into a
120    /// receipt at the builtin boundary. Current callers forward the engine-owned
121    /// receipt, whose identity and measured outcome stay authoritative. Legacy
122    /// flat payloads still normalize here and receive a new identity.
123    pub fn from_host_payload(session_id: &str, payload: &serde_json::Value) -> Self {
124        let str_field = |key: &str| {
125            payload
126                .get(key)
127                .and_then(serde_json::Value::as_str)
128                .map(str::to_string)
129        };
130        let usize_field = |key: &str| {
131            payload
132                .get(key)
133                .and_then(serde_json::Value::as_u64)
134                .unwrap_or(0) as usize
135        };
136        if let Some(mut receipt) = Self::from_event_metadata(Some(payload))
137            .filter(|receipt| !receipt.receipt_id.is_empty())
138        {
139            receipt.session_id = Some(session_id.to_string());
140            if let Some(mode) = str_field("mode") {
141                receipt.mode = mode;
142            }
143            if let Some(reason) = str_field("reason") {
144                receipt.reason = reason;
145            }
146            if let Some(strategy) = str_field("strategy") {
147                receipt.strategy = strategy;
148            }
149            if let Some(requested_strategy) = str_field("requested_strategy") {
150                receipt.requested_strategy = Some(requested_strategy);
151            }
152            if let Some(threshold_source) = str_field("threshold_source") {
153                receipt.threshold_source = Some(threshold_source);
154            }
155            return receipt;
156        }
157        let strategy = str_field("strategy")
158            .or_else(|| str_field("engine_strategy"))
159            .unwrap_or_default();
160        let engine_strategy = str_field("engine_strategy").unwrap_or_else(|| strategy.clone());
161        Self {
162            schema_version: COMPACTION_RECEIPT_SCHEMA_VERSION,
163            receipt_id: new_compaction_receipt_id(),
164            session_id: Some(session_id.to_string()),
165            transcript_id: None,
166            mode: str_field("mode").unwrap_or_else(|| "auto".to_string()),
167            reason: str_field("reason").unwrap_or_else(|| "threshold".to_string()),
168            strategy,
169            engine_strategy,
170            requested_strategy: str_field("requested_strategy"),
171            resolved_threshold_tokens: payload
172                .get("resolved_threshold_tokens")
173                .and_then(serde_json::Value::as_u64)
174                .map(|value| value as usize),
175            threshold_source: str_field("threshold_source"),
176            hard_limit_tokens: payload
177                .get("hard_limit_tokens")
178                .and_then(serde_json::Value::as_u64)
179                .map(|value| value as usize),
180            archived_messages: usize_field("archived_messages"),
181            estimated_tokens_before: usize_field("estimated_tokens_before"),
182            estimated_tokens_after: usize_field("estimated_tokens_after"),
183            snapshot_asset_id: str_field("snapshot_asset_id"),
184            instruction_mode: str_field("instruction_mode"),
185            instruction_source: str_field("instruction_source"),
186            compaction_policy: payload.get("compaction_policy").cloned(),
187            recap: payload
188                .get("recap")
189                .and_then(|value| serde_json::from_value::<RecapMetrics>(value.clone()).ok()),
190            // A host script may forward the engine's typed measurement. When it
191            // does not, `None` remains "not measured", never a measured zero.
192            source_measurement: payload.get("source_measurement").and_then(|value| {
193                serde_json::from_value::<CompactionSourceMeasurement>(value.clone()).ok()
194            }),
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn receipt_round_trips_through_json_with_recap_and_policy() {
205        let receipt = CompactionReceipt {
206            schema_version: COMPACTION_RECEIPT_SCHEMA_VERSION,
207            receipt_id: "compaction-abc".to_string(),
208            session_id: Some("session-1".to_string()),
209            transcript_id: Some("session-1".to_string()),
210            mode: "auto".to_string(),
211            reason: "threshold".to_string(),
212            strategy: "hybrid".to_string(),
213            engine_strategy: "observation_mask".to_string(),
214            requested_strategy: Some("hybrid".to_string()),
215            resolved_threshold_tokens: Some(3_000),
216            threshold_source: Some("token_threshold".to_string()),
217            hard_limit_tokens: Some(8_000),
218            archived_messages: 7,
219            estimated_tokens_before: 4000,
220            estimated_tokens_after: 1200,
221            snapshot_asset_id: Some("snapshot-9".to_string()),
222            instruction_mode: Some("extend".to_string()),
223            instruction_source: Some("author".to_string()),
224            compaction_policy: Some(serde_json::json!({"scope": "summary"})),
225            recap: Some(RecapMetrics {
226                recap_bytes: 512,
227                budget_bytes: 16_000,
228                kept_results_count: 3,
229                dropped_count: 1,
230                carried_prior_recap: true,
231            }),
232            // A measured zero must round-trip as a measured zero, not collapse
233            // into "no measurement".
234            source_measurement: Some(CompactionSourceMeasurement {
235                source_message_count: Some(7),
236                source_bytes: Some(4_096),
237                summary_bytes: Some(512),
238                carried_source_bytes: Some(0),
239            }),
240        };
241        let json = receipt.to_json();
242        let decoded = CompactionReceipt::from_event_metadata(Some(&serde_json::json!({
243            "receipt": json,
244        })))
245        .expect("embedded receipt decodes");
246        assert_eq!(decoded, receipt);
247    }
248
249    #[test]
250    fn absent_receipt_key_yields_none_for_legacy_migration() {
251        let legacy = serde_json::json!({
252            "mode": "manual",
253            "strategy": "truncate",
254            "archived_messages": 3,
255        });
256        assert!(CompactionReceipt::from_event_metadata(Some(&legacy)).is_none());
257        assert!(CompactionReceipt::from_event_metadata(None).is_none());
258    }
259
260    #[test]
261    fn missing_schema_version_defaults_to_current() {
262        let receipt: CompactionReceipt = serde_json::from_value(serde_json::json!({
263            "receipt_id": "compaction-xyz",
264            "mode": "manual",
265        }))
266        .expect("partial receipt loads");
267        assert_eq!(receipt.schema_version, COMPACTION_RECEIPT_SCHEMA_VERSION);
268        assert_eq!(receipt.receipt_id, "compaction-xyz");
269        assert!(receipt.recap.is_none());
270    }
271
272    #[test]
273    fn host_payload_preserves_engine_truth_and_measured_zeroes() {
274        let receipt = CompactionReceipt::from_host_payload(
275            "session-1",
276            &serde_json::json!({
277                "mode": "auto",
278                "reason": "threshold",
279                "strategy": "hybrid",
280                "requested_strategy": "llm",
281                "engine_strategy": "llm",
282                "resolved_threshold_tokens": 0,
283                "threshold_source": "token_threshold",
284                "hard_limit_tokens": 8_000,
285                "source_measurement": {
286                    "source_message_count": 4,
287                    "source_bytes": 2_048,
288                    "summary_bytes": 512,
289                    "carried_source_bytes": 0
290                }
291            }),
292        );
293
294        assert_eq!(receipt.requested_strategy.as_deref(), Some("llm"));
295        assert_eq!(receipt.engine_strategy, "llm");
296        assert_eq!(receipt.resolved_threshold_tokens, Some(0));
297        assert_eq!(receipt.threshold_source.as_deref(), Some("token_threshold"));
298        assert_eq!(receipt.hard_limit_tokens, Some(8_000));
299        assert_eq!(
300            receipt
301                .source_measurement
302                .expect("source measurement is retained")
303                .carried_source_bytes,
304            Some(0),
305        );
306    }
307
308    #[test]
309    fn host_payload_preserves_forwarded_engine_receipt_identity_and_outcome() {
310        let engine_receipt = CompactionReceipt {
311            receipt_id: "compaction-engine-owned".to_string(),
312            mode: "manual".to_string(),
313            reason: "manual".to_string(),
314            strategy: "llm".to_string(),
315            engine_strategy: "llm".to_string(),
316            requested_strategy: Some("llm".to_string()),
317            resolved_threshold_tokens: Some(7),
318            threshold_source: Some("token_threshold".to_string()),
319            hard_limit_tokens: Some(99),
320            source_measurement: Some(CompactionSourceMeasurement {
321                summary_bytes: Some(123),
322                ..CompactionSourceMeasurement::default()
323            }),
324            ..CompactionReceipt::default()
325        };
326        let receipt = CompactionReceipt::from_host_payload(
327            "session-live",
328            &serde_json::json!({
329                "receipt": engine_receipt.to_json(),
330                "mode": "auto",
331                "reason": "threshold",
332                "strategy": "policy-label",
333                "requested_strategy": "custom",
334                "threshold_source": "pre_compact_modify",
335                "engine_strategy": "stale-flat-value",
336                "resolved_threshold_tokens": 999,
337                "hard_limit_tokens": 1000,
338                "source_measurement": {"summary_bytes": 1}
339            }),
340        );
341
342        assert_eq!(receipt.receipt_id, "compaction-engine-owned");
343        assert_eq!(receipt.session_id.as_deref(), Some("session-live"));
344        assert_eq!(receipt.mode, "auto");
345        assert_eq!(receipt.reason, "threshold");
346        assert_eq!(receipt.strategy, "policy-label");
347        assert_eq!(receipt.requested_strategy.as_deref(), Some("custom"));
348        assert_eq!(receipt.engine_strategy, "llm");
349        assert_eq!(receipt.resolved_threshold_tokens, Some(7));
350        assert_eq!(
351            receipt.threshold_source.as_deref(),
352            Some("pre_compact_modify")
353        );
354        assert_eq!(receipt.hard_limit_tokens, Some(99));
355        assert_eq!(
356            receipt
357                .source_measurement
358                .and_then(|value| value.summary_bytes),
359            Some(123),
360        );
361    }
362}