Skip to main content

ai_memory/hooks/
decision.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3//
4// v0.7 Track G — Task G4: hook decision contract.
5//
6// G3 (PR #567) shipped a *local* prototype of `HookDecision` in
7// `src/hooks/executor.rs` with only `Allow` + `Deny` so the
8// subprocess executor had something to deserialize against. G4
9// lifts the type into this dedicated module and adds the two
10// remaining variants the v0.7 epic calls for: `Modify(MemoryDelta)`
11// (a pre-event-only delta the executor merges back into the
12// in-flight payload) and `AskUser` (an interactive prompt the
13// chain runner G5 will fan out to the operator surface).
14//
15// # JSON wire contract
16//
17// Every decision is a single JSON object with an `action`
18// discriminator. The exact shapes:
19//
20// ```json
21// {"action": "allow"}
22// {"action": "modify", "delta": {...}}
23// {"action": "deny",   "reason": "redact required", "code": 403}
24// {"action": "ask_user", "prompt": "...", "options": ["yes","no"], "default": "no"}
25// ```
26//
27// * `allow` carries no fields; an empty `{}` payload (or empty
28//   stdout) is *also* treated as `Allow` so a no-op observability
29//   hook can `print("{}\n")` from any language and stay correct.
30// * `modify` requires a `delta` field. The delta type is
31//   [`crate::hooks::events::MemoryDelta`] — every field is optional
32//   so a hook may rewrite only what it cares about.
33// * `deny` requires a `reason`; `code` defaults to `403` if the
34//   hook omits it (matches the G3 prototype's behaviour).
35// * `ask_user` requires `prompt` and `options`; `default` is
36//   optional and names one of `options`. The chain runner (G5)
37//   surfaces `AskUser` to the operator and resumes the chain
38//   once the human picks an option.
39//
40// Unknown `action` strings, missing required fields, and trailing
41// junk are all rejected with [`DecisionParseError`]. The executor
42// surfaces those as a `tracing::warn!("hook returned malformed
43// decision")` and degrades to `Allow` so a buggy hook can't
44// brick the request path — the bias is "fail open, log loudly".
45//
46// # Pre-event-only `Modify` validation
47//
48// `Modify` only makes sense for pre- events: post- events report
49// what *already happened*, so there's nothing for a delta to
50// rewrite. The epic offered a choice between a compile-time guard
51// (separate types per pre/post) and a runtime guard in the
52// dispatcher. We picked the runtime guard:
53//
54//   * The compile-time path would fork the `HookDecision` type
55//     into `PreHookDecision` / `PostHookDecision`, double the
56//     surface area on every executor + chain method, and force
57//     callers to know an event's pre/post-ness at call sites that
58//     today take an opaque `HookEvent` tag.
59//   * The runtime path is a single function call —
60//     [`HookDecision::degrade_modify_for_post_event`] — that the
61//     dispatcher invokes after parsing the child's response. If a
62//     hook returns `Modify` for a post- event we log a warning
63//     and treat it as `Allow`. Same fail-open posture as the
64//     malformed-payload path.
65
66use serde::{Deserialize, Serialize};
67use serde_json::Value;
68
69use super::events::{HookEvent, MemoryDelta};
70
71// ---------------------------------------------------------------------------
72// HookDecision — full G4 enum
73// ---------------------------------------------------------------------------
74
75/// The four decision shapes a hook subprocess may return.
76///
77/// See the module-level documentation for the JSON wire contract
78/// and the runtime validation rules.
79///
80/// #969 — `PartialEq` is now derived. Pre-#969 it was hand-rolled
81/// on the (mistaken) premise that `serde_json::Value` was not
82/// `PartialEq`; it IS (serde_json 1.0 derives `Eq + PartialEq + Hash`
83/// on `Value`). The real blocker for `derive(Eq)` is `Option<f64>`
84/// inside `MemoryDelta`, which is `PartialEq` but not `Eq`.
85#[derive(Debug, Clone, PartialEq, Serialize)]
86#[serde(tag = "action", rename_all = "snake_case")]
87pub enum HookDecision {
88    /// Continue the memory operation unchanged. Wire shape:
89    /// `{"action":"allow"}` (or empty `{}` / empty stdout).
90    Allow,
91    /// Rewrite the in-flight payload before the memory operation
92    /// runs. Only valid on pre- events; on post- events the
93    /// dispatcher logs a warning and degrades to `Allow`.
94    Modify(ModifyPayload),
95    /// Halt the memory operation. `reason` surfaces in the
96    /// operator log and (when G7+ wires the executor into the
97    /// request path) the API response. `code` is an HTTP-style
98    /// integer the API surface translates to a status code.
99    Deny {
100        reason: String,
101        #[serde(default = "default_deny_code")]
102        code: i32,
103    },
104    /// Pause the chain and surface `prompt` to the operator
105    /// alongside `options`. The chain runner (G5) resumes once
106    /// the human picks one. `default` (if present) names the
107    /// option the runner falls back to on operator timeout.
108    AskUser {
109        prompt: String,
110        options: Vec<String>,
111        #[serde(skip_serializing_if = "Option::is_none")]
112        default: Option<String>,
113    },
114}
115
116/// Payload wrapper for [`HookDecision::Modify`]. The wire shape
117/// is `{"action":"modify","delta":{...}}`, so the inner field is
118/// named `delta` rather than letting serde flatten the
119/// [`MemoryDelta`] fields onto the decision object — keeping the
120/// delta nested means future expansions (extra metadata, hook
121/// trace ids) won't collide with `MemoryDelta` field names.
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123pub struct ModifyPayload {
124    pub delta: MemoryDelta,
125}
126
127fn default_deny_code() -> i32 {
128    403
129}
130
131// ---------------------------------------------------------------------------
132// DecisionParseError — strict deserialization errors
133// ---------------------------------------------------------------------------
134
135/// Errors surfaced by [`HookDecision::parse`]. Hand-rolled
136/// `Display + Error` per the v0.7 lesson (no `thiserror` in this
137/// crate's hot dependency tree).
138///
139/// Each variant is intentionally narrow so the executor's warning
140/// log can name the failure mode (`unknown action "foo"` vs
141/// `missing required field "reason"`).
142#[derive(Debug)]
143pub enum DecisionParseError {
144    /// The payload was not a JSON object (e.g. an array or scalar).
145    NotAnObject,
146    /// The payload was a JSON object but had no `action` key. This
147    /// is *not* the same as an empty `{}` — empty objects are
148    /// treated as `Allow` per the wire contract. `NotAnObject`
149    /// fires only when the bytes parse as JSON but `action` is
150    /// missing on a non-empty object.
151    MissingAction,
152    /// The `action` discriminator named a string we don't recognise.
153    UnknownAction(String),
154    /// The decision shape is recognised but a required field is
155    /// missing (`Deny` without `reason`, `Modify` without `delta`,
156    /// `AskUser` without `prompt` or `options`).
157    MissingField {
158        action: &'static str,
159        field: &'static str,
160    },
161    /// Underlying JSON syntax / type error from `serde_json`.
162    Malformed(String),
163}
164
165impl std::fmt::Display for DecisionParseError {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        match self {
168            DecisionParseError::NotAnObject => {
169                write!(f, "hook decision must be a JSON object")
170            }
171            DecisionParseError::MissingAction => {
172                write!(f, "hook decision missing required \"action\" field")
173            }
174            DecisionParseError::UnknownAction(a) => {
175                write!(f, "hook decision has unknown action \"{a}\"")
176            }
177            DecisionParseError::MissingField { action, field } => {
178                write!(
179                    f,
180                    "hook decision action=\"{action}\" missing required field \"{field}\""
181                )
182            }
183            DecisionParseError::Malformed(msg) => {
184                write!(f, "hook decision malformed: {msg}")
185            }
186        }
187    }
188}
189
190impl std::error::Error for DecisionParseError {}
191
192// ---------------------------------------------------------------------------
193// HookDecision — parsing + runtime validation
194// ---------------------------------------------------------------------------
195
196impl HookDecision {
197    /// Parse a decision payload from a hook subprocess.
198    ///
199    /// An empty / whitespace-only line and a literal `{}` are both
200    /// treated as `Allow` per the wire contract — see the
201    /// module-level documentation. Anything else is parsed
202    /// strictly: unknown actions, missing required fields, and
203    /// non-object payloads all return a [`DecisionParseError`].
204    ///
205    /// # Errors
206    ///
207    /// Returns [`DecisionParseError`] when the payload is not a
208    /// JSON object, when `action` is unknown, when a required
209    /// field is missing, or when the JSON itself is syntactically
210    /// invalid.
211    /// `"<field>" must be a string` — shared malformed-field shape
212    /// (#1558 batch 6; single synthesis site for the repeated message).
213    fn malformed_must_be_string(field: &str) -> DecisionParseError {
214        DecisionParseError::Malformed(format!("\"{field}\" must be a string"))
215    }
216
217    pub fn parse(line: &str) -> Result<Self, DecisionParseError> {
218        let trimmed = line.trim();
219        if trimmed.is_empty() || trimmed == "{}" {
220            return Ok(HookDecision::Allow);
221        }
222
223        let value: Value = serde_json::from_str(trimmed)
224            .map_err(|e| DecisionParseError::Malformed(e.to_string()))?;
225        let obj = value.as_object().ok_or(DecisionParseError::NotAnObject)?;
226
227        // Empty object after parse — same fail-open semantics as
228        // the literal "{}" short-circuit above.
229        if obj.is_empty() {
230            return Ok(HookDecision::Allow);
231        }
232
233        let action = obj
234            .get("action")
235            .ok_or(DecisionParseError::MissingAction)?
236            .as_str()
237            .ok_or_else(|| Self::malformed_must_be_string("action"))?;
238
239        match action {
240            "allow" => Ok(HookDecision::Allow),
241            "modify" => {
242                let delta_v = obj.get("delta").ok_or(DecisionParseError::MissingField {
243                    action: "modify",
244                    field: "delta",
245                })?;
246                let delta: MemoryDelta = serde_json::from_value(delta_v.clone())
247                    .map_err(|e| DecisionParseError::Malformed(e.to_string()))?;
248                Ok(HookDecision::Modify(ModifyPayload { delta }))
249            }
250            "deny" => {
251                let reason = obj
252                    .get("reason")
253                    .ok_or(DecisionParseError::MissingField {
254                        action: "deny",
255                        field: "reason",
256                    })?
257                    .as_str()
258                    .ok_or_else(|| Self::malformed_must_be_string("reason"))?
259                    .to_string();
260                let code = obj
261                    .get("code")
262                    .and_then(serde_json::Value::as_i64)
263                    .map_or_else(default_deny_code, |c| {
264                        i32::try_from(c).unwrap_or(default_deny_code())
265                    });
266                Ok(HookDecision::Deny { reason, code })
267            }
268            "ask_user" => {
269                let prompt = obj
270                    .get("prompt")
271                    .ok_or(DecisionParseError::MissingField {
272                        action: "ask_user",
273                        field: "prompt",
274                    })?
275                    .as_str()
276                    .ok_or_else(|| Self::malformed_must_be_string("prompt"))?
277                    .to_string();
278                let options_v = obj.get("options").ok_or(DecisionParseError::MissingField {
279                    action: "ask_user",
280                    field: "options",
281                })?;
282                let options: Vec<String> = serde_json::from_value(options_v.clone())
283                    .map_err(|e| DecisionParseError::Malformed(e.to_string()))?;
284                let default = match obj.get("default") {
285                    None => None,
286                    Some(Value::Null) => None,
287                    Some(v) => Some(
288                        v.as_str()
289                            .ok_or_else(|| Self::malformed_must_be_string("default"))?
290                            .to_string(),
291                    ),
292                };
293                Ok(HookDecision::AskUser {
294                    prompt,
295                    options,
296                    default,
297                })
298            }
299            other => Err(DecisionParseError::UnknownAction(other.to_string())),
300        }
301    }
302
303    /// Runtime guard for the pre-event-only constraint on
304    /// `Modify`. If `self` is `Modify` and `event` is a post-
305    /// event, log a warning and return `Allow`. Otherwise return
306    /// `self` unchanged.
307    ///
308    /// The dispatcher (G5) calls this after parsing the child's
309    /// decision but before applying the delta, so a misbehaving
310    /// hook can't sneak a `Modify` past a post- event.
311    #[must_use]
312    pub fn degrade_modify_for_post_event(self, event: HookEvent) -> Self {
313        if matches!(self, HookDecision::Modify(_)) && !is_pre_event(event) {
314            tracing::warn!(
315                event = ?event,
316                "hooks: Modify decision returned for post- event; degrading to Allow"
317            );
318            return HookDecision::Allow;
319        }
320        self
321    }
322}
323
324/// Returns `true` if `event` is a pre- variant (i.e. fires before
325/// the underlying memory operation runs).
326///
327/// Lives next to [`HookDecision::degrade_modify_for_post_event`]
328/// because the runtime guard is the only consumer today; G5's
329/// chain runner will reach for it the same way when wiring
330/// `Modify` accumulation through the pipeline.
331///
332/// ARCH-7 (FX-C4-batch2, 2026-05-26): the body uses an EXHAUSTIVE
333/// `match` over `HookEvent` (rather than the prior `matches!` macro)
334/// so adding a 26th hook variant fails compilation at THIS function
335/// rather than silently defaulting the new variant to the
336/// "post-event" treatment. The `#[deny(unreachable_patterns)]` outer
337/// gate catches the inverse failure mode (a duplicate / dead arm
338/// signalling a stale match table). The test
339/// `arch_7_is_pre_event_exhaustive_on_all_known_variants` in the
340/// inline tests below + `tests/hook_pipeline_exhaustiveness.rs`
341/// pin the coverage.
342#[must_use]
343#[deny(unreachable_patterns)]
344pub fn is_pre_event(event: HookEvent) -> bool {
345    match event {
346        // ---- pre-events: `Modify` decisions ARE honoured -----------------
347        HookEvent::PreStore
348        | HookEvent::PreRecall
349        | HookEvent::PreSearch
350        | HookEvent::PreDelete
351        | HookEvent::PrePromote
352        | HookEvent::PreLink
353        | HookEvent::PreConsolidate
354        | HookEvent::PreGovernanceDecision
355        | HookEvent::PreArchive
356        | HookEvent::PreTranscriptStore
357        // G10: hot-path query expansion fires before the recall
358        // call — Modify decisions rewrite the in-flight query.
359        | HookEvent::PreRecallExpand
360        // v0.7.0 Task 6/8: pre_reflect fires before the depth-cap
361        // check so a Deny veto refuses the reflection BEFORE the
362        // substrate evaluates `effective_max_reflection_depth()`.
363        | HookEvent::PreReflect
364        // v0.7.0 L1-7: pre_compaction fires before the cluster is
365        // processed by a CompactionPass — Deny aborts the cluster.
366        | HookEvent::PreCompaction => true,
367
368        // ---- post-/on- events: `Modify` decisions are degraded to Allow --
369        HookEvent::PostStore
370        | HookEvent::PostRecall
371        | HookEvent::PostSearch
372        | HookEvent::PostDelete
373        | HookEvent::PostPromote
374        | HookEvent::PostLink
375        | HookEvent::PostConsolidate
376        | HookEvent::PostGovernanceDecision
377        | HookEvent::OnIndexEviction
378        | HookEvent::PostTranscriptStore
379        | HookEvent::PostReflect
380        | HookEvent::OnCompactionRollback => false,
381    }
382}
383
384// ---------------------------------------------------------------------------
385// Custom Deserialize — strict, named errors
386// ---------------------------------------------------------------------------
387
388impl<'de> Deserialize<'de> for HookDecision {
389    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
390    where
391        D: serde::Deserializer<'de>,
392    {
393        // Funnel through `parse` so the strict-validation path is
394        // the same one the executor uses on stdout. Any
395        // [`DecisionParseError`] becomes a serde custom error.
396        let value = Value::deserialize(deserializer)?;
397        let as_text = serde_json::to_string(&value).map_err(serde::de::Error::custom)?;
398        HookDecision::parse(&as_text).map_err(serde::de::Error::custom)
399    }
400}
401
402// ---------------------------------------------------------------------------
403// Tests
404// ---------------------------------------------------------------------------
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use serde_json::json;
410
411    // ---- Round-trip per variant -------------------------------------------
412
413    #[test]
414    fn allow_round_trips() {
415        let d = HookDecision::Allow;
416        let json = serde_json::to_string(&d).expect("encode");
417        assert_eq!(json, r#"{"action":"allow"}"#);
418        let back: HookDecision = serde_json::from_str(&json).expect("decode");
419        assert_eq!(back, HookDecision::Allow);
420    }
421
422    #[test]
423    fn modify_round_trips_with_delta() {
424        let delta = MemoryDelta {
425            tags: Some(vec!["redacted".into()]),
426            priority: Some(5),
427            ..Default::default()
428        };
429        let d = HookDecision::Modify(ModifyPayload {
430            delta: delta.clone(),
431        });
432        let json = serde_json::to_string(&d).expect("encode");
433        // Wire shape sanity: action + delta nested.
434        let v: Value = serde_json::from_str(&json).expect("parse");
435        assert_eq!(v["action"], json!("modify"));
436        assert_eq!(v["delta"]["tags"], json!(["redacted"]));
437        assert_eq!(v["delta"]["priority"], json!(5));
438
439        let back: HookDecision = serde_json::from_str(&json).expect("decode");
440        assert_eq!(back, HookDecision::Modify(ModifyPayload { delta }));
441    }
442
443    #[test]
444    fn deny_round_trips_with_explicit_code() {
445        let d = HookDecision::Deny {
446            reason: "redact required".into(),
447            code: 451,
448        };
449        let json = serde_json::to_string(&d).expect("encode");
450        let back: HookDecision = serde_json::from_str(&json).expect("decode");
451        assert_eq!(back, d);
452    }
453
454    #[test]
455    fn deny_default_code_when_omitted() {
456        let d = HookDecision::parse(r#"{"action":"deny","reason":"nope"}"#).expect("parse");
457        match d {
458            HookDecision::Deny { reason, code } => {
459                assert_eq!(reason, "nope");
460                assert_eq!(code, 403, "missing code defaults to 403");
461            }
462            other => panic!("expected Deny, got {other:?}"),
463        }
464    }
465
466    #[test]
467    fn ask_user_round_trips() {
468        let d = HookDecision::AskUser {
469            prompt: "Promote to long-term?".into(),
470            options: vec!["yes".into(), "no".into()],
471            default: Some("no".into()),
472        };
473        let json = serde_json::to_string(&d).expect("encode");
474        let v: Value = serde_json::from_str(&json).expect("parse");
475        assert_eq!(v["action"], json!("ask_user"));
476        assert_eq!(v["options"], json!(["yes", "no"]));
477        assert_eq!(v["default"], json!("no"));
478
479        let back: HookDecision = serde_json::from_str(&json).expect("decode");
480        assert_eq!(back, d);
481    }
482
483    #[test]
484    fn ask_user_default_optional() {
485        let raw = r#"{"action":"ask_user","prompt":"continue?","options":["a","b"]}"#;
486        let d = HookDecision::parse(raw).expect("parse");
487        match d {
488            HookDecision::AskUser {
489                prompt,
490                options,
491                default,
492            } => {
493                assert_eq!(prompt, "continue?");
494                assert_eq!(options, vec!["a".to_string(), "b".to_string()]);
495                assert!(default.is_none());
496            }
497            other => panic!("expected AskUser, got {other:?}"),
498        }
499    }
500
501    // ---- Allow shorthand (empty payload) ----------------------------------
502
503    #[test]
504    fn empty_payload_treated_as_allow() {
505        assert_eq!(HookDecision::parse("").unwrap(), HookDecision::Allow);
506        assert_eq!(HookDecision::parse("   ").unwrap(), HookDecision::Allow);
507        assert_eq!(HookDecision::parse("{}").unwrap(), HookDecision::Allow);
508        assert_eq!(HookDecision::parse("{ }").unwrap(), HookDecision::Allow);
509    }
510
511    // ---- Strict-validation error surface ----------------------------------
512
513    #[test]
514    fn unknown_action_rejected_with_named_error() {
515        let err = HookDecision::parse(r#"{"action":"explode"}"#).unwrap_err();
516        match err {
517            DecisionParseError::UnknownAction(a) => assert_eq!(a, "explode"),
518            other => panic!("expected UnknownAction, got {other:?}"),
519        }
520    }
521
522    #[test]
523    fn missing_action_rejected() {
524        let err = HookDecision::parse(r#"{"reason":"why"}"#).unwrap_err();
525        assert!(matches!(err, DecisionParseError::MissingAction));
526    }
527
528    #[test]
529    fn deny_missing_reason_rejected() {
530        let err = HookDecision::parse(r#"{"action":"deny"}"#).unwrap_err();
531        match err {
532            DecisionParseError::MissingField { action, field } => {
533                assert_eq!(action, "deny");
534                assert_eq!(field, "reason");
535            }
536            other => panic!("expected MissingField, got {other:?}"),
537        }
538    }
539
540    #[test]
541    fn modify_missing_delta_rejected() {
542        let err = HookDecision::parse(r#"{"action":"modify"}"#).unwrap_err();
543        match err {
544            DecisionParseError::MissingField { action, field } => {
545                assert_eq!(action, "modify");
546                assert_eq!(field, "delta");
547            }
548            other => panic!("expected MissingField, got {other:?}"),
549        }
550    }
551
552    #[test]
553    fn ask_user_missing_prompt_rejected() {
554        let err = HookDecision::parse(r#"{"action":"ask_user","options":["a"]}"#).unwrap_err();
555        match err {
556            DecisionParseError::MissingField { action, field } => {
557                assert_eq!(action, "ask_user");
558                assert_eq!(field, "prompt");
559            }
560            other => panic!("expected MissingField, got {other:?}"),
561        }
562    }
563
564    #[test]
565    fn ask_user_missing_options_rejected() {
566        let err = HookDecision::parse(r#"{"action":"ask_user","prompt":"?"}"#).unwrap_err();
567        match err {
568            DecisionParseError::MissingField { action, field } => {
569                assert_eq!(action, "ask_user");
570                assert_eq!(field, "options");
571            }
572            other => panic!("expected MissingField, got {other:?}"),
573        }
574    }
575
576    #[test]
577    fn non_object_payload_rejected() {
578        let err = HookDecision::parse(r#"["allow"]"#).unwrap_err();
579        assert!(matches!(err, DecisionParseError::NotAnObject));
580    }
581
582    #[test]
583    fn malformed_json_rejected() {
584        let err = HookDecision::parse(r"not json at all").unwrap_err();
585        assert!(matches!(err, DecisionParseError::Malformed(_)));
586    }
587
588    // ---- Modify-on-post-event runtime guard --------------------------------
589
590    /// Stand-in for G5's dispatcher: parses a decision, then runs
591    /// the runtime guard. This is the harness the executor will
592    /// reach for once the chain runner lands.
593    fn dispatch(event: HookEvent, raw: &str) -> HookDecision {
594        let parsed = HookDecision::parse(raw).expect("parse");
595        parsed.degrade_modify_for_post_event(event)
596    }
597
598    #[test]
599    fn modify_on_pre_event_passes_through() {
600        let raw = r#"{"action":"modify","delta":{"priority":9}}"#;
601        let d = dispatch(HookEvent::PreStore, raw);
602        match d {
603            HookDecision::Modify(m) => assert_eq!(m.delta.priority, Some(9)),
604            other => panic!("expected Modify, got {other:?}"),
605        }
606    }
607
608    #[test]
609    fn modify_on_post_event_degrades_to_allow() {
610        let raw = r#"{"action":"modify","delta":{"priority":9}}"#;
611        // PostStore is a post- event — Modify must degrade.
612        assert_eq!(
613            dispatch(HookEvent::PostStore, raw),
614            HookDecision::Allow,
615            "Modify on post_store must degrade to Allow"
616        );
617        assert_eq!(
618            dispatch(HookEvent::PostRecall, raw),
619            HookDecision::Allow,
620            "Modify on post_recall must degrade to Allow"
621        );
622        assert_eq!(
623            dispatch(HookEvent::OnIndexEviction, raw),
624            HookDecision::Allow,
625            "Modify on on_index_eviction must degrade to Allow"
626        );
627    }
628
629    #[test]
630    fn allow_on_post_event_unchanged() {
631        // The guard only touches Modify.
632        assert_eq!(
633            dispatch(HookEvent::PostStore, r#"{"action":"allow"}"#),
634            HookDecision::Allow
635        );
636    }
637
638    #[test]
639    fn deny_on_post_event_unchanged() {
640        let raw = r#"{"action":"deny","reason":"x","code":500}"#;
641        assert_eq!(
642            dispatch(HookEvent::PostStore, raw),
643            HookDecision::Deny {
644                reason: "x".into(),
645                code: 500
646            }
647        );
648    }
649
650    // ---- is_pre_event coverage --------------------------------------------
651
652    #[test]
653    fn is_pre_event_classifies_all_variants() {
654        // Pre- variants (G10 added PreRecallExpand; v0.7.0 Task 6/8
655        // added PreReflect; L1-7 added PreCompaction).
656        for ev in [
657            HookEvent::PreStore,
658            HookEvent::PreRecall,
659            HookEvent::PreSearch,
660            HookEvent::PreDelete,
661            HookEvent::PrePromote,
662            HookEvent::PreLink,
663            HookEvent::PreConsolidate,
664            HookEvent::PreGovernanceDecision,
665            HookEvent::PreArchive,
666            HookEvent::PreTranscriptStore,
667            HookEvent::PreRecallExpand,
668            HookEvent::PreReflect,
669            HookEvent::PreCompaction,
670        ] {
671            assert!(is_pre_event(ev), "expected {ev:?} to be a pre- event");
672        }
673        // Post- + on- variants (v0.7.0 Task 6/8 added PostReflect;
674        // L1-7 added OnCompactionRollback — notify-only).
675        for ev in [
676            HookEvent::PostStore,
677            HookEvent::PostRecall,
678            HookEvent::PostSearch,
679            HookEvent::PostDelete,
680            HookEvent::PostPromote,
681            HookEvent::PostLink,
682            HookEvent::PostConsolidate,
683            HookEvent::PostGovernanceDecision,
684            HookEvent::OnIndexEviction,
685            HookEvent::PostTranscriptStore,
686            HookEvent::PostReflect,
687            HookEvent::OnCompactionRollback,
688        ] {
689            assert!(!is_pre_event(ev), "expected {ev:?} to be a post-/on- event");
690        }
691    }
692
693    // ---- Display surface for DecisionParseError ----------------------------
694
695    #[test]
696    fn parse_error_display_is_descriptive() {
697        let cases = [
698            DecisionParseError::NotAnObject,
699            DecisionParseError::MissingAction,
700            DecisionParseError::UnknownAction("foo".into()),
701            DecisionParseError::MissingField {
702                action: "deny",
703                field: "reason",
704            },
705            DecisionParseError::Malformed("expected `,`".into()),
706        ];
707        for e in &cases {
708            let s = e.to_string();
709            assert!(!s.is_empty(), "Display empty for {e:?}");
710            assert!(
711                s.contains("hook decision"),
712                "Display missing context for {e:?}: {s}"
713            );
714        }
715    }
716
717    #[test]
718    fn parse_action_must_be_string() {
719        let err = HookDecision::parse(r#"{"action": 42}"#).unwrap_err();
720        match err {
721            DecisionParseError::Malformed(m) => assert!(m.contains("must be a string")),
722            other => panic!("expected Malformed, got {other:?}"),
723        }
724    }
725
726    #[test]
727    fn parse_deny_reason_must_be_string() {
728        let err = HookDecision::parse(r#"{"action":"deny","reason": 99}"#).unwrap_err();
729        match err {
730            DecisionParseError::Malformed(m) => assert!(m.contains("reason")),
731            other => panic!("expected Malformed, got {other:?}"),
732        }
733    }
734
735    #[test]
736    fn parse_ask_user_prompt_must_be_string() {
737        let err =
738            HookDecision::parse(r#"{"action":"ask_user","prompt":1,"options":["a"]}"#).unwrap_err();
739        match err {
740            DecisionParseError::Malformed(m) => assert!(m.contains("prompt")),
741            other => panic!("expected Malformed, got {other:?}"),
742        }
743    }
744
745    #[test]
746    fn parse_ask_user_default_must_be_string_when_present() {
747        let err = HookDecision::parse(
748            r#"{"action":"ask_user","prompt":"p","options":["a"],"default":42}"#,
749        )
750        .unwrap_err();
751        match err {
752            DecisionParseError::Malformed(m) => assert!(m.contains("default")),
753            other => panic!("expected Malformed, got {other:?}"),
754        }
755    }
756
757    #[test]
758    fn parse_ask_user_default_null_is_none() {
759        let d = HookDecision::parse(
760            r#"{"action":"ask_user","prompt":"q","options":["yes","no"],"default":null}"#,
761        )
762        .expect("parse");
763        match d {
764            HookDecision::AskUser { default, .. } => assert!(default.is_none()),
765            other => panic!("expected AskUser, got {other:?}"),
766        }
767    }
768
769    #[test]
770    fn parse_modify_with_invalid_delta_returns_malformed() {
771        // delta must deserialise into MemoryDelta; an entirely wrong shape
772        // forces the serde_json::from_value error path.
773        let err = HookDecision::parse(r#"{"action":"modify","delta": 7}"#).unwrap_err();
774        match err {
775            DecisionParseError::Malformed(_) => {}
776            other => panic!("expected Malformed, got {other:?}"),
777        }
778    }
779
780    #[test]
781    fn parse_ask_user_options_must_be_array_of_strings() {
782        let err = HookDecision::parse(r#"{"action":"ask_user","prompt":"p","options":"nope"}"#)
783            .unwrap_err();
784        match err {
785            DecisionParseError::Malformed(_) => {}
786            other => panic!("expected Malformed, got {other:?}"),
787        }
788    }
789
790    #[test]
791    fn parse_deny_code_out_of_i32_range_falls_back_to_default() {
792        // Code larger than i32::MAX falls back to the default 403.
793        let raw = r#"{"action":"deny","reason":"big code","code": 9999999999}"#;
794        let d = HookDecision::parse(raw).expect("parse");
795        match d {
796            HookDecision::Deny { code, .. } => assert_eq!(code, 403),
797            other => panic!("expected Deny, got {other:?}"),
798        }
799    }
800
801    #[test]
802    fn hook_decision_deserialize_via_serde_routes_through_parse() {
803        // The custom Deserialize impl funnels through `parse`.
804        let raw = r#"{"action":"allow"}"#;
805        let d: HookDecision = serde_json::from_str(raw).expect("decode");
806        assert_eq!(d, HookDecision::Allow);
807    }
808
809    #[test]
810    fn hook_decision_deserialize_unknown_action_returns_serde_error() {
811        let raw = r#"{"action":"explode"}"#;
812        let r: Result<HookDecision, _> = serde_json::from_str(raw);
813        assert!(r.is_err());
814    }
815
816    #[test]
817    fn hook_decision_partial_eq_modify_with_equal_deltas() {
818        let a = HookDecision::Modify(ModifyPayload {
819            delta: MemoryDelta {
820                tags: Some(vec!["x".into()]),
821                ..Default::default()
822            },
823        });
824        let b = HookDecision::Modify(ModifyPayload {
825            delta: MemoryDelta {
826                tags: Some(vec!["x".into()]),
827                ..Default::default()
828            },
829        });
830        assert_eq!(a, b);
831    }
832
833    #[test]
834    fn hook_decision_partial_eq_modify_with_different_deltas() {
835        let a = HookDecision::Modify(ModifyPayload {
836            delta: MemoryDelta {
837                tags: Some(vec!["x".into()]),
838                ..Default::default()
839            },
840        });
841        let b = HookDecision::Modify(ModifyPayload {
842            delta: MemoryDelta {
843                tags: Some(vec!["y".into()]),
844                ..Default::default()
845            },
846        });
847        assert_ne!(a, b);
848    }
849
850    #[test]
851    fn hook_decision_partial_eq_distinct_variants() {
852        assert_ne!(
853            HookDecision::Allow,
854            HookDecision::Deny {
855                reason: "x".into(),
856                code: 403,
857            }
858        );
859        assert_ne!(
860            HookDecision::Deny {
861                reason: "a".into(),
862                code: 403,
863            },
864            HookDecision::AskUser {
865                prompt: "p".into(),
866                options: vec!["a".into()],
867                default: None,
868            }
869        );
870    }
871
872    #[test]
873    fn parse_array_payload_rejected_as_not_object() {
874        let err = HookDecision::parse("[1,2,3]").unwrap_err();
875        assert!(matches!(err, DecisionParseError::NotAnObject));
876    }
877}