Skip to main content

rightkit_hooks/
membrane.rs

1//! Membrane's hook wire types: the module status, dispatch result and host response that the
2//! shipped `membrane.hook.status` JSON and HookHost responses carry.
3//!
4//! These are data shapes only. Membrane's module bodies, normalization and transport stay with
5//! Membrane; this module lets a consumer read and write the same JSON. Event names use
6//! [`EventKind`], whose serde form is the host event name, so the strings match Membrane's
7//! `HookEvent` exactly. Membrane's `HookInputEnvelopeV1` is not repeated here: [`HookEvent`]
8//! already normalizes the same fields.
9//!
10//! [`HookHostResponseV1`] serializes `hookSpecificOutput` only for events whose host schema takes
11//! it, and emits `decision` and `reason` only on `Stop`. Emitting an unsupported variant makes
12//! Claude reject the whole response, so this follows Membrane's projection exactly.
13//!
14//! [`HookEvent`]: crate::event::HookEvent
15
16use crate::event::EventKind;
17use serde::{Deserialize, Deserializer, Serialize, Serializer};
18use serde_json::Value;
19
20/// Schema version of every Membrane hook wire type.
21pub const HOOK_SCHEMA_VERSION: u32 = 1;
22/// Per-module wall budget Membrane registers for its modules.
23pub const HOOK_MODULE_DEADLINE_MS: u64 = 3_000;
24
25/// One Membrane hook module. The serde names are the shipped `membrane.*` ids.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27pub enum HookModuleId {
28    #[serde(rename = "membrane.cortex-status")]
29    CortexStatus,
30    #[serde(rename = "membrane.memory-rearm")]
31    MemoryRearm,
32    #[serde(rename = "membrane.memory-recall")]
33    MemoryRecall,
34    #[serde(rename = "membrane.memory-pre-compact")]
35    MemoryPreCompact,
36    #[serde(rename = "membrane.memory-post-compact")]
37    MemoryPostCompact,
38    #[serde(rename = "membrane.memory-bump")]
39    MemoryBump,
40    #[serde(rename = "membrane.diagnostics-fence")]
41    DiagnosticsFence,
42    #[serde(rename = "membrane.memory-conflict")]
43    MemoryConflict,
44    #[serde(rename = "membrane.tool-observer")]
45    ToolObserver,
46    #[serde(rename = "membrane.memory-ingest")]
47    MemoryIngest,
48    #[serde(rename = "membrane.diagnostics-observe")]
49    DiagnosticsObserve,
50    #[serde(rename = "membrane.diagnostics-completion-fence")]
51    DiagnosticsCompletionFence,
52    #[serde(rename = "membrane.memory-nag")]
53    MemoryNag,
54    #[serde(rename = "membrane.memory-failure")]
55    MemoryFailure,
56    #[serde(rename = "membrane.memory-episode")]
57    MemoryEpisode,
58    #[serde(rename = "membrane.memory-session-end")]
59    MemorySessionEnd,
60}
61
62impl HookModuleId {
63    /// Every module, in Membrane's dispatch order.
64    pub const ORDERED: [Self; 16] = [
65        Self::CortexStatus,
66        Self::MemoryRearm,
67        Self::MemoryRecall,
68        Self::MemoryPreCompact,
69        Self::MemoryPostCompact,
70        Self::MemoryBump,
71        Self::DiagnosticsFence,
72        Self::MemoryConflict,
73        Self::ToolObserver,
74        Self::MemoryIngest,
75        Self::DiagnosticsObserve,
76        Self::DiagnosticsCompletionFence,
77        Self::MemoryNag,
78        Self::MemoryFailure,
79        Self::MemoryEpisode,
80        Self::MemorySessionEnd,
81    ];
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum HookModuleState {
87    Available,
88    Unavailable,
89    Skipped,
90    Blocked,
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum HookInvocationStatus {
96    Ok,
97    Error,
98}
99
100/// A module's typed status, matching `membrane.hook.status` JSON.
101#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase", deny_unknown_fields)]
103pub struct HookModuleOutputV1 {
104    pub schema_version: u32,
105    pub kind: String,
106    pub state: HookModuleState,
107    pub reason: String,
108    pub detail: Value,
109}
110
111impl HookModuleOutputV1 {
112    pub fn status(state: HookModuleState, reason: impl Into<String>, detail: Value) -> Self {
113        Self {
114            schema_version: HOOK_SCHEMA_VERSION,
115            kind: "membrane.hook.status".to_owned(),
116            state,
117            reason: reason.into(),
118            detail,
119        }
120    }
121
122    pub fn skipped() -> Self {
123        Self::status(
124            HookModuleState::Skipped,
125            "event_not_applicable",
126            Value::Null,
127        )
128    }
129}
130
131#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub struct HookModuleResultV1 {
134    pub id: HookModuleId,
135    pub status: HookInvocationStatus,
136    pub output: Option<HookModuleOutputV1>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub error: Option<String>,
139}
140
141impl HookModuleResultV1 {
142    pub fn ok(id: HookModuleId, output: HookModuleOutputV1) -> Self {
143        Self {
144            id,
145            status: HookInvocationStatus::Ok,
146            output: Some(output),
147            error: None,
148        }
149    }
150
151    pub fn skipped(id: HookModuleId) -> Self {
152        Self::ok(id, HookModuleOutputV1::skipped())
153    }
154
155    pub fn error(id: HookModuleId, error: impl Into<String>) -> Self {
156        Self {
157            id,
158            status: HookInvocationStatus::Error,
159            output: None,
160            error: Some(error.into()),
161        }
162    }
163}
164
165#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase", deny_unknown_fields)]
167pub struct HookDispatchResultV1 {
168    pub schema_version: u32,
169    pub event: EventKind,
170    pub status: HookInvocationStatus,
171    pub results: Vec<HookModuleResultV1>,
172}
173
174impl HookDispatchResultV1 {
175    pub fn new(event: EventKind, results: Vec<HookModuleResultV1>) -> Self {
176        let status = aggregate_hook_status(&results);
177        Self {
178            schema_version: HOOK_SCHEMA_VERSION,
179            event,
180            status,
181            results,
182        }
183    }
184}
185
186/// `Error` when any module failed, otherwise `Ok`.
187pub fn aggregate_hook_status(results: &[HookModuleResultV1]) -> HookInvocationStatus {
188    if results
189        .iter()
190        .any(|result| result.status == HookInvocationStatus::Error)
191    {
192        HookInvocationStatus::Error
193    } else {
194        HookInvocationStatus::Ok
195    }
196}
197
198#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200pub enum HookHostDecision {
201    Block,
202}
203
204#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum HookPermissionDecision {
207    Deny,
208}
209
210#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212pub struct HookSpecificOutputV1 {
213    pub hook_event_name: EventKind,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub permission_decision: Option<HookPermissionDecision>,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub permission_decision_reason: Option<String>,
218    pub additional_context: String,
219}
220
221/// The HookHost response: the host's own control fields plus the full `membraneHook` record.
222///
223/// Deserialization is custom (see the `Deserialize` impl below): `hookSpecificOutput` is absent on
224/// the wire for events without a host projection, so it is rebuilt from `membraneHook.event`.
225#[derive(Clone, Debug, PartialEq)]
226pub struct HookHostResponseV1 {
227    pub decision: Option<HookHostDecision>,
228    pub reason: Option<String>,
229    pub hook_specific_output: HookSpecificOutputV1,
230    pub membrane_hook: HookDispatchResultV1,
231}
232
233/// Wire shape read by [`HookHostResponseV1`]'s `Deserialize` impl.
234#[derive(Deserialize)]
235#[serde(rename_all = "camelCase", deny_unknown_fields)]
236struct HookHostResponseWireV1 {
237    decision: Option<HookHostDecision>,
238    reason: Option<String>,
239    hook_specific_output: Option<HookSpecificOutputV1>,
240    membrane_hook: HookDispatchResultV1,
241}
242
243impl<'de> Deserialize<'de> for HookHostResponseV1 {
244    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
245        let wire = HookHostResponseWireV1::deserialize(deserializer)?;
246        let event = &wire.membrane_hook.event;
247        let hook_specific_output = match wire.hook_specific_output {
248            Some(output) => output,
249            // Events without a host projection never carry hookSpecificOutput on the wire; the
250            // projection's values for them are not transmitted, so the carrier starts empty.
251            None if !takes_host_projection(event) => HookSpecificOutputV1 {
252                hook_event_name: event.clone(),
253                permission_decision: None,
254                permission_decision_reason: None,
255                additional_context: String::new(),
256            },
257            None => {
258                return Err(<D::Error as serde::de::Error>::missing_field(
259                    "hookSpecificOutput",
260                ));
261            }
262        };
263        Ok(Self {
264            decision: wire.decision,
265            reason: wire.reason,
266            hook_specific_output,
267            membrane_hook: wire.membrane_hook,
268        })
269    }
270}
271
272/// Events on which a blocked module denies the action.
273fn is_deny_boundary(event: &EventKind) -> bool {
274    matches!(event, EventKind::PreToolUse | EventKind::Stop)
275}
276
277/// Events whose host schema takes `hookSpecificOutput`.
278fn takes_host_projection(event: &EventKind) -> bool {
279    matches!(
280        event,
281        EventKind::SessionStart
282            | EventKind::UserPromptSubmit
283            | EventKind::PreToolUse
284            | EventKind::PostToolUse
285            | EventKind::PostToolUseFailure
286    )
287}
288
289// Only events with a native context projection receive hookSpecificOutput. Other lifecycle
290// callbacks keep their typed receipts, with top-level Stop control. Emitting unsupported event
291// variants makes Claude reject the entire response.
292impl Serialize for HookHostResponseV1 {
293    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
294        use serde::ser::SerializeMap;
295        let event = &self.hook_specific_output.hook_event_name;
296        let omit_host_projection = !takes_host_projection(event);
297        let count = 1
298            + usize::from(!omit_host_projection)
299            + usize::from(self.decision.is_some())
300            + usize::from(self.reason.is_some());
301        let mut map = serializer.serialize_map(Some(count))?;
302        if let Some(decision) = &self.decision {
303            map.serialize_entry("decision", decision)?;
304        }
305        if let Some(reason) = &self.reason {
306            map.serialize_entry("reason", reason)?;
307        }
308        if !omit_host_projection {
309            map.serialize_entry("hookSpecificOutput", &self.hook_specific_output)?;
310        }
311        map.serialize_entry("membraneHook", &self.membrane_hook)?;
312        map.end()
313    }
314}
315
316/// Project native module results into the deterministic HookHost response.
317pub fn project_hook_host_response(result: HookDispatchResultV1) -> HookHostResponseV1 {
318    let additional_context = result
319        .results
320        .iter()
321        .filter_map(|entry| {
322            entry
323                .output
324                .as_ref()
325                .and_then(|output| output.detail.get("additionalContext"))
326                .and_then(Value::as_str)
327        })
328        .filter(|context| !context.is_empty())
329        .collect::<Vec<_>>()
330        .join("\n\n");
331    let blocked = result.results.iter().find(|entry| {
332        entry
333            .output
334            .as_ref()
335            .is_some_and(|output| output.state == HookModuleState::Blocked)
336    });
337    let deny = is_deny_boundary(&result.event) && blocked.is_some();
338    let reason = blocked
339        .and_then(|entry| entry.output.as_ref())
340        .and_then(|output| {
341            output
342                .detail
343                .get("detail")
344                .and_then(Value::as_str)
345                .map(str::to_owned)
346                .or_else(|| Some(output.reason.clone()))
347        })
348        .or_else(|| deny.then(|| "semantic edit fence not cleared".to_owned()));
349    let is_stop = result.event == EventKind::Stop;
350    let is_pre_tool = result.event == EventKind::PreToolUse;
351    HookHostResponseV1 {
352        decision: (deny && is_stop).then_some(HookHostDecision::Block),
353        reason: (deny && is_stop).then(|| reason.clone()).flatten(),
354        hook_specific_output: HookSpecificOutputV1 {
355            hook_event_name: result.event.clone(),
356            permission_decision: (deny && is_pre_tool).then_some(HookPermissionDecision::Deny),
357            permission_decision_reason: (deny && is_pre_tool).then(|| reason.clone()).flatten(),
358            additional_context,
359        },
360        membrane_hook: result,
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use serde_json::json;
368
369    fn blocked(event: EventKind) -> HookDispatchResultV1 {
370        let output = HookModuleOutputV1::status(
371            HookModuleState::Blocked,
372            "fence_open",
373            json!({"detail": "tests not run", "additionalContext": "ctx"}),
374        );
375        HookDispatchResultV1::new(
376            event,
377            vec![HookModuleResultV1::ok(
378                HookModuleId::DiagnosticsFence,
379                output,
380            )],
381        )
382    }
383
384    #[test]
385    fn module_ids_use_membrane_names_in_order() {
386        assert_eq!(
387            serde_json::to_value(HookModuleId::MemoryRecall).unwrap(),
388            json!("membrane.memory-recall")
389        );
390        assert_eq!(HookModuleId::ORDERED.len(), 16);
391        assert_eq!(HookModuleId::ORDERED[0], HookModuleId::CortexStatus);
392    }
393
394    #[test]
395    fn stop_block_projects_top_level_decision_only() {
396        let response = project_hook_host_response(blocked(EventKind::Stop));
397        let value = serde_json::to_value(&response).unwrap();
398        assert_eq!(value["decision"], json!("block"));
399        assert_eq!(value["reason"], json!("tests not run"));
400        assert!(value.get("hookSpecificOutput").is_none());
401        assert_eq!(value["membraneHook"]["event"], json!("Stop"));
402    }
403
404    #[test]
405    fn pre_tool_use_block_projects_deny() {
406        let response = project_hook_host_response(blocked(EventKind::PreToolUse));
407        let value = serde_json::to_value(&response).unwrap();
408        assert!(value.get("decision").is_none());
409        assert_eq!(
410            value["hookSpecificOutput"]["permissionDecision"],
411            json!("deny")
412        );
413        assert_eq!(
414            value["hookSpecificOutput"]["permissionDecisionReason"],
415            json!("tests not run")
416        );
417        assert_eq!(
418            value["hookSpecificOutput"]["additionalContext"],
419            json!("ctx")
420        );
421    }
422
423    #[test]
424    fn unsupported_event_omits_host_projection() {
425        let response = project_hook_host_response(blocked(EventKind::PreCompact));
426        let value = serde_json::to_value(&response).unwrap();
427        assert!(value.get("hookSpecificOutput").is_none());
428        assert!(value.get("decision").is_none());
429        assert_eq!(value["membraneHook"]["event"], json!("PreCompact"));
430    }
431
432    #[test]
433    fn response_round_trips_and_rejects_unknown_fields() {
434        let response = project_hook_host_response(blocked(EventKind::SessionStart));
435        let text = serde_json::to_string(&response).unwrap();
436        let back: HookHostResponseV1 = serde_json::from_str(&text).unwrap();
437        assert_eq!(back.membrane_hook, response.membrane_hook);
438        let extra = text.replacen('{', "{\"unexpected\":1,", 1);
439        assert!(serde_json::from_str::<HookHostResponseV1>(&extra).is_err());
440    }
441
442    #[test]
443    fn response_round_trips_for_every_event_kind() {
444        let kinds = [
445            EventKind::SessionStart,
446            EventKind::UserPromptSubmit,
447            EventKind::PreToolUse,
448            EventKind::PostToolUse,
449            EventKind::PostToolUseFailure,
450            EventKind::Stop,
451            EventKind::SubagentStart,
452            EventKind::SubagentStop,
453            EventKind::PreCompact,
454            EventKind::PostCompact,
455            EventKind::SessionEnd,
456            EventKind::Notification,
457            EventKind::Other("CustomEvent".to_owned()),
458        ];
459        for kind in kinds {
460            let response = project_hook_host_response(blocked(kind.clone()));
461            let wire = serde_json::to_string(&response).unwrap();
462            let back: HookHostResponseV1 = serde_json::from_str(&wire)
463                .unwrap_or_else(|error| panic!("{kind:?} failed to read: {error}"));
464            assert_eq!(back.membrane_hook, response.membrane_hook, "{kind:?}");
465            assert_eq!(back.decision, response.decision, "{kind:?}");
466            assert_eq!(back.reason, response.reason, "{kind:?}");
467            assert_eq!(back.hook_specific_output.hook_event_name, kind);
468            // The wire is the contract: re-serializing the parsed value must reproduce it exactly.
469            assert_eq!(serde_json::to_string(&back).unwrap(), wire, "{kind:?}");
470            if takes_host_projection(&kind) {
471                assert_eq!(back, response, "{kind:?}");
472            } else {
473                let value = serde_json::to_value(&back).unwrap();
474                assert!(value.get("hookSpecificOutput").is_none(), "{kind:?}");
475            }
476        }
477    }
478
479    #[test]
480    fn projection_events_require_hook_specific_output_on_read() {
481        let response = project_hook_host_response(blocked(EventKind::PreToolUse));
482        let mut value = serde_json::to_value(&response).unwrap();
483        value.as_object_mut().unwrap().remove("hookSpecificOutput");
484        let text = value.to_string();
485        assert!(serde_json::from_str::<HookHostResponseV1>(&text).is_err());
486    }
487
488    #[test]
489    fn aggregate_status_reports_any_error() {
490        let ok = HookModuleResultV1::skipped(HookModuleId::MemoryBump);
491        let err = HookModuleResultV1::error(HookModuleId::MemoryNag, "boom");
492        assert_eq!(
493            aggregate_hook_status(std::slice::from_ref(&ok)),
494            HookInvocationStatus::Ok
495        );
496        assert_eq!(
497            aggregate_hook_status(&[ok, err]),
498            HookInvocationStatus::Error
499        );
500    }
501}