Skip to main content

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}