ai_memory/hooks/events.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3//
4// v0.7 Track G — Task G2: lifecycle event types + JSON payload structs.
5//
6// G1 (PR #554) shipped the on-disk hook configuration schema and a
7// 20-variant `HookEvent` *stub* in `src/hooks/config.rs`. G2 lifts
8// `HookEvent` out of `config.rs` into this module, attaches a
9// payload struct to every variant, and pins the JSON wire shape
10// the executor (G3) will use to talk to subprocess hooks over
11// stdio.
12//
13// # Wire contract
14//
15// Every payload type derives `Serialize + Deserialize`. The hook
16// pipeline marshals payloads to JSON, writes them to the hook
17// child's stdin, and reads a `HookDecision` (G4) back from stdout.
18// `Pre*` payloads are *deltas* the hook may mutate before the
19// memory operation runs; `Post*` payloads are read-only snapshots
20// of the operation's effect and exist for observability /
21// telemetry hooks.
22//
23// # Why payloads live in a separate module from `HookEvent`
24//
25// The `HookEvent` enum itself is tag-only (Copy, Hash) so a config
26// loader can match on a name without depending on every payload
27// type. The payload types include owned strings, optional fields,
28// and `serde_json::Value` bags, none of which is `Copy`. Splitting
29// the tag from the payload is the same shape as `tracing::Event` /
30// `tracing::Metadata` and keeps `crate::hooks::config` free of any
31// dependency on `crate::models` or `crate::transcripts`.
32//
33// # Backward compatibility with G1
34//
35// `crate::hooks::config::HookEvent` is preserved as a `pub use`
36// re-export so the G1 call sites (`HookConfig.event: HookEvent`,
37// `validate_hook`, the existing tests) keep compiling unchanged.
38// The canonical path going forward is `crate::hooks::HookEvent`.
39//
40// # Where each event will fire (G3-G11)
41//
42// Each variant carries a `// TODO(G3-G11): wire here at <file>:<line>`
43// doc-comment naming the source-code location the executor will
44// hook into when later tasks land. The line numbers are
45// *approximate* — pinned against the heads of the relevant
46// functions on `main` at the time of G2 — and are intended as
47// hints for the implementer of G3-G11, not load-bearing
48// invariants.
49
50use serde::{Deserialize, Serialize};
51use serde_json::Value;
52
53use crate::models::{Memory, MemoryLink, Tier};
54
55// ---------------------------------------------------------------------------
56// HookEvent — the 21 lifecycle event tags
57// ---------------------------------------------------------------------------
58
59/// The 21 lifecycle events the hook pipeline supports.
60///
61/// `HookEvent` is the *tag* an operator names in `hooks.toml`
62/// (`event = "post_store"`) and the discriminator the executor
63/// uses when routing a payload to its subscribed hook chain.
64///
65/// Payload types are defined in this module — see the per-variant
66/// payload table in the module-level documentation and the
67/// individual variant doc-comments.
68///
69/// Serde uses snake_case so the on-disk and on-wire spelling
70/// matches the table in `docs/v0.7/V0.7-EPIC.md` § Track G2.
71///
72/// # NSA CSI MCP Security mapping
73///
74/// Primary defense against **NSA concern (c) Poor approval workflows**
75/// and implementation of **NSA recommendation (d) Constrain and
76/// sandbox tool execution** + **(f) Filter and monitor output
77/// pipelines and chained execution** per U/OO/6030316-26 (May 2026
78/// v1.0). 25 lifecycle events (20 baseline + 5 v0.7.0 additions:
79/// `PreRecallExpand`, `PreReflect`, `PostReflect`, `PreCompaction`,
80/// `OnCompactionRollback`) give operators a substrate-side hook for
81/// every memory operation, with the four-way decision contract
82/// (`Allow` / `Modify` / `Deny` / `AskUser`) and chain ordering
83/// (priority-desc, first-Deny short-circuits). Default-off — a v0.7
84/// install with no `~/.config/ai-memory/hooks.toml` behaves
85/// identically to v0.6.4. Capability inventory anchor:
86/// `track_g_hook_pipeline`. Mapping narrative in
87/// `docs/compliance/nsa-csi-mcp.html` §3.3 (concern c), §4.4
88/// (recommendation d), and §4.6 (recommendation f).
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum HookEvent {
92 /// Fires before a memory is persisted. Payload: [`MemoryDelta`] (writable).
93 ///
94 /// TODO(G3-G11): wire here at `crate::storage::insert`.
95 PreStore,
96 /// Fires after a memory has been persisted. Payload: [`Memory`] (read-only).
97 ///
98 /// TODO(G3-G11): wire here at `crate::storage::insert` (post-INSERT).
99 PostStore,
100 /// Fires before a recall query executes. Payload: [`RecallQuery`] (writable).
101 ///
102 /// TODO(G3-G11): wire here at `crate::storage::recall`.
103 PreRecall,
104 /// Fires after a recall query returns. Payload: [`RecallResult`] (read-only).
105 ///
106 /// TODO(G3-G11): wire here at `crate::storage::recall` (post-return).
107 PostRecall,
108 /// Fires before a full-text search executes. Payload: [`SearchQuery`] (writable).
109 ///
110 /// TODO(G3-G11): wire here at `crate::storage::search`.
111 PreSearch,
112 /// Fires after a full-text search returns. Payload: [`SearchResult`] (read-only).
113 ///
114 /// TODO(G3-G11): wire here at `crate::storage::search` (post-return).
115 PostSearch,
116 /// Fires before a memory is deleted. Payload: [`MemoryRef`] (writable target id).
117 ///
118 /// TODO(G3-G11): wire here at `crate::storage::delete`.
119 PreDelete,
120 /// Fires after a memory has been deleted. Payload: [`MemoryRef`] (read-only).
121 ///
122 /// TODO(G3-G11): wire here at `crate::storage::delete` (post-DELETE).
123 PostDelete,
124 /// Fires before a tier promotion. Payload: [`PromoteDelta`] (writable target tier).
125 ///
126 /// TODO(G3-G11): wire here at `crate::storage::promote_to_namespace`.
127 PrePromote,
128 /// Fires after a tier promotion. Payload: [`PromoteResult`] (read-only).
129 ///
130 /// TODO(G3-G11): wire here at `crate::storage::promote_to_namespace` (post-UPDATE).
131 PostPromote,
132 /// Fires before a link is created. Payload: [`LinkDelta`] (writable).
133 ///
134 /// TODO(G3-G11): wire here at `crate::storage::create_link`.
135 PreLink,
136 /// Fires after a link has been created. Payload: [`Link`] (read-only).
137 ///
138 /// TODO(G3-G11): wire here at `crate::storage::create_link` (post-INSERT).
139 PostLink,
140 /// Fires before a consolidation pass runs. Payload: [`ConsolidationDelta`] (writable).
141 ///
142 /// TODO(G3-G11): wire here at `crate::storage::consolidate`.
143 PreConsolidate,
144 /// Fires after a consolidation pass completes. Payload: [`ConsolidationResult`] (read-only).
145 ///
146 /// TODO(G3-G11): wire here at `crate::storage::consolidate` (post-return).
147 PostConsolidate,
148 /// Fires before a governance gate decision. Payload: [`GovernanceContext`] (writable).
149 ///
150 /// TODO(G3-G11): wire here at `crate::storage::enforce_governance`.
151 PreGovernanceDecision,
152 /// Fires after a governance gate decision. Payload: [`GovernanceDecision`] (read-only).
153 ///
154 /// TODO(G3-G11): wire here at `crate::storage::enforce_governance` (post-return).
155 PostGovernanceDecision,
156 /// Fires when the ANN index evicts an entry. Payload: [`EvictionEvent`] (read-only).
157 ///
158 /// TODO(G3-G11): wire here at `crate::hnsw` (`hnsw.eviction` log site).
159 OnIndexEviction,
160 /// Fires before a memory is archived. Payload: [`MemoryRef`] (writable target id).
161 ///
162 /// TODO(G3-G11): wire here at `crate::storage::archive_memory`.
163 PreArchive,
164 /// Fires before a transcript is stored. Payload: [`TranscriptDelta`] (writable).
165 ///
166 /// TODO(G3-G11): wire here at `crate::transcripts::store`.
167 PreTranscriptStore,
168 /// Fires after a transcript has been stored. Payload: [`Transcript`] (read-only).
169 ///
170 /// TODO(G3-G11): wire here at `crate::transcripts::store` (post-INSERT).
171 PostTranscriptStore,
172 /// G10: fires *synchronously* on the recall hot path before the
173 /// embedder / DB call to allow query expansion (synonyms,
174 /// spelling correction, harness-specific normalization). Payload:
175 /// [`RecallExpandQuery`] (writable). Distinct from `PreRecall`
176 /// because the budget is the recall p95 (50ms) — operators MUST
177 /// configure this hook in `mode = "daemon"` to amortize spawn
178 /// cost. Classified as [`crate::hooks::EventClass::HotPath`].
179 ///
180 /// Wires here at `crate::mcp::handle_recall` (top of fn).
181 PreRecallExpand,
182 /// v0.7.0 recursive-learning Task 6/8 — fires BEFORE the
183 /// depth-cap check inside `db::reflect`. **Decision-class** hook:
184 /// handlers may VETO the reflection by returning `Deny`, which
185 /// propagates an error up to the caller distinct from a cap
186 /// refusal (caller-policy refusals like "this agent is
187 /// rate-limited" vs the substrate cap refusal Task 5 audits).
188 /// Payload: [`ReflectDelta`] (writable — handlers may rewrite the
189 /// proposed reflection's tier / tags / priority / metadata before
190 /// the cap check evaluates). Classified as
191 /// [`crate::hooks::EventClass::Write`].
192 ///
193 /// Wires here at `crate::storage::reflect` step 4 (after source-load /
194 /// depth computation, BEFORE step 5 cap check).
195 PreReflect,
196 /// v0.7.0 recursive-learning Task 6/8 — fires AFTER the
197 /// reflection transaction commits. **Notify-class** hook:
198 /// handlers cannot veto; their return value is ignored beyond
199 /// logging. Payload: [`ReflectResult`] (read-only — the
200 /// post-commit envelope mirrors the `memory_reflect` MCP
201 /// response). Classified as [`crate::hooks::EventClass::Write`].
202 ///
203 /// Wires here at `crate::storage::reflect` step 7 (after COMMIT
204 /// succeeds, before returning `ReflectOutcome` to the caller).
205 /// Layers on top of the existing `memory_store` webhook event the
206 /// MCP handler dispatches — both fire on a successful reflect.
207 PostReflect,
208 /// v0.7.0 L1-7 compaction pipeline — fires BEFORE a compaction
209 /// pass (consolidation, reflection, …) processes a cluster.
210 /// **Decision-class** hook: handlers may Allow (default), Modify
211 /// (rewrite the cluster's candidate id list), Deny (abort the
212 /// cluster — no summarise, no persist, no verify), or AskUser.
213 /// Payload: [`CompactionDelta`] (writable — the candidate id list
214 /// and the pass name). Classified as
215 /// [`crate::hooks::EventClass::Write`].
216 ///
217 /// Wires here at `src/curator/compaction.rs` (before
218 /// `ConsolidationPass::summarize` is called for each cluster).
219 PreCompaction,
220 /// v0.7.0 L1-7 compaction pipeline — fires when the verify step
221 /// of a compaction pass fails. **Notify-class** hook: handlers
222 /// cannot veto; their return value is ignored beyond logging.
223 /// Payload: [`CompactionRollbackEvent`] (read-only — names the
224 /// summary id and pass that failed).
225 ///
226 /// NOTE: actual rollback (re-inserting source rows, invalidating
227 /// the summary) is deferred to v0.8.0 Pillar 2.5 (issue #664).
228 /// This hook fires NOW so integrations can detect and report
229 /// verify failures; the rollback mechanics ship later.
230 ///
231 /// Classified as [`crate::hooks::EventClass::Write`].
232 OnCompactionRollback,
233}
234
235// ---------------------------------------------------------------------------
236// Pre/Post-store payloads
237// ---------------------------------------------------------------------------
238
239/// Writable delta a `pre_store` hook may mutate before the row is
240/// persisted.
241///
242/// Mirrors the user-controllable fields of `crate::models::CreateMemory`
243/// — but as a JSON-friendly bag with every field optional so a hook
244/// can return a partial diff (e.g. just rewriting `tags`) without
245/// echoing the whole memory back over stdio. The executor (G3)
246/// merges `Some(_)` fields onto the in-flight `CreateMemory`
247/// before calling `db::insert`.
248// #969 — `PartialEq` derive enables direct equality in `ChainResult`,
249// `HookDecision`, and `Decision` enums that wrap a `MemoryDelta`.
250// Pre-#969 those enums hand-rolled equality via
251// `serde_json::to_value(a).ok() == serde_json::to_value(b).ok()` on
252// the (mistaken) premise that `serde_json::Value` was not
253// `PartialEq` — it IS (`serde_json-1.0/src/value/mod.rs:115` derives
254// `Eq, PartialEq, Hash`). The real blocker is `Option<f64>` below,
255// which is `PartialEq` but not `Eq`; that blocks `derive(Eq)` but
256// not `derive(PartialEq)`.
257#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
258pub struct MemoryDelta {
259 #[serde(skip_serializing_if = "Option::is_none")]
260 pub tier: Option<Tier>,
261 #[serde(skip_serializing_if = "Option::is_none")]
262 pub namespace: Option<String>,
263 #[serde(skip_serializing_if = "Option::is_none")]
264 pub title: Option<String>,
265 #[serde(skip_serializing_if = "Option::is_none")]
266 pub content: Option<String>,
267 #[serde(skip_serializing_if = "Option::is_none")]
268 pub tags: Option<Vec<String>>,
269 #[serde(skip_serializing_if = "Option::is_none")]
270 pub priority: Option<i32>,
271 #[serde(skip_serializing_if = "Option::is_none")]
272 pub confidence: Option<f64>,
273 #[serde(skip_serializing_if = "Option::is_none")]
274 pub source: Option<String>,
275 #[serde(skip_serializing_if = "Option::is_none")]
276 pub expires_at: Option<String>,
277 #[serde(skip_serializing_if = "Option::is_none")]
278 pub metadata: Option<Value>,
279}
280
281// ---------------------------------------------------------------------------
282// Pre/Post-recall payloads
283// ---------------------------------------------------------------------------
284
285/// Writable recall query a `pre_recall` hook may rewrite before
286/// the recall executes. Mirrors the public `memory_recall` MCP /
287/// HTTP request shape; fields are optional so a hook may rewrite
288/// only the parts it cares about (e.g. injecting a `namespace`
289/// filter for tenant isolation).
290#[derive(Debug, Clone, Default, Serialize, Deserialize)]
291pub struct RecallQuery {
292 #[serde(skip_serializing_if = "Option::is_none")]
293 pub query: Option<String>,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub namespace: Option<String>,
296 #[serde(skip_serializing_if = "Option::is_none")]
297 pub limit: Option<usize>,
298 #[serde(skip_serializing_if = "Option::is_none")]
299 pub tier: Option<Tier>,
300 #[serde(skip_serializing_if = "Option::is_none")]
301 pub tags: Option<Vec<String>>,
302 #[serde(skip_serializing_if = "Option::is_none")]
303 pub budget_tokens: Option<usize>,
304}
305
306/// G10 hot-path payload for [`HookEvent::PreRecallExpand`]. Carries
307/// only the three fields a query-expansion hook needs to make a
308/// rewrite decision — the original `query` text, the recall
309/// `namespace` filter (empty string when the caller did not pass
310/// one), and `k`, the recall limit. Kept narrow on purpose: the
311/// hook fires inside the 50ms recall budget, so the wire payload
312/// stays small to keep daemon-mode round-trip latency in the low
313/// micros.
314///
315/// All three fields are required (no `Option<…>`) because the hot
316/// path calls this hook with concrete values — the caller has
317/// already resolved namespace defaults and limit clamping.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct RecallExpandQuery {
320 pub query: String,
321 pub namespace: String,
322 pub k: u32,
323}
324
325/// Read-only snapshot of a recall's result returned to a
326/// `post_recall` hook. The `memories` vector reuses
327/// [`crate::models::Memory`] verbatim so post-hooks can inspect
328/// every field the recall surfaced (tier, score-driving
329/// metadata, etc.) without an additional translation layer.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct RecallResult {
332 pub query: String,
333 pub memories: Vec<Memory>,
334 /// Total cl100k_base tokens (or `len/4` byte estimate when
335 /// the budget path was skipped) the recall consumed. Mirrors
336 /// the v0.6.3 `tokens_used` field on the wire envelope.
337 #[serde(skip_serializing_if = "Option::is_none")]
338 pub tokens_used: Option<usize>,
339}
340
341// ---------------------------------------------------------------------------
342// Pre/Post-search payloads
343// ---------------------------------------------------------------------------
344
345/// Writable FTS search query for `pre_search` hooks. Same shape
346/// as [`RecallQuery`] minus the budget knob — search is the
347/// uncapped FTS surface; the budget machinery is recall-only.
348#[derive(Debug, Clone, Default, Serialize, Deserialize)]
349pub struct SearchQuery {
350 #[serde(skip_serializing_if = "Option::is_none")]
351 pub query: Option<String>,
352 #[serde(skip_serializing_if = "Option::is_none")]
353 pub namespace: Option<String>,
354 #[serde(skip_serializing_if = "Option::is_none")]
355 pub limit: Option<usize>,
356 #[serde(skip_serializing_if = "Option::is_none")]
357 pub tags: Option<Vec<String>>,
358}
359
360/// Read-only result returned to `post_search` hooks.
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct SearchResult {
363 pub query: String,
364 pub memories: Vec<Memory>,
365}
366
367// ---------------------------------------------------------------------------
368// Pre/Post-delete + pre-archive payloads
369// ---------------------------------------------------------------------------
370
371/// Pointer at a single memory by id. Used by `pre_delete`,
372/// `post_delete`, and `pre_archive` — operations that take an id
373/// and don't need the full row to make a decision.
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct MemoryRef {
376 pub id: String,
377}
378
379// ---------------------------------------------------------------------------
380// Pre/Post-promote payloads
381// ---------------------------------------------------------------------------
382
383/// Writable delta for `pre_promote` — a hook may rewrite the
384/// target tier before the promotion runs, e.g. to refuse
385/// promotion to `long` tier for transient agent output.
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct PromoteDelta {
388 pub id: String,
389 pub from_tier: Tier,
390 pub to_tier: Tier,
391}
392
393/// Read-only result for `post_promote` — the resolved tier
394/// transition after the operation completed.
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct PromoteResult {
397 pub id: String,
398 pub from_tier: Tier,
399 pub to_tier: Tier,
400}
401
402// ---------------------------------------------------------------------------
403// Pre/Post-link payloads
404// ---------------------------------------------------------------------------
405
406/// Writable delta for `pre_link`. Mirrors the user-controllable
407/// surface of `MemoryLink` so hooks can rewrite the relation
408/// (e.g. demote `contradicts` → `related_to` if the source
409/// confidence is low) before the row is inserted.
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct LinkDelta {
412 pub source_id: String,
413 pub target_id: String,
414 pub relation: String,
415}
416
417/// Read-only `post_link` payload. Re-uses
418/// [`crate::models::MemoryLink`] so the wire shape matches the
419/// existing v0.6.3 link surface and downstream consumers don't
420/// need a translation table.
421pub type Link = MemoryLink;
422
423// ---------------------------------------------------------------------------
424// Pre/Post-consolidate payloads
425// ---------------------------------------------------------------------------
426
427/// Writable delta for `pre_consolidate`. Names the namespace and
428/// candidate memory ids the consolidator is about to operate on.
429/// A hook may shrink (or veto via `HookDecision::Deny` in G4) the
430/// candidate set before the consolidation runs.
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct ConsolidationDelta {
433 pub namespace: String,
434 pub candidate_ids: Vec<String>,
435}
436
437/// Read-only `post_consolidate` payload. Reports the resolved
438/// merge / supersede outcome so observability hooks can surface
439/// consolidation activity to operators.
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct ConsolidationResult {
442 pub namespace: String,
443 /// Memory ids that were merged into a consolidated row.
444 pub merged_ids: Vec<String>,
445 /// The id of the consolidated row, when one was produced.
446 #[serde(skip_serializing_if = "Option::is_none")]
447 pub result_id: Option<String>,
448}
449
450// ---------------------------------------------------------------------------
451// Pre/Post-governance-decision payloads
452// ---------------------------------------------------------------------------
453
454/// Writable governance context passed to `pre_governance_decision`
455/// hooks. Hooks see the namespace, the action under review, and
456/// the requesting agent identity, and may augment / rewrite any
457/// of these before `enforce_governance` runs.
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct GovernanceContext {
460 pub namespace: String,
461 pub action: String,
462 pub agent_id: String,
463 #[serde(skip_serializing_if = "Option::is_none")]
464 pub memory_id: Option<String>,
465}
466
467/// Read-only outcome of a governance gate decision. Mirrors the
468/// allow/deny/pending shape `enforce_governance` returns; the
469/// optional `pending_id` correlates an `Ask` outcome with the
470/// row in `pending_actions`.
471#[derive(Debug, Clone, Serialize, Deserialize)]
472#[serde(rename_all = "snake_case")]
473pub enum GovernanceOutcome {
474 Allow,
475 Deny,
476 Ask,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct GovernanceDecision {
481 pub namespace: String,
482 pub action: String,
483 pub agent_id: String,
484 pub outcome: GovernanceOutcome,
485 #[serde(skip_serializing_if = "Option::is_none")]
486 pub reason: Option<String>,
487 #[serde(skip_serializing_if = "Option::is_none")]
488 pub pending_id: Option<String>,
489}
490
491// ---------------------------------------------------------------------------
492// Index eviction payload
493// ---------------------------------------------------------------------------
494
495/// `on_index_eviction` payload — fired when the HNSW index
496/// evicts an entry under capacity pressure. Lets observability
497/// hooks (datadog, prometheus pushgateway, etc.) surface the
498/// eviction without polling the `index_evictions_total` counter.
499///
500/// G8 (v0.7) widened the wire shape from `{ memory_id }` to the
501/// full `{ memory_id, namespace, evicted_at, reason }` so a hook
502/// can re-index, archive, or notify with enough context to do
503/// its job without re-querying the DB. Older `{ memory_id }`-only
504/// payloads still parse — `namespace`, `evicted_at`, and `reason`
505/// default to empty strings on the decode side via
506/// `serde(default)` so v0.7 hooks remain backward-compatible with
507/// any v0.7-rc / G2-stub fixtures that might still be on disk.
508#[derive(Debug, Clone, Serialize, Deserialize)]
509pub struct EvictionEvent {
510 /// Stringified id of the memory whose embedding was evicted
511 /// from the HNSW hot index. Matches the `evicted_id` field in
512 /// the `hnsw.eviction` tracing event so log + hook payloads
513 /// correlate.
514 pub memory_id: String,
515 /// Namespace the evicted memory lived in. The current HNSW
516 /// fire site (G8) does not have the namespace in scope at
517 /// eviction time; G9+ will plumb it through. Empty string
518 /// today; populated from the test-only `fire_on_index_eviction`
519 /// helper so the wire contract is exercised.
520 #[serde(default)]
521 pub namespace: String,
522 /// RFC-3339 wall-clock timestamp of the eviction. Matches the
523 /// format used by `Memory.created_at` so hook authors can
524 /// reuse the same date parser.
525 #[serde(default)]
526 pub evicted_at: String,
527 /// Free-form machine-tag for *why* the eviction happened.
528 /// Today the only fire site uses `"max_entries_reached"`
529 /// (matching the existing `hnsw.eviction` tracing event); G9+
530 /// may add `"ttl_expired"`, `"manual"`, etc.
531 #[serde(default)]
532 pub reason: String,
533}
534
535impl EvictionEvent {
536 /// Construct an eviction payload tagged with the current
537 /// wall-clock time (RFC-3339, matching the rest of the
538 /// codebase's timestamp shape).
539 #[must_use]
540 pub fn new(
541 memory_id: impl Into<String>,
542 namespace: impl Into<String>,
543 reason: impl Into<String>,
544 ) -> Self {
545 Self {
546 memory_id: memory_id.into(),
547 namespace: namespace.into(),
548 evicted_at: rfc3339_now(),
549 reason: reason.into(),
550 }
551 }
552}
553
554/// Tiny RFC-3339 formatter used by `EvictionEvent::new`. Keeps
555/// the chrono dep out of `events.rs` — a UNIX-seconds → ISO 8601
556/// projection is cheap and lossless for the second-precision
557/// timestamps every other model in this crate uses.
558fn rfc3339_now() -> String {
559 use std::time::{SystemTime, UNIX_EPOCH};
560 // The hooks subsystem already pulls chrono in transitively via
561 // `crate::models`; reach for it here too so the wire shape
562 // matches `Memory.created_at` byte-for-byte.
563 let secs = SystemTime::now()
564 .duration_since(UNIX_EPOCH)
565 .map(|d| d.as_secs())
566 .unwrap_or(0);
567 // chrono is already a workspace dep — see Cargo.toml.
568 chrono::DateTime::<chrono::Utc>::from_timestamp(secs as i64, 0)
569 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
570 .unwrap_or_default()
571}
572
573// ---------------------------------------------------------------------------
574// Pre/Post-reflect payloads (v0.7.0 recursive-learning Task 6/8)
575// ---------------------------------------------------------------------------
576
577/// Writable delta a `pre_reflect` hook may mutate before `db::reflect`
578/// evaluates the depth-cap. Mirrors the user-controllable fields of
579/// `crate::db::ReflectInput` — but as a JSON-friendly bag with every
580/// field optional so a hook may return a partial diff (e.g. just
581/// rewriting `tags` or `priority`) without echoing the whole input
582/// back over stdio. Fields a `pre_reflect` hook may not safely
583/// override (`source_ids`, `agent_id`) are intentionally absent here —
584/// rewriting either would silently change the audit provenance of a
585/// downstream refusal, which is the wrong shape for a hook contract.
586#[derive(Debug, Clone, Default, Serialize, Deserialize)]
587pub struct ReflectDelta {
588 #[serde(skip_serializing_if = "Option::is_none")]
589 pub tier: Option<Tier>,
590 #[serde(skip_serializing_if = "Option::is_none")]
591 pub namespace: Option<String>,
592 #[serde(skip_serializing_if = "Option::is_none")]
593 pub title: Option<String>,
594 #[serde(skip_serializing_if = "Option::is_none")]
595 pub content: Option<String>,
596 #[serde(skip_serializing_if = "Option::is_none")]
597 pub tags: Option<Vec<String>>,
598 #[serde(skip_serializing_if = "Option::is_none")]
599 pub priority: Option<i32>,
600 #[serde(skip_serializing_if = "Option::is_none")]
601 pub confidence: Option<f64>,
602 #[serde(skip_serializing_if = "Option::is_none")]
603 pub metadata: Option<Value>,
604}
605
606/// Read-only result returned to a `post_reflect` hook. Mirrors the
607/// `crate::db::ReflectOutcome` wire shape (id, reflection_depth,
608/// reflects_on, namespace) so the post-hook can correlate the new
609/// reflection memory with the sources it was derived from. The new
610/// memory is already durable at hook-fire time — the hook may read it
611/// back via the same connection without racing the writer.
612#[derive(Debug, Clone, Serialize, Deserialize)]
613pub struct ReflectResult {
614 pub id: String,
615 pub reflection_depth: i32,
616 pub reflects_on: Vec<String>,
617 pub namespace: String,
618}
619
620// ---------------------------------------------------------------------------
621// Compaction payloads (v0.7.0 L1-7)
622// ---------------------------------------------------------------------------
623
624/// Writable delta for [`HookEvent::PreCompaction`]. Names the compaction
625/// pass and the candidate memory ids it is about to operate on. A hook
626/// may shrink (or veto via `HookDecision::Deny`) the candidate set before
627/// the pass summarises.
628///
629/// `pass_name` matches [`crate::curator::pipeline::CompactionPass::name`]
630/// so a hook can filter by strategy (`"consolidation"`, `"reflection"`, …).
631#[derive(Debug, Clone, Serialize, Deserialize)]
632pub struct CompactionDelta {
633 /// Name of the compaction pass (e.g. `"consolidation"`).
634 pub pass_name: String,
635 /// Memory ids in the cluster about to be compacted. A hook may
636 /// return a `Modify` delta with a shorter list to reduce the cluster.
637 pub candidate_ids: Vec<String>,
638 /// Namespace all candidates share.
639 pub namespace: String,
640}
641
642/// Read-only payload for [`HookEvent::OnCompactionRollback`]. Carries
643/// enough context for an observability hook to log, page, or re-index
644/// without re-querying the database.
645#[derive(Debug, Clone, Serialize, Deserialize)]
646pub struct CompactionRollbackEvent {
647 /// Name of the compaction pass that failed the verify step.
648 pub pass_name: String,
649 /// Id of the summary memory whose verify step failed.
650 pub summary_id: String,
651 /// Namespace the cluster belonged to.
652 pub namespace: String,
653 /// Human-readable description of the verify failure.
654 pub reason: String,
655}
656
657// ---------------------------------------------------------------------------
658// Transcript payloads (I-track interop)
659// ---------------------------------------------------------------------------
660
661/// Writable delta for `pre_transcript_store`. Hooks may rewrite
662/// the namespace, the raw content, or the TTL before the
663/// transcript blob is compressed and persisted. Content is
664/// passed in clear text — compression happens server-side.
665#[derive(Debug, Clone, Default, Serialize, Deserialize)]
666pub struct TranscriptDelta {
667 #[serde(skip_serializing_if = "Option::is_none")]
668 pub namespace: Option<String>,
669 #[serde(skip_serializing_if = "Option::is_none")]
670 pub content: Option<String>,
671 /// TTL in seconds from "now"; `None` means no expiry.
672 #[serde(skip_serializing_if = "Option::is_none")]
673 pub ttl_secs: Option<i64>,
674}
675
676/// Read-only handle returned to `post_transcript_store` hooks.
677///
678/// Mirrors `crate::transcripts::Transcript` field-for-field
679/// (which is *not* `Serialize` itself — it's an internal storage
680/// handle). The executor (G3) will project from the internal
681/// type into this wire-shaped struct before fanning out to hook
682/// subscribers.
683#[derive(Debug, Clone, Serialize, Deserialize)]
684pub struct Transcript {
685 pub id: String,
686 pub namespace: String,
687 pub created_at: String,
688 #[serde(skip_serializing_if = "Option::is_none")]
689 pub expires_at: Option<String>,
690 pub compressed_size: i64,
691 pub original_size: i64,
692}
693
694impl From<&crate::transcripts::Transcript> for Transcript {
695 fn from(t: &crate::transcripts::Transcript) -> Self {
696 Self {
697 id: t.id.clone(),
698 namespace: t.namespace.clone(),
699 created_at: t.created_at.clone(),
700 expires_at: t.expires_at.clone(),
701 compressed_size: t.compressed_size,
702 original_size: t.original_size,
703 }
704 }
705}
706
707// ---------------------------------------------------------------------------
708// Tests — JSON round-trip per representative variant
709// ---------------------------------------------------------------------------
710//
711// Per the G2 prompt: aim for ~5-10 representative tests, not 20
712// individual ones. We cover (a) the `HookEvent` tag itself for
713// every variant in one pass and (b) a JSON round-trip per payload
714// *family*: store / recall / search / delete / promote / link /
715// consolidate / governance / eviction / transcript.
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720
721 /// Every `HookEvent` variant must round-trip through JSON
722 /// with snake_case spelling. A single table-driven test keeps
723 /// the assertion surface compact.
724 #[test]
725 fn hook_event_all_variants_round_trip() {
726 let table = [
727 (HookEvent::PreStore, "\"pre_store\""),
728 (HookEvent::PostStore, "\"post_store\""),
729 (HookEvent::PreRecall, "\"pre_recall\""),
730 (HookEvent::PostRecall, "\"post_recall\""),
731 (HookEvent::PreSearch, "\"pre_search\""),
732 (HookEvent::PostSearch, "\"post_search\""),
733 (HookEvent::PreDelete, "\"pre_delete\""),
734 (HookEvent::PostDelete, "\"post_delete\""),
735 (HookEvent::PrePromote, "\"pre_promote\""),
736 (HookEvent::PostPromote, "\"post_promote\""),
737 (HookEvent::PreLink, "\"pre_link\""),
738 (HookEvent::PostLink, "\"post_link\""),
739 (HookEvent::PreConsolidate, "\"pre_consolidate\""),
740 (HookEvent::PostConsolidate, "\"post_consolidate\""),
741 (
742 HookEvent::PreGovernanceDecision,
743 "\"pre_governance_decision\"",
744 ),
745 (
746 HookEvent::PostGovernanceDecision,
747 "\"post_governance_decision\"",
748 ),
749 (HookEvent::OnIndexEviction, "\"on_index_eviction\""),
750 (HookEvent::PreArchive, "\"pre_archive\""),
751 (HookEvent::PreTranscriptStore, "\"pre_transcript_store\""),
752 (HookEvent::PostTranscriptStore, "\"post_transcript_store\""),
753 (HookEvent::PreRecallExpand, "\"pre_recall_expand\""),
754 (HookEvent::PreReflect, "\"pre_reflect\""),
755 (HookEvent::PostReflect, "\"post_reflect\""),
756 // v0.7.0 L1-7: compaction pipeline events (24th + 25th).
757 (HookEvent::PreCompaction, "\"pre_compaction\""),
758 (
759 HookEvent::OnCompactionRollback,
760 "\"on_compaction_rollback\"",
761 ),
762 ];
763
764 // Pin the count at the type boundary so adding a 26th
765 // variant without updating the table fails this test. G2
766 // shipped 20; G10 added the 21st (`pre_recall_expand`);
767 // v0.7.0 recursive-learning Task 6/8 added the 22nd +
768 // 23rd (`pre_reflect`, `post_reflect`); L1-7 adds the
769 // 24th + 25th (`pre_compaction`, `on_compaction_rollback`).
770 assert_eq!(
771 table.len(),
772 25,
773 "L1-7 raises the count from 23 to 25 (adds pre_compaction + on_compaction_rollback)"
774 );
775
776 for (variant, expected_json) in table {
777 let encoded = serde_json::to_string(&variant).expect("variant encodes");
778 assert_eq!(encoded, expected_json, "variant {variant:?} mis-encoded");
779 let decoded: HookEvent = serde_json::from_str(&encoded).expect("variant decodes");
780 assert_eq!(decoded, variant, "variant {variant:?} did not round-trip");
781 }
782 }
783
784 #[test]
785 fn memory_delta_partial_serialization_omits_none_fields() {
786 let delta = MemoryDelta {
787 tags: Some(vec!["urgent".into(), "v0.7".into()]),
788 priority: Some(80),
789 ..Default::default()
790 };
791 let v: Value = serde_json::to_value(&delta).expect("encode");
792 // Only the fields the hook touched should appear on the wire.
793 assert_eq!(v["tags"], serde_json::json!(["urgent", "v0.7"]));
794 assert_eq!(v["priority"], serde_json::json!(80));
795 assert!(v.get("title").is_none());
796 assert!(v.get("content").is_none());
797 assert!(v.get("metadata").is_none());
798
799 // And the partial round-trips.
800 let back: MemoryDelta = serde_json::from_value(v).expect("decode");
801 assert_eq!(
802 back.tags.as_deref(),
803 Some(&["urgent".into(), "v0.7".into()][..])
804 );
805 assert_eq!(back.priority, Some(80));
806 assert!(back.title.is_none());
807 }
808
809 #[test]
810 fn recall_query_round_trips() {
811 let q = RecallQuery {
812 query: Some("auth tokens".into()),
813 namespace: Some("team/security".into()),
814 limit: Some(10),
815 tier: Some(Tier::Long),
816 tags: Some(vec!["secrets".into()]),
817 budget_tokens: Some(2_048),
818 };
819 let json = serde_json::to_string(&q).expect("encode");
820 let back: RecallQuery = serde_json::from_str(&json).expect("decode");
821 assert_eq!(back.query.as_deref(), Some("auth tokens"));
822 assert_eq!(back.namespace.as_deref(), Some("team/security"));
823 assert_eq!(back.limit, Some(10));
824 assert_eq!(back.tier, Some(Tier::Long));
825 assert_eq!(back.budget_tokens, Some(2_048));
826 }
827
828 #[test]
829 fn recall_expand_query_round_trips() {
830 // G10 hot-path payload: the wire shape MUST stay narrow
831 // (just `query`, `namespace`, `k`) so daemon-mode hooks can
832 // round-trip inside the 50ms recall budget.
833 let q = RecallExpandQuery {
834 query: "auht tokn".into(),
835 namespace: "team/security".into(),
836 k: 10,
837 };
838 let json = serde_json::to_string(&q).expect("encode");
839 let back: RecallExpandQuery = serde_json::from_str(&json).expect("decode");
840 assert_eq!(back.query, "auht tokn");
841 assert_eq!(back.namespace, "team/security");
842 assert_eq!(back.k, 10);
843 // Sanity: no unexpected fields snuck onto the wire.
844 let v: Value = serde_json::from_str(&json).expect("parse");
845 let obj = v.as_object().expect("object");
846 assert_eq!(obj.len(), 3, "RecallExpandQuery is exactly 3 wire fields");
847 }
848
849 #[test]
850 fn search_query_and_result_round_trip() {
851 let sq = SearchQuery {
852 query: Some("postgres".into()),
853 namespace: Some("eng".into()),
854 limit: Some(5),
855 tags: None,
856 };
857 let json = serde_json::to_string(&sq).expect("encode SearchQuery");
858 let back: SearchQuery = serde_json::from_str(&json).expect("decode SearchQuery");
859 assert_eq!(back.query.as_deref(), Some("postgres"));
860 assert!(back.tags.is_none());
861
862 let sr = SearchResult {
863 query: "postgres".into(),
864 memories: vec![],
865 };
866 let json = serde_json::to_string(&sr).expect("encode SearchResult");
867 let back: SearchResult = serde_json::from_str(&json).expect("decode SearchResult");
868 assert_eq!(back.query, "postgres");
869 assert!(back.memories.is_empty());
870 }
871
872 #[test]
873 fn memory_ref_round_trips() {
874 let r = MemoryRef {
875 id: "01HZX0R5GZ8R3KJYV1Y3M9YW2T".into(),
876 };
877 let json = serde_json::to_string(&r).expect("encode");
878 let back: MemoryRef = serde_json::from_str(&json).expect("decode");
879 assert_eq!(back.id, r.id);
880
881 // Same payload backs PreDelete / PostDelete / PreArchive.
882 // The variant tag is independent so it's fine to reuse.
883 assert_eq!(
884 serde_json::to_string(&HookEvent::PreArchive).unwrap(),
885 "\"pre_archive\""
886 );
887 }
888
889 #[test]
890 fn promote_delta_and_result_round_trip() {
891 let d = PromoteDelta {
892 id: "abc".into(),
893 from_tier: Tier::Short,
894 to_tier: Tier::Long,
895 };
896 let json = serde_json::to_string(&d).expect("encode");
897 let back: PromoteDelta = serde_json::from_str(&json).expect("decode");
898 assert_eq!(back.from_tier, Tier::Short);
899 assert_eq!(back.to_tier, Tier::Long);
900
901 let r = PromoteResult {
902 id: "abc".into(),
903 from_tier: Tier::Short,
904 to_tier: Tier::Mid,
905 };
906 let back: PromoteResult =
907 serde_json::from_str(&serde_json::to_string(&r).unwrap()).expect("decode");
908 assert_eq!(back.to_tier, Tier::Mid);
909 }
910
911 #[test]
912 fn link_delta_and_post_link_round_trip() {
913 let d = LinkDelta {
914 source_id: "src".into(),
915 target_id: "tgt".into(),
916 relation: "related_to".into(),
917 };
918 let json = serde_json::to_string(&d).expect("encode");
919 let back: LinkDelta = serde_json::from_str(&json).expect("decode");
920 assert_eq!(back.relation, "related_to");
921
922 // Link is a re-export of MemoryLink — exercise its serde path.
923 let post = Link {
924 source_id: "src".into(),
925 target_id: "tgt".into(),
926 relation: crate::models::MemoryLinkRelation::RelatedTo,
927 created_at: "2026-05-05T00:00:00Z".into(),
928 signature: None,
929 observed_by: None,
930 valid_from: None,
931 valid_until: None,
932 attest_level: None,
933 };
934 let json = serde_json::to_string(&post).expect("encode Link");
935 let back: Link = serde_json::from_str(&json).expect("decode Link");
936 assert_eq!(back.source_id, "src");
937 assert_eq!(back.created_at, "2026-05-05T00:00:00Z");
938 }
939
940 #[test]
941 fn consolidation_payloads_round_trip() {
942 let d = ConsolidationDelta {
943 namespace: "team/ops".into(),
944 candidate_ids: vec!["a".into(), "b".into(), "c".into()],
945 };
946 let back: ConsolidationDelta =
947 serde_json::from_str(&serde_json::to_string(&d).unwrap()).expect("decode");
948 assert_eq!(back.candidate_ids.len(), 3);
949
950 let r = ConsolidationResult {
951 namespace: "team/ops".into(),
952 merged_ids: vec!["a".into(), "b".into()],
953 result_id: Some("merged-1".into()),
954 };
955 let json = serde_json::to_string(&r).expect("encode");
956 let v: Value = serde_json::from_str(&json).expect("parse");
957 assert_eq!(v["result_id"], serde_json::json!("merged-1"));
958
959 // Verify the skip-if-none bites.
960 let r_no_result = ConsolidationResult {
961 namespace: "team/ops".into(),
962 merged_ids: vec![],
963 result_id: None,
964 };
965 let v: Value = serde_json::to_value(&r_no_result).expect("encode");
966 assert!(v.get("result_id").is_none());
967 }
968
969 #[test]
970 fn governance_payloads_round_trip() {
971 let ctx = GovernanceContext {
972 namespace: "team/security".into(),
973 action: "memory_store".into(),
974 agent_id: "agent-1".into(),
975 memory_id: None,
976 };
977 let back: GovernanceContext =
978 serde_json::from_str(&serde_json::to_string(&ctx).unwrap()).expect("decode");
979 assert_eq!(back.action, "memory_store");
980 assert!(back.memory_id.is_none());
981
982 let dec = GovernanceDecision {
983 namespace: "team/security".into(),
984 action: "memory_store".into(),
985 agent_id: "agent-1".into(),
986 outcome: GovernanceOutcome::Ask,
987 reason: Some("requires human review".into()),
988 pending_id: Some("pending-1".into()),
989 };
990 let json = serde_json::to_string(&dec).expect("encode");
991 let v: Value = serde_json::from_str(&json).expect("parse");
992 assert_eq!(v["outcome"], serde_json::json!("ask"));
993 let back: GovernanceDecision = serde_json::from_value(v).expect("decode");
994 assert!(matches!(back.outcome, GovernanceOutcome::Ask));
995 assert_eq!(back.pending_id.as_deref(), Some("pending-1"));
996 }
997
998 #[test]
999 fn eviction_event_round_trips() {
1000 // G8 widened the payload to carry the namespace, the
1001 // RFC-3339 wall-clock eviction time, and a machine-tag
1002 // for the reason. The full wire shape must round-trip
1003 // verbatim.
1004 let ev = EvictionEvent {
1005 memory_id: "m-1".into(),
1006 namespace: "team/ops".into(),
1007 evicted_at: "2026-05-05T12:34:56Z".into(),
1008 reason: "max_entries_reached".into(),
1009 };
1010 let json = serde_json::to_string(&ev).expect("encode");
1011 let back: EvictionEvent = serde_json::from_str(&json).expect("decode");
1012 assert_eq!(back.memory_id, "m-1");
1013 assert_eq!(back.namespace, "team/ops");
1014 assert_eq!(back.evicted_at, "2026-05-05T12:34:56Z");
1015 assert_eq!(back.reason, "max_entries_reached");
1016 }
1017
1018 #[test]
1019 fn eviction_event_decodes_legacy_memory_id_only_payload() {
1020 // G2 shipped `EvictionEvent { memory_id }`; G8 widened it.
1021 // Backward compatibility: a legacy `{ memory_id }`-only
1022 // fixture must still parse so any v0.7-rc on-disk hook
1023 // payloads keep loading. `serde(default)` on the new fields
1024 // gives empty-string defaults.
1025 let legacy = r#"{"memory_id":"m-legacy"}"#;
1026 let back: EvictionEvent = serde_json::from_str(legacy).expect("decode legacy");
1027 assert_eq!(back.memory_id, "m-legacy");
1028 assert!(back.namespace.is_empty());
1029 assert!(back.evicted_at.is_empty());
1030 assert!(back.reason.is_empty());
1031 }
1032
1033 #[test]
1034 fn eviction_event_new_stamps_rfc3339_timestamp() {
1035 let ev = EvictionEvent::new("m-1", "team/ops", "max_entries_reached");
1036 assert_eq!(ev.memory_id, "m-1");
1037 assert_eq!(ev.namespace, "team/ops");
1038 assert_eq!(ev.reason, "max_entries_reached");
1039 // RFC-3339 second-precision UTC: `YYYY-MM-DDTHH:MM:SSZ`.
1040 // The cheapest invariant to assert without freezing the
1041 // clock: trailing `Z`, length 20, all ASCII.
1042 assert_eq!(ev.evicted_at.len(), 20, "got {:?}", ev.evicted_at);
1043 assert!(
1044 ev.evicted_at.ends_with('Z'),
1045 "expected trailing Z, got {:?}",
1046 ev.evicted_at
1047 );
1048 }
1049
1050 #[test]
1051 fn reflect_delta_partial_serialization_omits_none_fields() {
1052 // v0.7.0 Task 6/8 — ReflectDelta wire shape sanity. Only
1053 // hook-touched fields should surface on the wire.
1054 let delta = ReflectDelta {
1055 tags: Some(vec!["rate-limited".into(), "policy".into()]),
1056 priority: Some(2),
1057 ..Default::default()
1058 };
1059 let v: Value = serde_json::to_value(&delta).expect("encode");
1060 assert_eq!(v["tags"], serde_json::json!(["rate-limited", "policy"]));
1061 assert_eq!(v["priority"], serde_json::json!(2));
1062 assert!(v.get("title").is_none());
1063 assert!(v.get("content").is_none());
1064 assert!(v.get("metadata").is_none());
1065
1066 let back: ReflectDelta = serde_json::from_value(v).expect("decode");
1067 assert_eq!(back.priority, Some(2));
1068 assert_eq!(
1069 back.tags.as_deref(),
1070 Some(&["rate-limited".to_string(), "policy".to_string()][..])
1071 );
1072 }
1073
1074 #[test]
1075 fn reflect_result_round_trips() {
1076 // v0.7.0 Task 6/8 — ReflectResult is the post-commit envelope
1077 // a post_reflect hook receives. Mirrors db::ReflectOutcome
1078 // (id, reflection_depth, reflects_on, namespace) field-for-
1079 // field so a hook author doesn't have to learn a second shape.
1080 let r = ReflectResult {
1081 id: "01HZX0R5GZ8R3KJYV1Y3M9YW2T".into(),
1082 reflection_depth: 2,
1083 reflects_on: vec!["src-a".into(), "src-b".into()],
1084 namespace: "team/ops".into(),
1085 };
1086 let json = serde_json::to_string(&r).expect("encode");
1087 let back: ReflectResult = serde_json::from_str(&json).expect("decode");
1088 assert_eq!(back.id, r.id);
1089 assert_eq!(back.reflection_depth, 2);
1090 assert_eq!(back.reflects_on, vec!["src-a".to_string(), "src-b".into()]);
1091 assert_eq!(back.namespace, "team/ops");
1092 }
1093
1094 #[test]
1095 fn transcript_payloads_round_trip_and_project_from_internal() {
1096 let delta = TranscriptDelta {
1097 namespace: Some("agent/claude".into()),
1098 content: Some("hello world".into()),
1099 ttl_secs: Some(crate::SECS_PER_HOUR),
1100 };
1101 let json = serde_json::to_string(&delta).expect("encode");
1102 let back: TranscriptDelta = serde_json::from_str(&json).expect("decode");
1103 assert_eq!(back.namespace.as_deref(), Some("agent/claude"));
1104 assert_eq!(back.ttl_secs, Some(crate::SECS_PER_HOUR));
1105
1106 // Project from the internal storage handle to the wire shape.
1107 let internal = crate::transcripts::Transcript {
1108 id: "tr-1".into(),
1109 namespace: "agent/claude".into(),
1110 created_at: "2026-05-05T00:00:00Z".into(),
1111 expires_at: None,
1112 compressed_size: 42,
1113 original_size: 256,
1114 };
1115 let wire: Transcript = (&internal).into();
1116 let json = serde_json::to_string(&wire).expect("encode wire");
1117 let back: Transcript = serde_json::from_str(&json).expect("decode wire");
1118 assert_eq!(back.id, "tr-1");
1119 assert_eq!(back.compressed_size, 42);
1120 assert_eq!(back.original_size, 256);
1121 assert!(back.expires_at.is_none());
1122 }
1123}