Skip to main content

khive_pack_brain/
lib.rs

1pub mod event;
2pub mod fold;
3pub mod persist;
4pub mod section;
5pub mod state;
6pub mod tunable;
7
8use std::{sync::Mutex, time::Instant};
9
10use async_trait::async_trait;
11use chrono::Utc;
12use serde::Deserialize;
13use serde_json::{json, Value};
14
15use khive_fold::{Fold, FoldContext};
16use khive_runtime::pack::PackRuntime;
17use khive_runtime::{
18    micros_to_iso, DispatchHook, EventView, KhiveRuntime, NamespaceToken, RuntimeError,
19    VerbRegistry,
20};
21use khive_storage::event::{Event, EventFilter};
22use khive_storage::types::PageRequest;
23use khive_types::{HandlerDef, Pack, ParamDef, VerbCategory, Visibility};
24
25use crate::fold::{BalancedRecallFold, SectionPosteriorFold};
26use crate::section::derive_deterministic_weights;
27use crate::state::{
28    BrainState, ProfileBinding, ProfileLifecycle, ProfileRecord, SectionPosteriorState, SectionType,
29};
30
31const ENTITY_CACHE_CAPACITY: usize = 10_000;
32
33// ── Profile record sync helper ────────────────────────────────────────────────
34
35/// Sync the `balanced-recall-v1` profile record to match the live
36/// `balanced_recall` state.
37///
38/// Fix #356 (MAJ-003): called from both `handle_feedback` and `on_dispatch`
39/// so the record is never stale regardless of which path updated the state.
40/// Fix #295: also called from `handle_reset` so the profile record reflects
41/// restored domain-informed priors immediately after reset.
42pub(crate) fn sync_balanced_recall_record(state: &mut BrainState) {
43    let total_ev = state.balanced_recall.total_events;
44    let snap_val = serde_json::to_value(state.balanced_recall.to_snapshot()).ok();
45    if let Some(record) = state.profiles.get_mut("balanced-recall-v1") {
46        record.total_events = total_ev;
47        record.state_snapshot = snap_val;
48    }
49}
50
51// ── Handler table ─────────────────────────────────────────────────────────────
52
53/// Brain pack verb surface per ADR-032 §11.
54///
55/// Visibility::Verb  = exposed on the MCP `request` tool.
56/// Visibility::Subhandler = internal / operator-only.
57///
58/// ADR-025: illocutionary classification applied.
59static BRAIN_HANDLERS: &[HandlerDef] = &[
60    // ── Assertive (read) verbs ────────────────────────────────────────────
61    HandlerDef {
62        name: "brain.state",
63        description: "Return current BrainState snapshot for inspection",
64        visibility: Visibility::Subhandler,
65        category: VerbCategory::Assertive,
66        params: &[],
67    },
68    HandlerDef {
69        name: "brain.config",
70        description: "Return projected config for a named pack parameter",
71        visibility: Visibility::Subhandler,
72        category: VerbCategory::Assertive,
73        params: &[ParamDef {
74            name: "parameter",
75            param_type: "string",
76            required: false,
77            description: "Specific parameter to query: \"recall::relevance_weight\" | \"recall::salience_weight\" | \"recall::temporal_weight\". Omit to return all.",
78        }],
79    },
80    HandlerDef {
81        name: "brain.events",
82        description: "List recent brain-relevant events for debugging",
83        visibility: Visibility::Subhandler,
84        category: VerbCategory::Assertive,
85        params: &[ParamDef {
86            name: "limit",
87            param_type: "integer",
88            required: false,
89            description: "Maximum events to return (default 20, max 100).",
90        }],
91    },
92    HandlerDef {
93        name: "brain.profiles",
94        description: "List profiles, optionally filtered by lifecycle",
95        visibility: Visibility::Verb,
96        category: VerbCategory::Assertive,
97        params: &[ParamDef {
98            name: "lifecycle",
99            param_type: "string",
100            required: false,
101            description: "Filter profiles by lifecycle state: \"active\" | \"inactive\" | \"archived\". Omit to return all.",
102        }],
103    },
104    HandlerDef {
105        name: "brain.profile",
106        description: "Profile metadata, latest snapshot, current state summary",
107        visibility: Visibility::Verb,
108        category: VerbCategory::Assertive,
109        params: &[ParamDef {
110            name: "profile_id",
111            param_type: "string",
112            required: true,
113            description: "Profile ID string (e.g. \"balanced-recall-v1\"). NOT a UUID — use the string identifier. Alias: id.",
114        }],
115    },
116    HandlerDef {
117        name: "brain.resolve",
118        description: "Show which profile would serve a caller context",
119        visibility: Visibility::Verb,
120        category: VerbCategory::Assertive,
121        params: &[
122            ParamDef {
123                name: "consumer_kind",
124                param_type: "string",
125                required: true,
126                description: "Verb or operation type the caller is about to perform (e.g. \"recall\").",
127            },
128            ParamDef {
129                name: "actor",
130                param_type: "string",
131                required: false,
132                description: "Caller actor identifier. Defaults to \"*\" wildcard match.",
133            },
134            ParamDef {
135                name: "namespace",
136                param_type: "string",
137                required: false,
138                description: "Namespace for binding resolution. Defaults to \"*\" wildcard match.",
139            },
140        ],
141    },
142    // ── Commissive (write state) verbs ────────────────────────────────────
143    HandlerDef {
144        name: "brain.activate",
145        description: "Move a profile to Active (start live update loop)",
146        visibility: Visibility::Verb,
147        category: VerbCategory::Commissive,
148        params: &[ParamDef {
149            name: "profile_id",
150            param_type: "string",
151            required: true,
152            description: "Profile ID to activate (e.g. \"balanced-recall-v1\").",
153        }],
154    },
155    HandlerDef {
156        name: "brain.deactivate",
157        description: "Move a profile to Inactive (stop live updates, retain state)",
158        visibility: Visibility::Verb,
159        category: VerbCategory::Commissive,
160        params: &[ParamDef {
161            name: "profile_id",
162            param_type: "string",
163            required: true,
164            description: "Profile ID to deactivate.",
165        }],
166    },
167    HandlerDef {
168        name: "brain.archive",
169        description: "Move a profile to Archived (read-only, audit-retained)",
170        visibility: Visibility::Verb,
171        category: VerbCategory::Declaration,
172        params: &[ParamDef {
173            name: "profile_id",
174            param_type: "string",
175            required: true,
176            description: "Profile ID to archive.",
177        }],
178    },
179    HandlerDef {
180        name: "brain.reset",
181        description: "Reset posteriors to priors (preserves event history)",
182        visibility: Visibility::Verb,
183        category: VerbCategory::Declaration,
184        params: &[ParamDef {
185            name: "profile_id",
186            param_type: "string",
187            required: false,
188            description: "Profile ID to reset (must exist and be active). Defaults to \"balanced-recall-v1\". Use brain.profiles() to list profiles.",
189        }],
190    },
191    HandlerDef {
192        name: "brain.feedback",
193        description: "Emit a FeedbackExplicit event into the shared log",
194        visibility: Visibility::Verb,
195        category: VerbCategory::Commissive,
196        params: &[
197            ParamDef {
198                name: "target_id",
199                param_type: "uuid",
200                required: true,
201                description: "UUID of the memory note or entity the feedback applies to.",
202            },
203            ParamDef {
204                name: "signal",
205                param_type: "string",
206                required: true,
207                description: "Feedback signal: \"useful\" | \"not_useful\" | \"wrong\".",
208            },
209            ParamDef {
210                name: "served_by_profile_id",
211                param_type: "string",
212                required: false,
213                description: "Profile ID that served the result being rated. Recorded in the event payload.",
214            },
215            ParamDef {
216                name: "section_signals",
217                param_type: "object",
218                required: false,
219                description: "Per-section feedback signals: {\"section_name\": \"useful\"|\"not_useful\"|\"wrong\"}. For knowledge_compose profiles.",
220            },
221        ],
222    },
223    HandlerDef {
224        name: "brain.auto_feedback",
225        description: "Emit implicit feedback for recall results supplied by an agent. \
226            Convenience verb: agents call this after memory.recall instead of constructing \
227            a brain.feedback call manually. Keeps memory and brain packs decoupled (#517).",
228        visibility: Visibility::Verb,
229        category: VerbCategory::Commissive,
230        params: &[
231            ParamDef {
232                name: "query",
233                param_type: "string",
234                required: true,
235                description: "Recall query that produced the results.",
236            },
237            ParamDef {
238                name: "results",
239                param_type: "array",
240                required: true,
241                description: "Recall result objects; the first object's note_id is credited.",
242            },
243            ParamDef {
244                name: "signal",
245                param_type: "string",
246                required: false,
247                description: "Feedback signal. Defaults to \"implicit_positive\".",
248            },
249            ParamDef {
250                name: "served_by_profile_id",
251                param_type: "string",
252                required: false,
253                description: "Profile ID that served the recall. Defaults like brain.feedback.",
254            },
255        ],
256    },
257    // ── Declaration verbs ─────────────────────────────────────────────────
258    HandlerDef {
259        name: "brain.bind",
260        description: "Write a row in the profile resolution table",
261        visibility: Visibility::Verb,
262        category: VerbCategory::Declaration,
263        params: &[
264            ParamDef {
265                name: "profile_id",
266                param_type: "string",
267                required: true,
268                description: "Profile ID to bind (must exist).",
269            },
270            ParamDef {
271                name: "actor",
272                param_type: "string",
273                required: false,
274                description: "Actor identifier to match. Default \"*\" (all actors). Cannot contain \"*\" inside a real value.",
275            },
276            ParamDef {
277                name: "namespace",
278                param_type: "string",
279                required: false,
280                description: "Namespace to match. Default \"*\" (all namespaces).",
281            },
282            ParamDef {
283                name: "consumer_kind",
284                param_type: "string",
285                required: false,
286                description: "Verb / operation kind to match. Default \"*\" (all kinds).",
287            },
288            ParamDef {
289                name: "priority",
290                param_type: "integer",
291                required: false,
292                description: "Binding priority; higher wins when multiple bindings match (default 0).",
293            },
294        ],
295    },
296    HandlerDef {
297        name: "brain.unbind",
298        description: "Remove rows from the profile resolution table. At least one filter (profile_id, actor, namespace, or consumer_kind) is required.",
299        visibility: Visibility::Verb,
300        category: VerbCategory::Declaration,
301        params: &[
302            ParamDef {
303                name: "profile_id",
304                param_type: "string",
305                required: false,
306                description: "Remove bindings for this profile ID. All filters use AND semantics. At least one filter is required.",
307            },
308            ParamDef {
309                name: "actor",
310                param_type: "string",
311                required: false,
312                description: "Remove bindings for this actor.",
313            },
314            ParamDef {
315                name: "namespace",
316                param_type: "string",
317                required: false,
318                description: "Remove bindings for this namespace.",
319            },
320            ParamDef {
321                name: "consumer_kind",
322                param_type: "string",
323                required: false,
324                description: "Remove bindings for this consumer_kind.",
325            },
326        ],
327    },
328    HandlerDef {
329        name: "brain.bindings",
330        description: "List rows in the profile resolution table, optionally filtered",
331        visibility: Visibility::Verb,
332        category: VerbCategory::Assertive,
333        params: &[
334            ParamDef {
335                name: "profile_id",
336                param_type: "string",
337                required: false,
338                description: "Filter bindings by profile ID.",
339            },
340            ParamDef {
341                name: "actor",
342                param_type: "string",
343                required: false,
344                description: "Filter bindings by actor.",
345            },
346            ParamDef {
347                name: "namespace",
348                param_type: "string",
349                required: false,
350                description: "Filter bindings by namespace.",
351            },
352            ParamDef {
353                name: "consumer_kind",
354                param_type: "string",
355                required: false,
356                description: "Filter bindings by consumer_kind.",
357            },
358        ],
359    },
360    HandlerDef {
361        name: "brain.create_profile",
362        description: "Create a new brain profile with given name and optional seed priors",
363        visibility: Visibility::Verb,
364        category: VerbCategory::Declaration,
365        params: &[
366            ParamDef {
367                name: "name",
368                param_type: "string",
369                required: true,
370                description: "Profile ID / name (alphanumeric, hyphens allowed, e.g. \"my-profile-v1\"). Must be unique.",
371            },
372            ParamDef {
373                name: "description",
374                param_type: "string",
375                required: false,
376                description: "Human-readable description for this profile.",
377            },
378            ParamDef {
379                name: "consumer_kind",
380                param_type: "string",
381                required: false,
382                description: "Operation kind this profile targets (e.g. \"recall\"). Default \"recall\".",
383            },
384            ParamDef {
385                name: "seed_priors",
386                param_type: "object",
387                required: false,
388                description: "Seed priors object. For knowledge_compose: {\"section_posteriors\": {\"overview\": {\"alpha\": 2.0, \"beta\": 2.0}, ...}}. For recall: {\"relevance\": {\"alpha\": 7.0, \"beta\": 3.0}, ...}.",
389            },
390        ],
391    },
392    // ── Legacy / internal ─────────────────────────────────────────────────
393    HandlerDef {
394        name: "brain.emit",
395        description: "Manually emit a feedback event (deprecated; use brain.feedback)",
396        visibility: Visibility::Subhandler,
397        category: VerbCategory::Commissive,
398        params: &[
399            ParamDef {
400                name: "target_id",
401                param_type: "uuid",
402                required: true,
403                description: "UUID of the record the feedback applies to.",
404            },
405            ParamDef {
406                name: "signal",
407                param_type: "string",
408                required: true,
409                description: "Feedback signal: \"useful\" | \"not_useful\" | \"wrong\". Deprecated: use brain.feedback instead.",
410            },
411            ParamDef {
412                name: "served_by_profile_id",
413                param_type: "string",
414                required: false,
415                description: "Profile ID that served the result.",
416            },
417        ],
418    },
419];
420
421// ── BrainPack ─────────────────────────────────────────────────────────────────
422
423/// Brain pack — profile-oriented auto-tuning (ADR-032).
424///
425/// `BrainState` holds the profile registry. `BalancedRecallFold` drives the
426/// v1 default profile. The old scalar `BrainState` design is superseded; see
427/// ADR-032 §1 and the migration notes in `state.rs`.
428pub struct BrainPack {
429    runtime: KhiveRuntime,
430    /// Profile registry + active balanced-recall state.
431    state: Mutex<BrainState>,
432    /// Fold for the built-in `balanced-recall-v1` profile.
433    fold: BalancedRecallFold,
434    /// Fold for per-profile section posteriors (ADR-048 Phase 1).
435    section_fold: SectionPosteriorFold,
436    /// Tracks which namespaces are loaded from DB and dirty event counts.
437    persistence: Mutex<persist::PersistenceTracker>,
438}
439
440impl Pack for BrainPack {
441    const NAME: &'static str = "brain";
442    const NOTE_KINDS: &'static [&'static str] = &[];
443    const ENTITY_KINDS: &'static [&'static str] = &[];
444    const HANDLERS: &'static [HandlerDef] = BRAIN_HANDLERS;
445    const REQUIRES: &'static [&'static str] = &["kg"];
446}
447
448impl BrainPack {
449    pub fn new(runtime: KhiveRuntime) -> Self {
450        let fold = BalancedRecallFold::new(ENTITY_CACHE_CAPACITY);
451        let section_fold = SectionPosteriorFold::new();
452        let state = BrainState::new(ENTITY_CACHE_CAPACITY);
453        Self {
454            runtime,
455            state: Mutex::new(state),
456            fold,
457            section_fold,
458            persistence: Mutex::new(persist::PersistenceTracker::new()),
459        }
460    }
461
462    async fn ensure_loaded(&self, token: &NamespaceToken) -> Result<(), RuntimeError> {
463        persist::ensure_loaded(
464            &self.runtime,
465            token,
466            &self.persistence,
467            &self.state,
468            &self.fold,
469            &self.section_fold,
470            ENTITY_CACHE_CAPACITY,
471        )
472        .await
473    }
474
475    /// Public snapshot of the current `BrainState`.
476    pub fn snapshot(&self) -> crate::state::BrainStateSnapshot {
477        self.state.lock().unwrap().to_snapshot()
478    }
479
480    // ── brain.state ───────────────────────────────────────────────────────
481
482    async fn handle_state(&self, _params: Value) -> Result<Value, RuntimeError> {
483        let state = self.state.lock().unwrap();
484        let snapshot = state.to_snapshot();
485        serde_json::to_value(&snapshot).map_err(|e| RuntimeError::InvalidInput(e.to_string()))
486    }
487
488    // ── brain.config ──────────────────────────────────────────────────────
489
490    async fn handle_config(&self, params: Value) -> Result<Value, RuntimeError> {
491        #[derive(Deserialize)]
492        #[serde(deny_unknown_fields)]
493        struct ConfigParams {
494            parameter: Option<String>,
495        }
496        let p: ConfigParams = serde_json::from_value(params)
497            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
498
499        let state = self.state.lock().unwrap();
500        let br = &state.balanced_recall;
501
502        let param_map = [
503            ("recall::relevance_weight", &br.relevance),
504            ("recall::salience_weight", &br.salience),
505            ("recall::temporal_weight", &br.temporal),
506        ];
507
508        match p.parameter {
509            Some(key) => {
510                let posterior = param_map
511                    .iter()
512                    .find(|(k, _)| *k == key)
513                    .map(|(_, p)| *p)
514                    .ok_or_else(|| {
515                        RuntimeError::NotFound(format!(
516                            "parameter {key:?}; valid: {}",
517                            param_map
518                                .iter()
519                                .map(|(k, _)| *k)
520                                .collect::<Vec<_>>()
521                                .join(", ")
522                        ))
523                    })?;
524                Ok(json!({
525                    "parameter": key,
526                    "mean": posterior.mean(),
527                    "variance": posterior.variance(),
528                    "ess": posterior.effective_sample_size(),
529                    "alpha": posterior.alpha,
530                    "beta": posterior.beta,
531                }))
532            }
533            None => {
534                let configs: serde_json::Map<String, Value> = param_map
535                    .iter()
536                    .map(|(k, p)| {
537                        (
538                            (*k).to_owned(),
539                            json!({
540                                "mean": p.mean(),
541                                "variance": p.variance(),
542                                "ess": p.effective_sample_size(),
543                            }),
544                        )
545                    })
546                    .collect();
547                Ok(Value::Object(configs))
548            }
549        }
550    }
551
552    // ── brain.events ──────────────────────────────────────────────────────
553
554    async fn handle_events(
555        &self,
556        token: &NamespaceToken,
557        params: Value,
558    ) -> Result<Value, RuntimeError> {
559        #[derive(Deserialize)]
560        #[serde(deny_unknown_fields)]
561        struct EventsParams {
562            limit: Option<u32>,
563        }
564        let p: EventsParams = serde_json::from_value(params)
565            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
566
567        let limit = p.limit.unwrap_or(20).min(100);
568        let ns = token.namespace().as_str().to_string();
569
570        let store = self.runtime.events(token)?;
571        let filter = EventFilter {
572            verbs: vec![
573                "recall".into(),
574                "search".into(),
575                "brain.feedback".into(),
576                "brain.emit".into(), // retained for backward-compat queries
577                "get".into(),
578                "remember".into(),
579            ],
580            ..EventFilter::default()
581        };
582        let _ = ns;
583        let page = store
584            .query_events(filter, PageRequest { offset: 0, limit })
585            .await
586            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
587
588        let events: Vec<Value> = page
589            .items
590            .iter()
591            .map(|e| {
592                json!({
593                    "id": e.id.to_string(),
594                    "verb": e.verb,
595                    "outcome": e.outcome,
596                    "target_id": e.target_id.map(|t| t.to_string()),
597                    "duration_us": e.duration_us,
598                    "created_at": micros_to_iso(e.created_at),
599                    "payload": e.payload,
600                })
601            })
602            .collect();
603
604        Ok(json!({
605            "count": events.len(),
606            "events": events,
607        }))
608    }
609
610    // ── brain.profiles ────────────────────────────────────────────────────
611
612    async fn handle_profiles(&self, params: Value) -> Result<Value, RuntimeError> {
613        #[derive(Deserialize)]
614        #[serde(deny_unknown_fields)]
615        struct ProfilesParams {
616            lifecycle: Option<String>,
617        }
618        let p: ProfilesParams = serde_json::from_value(params)
619            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
620
621        let state = self.state.lock().unwrap();
622        // UE5-H1: only expose the three public-facing lifecycle states.
623        // `defined` and `registered` are internal implementation states that
624        // callers cannot filter on; listing them in the error would confuse users.
625        let filter_lc: Option<ProfileLifecycle> = p
626            .lifecycle
627            .as_deref()
628            .map(|s| match s {
629                "active" => Ok(ProfileLifecycle::Active),
630                "inactive" => Ok(ProfileLifecycle::Inactive),
631                "archived" => Ok(ProfileLifecycle::Archived),
632                other => Err(RuntimeError::InvalidInput(format!(
633                    "invalid lifecycle {other:?}; expected one of 'active', 'inactive', 'archived'"
634                ))),
635            })
636            .transpose()?;
637
638        let profiles: Vec<&ProfileRecord> = state
639            .profiles
640            .values()
641            .filter(|r| filter_lc.as_ref().is_none_or(|lc| &r.lifecycle == lc))
642            .collect();
643
644        let items: Vec<Value> = profiles
645            .iter()
646            .map(|r| {
647                json!({
648                    "id": r.id,
649                    "description": r.description,
650                    "consumer_kind": r.consumer_kind,
651                    "state_class": r.state_class,
652                    "lifecycle": r.lifecycle,
653                    "total_events": r.total_events,
654                    "exploration_epoch": r.exploration_epoch,
655                    "created_at": r.created_at,
656                })
657            })
658            .collect();
659
660        Ok(json!({ "count": items.len(), "profiles": items }))
661    }
662
663    // ── brain.profile ─────────────────────────────────────────────────────
664
665    async fn handle_profile(&self, params: Value) -> Result<Value, RuntimeError> {
666        // H4: accept both `profile_id` (canonical) and `id` (legacy alias).
667        #[derive(Deserialize)]
668        #[serde(deny_unknown_fields)]
669        struct ProfileParams {
670            profile_id: Option<String>,
671            id: Option<String>,
672        }
673        let p: ProfileParams = serde_json::from_value(params)
674            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
675        let profile_id = p
676            .profile_id
677            .or(p.id)
678            .ok_or_else(|| RuntimeError::InvalidInput("missing field `profile_id`".into()))?;
679
680        let state = self.state.lock().unwrap();
681        let record = state
682            .profiles
683            .get(&profile_id)
684            .ok_or_else(|| RuntimeError::NotFound(format!("profile {:?}", profile_id)))?;
685
686        // Build per-section posterior summary for the response (ADR-048 §Phase1).
687        let section_summary = if let Some(ss) = state.section_states.get(&profile_id) {
688            let weights = derive_deterministic_weights(ss);
689            let mut sections_json: serde_json::Map<String, Value> =
690                serde_json::Map::with_capacity(ss.posteriors.len());
691            for (section, posterior) in &ss.posteriors {
692                let w = weights.get(section).copied().unwrap_or(0.0);
693                sections_json.insert(
694                    section.as_str().to_owned(),
695                    json!({
696                        "alpha": posterior.alpha,
697                        "beta": posterior.beta,
698                        "mean": posterior.mean(),
699                        "variance": posterior.variance(),
700                        "ess": posterior.effective_sample_size(),
701                        "weight": w,
702                    }),
703                );
704            }
705            Value::Object(sections_json)
706        } else {
707            Value::Null
708        };
709
710        Ok(json!({
711            "id": record.id,
712            "description": record.description,
713            "consumer_kind": record.consumer_kind,
714            "state_class": record.state_class,
715            "lifecycle": record.lifecycle,
716            "total_events": record.total_events,
717            "exploration_epoch": record.exploration_epoch,
718            "created_at": record.created_at,
719            "state_snapshot": record.state_snapshot,
720            "section_posteriors": section_summary,
721        }))
722    }
723
724    // ── brain.resolve ─────────────────────────────────────────────────────
725
726    async fn handle_resolve(&self, params: Value) -> Result<Value, RuntimeError> {
727        #[derive(Deserialize)]
728        #[serde(deny_unknown_fields)]
729        struct ResolveParams {
730            actor: Option<String>,
731            namespace: Option<String>,
732            consumer_kind: String,
733        }
734        let p: ResolveParams = serde_json::from_value(params)
735            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
736
737        let state = self.state.lock().unwrap();
738        // H3: return requested_consumer_kind + matched_consumer_kind separately so
739        // callers can distinguish a wildcard-binding match ("*") from an exact match.
740        match state.resolve_with_match(p.actor.as_deref(), p.namespace.as_deref(), &p.consumer_kind)
741        {
742            Some((record, matched_kind)) => Ok(json!({
743                "resolved_profile_id": record.id,
744                "lifecycle": record.lifecycle,
745                "requested_consumer_kind": p.consumer_kind,
746                "matched_consumer_kind": matched_kind,
747            })),
748            None => Err(RuntimeError::NotFound(format!(
749                "no profile resolved for consumer_kind={:?}",
750                p.consumer_kind
751            ))),
752        }
753    }
754
755    // ── brain.activate / deactivate / archive ─────────────────────────────
756
757    async fn handle_activate(&self, params: Value) -> Result<Value, RuntimeError> {
758        self.set_lifecycle(params, ProfileLifecycle::Active).await
759    }
760
761    async fn handle_deactivate(&self, params: Value) -> Result<Value, RuntimeError> {
762        self.set_lifecycle(params, ProfileLifecycle::Inactive).await
763    }
764
765    async fn handle_archive(&self, params: Value) -> Result<Value, RuntimeError> {
766        self.set_lifecycle(params, ProfileLifecycle::Archived).await
767    }
768
769    async fn set_lifecycle(
770        &self,
771        params: Value,
772        lifecycle: ProfileLifecycle,
773    ) -> Result<Value, RuntimeError> {
774        #[derive(Deserialize)]
775        #[serde(deny_unknown_fields)]
776        struct LifecycleParams {
777            profile_id: String,
778        }
779        let p: LifecycleParams = serde_json::from_value(params)
780            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
781
782        let mut state = self.state.lock().unwrap();
783        let record = state
784            .profiles
785            .get_mut(&p.profile_id)
786            .ok_or_else(|| RuntimeError::NotFound(format!("profile {:?}", p.profile_id)))?;
787
788        // Lifecycle DAG (ADR-032 §10):
789        //   defined   → registered → active ⟷ inactive → archived
790        //                         ↗
791        //
792        // Terminal state: archived is read-only/audit-retained.
793        // No transition OUT of archived is legal.
794        //
795        // Active → archived is also illegal; must deactivate first.
796        match (&record.lifecycle, &lifecycle) {
797            // archived is terminal — nothing leaves it
798            (ProfileLifecycle::Archived, _) => {
799                return Err(RuntimeError::InvalidInput(format!(
800                    "profile {:?} is archived; archive is terminal and no transition is permitted",
801                    p.profile_id
802                )));
803            }
804            // active → archived is illegal (must go through inactive first)
805            (ProfileLifecycle::Active, ProfileLifecycle::Archived) => {
806                return Err(RuntimeError::InvalidInput(format!(
807                    "profile {:?} is active; deactivate it before archiving",
808                    p.profile_id
809                )));
810            }
811            // all other transitions are permitted
812            _ => {}
813        }
814
815        record.lifecycle = lifecycle.clone();
816        Ok(json!({
817            "profile_id": p.profile_id,
818            "lifecycle": lifecycle,
819        }))
820    }
821
822    // ── brain.reset ───────────────────────────────────────────────────────
823
824    async fn handle_reset(&self, params: Value) -> Result<Value, RuntimeError> {
825        // profile_id is optional; defaults to "balanced-recall-v1".
826        #[derive(Deserialize)]
827        #[serde(deny_unknown_fields)]
828        struct ResetParams {
829            profile_id: Option<String>,
830        }
831        let p: ResetParams = serde_json::from_value(params)
832            .map_err(|e| RuntimeError::InvalidInput(format!("brain.reset: {e}")))?;
833        let profile_id = p
834            .profile_id
835            .unwrap_or_else(|| "balanced-recall-v1".to_string());
836        let mut state = self.state.lock().unwrap();
837
838        // Validate profile exists.
839        let lifecycle = state
840            .profiles
841            .get(&profile_id)
842            .ok_or_else(|| RuntimeError::NotFound(format!("profile {:?}", profile_id)))?
843            .lifecycle
844            .clone();
845
846        // Reject archived profiles — archive is terminal and read-only.
847        if lifecycle == ProfileLifecycle::Archived {
848            return Err(RuntimeError::InvalidInput(format!(
849                "profile {:?} is archived; archive is terminal and reset is not permitted",
850                profile_id
851            )));
852        }
853
854        if profile_id == "balanced-recall-v1" {
855            state.reset_posteriors();
856            // Fix #295: sync profile record after reset so brain.profile reflects
857            // the restored domain-informed priors, not stale pre-reset values.
858            sync_balanced_recall_record(&mut state);
859        } else if state.profile_states.contains_key(&profile_id) {
860            // User-created Bayesian profile — reset its own posteriors.
861            state.reset_profile_posteriors(&profile_id);
862        } else {
863            // Profile exists in registry but has no live state (e.g. non-Bayesian).
864            // Increment exploration_epoch on the record to mark the reset event.
865            if let Some(record) = state.profiles.get_mut(&profile_id) {
866                record.exploration_epoch += 1;
867            }
868        }
869
870        let epoch = if profile_id == "balanced-recall-v1" {
871            state.balanced_recall.exploration_epoch
872        } else {
873            state.profiles[&profile_id].exploration_epoch
874        };
875
876        Ok(json!({
877            "reset": true,
878            "profile_id": profile_id,
879            "exploration_epoch": epoch,
880        }))
881    }
882
883    // ── brain.feedback ────────────────────────────────────────────────────
884
885    async fn handle_feedback(
886        &self,
887        token: &NamespaceToken,
888        params: Value,
889    ) -> Result<Value, RuntimeError> {
890        let feedback_start = Instant::now();
891
892        #[derive(Deserialize)]
893        #[serde(deny_unknown_fields)]
894        struct FeedbackParams {
895            target_id: String,
896            signal: String,
897            served_by_profile_id: Option<String>,
898            section_signals: Option<serde_json::Value>,
899        }
900        let p: FeedbackParams = serde_json::from_value(params)
901            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
902
903        let target: uuid::Uuid = p
904            .target_id
905            .parse()
906            .map_err(|e| RuntimeError::InvalidInput(format!("invalid target_id: {e}")))?;
907
908        let signal = match p.signal.as_str() {
909            "useful" => "useful",
910            "not_useful" => "not_useful",
911            "wrong" => "wrong",
912            // Issue #268: semantic event taxonomy names are also valid.
913            "explicit_positive" => "explicit_positive",
914            "explicit_negative" => "explicit_negative",
915            "implicit_positive" => "implicit_positive",
916            "implicit_negative" => "implicit_negative",
917            "correction" => "correction",
918            other => {
919                return Err(RuntimeError::InvalidInput(format!(
920                    "unknown signal {other:?}; valid: useful | not_useful | wrong | \
921                     explicit_positive | explicit_negative | implicit_positive | \
922                     implicit_negative | correction"
923                )))
924            }
925        };
926
927        // C4: validate target_id resolves to an existing record in this namespace.
928        let resolved = self
929            .runtime
930            .resolve(token, target)
931            .await
932            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
933        if resolved.is_none() {
934            return Err(RuntimeError::NotFound(format!(
935                "target_id {:?} not found in namespace {:?}",
936                target,
937                token.namespace().as_str()
938            )));
939        }
940
941        // C4: compute the effective serving profile (explicit or default), then validate
942        // that it exists in the registry and is not Archived (ADR-032 §1106).
943        let effective_profile = p
944            .served_by_profile_id
945            .as_deref()
946            .unwrap_or("balanced-recall-v1");
947        {
948            let state = self.state.lock().unwrap();
949            match state.profiles.get(effective_profile) {
950                None => {
951                    return Err(RuntimeError::NotFound(format!(
952                        "serving profile {:?} not found in profile registry",
953                        effective_profile
954                    )));
955                }
956                Some(rec) if rec.lifecycle == crate::state::ProfileLifecycle::Archived => {
957                    return Err(RuntimeError::InvalidInput(format!(
958                        "serving profile {:?} is archived; feedback cannot credit archived profiles",
959                        effective_profile
960                    )));
961                }
962                Some(_) => {}
963            }
964        }
965
966        let mut data = json!({"signal": signal});
967        if let Some(ref profile_id) = p.served_by_profile_id {
968            data["served_by_profile_id"] = json!(profile_id);
969        }
970        if let Some(ref ss) = p.section_signals {
971            data["section_signals"] = ss.clone();
972        }
973
974        let duration_us = feedback_start.elapsed().as_micros().max(1) as i64;
975        let event = Event::new(
976            token.namespace().as_str().to_string(),
977            "brain.feedback",
978            khive_types::EventKind::FeedbackExplicit,
979            khive_types::SubstrateKind::Event,
980            "brain",
981        )
982        .with_target(target)
983        .with_payload(data)
984        .with_duration_us(duration_us);
985
986        let store = self.runtime.events(token)?;
987        store
988            .append_event(event.clone())
989            .await
990            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
991
992        let serving_profile_owned = p
993            .served_by_profile_id
994            .as_deref()
995            .unwrap_or("balanced-recall-v1")
996            .to_string();
997
998        {
999            let ctx = FoldContext::new();
1000            let mut state = self.state.lock().unwrap();
1001            let serving_profile = serving_profile_owned.as_str();
1002
1003            if serving_profile == "balanced-recall-v1" {
1004                let current_recall = std::mem::replace(
1005                    &mut state.balanced_recall,
1006                    crate::state::BalancedRecallState::new(0),
1007                );
1008                let updated = self.fold.reduce(current_recall, &event, &ctx);
1009                state.balanced_recall = updated;
1010                sync_balanced_recall_record(&mut state);
1011            } else if state.profile_states.contains_key(serving_profile) {
1012                let current = state
1013                    .profile_states
1014                    .remove(serving_profile)
1015                    .expect("key checked above");
1016                let updated = self.fold.reduce(current, &event, &ctx);
1017                let snap = serde_json::to_value(updated.to_snapshot()).ok();
1018                let total = updated.total_events;
1019                state
1020                    .profile_states
1021                    .insert(serving_profile.to_string(), updated);
1022                if let Some(record) = state.profiles.get_mut(serving_profile) {
1023                    record.total_events = total;
1024                    record.state_snapshot = snap;
1025                }
1026            } else {
1027                let current_recall = std::mem::replace(
1028                    &mut state.balanced_recall,
1029                    crate::state::BalancedRecallState::new(0),
1030                );
1031                let updated = self.fold.reduce(current_recall, &event, &ctx);
1032                state.balanced_recall = updated;
1033                sync_balanced_recall_record(&mut state);
1034            }
1035
1036            if let Some(section_state) = state.section_states.remove(serving_profile) {
1037                let updated = self.section_fold.reduce(section_state, &event, &ctx);
1038                state
1039                    .section_states
1040                    .insert(serving_profile.to_string(), updated);
1041            }
1042        }
1043
1044        // Persist feedback to brain_event_log; batch-upsert snapshot when dirty threshold reached.
1045        if let Err(e) = persist::persist_after_feedback(
1046            &self.runtime,
1047            token,
1048            &self.persistence,
1049            &self.state,
1050            &event,
1051            &serving_profile_owned,
1052        )
1053        .await
1054        {
1055            eprintln!("[brain] persistence failed (non-fatal): {e}");
1056        }
1057
1058        Ok(json!({
1059            "emitted": true,
1060            "event_id": event.id.to_string(),
1061            "verb": "brain.feedback",
1062            "signal": signal,
1063            "target_id": target.to_string(),
1064        }))
1065    }
1066
1067    // ── brain.auto_feedback ───────────────────────────────────────────────
1068
1069    /// Convenience verb for agents: emit implicit feedback for the first result
1070    /// returned by `memory.recall` without requiring a full UUID (#517).
1071    ///
1072    /// Accepts an 8-char Agent-mode `note_id` prefix (as returned by default
1073    /// `memory.recall` output) or a full 36-char UUID. If `results` is empty,
1074    /// returns `{"emitted": false, "reason": "no_results"}` without error.
1075    async fn handle_auto_feedback(
1076        &self,
1077        token: &NamespaceToken,
1078        params: Value,
1079    ) -> Result<Value, RuntimeError> {
1080        #[derive(Deserialize)]
1081        #[serde(deny_unknown_fields)]
1082        struct AutoFeedbackParams {
1083            query: String,
1084            results: Vec<AutoFeedbackResult>,
1085            signal: Option<String>,
1086            served_by_profile_id: Option<String>,
1087        }
1088
1089        #[derive(Deserialize)]
1090        struct AutoFeedbackResult {
1091            note_id: String,
1092        }
1093
1094        let p: AutoFeedbackParams = serde_json::from_value(params)
1095            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
1096        if p.query.trim().is_empty() {
1097            return Err(RuntimeError::InvalidInput(
1098                "auto_feedback: `query` must not be empty".into(),
1099            ));
1100        }
1101
1102        let Some(first) = p.results.first() else {
1103            return Ok(json!({
1104                "emitted": false,
1105                "verb": "brain.auto_feedback",
1106                "reason": "no_results",
1107            }));
1108        };
1109
1110        let target = resolve_auto_feedback_target(&self.runtime, token, &first.note_id).await?;
1111
1112        let mut feedback_params = json!({
1113            "target_id": target.to_string(),
1114            "signal": p.signal.as_deref().unwrap_or("implicit_positive"),
1115        });
1116        if let Some(ref profile_id) = p.served_by_profile_id {
1117            feedback_params["served_by_profile_id"] = json!(profile_id);
1118        }
1119
1120        let mut out = self.handle_feedback(token, feedback_params).await?;
1121        out["verb"] = json!("brain.auto_feedback");
1122        out["feedback_verb"] = json!("brain.feedback");
1123        out["result_count"] = json!(p.results.len());
1124        Ok(out)
1125    }
1126
1127    // ── brain.emit (deprecated) ───────────────────────────────────────────
1128
1129    /// Deprecated: use `brain.feedback`. Kept for backward-compat; routes to
1130    /// `handle_feedback` with the same parameters.
1131    async fn handle_emit(
1132        &self,
1133        token: &NamespaceToken,
1134        params: Value,
1135    ) -> Result<Value, RuntimeError> {
1136        self.handle_feedback(token, params).await
1137    }
1138
1139    // ── brain.bind ────────────────────────────────────────────────────────
1140
1141    async fn handle_bind(&self, params: Value) -> Result<Value, RuntimeError> {
1142        #[derive(Deserialize)]
1143        #[serde(deny_unknown_fields)]
1144        struct BindParams {
1145            profile_id: String,
1146            actor: Option<String>,
1147            namespace: Option<String>,
1148            consumer_kind: Option<String>,
1149            priority: Option<i32>,
1150        }
1151        let p: BindParams = serde_json::from_value(params)
1152            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
1153
1154        let mut state = self.state.lock().unwrap();
1155
1156        // Verify the profile exists and is not archived (C3: archived = terminal, no new bindings).
1157        match state.profiles.get(&p.profile_id) {
1158            None => {
1159                return Err(RuntimeError::NotFound(format!(
1160                    "profile {:?}",
1161                    p.profile_id
1162                )));
1163            }
1164            Some(record) if record.lifecycle == ProfileLifecycle::Archived => {
1165                return Err(RuntimeError::InvalidInput(format!(
1166                    "profile {:?} is archived; bindings to archived profiles are not permitted",
1167                    p.profile_id
1168                )));
1169            }
1170            Some(_) => {}
1171        }
1172
1173        let actor = p.actor.unwrap_or_else(|| "*".into());
1174        let namespace = p.namespace.unwrap_or_else(|| "*".into());
1175        let consumer_kind = p.consumer_kind.unwrap_or_else(|| "*".into());
1176
1177        // Validate that '*' is not used as a real value (ADR-032 §10 wildcard sentinel)
1178        for (field, val) in [
1179            ("actor", &actor),
1180            ("namespace", &namespace),
1181            ("consumer_kind", &consumer_kind),
1182        ] {
1183            if val.as_str() != "*" && val.contains('*') {
1184                return Err(RuntimeError::InvalidInput(format!(
1185                    "{field}: '*' is reserved as the wildcard sentinel and cannot appear inside a real value"
1186                )));
1187            }
1188        }
1189
1190        // Remove any existing binding for the same (actor, namespace, consumer_kind)
1191        state.bindings.retain(|b| {
1192            !(b.actor == actor && b.namespace == namespace && b.consumer_kind == consumer_kind)
1193        });
1194
1195        state.bindings.push(ProfileBinding {
1196            actor: actor.clone(),
1197            namespace: namespace.clone(),
1198            consumer_kind: consumer_kind.clone(),
1199            profile_id: p.profile_id.clone(),
1200            priority: p.priority.unwrap_or(0),
1201            created_at: Utc::now(),
1202        });
1203
1204        Ok(json!({
1205            "bound": true,
1206            "profile_id": p.profile_id,
1207            "actor": actor,
1208            "namespace": namespace,
1209            "consumer_kind": consumer_kind,
1210        }))
1211    }
1212
1213    // ── brain.unbind ──────────────────────────────────────────────────────
1214
1215    async fn handle_unbind(&self, params: Value) -> Result<Value, RuntimeError> {
1216        #[derive(Deserialize)]
1217        #[serde(deny_unknown_fields)]
1218        struct UnbindParams {
1219            profile_id: Option<String>,
1220            actor: Option<String>,
1221            namespace: Option<String>,
1222            consumer_kind: Option<String>,
1223        }
1224        let p: UnbindParams = serde_json::from_value(params)
1225            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
1226
1227        // C2: require at least one non-null filter to prevent accidental bulk-delete.
1228        if p.profile_id.is_none()
1229            && p.actor.is_none()
1230            && p.namespace.is_none()
1231            && p.consumer_kind.is_none()
1232        {
1233            return Err(RuntimeError::InvalidInput(
1234                "unbind requires at least one filter; pass profile_id, actor, namespace, or consumer_kind".into(),
1235            ));
1236        }
1237
1238        let mut state = self.state.lock().unwrap();
1239        let before = state.bindings.len();
1240
1241        state.bindings.retain(|b| {
1242            let pid_match = p.profile_id.as_ref().is_none_or(|id| &b.profile_id == id);
1243            let actor_match = p.actor.as_ref().is_none_or(|a| &b.actor == a);
1244            let ns_match = p.namespace.as_ref().is_none_or(|n| &b.namespace == n);
1245            let kind_match = p
1246                .consumer_kind
1247                .as_ref()
1248                .is_none_or(|k| &b.consumer_kind == k);
1249            // Retain if this binding does NOT match ALL of the provided filters.
1250            // A filter that is absent (None) matches everything — only bindings
1251            // satisfying every supplied criterion are removed.
1252            !(pid_match && actor_match && ns_match && kind_match)
1253        });
1254
1255        let removed = before - state.bindings.len();
1256        Ok(json!({ "unbound": removed }))
1257    }
1258
1259    // ── brain.bindings ────────────────────────────────────────────────────
1260
1261    async fn handle_bindings(&self, params: Value) -> Result<Value, RuntimeError> {
1262        // H2: inspection verb — list binding rows, optionally filtered.
1263        #[derive(Deserialize)]
1264        struct BindingsParams {
1265            profile_id: Option<String>,
1266            actor: Option<String>,
1267            namespace: Option<String>,
1268            consumer_kind: Option<String>,
1269        }
1270        let p: BindingsParams = serde_json::from_value(params)
1271            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
1272
1273        let state = self.state.lock().unwrap();
1274        let rows: Vec<Value> = state
1275            .bindings
1276            .iter()
1277            .filter(|b| {
1278                p.profile_id.as_ref().is_none_or(|id| &b.profile_id == id)
1279                    && p.actor.as_ref().is_none_or(|a| &b.actor == a)
1280                    && p.namespace.as_ref().is_none_or(|n| &b.namespace == n)
1281                    && p.consumer_kind
1282                        .as_ref()
1283                        .is_none_or(|k| &b.consumer_kind == k)
1284            })
1285            .map(|b| {
1286                json!({
1287                    "profile_id": b.profile_id,
1288                    "actor": b.actor,
1289                    "namespace": b.namespace,
1290                    "consumer_kind": b.consumer_kind,
1291                    "priority": b.priority,
1292                    "created_at": b.created_at,
1293                })
1294            })
1295            .collect();
1296
1297        Ok(json!({ "count": rows.len(), "bindings": rows }))
1298    }
1299
1300    // ── brain.create_profile ──────────────────────────────────────────────
1301
1302    async fn handle_create_profile(&self, params: Value) -> Result<Value, RuntimeError> {
1303        // H1: allow external agents to create new profiles via MCP.
1304        // seed_priors: optional object for seeding section priors, or null for defaults.
1305        #[derive(Deserialize)]
1306        struct CreateProfileParams {
1307            name: String,
1308            description: Option<String>,
1309            consumer_kind: Option<String>,
1310            seed_priors: Option<serde_json::Value>,
1311        }
1312        let p: CreateProfileParams = serde_json::from_value(params)
1313            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
1314
1315        // Validate profile-id grammar: trim, reject empty, enforce ASCII alphanumeric + hyphen.
1316        let name = p.name.trim().to_string();
1317        if name.is_empty() {
1318            return Err(RuntimeError::InvalidInput(
1319                "name must not be empty or whitespace-only".into(),
1320            ));
1321        }
1322        if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
1323            return Err(RuntimeError::InvalidInput(format!(
1324                "name {:?} is invalid; profile IDs must match [a-zA-Z0-9-]+ (alphanumeric and hyphens only)",
1325                name
1326            )));
1327        }
1328        let p_name = name;
1329
1330        // High: validate consumer_kind — reject empty/whitespace and wildcard sentinel.
1331        let consumer_kind = p.consumer_kind.unwrap_or_else(|| "recall".into());
1332        let ck_trimmed = consumer_kind.trim();
1333        if ck_trimmed.is_empty() {
1334            return Err(RuntimeError::InvalidInput(
1335                "consumer_kind must not be empty or whitespace".into(),
1336            ));
1337        }
1338        if ck_trimmed == "*" {
1339            return Err(RuntimeError::InvalidInput(
1340                "consumer_kind '*' is the wildcard sentinel and is not permitted for profile creation; provide a specific operation kind (e.g. \"recall\", \"search\")"
1341                    .into(),
1342            ));
1343        }
1344
1345        let description = p
1346            .description
1347            .unwrap_or_else(|| format!("User-created profile: {}", p_name));
1348
1349        let mut state = self.state.lock().unwrap();
1350
1351        if state.profiles.contains_key(&p_name) {
1352            return Err(RuntimeError::InvalidInput(format!(
1353                "profile {:?} already exists",
1354                p_name
1355            )));
1356        }
1357
1358        // Initialize live BalancedRecallState for this profile so that reset and
1359        // feedback can route to its actual posteriors rather than a metadata-only record.
1360        let ps = crate::state::BalancedRecallState::new(ENTITY_CACHE_CAPACITY);
1361        let snap = serde_json::to_value(ps.to_snapshot()).ok();
1362
1363        let record = ProfileRecord {
1364            id: p_name.clone(),
1365            description: description.clone(),
1366            consumer_kind: consumer_kind.clone(),
1367            state_class: "Bayesian".into(),
1368            lifecycle: ProfileLifecycle::Inactive,
1369            created_at: Utc::now(),
1370            state_snapshot: snap,
1371            total_events: 0,
1372            exploration_epoch: 0,
1373        };
1374
1375        // Seed section posteriors: parse explicit section_posteriors object if provided,
1376        // else use default informative priors from ADR-048 §5.
1377        let section_state = if let Some(ref seed) = p.seed_priors {
1378            if let Some(sp_obj) = seed.get("section_posteriors").and_then(|v| v.as_object()) {
1379                let mut priors = std::collections::HashMap::new();
1380                for (key, val) in sp_obj {
1381                    let st: SectionType = key.parse().map_err(|_| {
1382                        RuntimeError::InvalidInput(format!("unknown section type: {key:?}"))
1383                    })?;
1384                    let alpha = val.get("alpha").and_then(|v| v.as_f64()).ok_or_else(|| {
1385                        RuntimeError::InvalidInput(format!(
1386                            "missing or invalid alpha for section {key:?}"
1387                        ))
1388                    })?;
1389                    let beta = val.get("beta").and_then(|v| v.as_f64()).ok_or_else(|| {
1390                        RuntimeError::InvalidInput(format!(
1391                            "missing or invalid beta for section {key:?}"
1392                        ))
1393                    })?;
1394                    if alpha <= 0.0 || beta <= 0.0 {
1395                        return Err(RuntimeError::InvalidInput(format!(
1396                            "alpha and beta must be positive for section {key:?}; got alpha={alpha}, beta={beta}"
1397                        )));
1398                    }
1399                    priors.insert(st, crate::state::BetaPosterior::new(alpha, beta));
1400                }
1401                SectionPosteriorState::from_priors(priors)
1402            } else {
1403                return Err(RuntimeError::InvalidInput(
1404                    "seed_priors must contain a 'section_posteriors' object".into(),
1405                ));
1406            }
1407        } else {
1408            SectionPosteriorState::new()
1409        };
1410
1411        state.profiles.insert(p_name.clone(), record);
1412        state.profile_states.insert(p_name.clone(), ps);
1413        state.section_states.insert(p_name.clone(), section_state);
1414
1415        Ok(json!({
1416            "created": true,
1417            "profile_id": p_name,
1418            "consumer_kind": consumer_kind,
1419            "lifecycle": "inactive",
1420            "description": description,
1421        }))
1422    }
1423}
1424
1425// ── Inventory self-registration ───────────────────────────────────────────────
1426
1427struct BrainPackFactory;
1428
1429impl khive_runtime::PackFactory for BrainPackFactory {
1430    fn name(&self) -> &'static str {
1431        "brain"
1432    }
1433
1434    fn requires(&self) -> &'static [&'static str] {
1435        &["kg"]
1436    }
1437
1438    fn create(&self, runtime: KhiveRuntime) -> Box<dyn PackRuntime> {
1439        Box::new(BrainPack::new(runtime))
1440    }
1441}
1442
1443inventory::submit! { khive_runtime::PackRegistration(&BrainPackFactory) }
1444
1445// ── PackRuntime impl ──────────────────────────────────────────────────────────
1446
1447#[async_trait]
1448impl PackRuntime for BrainPack {
1449    fn name(&self) -> &str {
1450        <BrainPack as Pack>::NAME
1451    }
1452
1453    fn note_kinds(&self) -> &'static [&'static str] {
1454        <BrainPack as Pack>::NOTE_KINDS
1455    }
1456
1457    fn entity_kinds(&self) -> &'static [&'static str] {
1458        <BrainPack as Pack>::ENTITY_KINDS
1459    }
1460
1461    fn handlers(&self) -> &'static [HandlerDef] {
1462        BRAIN_HANDLERS
1463    }
1464
1465    fn requires(&self) -> &'static [&'static str] {
1466        <BrainPack as Pack>::REQUIRES
1467    }
1468
1469    async fn dispatch(
1470        &self,
1471        verb: &str,
1472        params: Value,
1473        _registry: &VerbRegistry,
1474        token: &NamespaceToken,
1475    ) -> Result<Value, RuntimeError> {
1476        self.ensure_loaded(token).await?;
1477        match verb {
1478            // Assertive
1479            "brain.state" => self.handle_state(params).await,
1480            "brain.config" => self.handle_config(params).await,
1481            "brain.events" => self.handle_events(token, params).await,
1482            "brain.profiles" => self.handle_profiles(params).await,
1483            "brain.profile" => self.handle_profile(params).await,
1484            "brain.resolve" => self.handle_resolve(params).await,
1485            "brain.bindings" => self.handle_bindings(params).await,
1486            // Commissive
1487            "brain.activate" => self.handle_activate(params).await,
1488            "brain.deactivate" => self.handle_deactivate(params).await,
1489            "brain.archive" => self.handle_archive(params).await,
1490            "brain.reset" => self.handle_reset(params).await,
1491            "brain.feedback" => self.handle_feedback(token, params).await,
1492            "brain.auto_feedback" => self.handle_auto_feedback(token, params).await,
1493            // Declaration
1494            "brain.bind" => self.handle_bind(params).await,
1495            "brain.unbind" => self.handle_unbind(params).await,
1496            "brain.create_profile" => self.handle_create_profile(params).await,
1497            // Legacy
1498            "brain.emit" => self.handle_emit(token, params).await,
1499            _ => Err(RuntimeError::InvalidInput(format!(
1500                "brain pack does not handle verb {verb:?}"
1501            ))),
1502        }
1503    }
1504}
1505
1506// ── brain.auto_feedback helpers ───────────────────────────────────────────────
1507
1508/// Resolve a `note_id` from `memory.recall` output to a full UUID.
1509///
1510/// Accepts a 36-char UUID directly, or an 8-char hex prefix (Agent-mode short
1511/// form). Returns `InvalidInput` if neither form matches or the prefix is
1512/// ambiguous.
1513async fn resolve_auto_feedback_target(
1514    runtime: &KhiveRuntime,
1515    token: &NamespaceToken,
1516    raw: &str,
1517) -> Result<uuid::Uuid, RuntimeError> {
1518    if let Ok(uuid) = raw.parse::<uuid::Uuid>() {
1519        return Ok(uuid);
1520    }
1521    if raw.len() >= 8 && raw.chars().all(|c| c.is_ascii_hexdigit()) {
1522        return runtime
1523            .resolve_prefix(token, raw)
1524            .await
1525            .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?
1526            .ok_or_else(|| {
1527                RuntimeError::InvalidInput(format!(
1528                    "auto_feedback: no record matches note_id prefix: {raw:?}"
1529                ))
1530            });
1531    }
1532    Err(RuntimeError::InvalidInput(format!(
1533        "auto_feedback: invalid note_id {raw:?}; expected full UUID or 8-char hex prefix"
1534    )))
1535}
1536
1537// ── DispatchHook impl ─────────────────────────────────────────────────────────
1538
1539/// `BrainPack` as a post-dispatch hook.
1540///
1541/// When registered via `VerbRegistryBuilder::with_dispatch_hook`, every
1542/// successful verb dispatch calls `on_dispatch` with a synthesized `Event`.
1543/// The event is fed into `BalancedRecallFold::reduce`, updating the brain's
1544/// posteriors in real time — no polling required.
1545#[async_trait]
1546impl DispatchHook for BrainPack {
1547    async fn on_dispatch(&self, view: &EventView) {
1548        // Fix #357 (MAJ-004): Brain observes pack events only — it must never
1549        // process its own state-transition events (ADR-032 §1). Skipping
1550        // brain.* verbs here prevents double-counting: handle_feedback already
1551        // calls fold.reduce directly, so the hook firing afterward would
1552        // increment total_events a second time.
1553        if view.event.verb.starts_with("brain.") {
1554            return;
1555        }
1556
1557        let ctx = FoldContext::new();
1558        let mut state = self.state.lock().unwrap();
1559        let current = std::mem::replace(
1560            &mut state.balanced_recall,
1561            crate::state::BalancedRecallState::new(0),
1562        );
1563        let updated = self.fold.reduce(current, &view.event, &ctx);
1564        state.balanced_recall = updated;
1565
1566        // Fix #356 (MAJ-003): sync profile record after every hook fire so that
1567        // brain.profile reflects the live total_events and state_snapshot.
1568        sync_balanced_recall_record(&mut state);
1569    }
1570}
1571
1572// ── Tests ─────────────────────────────────────────────────────────────────────
1573
1574#[cfg(test)]
1575mod tests {
1576    use super::*;
1577    use khive_runtime::{Namespace, VerbRegistryBuilder};
1578    use serde_json::json;
1579
1580    fn make_pack() -> (BrainPack, KhiveRuntime) {
1581        let rt = KhiveRuntime::memory().expect("in-memory runtime");
1582        let pack = BrainPack::new(rt.clone());
1583        (pack, rt)
1584    }
1585
1586    fn empty_registry() -> VerbRegistry {
1587        VerbRegistryBuilder::new()
1588            .build()
1589            .expect("empty registry builds successfully")
1590    }
1591
1592    /// Create a real entity in the runtime and return its UUID string.
1593    /// Used by feedback tests that need a valid target_id (C4 validation).
1594    async fn create_test_entity(rt: &KhiveRuntime, token: &NamespaceToken) -> String {
1595        let entity = rt
1596            .create_entity(token, "concept", None, "test-target", None, None, vec![])
1597            .await
1598            .expect("create test entity");
1599        entity.id.to_string()
1600    }
1601
1602    #[tokio::test]
1603    async fn dispatch_unknown_verb_returns_invalid_input() {
1604        let (pack, rt) = make_pack();
1605        let registry = empty_registry();
1606        let err = pack
1607            .dispatch(
1608                "brain.unknown",
1609                json!({}),
1610                &registry,
1611                &rt.authorize(Namespace::local()).unwrap(),
1612            )
1613            .await
1614            .unwrap_err();
1615        if let RuntimeError::InvalidInput(msg) = &err {
1616            assert!(
1617                msg.contains("brain.unknown"),
1618                "expected verb name in error: {msg}"
1619            );
1620        } else {
1621            panic!("expected InvalidInput, got {err:?}");
1622        }
1623    }
1624
1625    #[tokio::test]
1626    async fn dispatch_reset_returns_true_and_increments_epoch() {
1627        let (pack, rt) = make_pack();
1628        let registry = empty_registry();
1629        let result = pack
1630            .dispatch(
1631                "brain.reset",
1632                json!({"profile_id": "balanced-recall-v1"}),
1633                &registry,
1634                &rt.authorize(Namespace::local()).unwrap(),
1635            )
1636            .await
1637            .unwrap();
1638        assert_eq!(result["reset"], json!(true));
1639        assert_eq!(result["exploration_epoch"], json!(1u64));
1640        assert_eq!(result["profile_id"], json!("balanced-recall-v1"));
1641    }
1642
1643    #[tokio::test]
1644    async fn dispatch_reset_no_args_resets_default_profile() {
1645        // profile_id is optional; omitting it resets balanced-recall-v1 by default.
1646        let (pack, rt) = make_pack();
1647        let registry = empty_registry();
1648        let result = pack
1649            .dispatch(
1650                "brain.reset",
1651                json!({}),
1652                &registry,
1653                &rt.authorize(Namespace::local()).unwrap(),
1654            )
1655            .await
1656            .expect("reset with no args must succeed (defaults to balanced-recall-v1)");
1657        assert_eq!(result["reset"], json!(true));
1658        assert_eq!(result["profile_id"], json!("balanced-recall-v1"));
1659    }
1660
1661    #[tokio::test]
1662    async fn dispatch_reset_nonexistent_profile_returns_not_found() {
1663        let (pack, rt) = make_pack();
1664        let registry = empty_registry();
1665        let err = pack
1666            .dispatch(
1667                "brain.reset",
1668                json!({"profile_id": "ghost-profile"}),
1669                &registry,
1670                &rt.authorize(Namespace::local()).unwrap(),
1671            )
1672            .await
1673            .unwrap_err();
1674        assert!(
1675            matches!(err, RuntimeError::NotFound(_)),
1676            "reset on nonexistent profile must return NotFound, got {err:?}"
1677        );
1678    }
1679
1680    #[tokio::test]
1681    async fn dispatch_reset_archived_profile_returns_invalid_input() {
1682        let (pack, rt) = make_pack();
1683        let registry = empty_registry();
1684        let token = rt.authorize(Namespace::local()).unwrap();
1685
1686        // Archive the profile via the lifecycle DAG
1687        pack.dispatch(
1688            "brain.deactivate",
1689            json!({"profile_id": "balanced-recall-v1"}),
1690            &registry,
1691            &token,
1692        )
1693        .await
1694        .unwrap();
1695        pack.dispatch(
1696            "brain.archive",
1697            json!({"profile_id": "balanced-recall-v1"}),
1698            &registry,
1699            &token,
1700        )
1701        .await
1702        .unwrap();
1703
1704        let err = pack
1705            .dispatch(
1706                "brain.reset",
1707                json!({"profile_id": "balanced-recall-v1"}),
1708                &registry,
1709                &token,
1710            )
1711            .await
1712            .unwrap_err();
1713        if let RuntimeError::InvalidInput(msg) = &err {
1714            assert!(
1715                msg.contains("archived") || msg.contains("terminal"),
1716                "reset on archived profile must mention 'archived' or 'terminal'; got: {msg}"
1717            );
1718        } else {
1719            panic!("reset on archived profile must return InvalidInput, got {err:?}");
1720        }
1721    }
1722
1723    #[tokio::test]
1724    async fn dispatch_feedback_invalid_signal_returns_invalid_input() {
1725        let (pack, rt) = make_pack();
1726        let registry = empty_registry();
1727        let target = "00000000-0000-0000-0000-000000000001";
1728        let err = pack
1729            .dispatch(
1730                "brain.feedback",
1731                json!({"target_id": target, "signal": "bad_signal"}),
1732                &registry,
1733                &rt.authorize(Namespace::local()).unwrap(),
1734            )
1735            .await
1736            .unwrap_err();
1737        if let RuntimeError::InvalidInput(msg) = &err {
1738            assert!(
1739                msg.contains("bad_signal"),
1740                "expected signal name in error: {msg}"
1741            );
1742            assert!(
1743                msg.contains("valid"),
1744                "expected hint about valid values: {msg}"
1745            );
1746        } else {
1747            panic!("expected InvalidInput, got {err:?}");
1748        }
1749    }
1750
1751    #[tokio::test]
1752    async fn dispatch_state_returns_snapshot_fields() {
1753        let (pack, rt) = make_pack();
1754        let registry = empty_registry();
1755        let result = pack
1756            .dispatch(
1757                "brain.state",
1758                json!({}),
1759                &registry,
1760                &rt.authorize(Namespace::local()).unwrap(),
1761            )
1762            .await
1763            .unwrap();
1764        assert!(result.get("profiles").is_some(), "missing profiles");
1765        assert!(
1766            result.get("balanced_recall").is_some(),
1767            "missing balanced_recall"
1768        );
1769        assert!(result.get("bindings").is_some(), "missing bindings");
1770    }
1771
1772    #[tokio::test]
1773    async fn dispatch_profiles_returns_default_profile() {
1774        let (pack, rt) = make_pack();
1775        let registry = empty_registry();
1776        let result = pack
1777            .dispatch(
1778                "brain.profiles",
1779                json!({}),
1780                &registry,
1781                &rt.authorize(Namespace::local()).unwrap(),
1782            )
1783            .await
1784            .unwrap();
1785        let profiles = result["profiles"].as_array().unwrap();
1786        assert!(!profiles.is_empty(), "expected at least one profile");
1787        assert_eq!(profiles[0]["id"], json!("balanced-recall-v1"));
1788    }
1789
1790    #[tokio::test]
1791    async fn dispatch_profiles_filtered_by_lifecycle() {
1792        let (pack, rt) = make_pack();
1793        let registry = empty_registry();
1794        let result = pack
1795            .dispatch(
1796                "brain.profiles",
1797                json!({"lifecycle": "active"}),
1798                &registry,
1799                &rt.authorize(Namespace::local()).unwrap(),
1800            )
1801            .await
1802            .unwrap();
1803        let profiles = result["profiles"].as_array().unwrap();
1804        for p in profiles {
1805            assert_eq!(p["lifecycle"], json!("active"));
1806        }
1807    }
1808
1809    #[tokio::test]
1810    async fn dispatch_profile_returns_profile_details() {
1811        let (pack, rt) = make_pack();
1812        let registry = empty_registry();
1813        let result = pack
1814            .dispatch(
1815                "brain.profile",
1816                json!({"id": "balanced-recall-v1"}),
1817                &registry,
1818                &rt.authorize(Namespace::local()).unwrap(),
1819            )
1820            .await
1821            .unwrap();
1822        assert_eq!(result["id"], json!("balanced-recall-v1"));
1823        assert_eq!(result["state_class"], json!("Bayesian"));
1824        assert_eq!(result["consumer_kind"], json!("recall"));
1825    }
1826
1827    #[tokio::test]
1828    async fn dispatch_profile_not_found_returns_not_found() {
1829        let (pack, rt) = make_pack();
1830        let registry = empty_registry();
1831        let err = pack
1832            .dispatch(
1833                "brain.profile",
1834                json!({"id": "nonexistent"}),
1835                &registry,
1836                &rt.authorize(Namespace::local()).unwrap(),
1837            )
1838            .await
1839            .unwrap_err();
1840        assert!(matches!(err, RuntimeError::NotFound(_)));
1841    }
1842
1843    #[tokio::test]
1844    async fn dispatch_resolve_returns_default_profile_for_recall() {
1845        let (pack, rt) = make_pack();
1846        let registry = empty_registry();
1847        let result = pack
1848            .dispatch(
1849                "brain.resolve",
1850                json!({"consumer_kind": "recall"}),
1851                &registry,
1852                &rt.authorize(Namespace::local()).unwrap(),
1853            )
1854            .await
1855            .unwrap();
1856        assert_eq!(result["resolved_profile_id"], json!("balanced-recall-v1"));
1857    }
1858
1859    #[tokio::test]
1860    async fn dispatch_activate_and_deactivate_profile() {
1861        let (pack, rt) = make_pack();
1862        let registry = empty_registry();
1863        let token = rt.authorize(Namespace::local()).unwrap();
1864
1865        // Deactivate the default profile
1866        let result = pack
1867            .dispatch(
1868                "brain.deactivate",
1869                json!({"profile_id": "balanced-recall-v1"}),
1870                &registry,
1871                &token,
1872            )
1873            .await
1874            .unwrap();
1875        assert_eq!(result["lifecycle"], json!("inactive"));
1876
1877        // Verify via brain.profile
1878        let state = pack
1879            .dispatch(
1880                "brain.profile",
1881                json!({"id": "balanced-recall-v1"}),
1882                &registry,
1883                &token,
1884            )
1885            .await
1886            .unwrap();
1887        assert_eq!(state["lifecycle"], json!("inactive"));
1888
1889        // Reactivate
1890        let result = pack
1891            .dispatch(
1892                "brain.activate",
1893                json!({"profile_id": "balanced-recall-v1"}),
1894                &registry,
1895                &token,
1896            )
1897            .await
1898            .unwrap();
1899        assert_eq!(result["lifecycle"], json!("active"));
1900    }
1901
1902    #[tokio::test]
1903    async fn dispatch_archive_profile() {
1904        let (pack, rt) = make_pack();
1905        let registry = empty_registry();
1906        let token = rt.authorize(Namespace::local()).unwrap();
1907
1908        // Lifecycle DAG requires active → inactive before archiving.
1909        pack.dispatch(
1910            "brain.deactivate",
1911            json!({"profile_id": "balanced-recall-v1"}),
1912            &registry,
1913            &token,
1914        )
1915        .await
1916        .unwrap();
1917
1918        let result = pack
1919            .dispatch(
1920                "brain.archive",
1921                json!({"profile_id": "balanced-recall-v1"}),
1922                &registry,
1923                &token,
1924            )
1925            .await
1926            .unwrap();
1927        assert_eq!(result["lifecycle"], json!("archived"));
1928    }
1929
1930    #[tokio::test]
1931    async fn dispatch_activate_nonexistent_profile_returns_not_found() {
1932        let (pack, rt) = make_pack();
1933        let registry = empty_registry();
1934        let err = pack
1935            .dispatch(
1936                "brain.activate",
1937                json!({"profile_id": "ghost-profile"}),
1938                &registry,
1939                &rt.authorize(Namespace::local()).unwrap(),
1940            )
1941            .await
1942            .unwrap_err();
1943        assert!(matches!(err, RuntimeError::NotFound(_)));
1944    }
1945
1946    #[tokio::test]
1947    async fn dispatch_bind_and_resolve_explicit_binding() {
1948        let (pack, rt) = make_pack();
1949        let registry = empty_registry();
1950        let token = rt.authorize(Namespace::local()).unwrap();
1951
1952        // Bind balanced-recall-v1 for actor "agent-x"
1953        let result = pack
1954            .dispatch(
1955                "brain.bind",
1956                json!({
1957                    "profile_id": "balanced-recall-v1",
1958                    "actor": "agent-x",
1959                    "consumer_kind": "recall"
1960                }),
1961                &registry,
1962                &token,
1963            )
1964            .await
1965            .unwrap();
1966        assert_eq!(result["bound"], json!(true));
1967        assert_eq!(result["actor"], json!("agent-x"));
1968
1969        // Resolve — should return the explicitly bound profile
1970        let resolved = pack
1971            .dispatch(
1972                "brain.resolve",
1973                json!({"actor": "agent-x", "consumer_kind": "recall"}),
1974                &registry,
1975                &token,
1976            )
1977            .await
1978            .unwrap();
1979        assert_eq!(resolved["resolved_profile_id"], json!("balanced-recall-v1"));
1980    }
1981
1982    #[tokio::test]
1983    async fn dispatch_bind_nonexistent_profile_returns_not_found() {
1984        let (pack, rt) = make_pack();
1985        let registry = empty_registry();
1986        let err = pack
1987            .dispatch(
1988                "brain.bind",
1989                json!({"profile_id": "ghost", "consumer_kind": "recall"}),
1990                &registry,
1991                &rt.authorize(Namespace::local()).unwrap(),
1992            )
1993            .await
1994            .unwrap_err();
1995        assert!(matches!(err, RuntimeError::NotFound(_)));
1996    }
1997
1998    #[tokio::test]
1999    async fn dispatch_unbind_removes_binding() {
2000        let (pack, rt) = make_pack();
2001        let registry = empty_registry();
2002        let token = rt.authorize(Namespace::local()).unwrap();
2003
2004        // Add a binding
2005        pack.dispatch(
2006            "brain.bind",
2007            json!({"profile_id": "balanced-recall-v1", "actor": "agent-y", "consumer_kind": "recall"}),
2008            &registry,
2009            &token,
2010        )
2011        .await
2012        .unwrap();
2013
2014        // Remove it
2015        let result = pack
2016            .dispatch(
2017                "brain.unbind",
2018                json!({"actor": "agent-y"}),
2019                &registry,
2020                &token,
2021            )
2022            .await
2023            .unwrap();
2024        assert_eq!(result["unbound"], json!(1u64));
2025    }
2026
2027    // ── UE5-H1: brain.profiles lifecycle filter public API ───────────────────
2028    //
2029    // The public lifecycle filter must only accept 'active', 'inactive', 'archived'.
2030    // Internal states 'defined' and 'registered' must not appear in the error message.
2031
2032    #[tokio::test]
2033    async fn ue5_h1_invalid_lifecycle_error_lists_only_public_states() {
2034        let (pack, rt) = make_pack();
2035        let registry = empty_registry();
2036        let token = rt.authorize(Namespace::local()).unwrap();
2037
2038        let err = pack
2039            .dispatch(
2040                "brain.profiles",
2041                json!({"lifecycle": "deleted"}),
2042                &registry,
2043                &token,
2044            )
2045            .await
2046            .unwrap_err();
2047        if let RuntimeError::InvalidInput(msg) = &err {
2048            // Must mention valid states
2049            assert!(
2050                msg.contains("active"),
2051                "UE5-H1: error must list 'active'; got: {msg}"
2052            );
2053            assert!(
2054                msg.contains("inactive"),
2055                "UE5-H1: error must list 'inactive'; got: {msg}"
2056            );
2057            assert!(
2058                msg.contains("archived"),
2059                "UE5-H1: error must list 'archived'; got: {msg}"
2060            );
2061            // Must NOT leak internal states
2062            assert!(
2063                !msg.contains("defined"),
2064                "UE5-H1: error must NOT expose internal 'defined' state; got: {msg}"
2065            );
2066            assert!(
2067                !msg.contains("registered"),
2068                "UE5-H1: error must NOT expose internal 'registered' state; got: {msg}"
2069            );
2070        } else {
2071            panic!("UE5-H1: expected InvalidInput, got {err:?}");
2072        }
2073    }
2074
2075    #[tokio::test]
2076    async fn ue5_h1_internal_lifecycle_values_rejected() {
2077        let (pack, rt) = make_pack();
2078        let registry = empty_registry();
2079        let token = rt.authorize(Namespace::local()).unwrap();
2080
2081        for internal_state in ["defined", "registered"] {
2082            let err = pack
2083                .dispatch(
2084                    "brain.profiles",
2085                    json!({"lifecycle": internal_state}),
2086                    &registry,
2087                    &token,
2088                )
2089                .await
2090                .unwrap_err();
2091            assert!(
2092                matches!(err, RuntimeError::InvalidInput(_)),
2093                "UE5-H1: internal lifecycle '{internal_state}' must be rejected, got {err:?}"
2094            );
2095        }
2096    }
2097
2098    // ── B-C1 regression: lifecycle terminal-state enforcement ─────────────────
2099    //
2100    // archived is terminal: no transition out of archived is permitted.
2101    // active → archived is illegal: must deactivate first.
2102    // active ⟷ inactive is the only reversible pair.
2103
2104    #[tokio::test]
2105    async fn b_c1_archived_activate_is_rejected() {
2106        let (pack, rt) = make_pack();
2107        let registry = empty_registry();
2108        let token = rt.authorize(Namespace::local()).unwrap();
2109
2110        // Deactivate first (active → inactive), then archive (inactive → archived)
2111        pack.dispatch(
2112            "brain.deactivate",
2113            json!({"profile_id": "balanced-recall-v1"}),
2114            &registry,
2115            &token,
2116        )
2117        .await
2118        .unwrap();
2119        pack.dispatch(
2120            "brain.archive",
2121            json!({"profile_id": "balanced-recall-v1"}),
2122            &registry,
2123            &token,
2124        )
2125        .await
2126        .unwrap();
2127
2128        // Attempt to activate an archived profile — must fail
2129        let err = pack
2130            .dispatch(
2131                "brain.activate",
2132                json!({"profile_id": "balanced-recall-v1"}),
2133                &registry,
2134                &token,
2135            )
2136            .await
2137            .unwrap_err();
2138        if let RuntimeError::InvalidInput(msg) = &err {
2139            assert!(
2140                msg.contains("terminal") || msg.contains("archived"),
2141                "B-C1: error must mention 'terminal' or 'archived'; got: {msg}"
2142            );
2143        } else {
2144            panic!("B-C1: expected InvalidInput, got {err:?}");
2145        }
2146    }
2147
2148    #[tokio::test]
2149    async fn b_c1_archived_deactivate_is_rejected() {
2150        let (pack, rt) = make_pack();
2151        let registry = empty_registry();
2152        let token = rt.authorize(Namespace::local()).unwrap();
2153
2154        // Get to archived via active → inactive → archived
2155        pack.dispatch(
2156            "brain.deactivate",
2157            json!({"profile_id": "balanced-recall-v1"}),
2158            &registry,
2159            &token,
2160        )
2161        .await
2162        .unwrap();
2163        pack.dispatch(
2164            "brain.archive",
2165            json!({"profile_id": "balanced-recall-v1"}),
2166            &registry,
2167            &token,
2168        )
2169        .await
2170        .unwrap();
2171
2172        // Attempt deactivate on archived profile — must fail
2173        let err = pack
2174            .dispatch(
2175                "brain.deactivate",
2176                json!({"profile_id": "balanced-recall-v1"}),
2177                &registry,
2178                &token,
2179            )
2180            .await
2181            .unwrap_err();
2182        assert!(
2183            matches!(err, RuntimeError::InvalidInput(_)),
2184            "B-C1: deactivate on archived must return InvalidInput, got {err:?}"
2185        );
2186    }
2187
2188    #[tokio::test]
2189    async fn b_c1_active_to_archived_direct_is_rejected() {
2190        let (pack, rt) = make_pack();
2191        let registry = empty_registry();
2192        let token = rt.authorize(Namespace::local()).unwrap();
2193
2194        // Profile starts active — direct archive must fail (must go through inactive)
2195        let err = pack
2196            .dispatch(
2197                "brain.archive",
2198                json!({"profile_id": "balanced-recall-v1"}),
2199                &registry,
2200                &token,
2201            )
2202            .await
2203            .unwrap_err();
2204        if let RuntimeError::InvalidInput(msg) = &err {
2205            assert!(
2206                msg.contains("deactivate") || msg.contains("inactive"),
2207                "B-C1: active→archived error must hint at deactivate; got: {msg}"
2208            );
2209        } else {
2210            panic!("B-C1: expected InvalidInput for active→archived, got {err:?}");
2211        }
2212    }
2213
2214    #[tokio::test]
2215    async fn b_c1_inactive_to_archived_is_permitted() {
2216        let (pack, rt) = make_pack();
2217        let registry = empty_registry();
2218        let token = rt.authorize(Namespace::local()).unwrap();
2219
2220        // active → inactive → archived: legal path
2221        pack.dispatch(
2222            "brain.deactivate",
2223            json!({"profile_id": "balanced-recall-v1"}),
2224            &registry,
2225            &token,
2226        )
2227        .await
2228        .unwrap();
2229        let result = pack
2230            .dispatch(
2231                "brain.archive",
2232                json!({"profile_id": "balanced-recall-v1"}),
2233                &registry,
2234                &token,
2235            )
2236            .await
2237            .unwrap();
2238        assert_eq!(
2239            result["lifecycle"],
2240            json!("archived"),
2241            "B-C1: inactive→archived must succeed"
2242        );
2243    }
2244
2245    // Regression test for MAJ-002: unbind with multiple filters must use AND semantics,
2246    // removing only the binding that satisfies ALL supplied criteria.
2247    #[tokio::test]
2248    async fn dispatch_unbind_uses_and_not_or() {
2249        let (pack, rt) = make_pack();
2250        let registry = empty_registry();
2251        let token = rt.authorize(Namespace::local()).unwrap();
2252
2253        // binding 1: ns=A, profile=P1 (the one we want to remove)
2254        pack.dispatch(
2255            "brain.bind",
2256            json!({"profile_id": "balanced-recall-v1", "namespace": "ns-a", "consumer_kind": "recall"}),
2257            &registry,
2258            &token,
2259        )
2260        .await
2261        .unwrap();
2262
2263        // binding 2: ns=B, profile=P1 (must survive)
2264        pack.dispatch(
2265            "brain.bind",
2266            json!({"profile_id": "balanced-recall-v1", "namespace": "ns-b", "consumer_kind": "recall"}),
2267            &registry,
2268            &token,
2269        )
2270        .await
2271        .unwrap();
2272
2273        // Unbind using both filters: only binding-1 should be removed
2274        let result = pack
2275            .dispatch(
2276                "brain.unbind",
2277                json!({"namespace": "ns-a", "profile_id": "balanced-recall-v1"}),
2278                &registry,
2279                &token,
2280            )
2281            .await
2282            .unwrap();
2283        assert_eq!(
2284            result["unbound"],
2285            json!(1u64),
2286            "should remove exactly one binding"
2287        );
2288
2289        // binding-2 (ns-b) must still exist
2290        let state = pack.state.lock().unwrap();
2291        let remaining: Vec<_> = state
2292            .bindings
2293            .iter()
2294            .filter(|b| b.namespace == "ns-b")
2295            .collect();
2296        assert_eq!(remaining.len(), 1, "ns-b binding must survive the unbind");
2297    }
2298
2299    #[tokio::test]
2300    async fn dispatch_config_all_parameters() {
2301        let (pack, rt) = make_pack();
2302        let registry = empty_registry();
2303        let result = pack
2304            .dispatch(
2305                "brain.config",
2306                json!({}),
2307                &registry,
2308                &rt.authorize(Namespace::local()).unwrap(),
2309            )
2310            .await
2311            .unwrap();
2312        let obj = result.as_object().unwrap();
2313        assert!(obj.contains_key("recall::relevance_weight"));
2314        assert!(obj.contains_key("recall::salience_weight"));
2315        assert!(obj.contains_key("recall::temporal_weight"));
2316    }
2317
2318    #[tokio::test]
2319    async fn dispatch_config_single_parameter() {
2320        let (pack, rt) = make_pack();
2321        let registry = empty_registry();
2322        let result = pack
2323            .dispatch(
2324                "brain.config",
2325                json!({"parameter": "recall::relevance_weight"}),
2326                &registry,
2327                &rt.authorize(Namespace::local()).unwrap(),
2328            )
2329            .await
2330            .unwrap();
2331        assert_eq!(result["parameter"], json!("recall::relevance_weight"));
2332        // Prior is Beta(7,3): mean = 0.7
2333        let mean = result["mean"].as_f64().unwrap();
2334        assert!((mean - 0.7).abs() < 1e-6);
2335    }
2336
2337    // ── Regression tests (issues #355, #356, #357, #295) ──────────────────────
2338
2339    // #356 (MAJ-003): profile_record.total_events must stay in sync with
2340    // balanced_recall.total_events via BOTH the handle_feedback path AND the
2341    // on_dispatch hook path.  The previous fix only wired the sync helper; this
2342    // test pins that removing EITHER call would be caught.
2343    //
2344    // Part A — handle_feedback path (unchanged from before).
2345    // Part B — on_dispatch path: introduce a deliberate desync by reaching into
2346    //   the live state directly, then fire on_dispatch to verify the sync
2347    //   corrects it.  This would fail if sync_balanced_recall_record is removed
2348    //   from on_dispatch.
2349    #[tokio::test]
2350    async fn test_356_profile_record_total_events_synced_after_feedback() {
2351        let (pack, rt) = make_pack();
2352        let registry = empty_registry();
2353        let token = rt.authorize(Namespace::local()).unwrap();
2354        let target = create_test_entity(&rt, &token).await;
2355
2356        // Part A: handle_feedback path.
2357        for _ in 0..3 {
2358            pack.dispatch(
2359                "brain.feedback",
2360                json!({"target_id": target, "signal": "useful"}),
2361                &registry,
2362                &token,
2363            )
2364            .await
2365            .unwrap();
2366        }
2367
2368        let snap = pack.snapshot();
2369        let live_total = snap.balanced_recall.total_events;
2370
2371        let record_result = pack
2372            .dispatch(
2373                "brain.profile",
2374                json!({"id": "balanced-recall-v1"}),
2375                &registry,
2376                &token,
2377            )
2378            .await
2379            .unwrap();
2380        let record_total = record_result["total_events"].as_u64().unwrap();
2381
2382        assert_eq!(
2383            live_total, record_total,
2384            "#356 part-A: profile_record.total_events ({record_total}) must equal \
2385             balanced_recall.total_events ({live_total}) after feedback calls"
2386        );
2387        assert_eq!(live_total, 3, "expected exactly 3 events from part A");
2388
2389        // Part B: on_dispatch path.
2390        // Deliberately desync the record by bumping balanced_recall.total_events
2391        // directly (simulating what would happen if only on_dispatch updated the
2392        // live state but the sync call were missing).
2393        {
2394            let mut state = pack.state.lock().unwrap();
2395            state.balanced_recall.total_events += 7; // introduce desync
2396                                                     // Profile record still says `live_total` at this point.
2397        }
2398
2399        // Fire on_dispatch with an irrelevant (non-brain) verb event — this is
2400        // exactly what the runtime hook does for every non-brain verb dispatch.
2401        // The sync helper inside on_dispatch must correct the desync.
2402        let hook_event = {
2403            use khive_types::{EventKind, SubstrateKind};
2404            let mut e = khive_storage::event::Event::new(
2405                "local",
2406                "search",
2407                EventKind::Audit,
2408                SubstrateKind::Event,
2409                "kg",
2410            );
2411            e.outcome = khive_types::EventOutcome::Success;
2412            e
2413        };
2414        let hook_view = khive_runtime::EventView {
2415            event: hook_event,
2416            observations: Vec::new(),
2417        };
2418        pack.on_dispatch(&hook_view).await;
2419
2420        // After on_dispatch, the record must reflect the new (desynced) live total.
2421        let after_hook = pack
2422            .dispatch(
2423                "brain.profile",
2424                json!({"id": "balanced-recall-v1"}),
2425                &registry,
2426                &token,
2427            )
2428            .await
2429            .unwrap();
2430        let after_total = after_hook["total_events"].as_u64().unwrap();
2431        let live_after = pack.snapshot().balanced_recall.total_events;
2432        assert_eq!(
2433            after_total, live_after,
2434            "#356 part-B: on_dispatch sync must correct desync; \
2435             record shows {after_total}, live state shows {live_after}"
2436        );
2437    }
2438
2439    // #357 (MAJ-004): brain.feedback must NOT double-count total_events.
2440    //
2441    // The double-count path: VerbRegistry::dispatch calls the registered pack
2442    // handler (handle_feedback folds once) and then calls on_dispatch on every
2443    // registered hook — including BrainPack itself.  Without the brain.* guard
2444    // in on_dispatch, the hook fires a second fold.reduce, making total_events
2445    // == 2.  This test replicates that exact sequence so the test FAILS if the
2446    // guard is absent.
2447    #[tokio::test]
2448    async fn test_357_feedback_no_double_count() {
2449        let (pack, rt) = make_pack();
2450        let registry = empty_registry();
2451        let token = rt.authorize(Namespace::local()).unwrap();
2452        let target = create_test_entity(&rt, &token).await;
2453
2454        // Step 1: handle_feedback path — folds once, total_events becomes 1.
2455        pack.dispatch(
2456            "brain.feedback",
2457            json!({"target_id": target, "signal": "useful"}),
2458            &registry,
2459            &token,
2460        )
2461        .await
2462        .unwrap();
2463
2464        assert_eq!(
2465            pack.snapshot().balanced_recall.total_events,
2466            1,
2467            "#357 pre-hook: handle_feedback must fold exactly once"
2468        );
2469
2470        // Step 2: simulate the registry post-dispatch hook call with a brain.*
2471        // verb.  This is exactly what VerbRegistry::dispatch does after a
2472        // successful handler return.  The guard in on_dispatch must return
2473        // early here — without it, fold.reduce fires again → total_events = 2.
2474        let hook_event = {
2475            use khive_types::{EventKind, SubstrateKind};
2476            khive_storage::event::Event::new(
2477                "local",
2478                "brain.feedback",
2479                EventKind::FeedbackExplicit,
2480                SubstrateKind::Event,
2481                "brain",
2482            )
2483        };
2484        let hook_view = khive_runtime::EventView {
2485            event: hook_event,
2486            observations: Vec::new(),
2487        };
2488        pack.on_dispatch(&hook_view).await;
2489
2490        assert_eq!(
2491            pack.snapshot().balanced_recall.total_events,
2492            1,
2493            "#357: total_events must remain 1 after on_dispatch(brain.feedback); \
2494             guard absent if this reads 2"
2495        );
2496    }
2497
2498    // #295: brain.reset must restore domain-informed priors, not Beta(1,1).
2499    //
2500    // Strengthened per codex P12 Medium: this test now exercises the full
2501    // production path — handle_reset → reset_posteriors → sync helper — and
2502    // verifies that ALL three profile record fields (total_events,
2503    // exploration_epoch, state_snapshot) reflect the restored priors.
2504    //
2505    // It also creates a stale record via hook-only updates (bypassing
2506    // handle_feedback) before the reset, so the desync is real and not
2507    // incidentally corrected by the feedback path.
2508    #[tokio::test]
2509    async fn test_295_reset_restores_domain_priors_not_uniform() {
2510        let (pack, rt) = make_pack();
2511        let registry = empty_registry();
2512        let token = rt.authorize(Namespace::local()).unwrap();
2513
2514        // Step 1: accumulate state via hook-only updates (no handle_feedback).
2515        // This simulates the common case where brain observes external pack
2516        // events rather than explicit feedback calls.
2517        let hook_event = |verb: &str| {
2518            use khive_types::{EventKind, SubstrateKind};
2519            let mut e = khive_storage::event::Event::new(
2520                "local",
2521                verb,
2522                EventKind::Audit,
2523                SubstrateKind::Event,
2524                "kg",
2525            );
2526            e.outcome = khive_types::EventOutcome::Success;
2527            e
2528        };
2529
2530        // Fire 4 hook events for a non-brain verb (simulates external recall/search).
2531        for _ in 0..4 {
2532            let view = khive_runtime::EventView {
2533                event: hook_event("search"),
2534                observations: Vec::new(),
2535            };
2536            pack.on_dispatch(&view).await;
2537        }
2538
2539        // Step 2: also call handle_feedback directly to move salience away from prior.
2540        // C4: create a real entity so target_id validation passes.
2541        let target = create_test_entity(&rt, &token).await;
2542        for _ in 0..5 {
2543            pack.dispatch(
2544                "brain.feedback",
2545                json!({"target_id": target, "signal": "useful"}),
2546                &registry,
2547                &token,
2548            )
2549            .await
2550            .unwrap();
2551        }
2552
2553        // Verify state before reset: posteriors have moved, total_events > 0.
2554        let before = pack.snapshot();
2555        assert!(
2556            before.balanced_recall.salience.alpha > 2.0,
2557            "salience.alpha must have grown past prior after useful feedback"
2558        );
2559        assert!(
2560            before.balanced_recall.total_events >= 9,
2561            "expected at least 9 total events (4 hook + 5 feedback), got {}",
2562            before.balanced_recall.total_events
2563        );
2564        // Verify record was kept in sync before reset (both paths called sync helper).
2565        let pre_reset_record = pack
2566            .dispatch(
2567                "brain.profile",
2568                json!({"id": "balanced-recall-v1"}),
2569                &registry,
2570                &token,
2571            )
2572            .await
2573            .unwrap();
2574        assert_eq!(
2575            pre_reset_record["total_events"].as_u64().unwrap(),
2576            before.balanced_recall.total_events,
2577            "#295 pre-reset: profile record total_events out of sync before reset"
2578        );
2579
2580        // Step 3: call handle_reset via the production path (dispatch → handle_reset).
2581        let reset_result = pack
2582            .dispatch(
2583                "brain.reset",
2584                json!({"profile_id": "balanced-recall-v1"}),
2585                &registry,
2586                &token,
2587            )
2588            .await
2589            .unwrap();
2590        assert_eq!(reset_result["reset"], json!(true));
2591
2592        // Verify exploration_epoch incremented (reset_posteriors contract).
2593        let epoch_after = reset_result["exploration_epoch"].as_u64().unwrap();
2594        assert!(
2595            epoch_after > 0,
2596            "#295: exploration_epoch must increment after reset"
2597        );
2598
2599        // Step 4: after reset, posteriors must be domain-informed priors — NOT Beta(1,1).
2600        let after = pack.snapshot();
2601
2602        // salience prior = Beta(2,8)
2603        assert!(
2604            (after.balanced_recall.salience.alpha - 2.0).abs() < 1e-12,
2605            "#295: salience.alpha must be 2.0 after reset, got {}",
2606            after.balanced_recall.salience.alpha
2607        );
2608        assert!(
2609            (after.balanced_recall.salience.beta - 8.0).abs() < 1e-12,
2610            "#295: salience.beta must be 8.0 after reset, got {}",
2611            after.balanced_recall.salience.beta
2612        );
2613
2614        // temporal prior = Beta(1,9)
2615        assert!(
2616            (after.balanced_recall.temporal.alpha - 1.0).abs() < 1e-12,
2617            "#295: temporal.alpha must be 1.0 after reset, got {}",
2618            after.balanced_recall.temporal.alpha
2619        );
2620        assert!(
2621            (after.balanced_recall.temporal.beta - 9.0).abs() < 1e-12,
2622            "#295: temporal.beta must be 9.0 after reset, got {}",
2623            after.balanced_recall.temporal.beta
2624        );
2625
2626        // relevance prior = Beta(7,3)
2627        assert!(
2628            (after.balanced_recall.relevance.alpha - 7.0).abs() < 1e-12,
2629            "#295: relevance.alpha must be 7.0 after reset"
2630        );
2631
2632        // Step 5: brain.profile must reflect the reset state — ALL three fields.
2633        // This pins the sync_balanced_recall_record call inside handle_reset.
2634        // Removing that call would cause this assertion to fail.
2635        let record = pack
2636            .dispatch(
2637                "brain.profile",
2638                json!({"id": "balanced-recall-v1"}),
2639                &registry,
2640                &token,
2641            )
2642            .await
2643            .unwrap();
2644
2645        // total_events: after reset the state is a fresh BalancedRecallState
2646        // (total_events = 0), so the record must reflect that.
2647        let record_total = record["total_events"].as_u64().unwrap();
2648        assert_eq!(
2649            record_total, after.balanced_recall.total_events,
2650            "#295: profile record total_events ({record_total}) must match \
2651             live state ({}) after reset",
2652            after.balanced_recall.total_events
2653        );
2654
2655        // exploration_epoch: record must match the live state.
2656        let record_epoch = record["exploration_epoch"].as_u64().unwrap();
2657        assert_eq!(
2658            record_epoch, epoch_after,
2659            "#295: profile record exploration_epoch ({record_epoch}) must match \
2660             reset result ({epoch_after})"
2661        );
2662
2663        // state_snapshot: salience.alpha must be the prior value.
2664        let snap = &record["state_snapshot"];
2665        let sal_alpha = snap["salience"]["alpha"].as_f64().unwrap();
2666        assert!(
2667            (sal_alpha - 2.0).abs() < 1e-12,
2668            "#295: brain.profile state_snapshot salience.alpha must be 2.0 after reset, \
2669             got {sal_alpha}"
2670        );
2671    }
2672
2673    // Round-4 fix: brain.reset must reject unknown kwargs (deny_unknown_fields).
2674    #[tokio::test]
2675    async fn brain_reset_rejects_unknown_kwargs() {
2676        let (pack, rt) = make_pack();
2677        let registry = empty_registry();
2678        let token = rt.authorize(Namespace::local()).unwrap();
2679        let err = pack
2680            .dispatch(
2681                "brain.reset",
2682                json!({"unknownkw": "oops"}),
2683                &registry,
2684                &token,
2685            )
2686            .await
2687            .unwrap_err();
2688        assert!(
2689            matches!(err, RuntimeError::InvalidInput(_)),
2690            "brain.reset with unknown kwargs must return InvalidInput, got: {err:?}"
2691        );
2692        if let RuntimeError::InvalidInput(msg) = &err {
2693            assert!(
2694                msg.contains("brain.reset"),
2695                "error message must mention brain.reset, got: {msg}"
2696            );
2697        }
2698    }
2699
2700    // brain.reset with an empty params object must still succeed.
2701    #[tokio::test]
2702    async fn brain_reset_accepts_empty_params() {
2703        let (pack, rt) = make_pack();
2704        let registry = empty_registry();
2705        let token = rt.authorize(Namespace::local()).unwrap();
2706        let result = pack
2707            .dispatch("brain.reset", json!({}), &registry, &token)
2708            .await
2709            .expect("brain.reset() must succeed with empty params");
2710        assert_eq!(result["reset"], json!(true));
2711    }
2712
2713    // #355 (regression — real dispatch path): temporal posterior must update
2714    // when a recall hits via the on_dispatch hook carrying real hit/latency.
2715    //
2716    // This test exercises the production wiring added in the P12 codex fix:
2717    // the runtime now embeds duration_us + target_id in the hook event for
2718    // "recall" verbs.  Simulates that by constructing the hook event the way
2719    // the runtime now would, then verifies temporal.alpha increments.
2720    #[tokio::test]
2721    async fn test_355_posteriors_update_after_dispatch_via_hook() {
2722        let (pack, _rt) = make_pack();
2723        let before = pack.snapshot();
2724        let tmp_alpha_before = before.balanced_recall.temporal.alpha;
2725        let tmp_beta_before = before.balanced_recall.temporal.beta;
2726
2727        // Simulate the runtime hook event for a fast recall hit:
2728        // duration_us ≤ 50_000 (fast) and target_id is present (hit).
2729        let target_id = uuid::Uuid::new_v4();
2730        let fast_hit_event = {
2731            use khive_types::{EventKind, SubstrateKind};
2732            let mut e = khive_storage::event::Event::new(
2733                "local",
2734                "recall",
2735                EventKind::Audit,
2736                SubstrateKind::Event,
2737                "memory",
2738            );
2739            e.outcome = khive_types::EventOutcome::Success;
2740            e.target_id = Some(target_id);
2741            e.duration_us = 10_000; // 10 ms — fast hit
2742            e
2743        };
2744        let view = khive_runtime::EventView {
2745            event: fast_hit_event,
2746            observations: Vec::new(),
2747        };
2748        pack.on_dispatch(&view).await;
2749
2750        let after_fast = pack.snapshot();
2751        assert!(
2752            (after_fast.balanced_recall.temporal.alpha - (tmp_alpha_before + 1.0)).abs() < 1e-12,
2753            "#355: fast recall hit must increment temporal.alpha via hook: expected {}, got {}",
2754            tmp_alpha_before + 1.0,
2755            after_fast.balanced_recall.temporal.alpha
2756        );
2757        assert!(
2758            (after_fast.balanced_recall.temporal.beta - tmp_beta_before).abs() < 1e-12,
2759            "#355: fast hit must NOT increment temporal.beta"
2760        );
2761
2762        // Simulate a slow recall hit (duration_us > 50_000) → temporal failure.
2763        let slow_hit_event = {
2764            use khive_types::{EventKind, SubstrateKind};
2765            let mut e = khive_storage::event::Event::new(
2766                "local",
2767                "recall",
2768                EventKind::Audit,
2769                SubstrateKind::Event,
2770                "memory",
2771            );
2772            e.outcome = khive_types::EventOutcome::Success;
2773            e.target_id = Some(target_id);
2774            e.duration_us = 100_000; // 100 ms — slow
2775            e
2776        };
2777        let view2 = khive_runtime::EventView {
2778            event: slow_hit_event,
2779            observations: Vec::new(),
2780        };
2781        pack.on_dispatch(&view2).await;
2782
2783        let after_slow = pack.snapshot();
2784        assert!(
2785            (after_slow.balanced_recall.temporal.beta - (tmp_beta_before + 1.0)).abs() < 1e-12,
2786            "#355: slow recall hit must increment temporal.beta via hook: expected {}, got {}",
2787            tmp_beta_before + 1.0,
2788            after_slow.balanced_recall.temporal.beta
2789        );
2790
2791        // Simulate a recall miss (no target_id) → temporal failure.
2792        let miss_event = {
2793            use khive_types::{EventKind, SubstrateKind};
2794            let mut e = khive_storage::event::Event::new(
2795                "local",
2796                "recall",
2797                EventKind::Audit,
2798                SubstrateKind::Event,
2799                "memory",
2800            );
2801            e.outcome = khive_types::EventOutcome::Success;
2802            // target_id = None → RecallMiss
2803            e
2804        };
2805        let view3 = khive_runtime::EventView {
2806            event: miss_event,
2807            observations: Vec::new(),
2808        };
2809        pack.on_dispatch(&view3).await;
2810
2811        let after_miss = pack.snapshot();
2812        assert!(
2813            (after_miss.balanced_recall.temporal.beta - (tmp_beta_before + 2.0)).abs() < 1e-12,
2814            "#355: recall miss must further increment temporal.beta: expected {}, got {}",
2815            tmp_beta_before + 2.0,
2816            after_miss.balanced_recall.temporal.beta
2817        );
2818    }
2819
2820    // ── Wave-4 Critical regressions (C1-C4) ──────────────────────────────────
2821
2822    // C2: brain.unbind with zero filters must be rejected.
2823    #[tokio::test]
2824    async fn w4_c2_unbind_no_filter_is_rejected() {
2825        let (pack, rt) = make_pack();
2826        let registry = empty_registry();
2827        let token = rt.authorize(Namespace::local()).unwrap();
2828
2829        // Add a binding so there is something to accidentally wipe.
2830        pack.dispatch(
2831            "brain.bind",
2832            json!({"profile_id": "balanced-recall-v1", "actor": "agent-z", "consumer_kind": "recall"}),
2833            &registry,
2834            &token,
2835        )
2836        .await
2837        .unwrap();
2838
2839        let err = pack
2840            .dispatch("brain.unbind", json!({}), &registry, &token)
2841            .await
2842            .unwrap_err();
2843        if let RuntimeError::InvalidInput(msg) = &err {
2844            assert!(
2845                msg.contains("filter") || msg.contains("profile_id") || msg.contains("actor"),
2846                "C2: zero-filter unbind must mention required filter; got: {msg}"
2847            );
2848        } else {
2849            panic!("C2: zero-filter unbind must return InvalidInput, got {err:?}");
2850        }
2851
2852        // Binding must still be intact.
2853        let state = pack.state.lock().unwrap();
2854        assert!(
2855            !state.bindings.is_empty(),
2856            "C2: binding must survive the rejected unbind"
2857        );
2858    }
2859
2860    // C3: brain.bind must reject archived profiles.
2861    #[tokio::test]
2862    async fn w4_c3_bind_archived_profile_is_rejected() {
2863        let (pack, rt) = make_pack();
2864        let registry = empty_registry();
2865        let token = rt.authorize(Namespace::local()).unwrap();
2866
2867        // Archive the only profile.
2868        pack.dispatch(
2869            "brain.deactivate",
2870            json!({"profile_id": "balanced-recall-v1"}),
2871            &registry,
2872            &token,
2873        )
2874        .await
2875        .unwrap();
2876        pack.dispatch(
2877            "brain.archive",
2878            json!({"profile_id": "balanced-recall-v1"}),
2879            &registry,
2880            &token,
2881        )
2882        .await
2883        .unwrap();
2884
2885        let err = pack
2886            .dispatch(
2887                "brain.bind",
2888                json!({"profile_id": "balanced-recall-v1", "consumer_kind": "recall"}),
2889                &registry,
2890                &token,
2891            )
2892            .await
2893            .unwrap_err();
2894        if let RuntimeError::InvalidInput(msg) = &err {
2895            assert!(
2896                msg.contains("archived"),
2897                "C3: bind to archived profile must mention 'archived'; got: {msg}"
2898            );
2899        } else {
2900            panic!("C3: bind to archived profile must return InvalidInput, got {err:?}");
2901        }
2902    }
2903
2904    // C3: brain.resolve must skip bindings pointing at archived profiles.
2905    #[tokio::test]
2906    async fn w4_c3_resolve_skips_archived_binding() {
2907        let (pack, rt) = make_pack();
2908        let registry = empty_registry();
2909        let token = rt.authorize(Namespace::local()).unwrap();
2910
2911        // Force a binding directly into state (bypassing handle_bind guard) to simulate
2912        // a pre-existing binding that was created before the profile was archived.
2913        {
2914            let mut state = pack.state.lock().unwrap();
2915            state.bindings.push(crate::state::ProfileBinding {
2916                actor: "*".into(),
2917                namespace: "*".into(),
2918                consumer_kind: "recall".into(),
2919                profile_id: "balanced-recall-v1".into(),
2920                priority: 100,
2921                created_at: chrono::Utc::now(),
2922            });
2923            // Archive the profile in-state.
2924            state
2925                .profiles
2926                .get_mut("balanced-recall-v1")
2927                .unwrap()
2928                .lifecycle = ProfileLifecycle::Archived;
2929        }
2930
2931        // Resolve must NOT return the archived profile.
2932        let err = pack
2933            .dispatch(
2934                "brain.resolve",
2935                json!({"consumer_kind": "recall"}),
2936                &registry,
2937                &token,
2938            )
2939            .await
2940            .unwrap_err();
2941        assert!(
2942            matches!(err, RuntimeError::NotFound(_)),
2943            "C3: resolve with only archived binding must return NotFound, got {err:?}"
2944        );
2945    }
2946
2947    // C4: brain.feedback must reject nonexistent target_id.
2948    #[tokio::test]
2949    async fn w4_c4_feedback_rejects_nonexistent_target() {
2950        let (pack, rt) = make_pack();
2951        let registry = empty_registry();
2952        let token = rt.authorize(Namespace::local()).unwrap();
2953
2954        let err = pack
2955            .dispatch(
2956                "brain.feedback",
2957                json!({"target_id": "00000000-0000-0000-0000-000000000000", "signal": "useful"}),
2958                &registry,
2959                &token,
2960            )
2961            .await
2962            .unwrap_err();
2963        assert!(
2964            matches!(err, RuntimeError::NotFound(_)),
2965            "C4: feedback with nonexistent target_id must return NotFound, got {err:?}"
2966        );
2967    }
2968
2969    // C4: brain.feedback must reject nonexistent served_by_profile_id.
2970    #[tokio::test]
2971    async fn w4_c4_feedback_rejects_nonexistent_profile() {
2972        let (pack, rt) = make_pack();
2973        let registry = empty_registry();
2974        let token = rt.authorize(Namespace::local()).unwrap();
2975
2976        // Create a real entity for the valid target_id.
2977        let target = create_test_entity(&rt, &token).await;
2978
2979        let err = pack
2980            .dispatch(
2981                "brain.feedback",
2982                json!({"target_id": target, "signal": "useful", "served_by_profile_id": "fake-profile-xyz"}),
2983                &registry,
2984                &token,
2985            )
2986            .await
2987            .unwrap_err();
2988        assert!(
2989            matches!(err, RuntimeError::NotFound(_)),
2990            "C4: feedback with nonexistent served_by_profile_id must return NotFound, got {err:?}"
2991        );
2992    }
2993
2994    // C4: brain.feedback with valid target and known profile must succeed.
2995    #[tokio::test]
2996    async fn w4_c4_feedback_accepts_valid_target_and_profile() {
2997        let (pack, rt) = make_pack();
2998        let registry = empty_registry();
2999        let token = rt.authorize(Namespace::local()).unwrap();
3000
3001        let target = create_test_entity(&rt, &token).await;
3002
3003        let result = pack
3004            .dispatch(
3005                "brain.feedback",
3006                json!({"target_id": target, "signal": "useful", "served_by_profile_id": "balanced-recall-v1"}),
3007                &registry,
3008                &token,
3009            )
3010            .await
3011            .unwrap();
3012        assert_eq!(result["emitted"], json!(true));
3013        assert_eq!(result["signal"], json!("useful"));
3014    }
3015
3016    // H1: brain.create_profile creates a new inactive profile.
3017    #[tokio::test]
3018    async fn w4_h1_create_profile_creates_new_profile() {
3019        let (pack, rt) = make_pack();
3020        let registry = empty_registry();
3021        let token = rt.authorize(Namespace::local()).unwrap();
3022
3023        let result = pack
3024            .dispatch(
3025                "brain.create_profile",
3026                json!({"name": "my-profile-v1", "consumer_kind": "search", "description": "Custom search profile"}),
3027                &registry,
3028                &token,
3029            )
3030            .await
3031            .unwrap();
3032        assert_eq!(result["created"], json!(true));
3033        assert_eq!(result["profile_id"], json!("my-profile-v1"));
3034        assert_eq!(result["lifecycle"], json!("inactive"));
3035        assert_eq!(result["consumer_kind"], json!("search"));
3036
3037        // Verify it appears in brain.profiles.
3038        let profiles = pack
3039            .dispatch("brain.profiles", json!({}), &registry, &token)
3040            .await
3041            .unwrap();
3042        let ids: Vec<&str> = profiles["profiles"]
3043            .as_array()
3044            .unwrap()
3045            .iter()
3046            .filter_map(|p| p["id"].as_str())
3047            .collect();
3048        assert!(
3049            ids.contains(&"my-profile-v1"),
3050            "new profile must appear in brain.profiles"
3051        );
3052    }
3053
3054    // H1: duplicate name is rejected.
3055    #[tokio::test]
3056    async fn w4_h1_create_profile_duplicate_rejected() {
3057        let (pack, rt) = make_pack();
3058        let registry = empty_registry();
3059        let token = rt.authorize(Namespace::local()).unwrap();
3060
3061        let err = pack
3062            .dispatch(
3063                "brain.create_profile",
3064                json!({"name": "balanced-recall-v1"}),
3065                &registry,
3066                &token,
3067            )
3068            .await
3069            .unwrap_err();
3070        assert!(
3071            matches!(err, RuntimeError::InvalidInput(_)),
3072            "H1: duplicate profile name must return InvalidInput, got {err:?}"
3073        );
3074    }
3075
3076    // H2: brain.bindings lists binding rows.
3077    #[tokio::test]
3078    async fn w4_h2_bindings_lists_rows() {
3079        let (pack, rt) = make_pack();
3080        let registry = empty_registry();
3081        let token = rt.authorize(Namespace::local()).unwrap();
3082
3083        // Initially empty.
3084        let result = pack
3085            .dispatch("brain.bindings", json!({}), &registry, &token)
3086            .await
3087            .unwrap();
3088        assert_eq!(result["count"], json!(0u64));
3089        assert_eq!(result["bindings"], json!([]));
3090
3091        // Add a binding.
3092        pack.dispatch(
3093            "brain.bind",
3094            json!({"profile_id": "balanced-recall-v1", "actor": "agent-a", "consumer_kind": "recall"}),
3095            &registry,
3096            &token,
3097        )
3098        .await
3099        .unwrap();
3100
3101        let result2 = pack
3102            .dispatch("brain.bindings", json!({}), &registry, &token)
3103            .await
3104            .unwrap();
3105        assert_eq!(result2["count"], json!(1u64));
3106        let rows = result2["bindings"].as_array().unwrap();
3107        assert_eq!(rows[0]["actor"], json!("agent-a"));
3108        assert_eq!(rows[0]["profile_id"], json!("balanced-recall-v1"));
3109    }
3110
3111    // H2: brain.bindings supports filtering.
3112    #[tokio::test]
3113    async fn w4_h2_bindings_filtered() {
3114        let (pack, rt) = make_pack();
3115        let registry = empty_registry();
3116        let token = rt.authorize(Namespace::local()).unwrap();
3117
3118        pack.dispatch("brain.bind", json!({"profile_id": "balanced-recall-v1", "actor": "agent-1", "consumer_kind": "recall"}), &registry, &token).await.unwrap();
3119        pack.dispatch("brain.bind", json!({"profile_id": "balanced-recall-v1", "actor": "agent-2", "consumer_kind": "search"}), &registry, &token).await.unwrap();
3120
3121        let result = pack
3122            .dispatch(
3123                "brain.bindings",
3124                json!({"actor": "agent-1"}),
3125                &registry,
3126                &token,
3127            )
3128            .await
3129            .unwrap();
3130        assert_eq!(result["count"], json!(1u64));
3131        assert_eq!(result["bindings"][0]["actor"], json!("agent-1"));
3132    }
3133
3134    // H3: brain.resolve response includes both requested and matched consumer_kind.
3135    #[tokio::test]
3136    async fn w4_h3_resolve_returns_both_requested_and_matched_kind() {
3137        let (pack, rt) = make_pack();
3138        let registry = empty_registry();
3139        let token = rt.authorize(Namespace::local()).unwrap();
3140
3141        // Install a wildcard binding (consumer_kind = "*").
3142        pack.dispatch(
3143            "brain.bind",
3144            json!({"profile_id": "balanced-recall-v1", "consumer_kind": "*", "priority": 1}),
3145            &registry,
3146            &token,
3147        )
3148        .await
3149        .unwrap();
3150
3151        // Query for a different consumer_kind — wildcard binding matches.
3152        let result = pack
3153            .dispatch(
3154                "brain.resolve",
3155                json!({"consumer_kind": "search"}),
3156                &registry,
3157                &token,
3158            )
3159            .await
3160            .unwrap();
3161
3162        assert_eq!(
3163            result["requested_consumer_kind"],
3164            json!("search"),
3165            "H3: requested_consumer_kind must equal the query"
3166        );
3167        assert_eq!(
3168            result["matched_consumer_kind"],
3169            json!("*"),
3170            "H3: matched_consumer_kind must show the wildcard binding"
3171        );
3172        assert_eq!(result["resolved_profile_id"], json!("balanced-recall-v1"));
3173    }
3174
3175    // H3: exact match returns matching kind in matched_consumer_kind.
3176    #[tokio::test]
3177    async fn w4_h3_resolve_exact_match_returns_exact_kind() {
3178        let (pack, rt) = make_pack();
3179        let registry = empty_registry();
3180        let token = rt.authorize(Namespace::local()).unwrap();
3181
3182        // Default fallback (no binding) uses profile's consumer_kind.
3183        let result = pack
3184            .dispatch(
3185                "brain.resolve",
3186                json!({"consumer_kind": "recall"}),
3187                &registry,
3188                &token,
3189            )
3190            .await
3191            .unwrap();
3192        assert_eq!(result["requested_consumer_kind"], json!("recall"));
3193        assert_eq!(result["matched_consumer_kind"], json!("recall"));
3194    }
3195
3196    // Round-2 fix 3: archived high-priority binding + live lower-priority wildcard → live wins.
3197    #[tokio::test]
3198    async fn r2_archived_exact_binding_defers_to_live_wildcard() {
3199        let (pack, rt) = make_pack();
3200        let registry = empty_registry();
3201        let token = rt.authorize(Namespace::local()).unwrap();
3202
3203        // Create a second live profile.
3204        pack.dispatch(
3205            "brain.create_profile",
3206            json!({"name": "search-v1", "consumer_kind": "search"}),
3207            &registry,
3208            &token,
3209        )
3210        .await
3211        .unwrap();
3212        pack.dispatch(
3213            "brain.activate",
3214            json!({"profile_id": "search-v1"}),
3215            &registry,
3216            &token,
3217        )
3218        .await
3219        .unwrap();
3220
3221        // Insert a high-priority exact binding pointing at the default profile.
3222        {
3223            let mut state = pack.state.lock().unwrap();
3224            state.bindings.push(crate::state::ProfileBinding {
3225                actor: "*".into(),
3226                namespace: "*".into(),
3227                consumer_kind: "search".into(),
3228                profile_id: "balanced-recall-v1".into(),
3229                priority: 100,
3230                created_at: chrono::Utc::now(),
3231            });
3232            // Archive balanced-recall-v1 in-state to simulate it being retired.
3233            state
3234                .profiles
3235                .get_mut("balanced-recall-v1")
3236                .unwrap()
3237                .lifecycle = crate::state::ProfileLifecycle::Archived;
3238        }
3239
3240        // Add a lower-priority wildcard binding pointing at the live profile.
3241        pack.dispatch(
3242            "brain.bind",
3243            json!({"profile_id": "search-v1", "consumer_kind": "*", "priority": 1}),
3244            &registry,
3245            &token,
3246        )
3247        .await
3248        .unwrap();
3249
3250        // Resolve must return the live lower-priority profile, NOT fall to default.
3251        let result = pack
3252            .dispatch(
3253                "brain.resolve",
3254                json!({"consumer_kind": "search"}),
3255                &registry,
3256                &token,
3257            )
3258            .await
3259            .unwrap();
3260        assert_eq!(
3261            result["resolved_profile_id"],
3262            json!("search-v1"),
3263            "r2 fix 3: archived high-priority binding must not suppress the live wildcard binding"
3264        );
3265    }
3266
3267    // Round-2 fix 4: brain.feedback rejects archived served_by_profile_id.
3268    #[tokio::test]
3269    async fn r2_feedback_rejects_archived_served_by_profile() {
3270        let (pack, rt) = make_pack();
3271        let registry = empty_registry();
3272        let token = rt.authorize(Namespace::local()).unwrap();
3273
3274        let target = create_test_entity(&rt, &token).await;
3275
3276        // Archive the only profile.
3277        pack.dispatch(
3278            "brain.deactivate",
3279            json!({"profile_id": "balanced-recall-v1"}),
3280            &registry,
3281            &token,
3282        )
3283        .await
3284        .unwrap();
3285        pack.dispatch(
3286            "brain.archive",
3287            json!({"profile_id": "balanced-recall-v1"}),
3288            &registry,
3289            &token,
3290        )
3291        .await
3292        .unwrap();
3293
3294        let err = pack
3295            .dispatch(
3296                "brain.feedback",
3297                json!({
3298                    "target_id": target,
3299                    "signal": "useful",
3300                    "served_by_profile_id": "balanced-recall-v1"
3301                }),
3302                &registry,
3303                &token,
3304            )
3305            .await
3306            .unwrap_err();
3307        if let RuntimeError::InvalidInput(msg) = &err {
3308            assert!(
3309                msg.contains("archived"),
3310                "r2 fix 4: feedback to archived profile must mention 'archived'; got: {msg}"
3311            );
3312        } else {
3313            panic!("r2 fix 4: feedback to archived served_by_profile_id must return InvalidInput, got {err:?}");
3314        }
3315    }
3316
3317    // Round-2 fix 5: brain.create_profile rejects empty and wildcard consumer_kind.
3318    #[tokio::test]
3319    async fn r2_create_profile_rejects_empty_consumer_kind() {
3320        let (pack, rt) = make_pack();
3321        let registry = empty_registry();
3322        let token = rt.authorize(Namespace::local()).unwrap();
3323
3324        let err = pack
3325            .dispatch(
3326                "brain.create_profile",
3327                json!({"name": "bad-profile", "consumer_kind": ""}),
3328                &registry,
3329                &token,
3330            )
3331            .await
3332            .unwrap_err();
3333        assert!(
3334            matches!(err, RuntimeError::InvalidInput(_)),
3335            "r2 fix 5: empty consumer_kind must return InvalidInput, got {err:?}"
3336        );
3337    }
3338
3339    #[tokio::test]
3340    async fn r2_create_profile_rejects_wildcard_consumer_kind() {
3341        let (pack, rt) = make_pack();
3342        let registry = empty_registry();
3343        let token = rt.authorize(Namespace::local()).unwrap();
3344
3345        let err = pack
3346            .dispatch(
3347                "brain.create_profile",
3348                json!({"name": "wildcard-profile", "consumer_kind": "*"}),
3349                &registry,
3350                &token,
3351            )
3352            .await
3353            .unwrap_err();
3354        if let RuntimeError::InvalidInput(msg) = &err {
3355            assert!(
3356                msg.contains("wildcard") || msg.contains("sentinel") || msg.contains("*"),
3357                "r2 fix 5: wildcard consumer_kind rejection must explain the issue; got: {msg}"
3358            );
3359        } else {
3360            panic!("r2 fix 5: wildcard consumer_kind must return InvalidInput, got {err:?}");
3361        }
3362    }
3363
3364    #[tokio::test]
3365    async fn r2_create_profile_rejects_whitespace_consumer_kind() {
3366        let (pack, rt) = make_pack();
3367        let registry = empty_registry();
3368        let token = rt.authorize(Namespace::local()).unwrap();
3369
3370        let err = pack
3371            .dispatch(
3372                "brain.create_profile",
3373                json!({"name": "ws-profile", "consumer_kind": "   "}),
3374                &registry,
3375                &token,
3376            )
3377            .await
3378            .unwrap_err();
3379        assert!(
3380            matches!(err, RuntimeError::InvalidInput(_)),
3381            "r2 fix 5: whitespace consumer_kind must return InvalidInput, got {err:?}"
3382        );
3383    }
3384
3385    // Round-2 fix 6: brain.bindings AND-semantics pinned with ≥3 bindings and combined filters.
3386    #[tokio::test]
3387    async fn r2_bindings_and_semantics_multi_filter() {
3388        let (pack, rt) = make_pack();
3389        let registry = empty_registry();
3390        let token = rt.authorize(Namespace::local()).unwrap();
3391
3392        // Create two extra profiles for variety.
3393        for name in ["alpha-v1", "beta-v1"] {
3394            pack.dispatch(
3395                "brain.create_profile",
3396                json!({"name": name, "consumer_kind": "recall"}),
3397                &registry,
3398                &token,
3399            )
3400            .await
3401            .unwrap();
3402            pack.dispatch(
3403                "brain.activate",
3404                json!({"profile_id": name}),
3405                &registry,
3406                &token,
3407            )
3408            .await
3409            .unwrap();
3410        }
3411
3412        // Three bindings with distinct (actor, namespace, consumer_kind) combos.
3413        pack.dispatch(
3414            "brain.bind",
3415            json!({"profile_id": "balanced-recall-v1", "actor": "agent-A", "namespace": "ns-1", "consumer_kind": "recall"}),
3416            &registry,
3417            &token,
3418        )
3419        .await
3420        .unwrap();
3421        pack.dispatch(
3422            "brain.bind",
3423            json!({"profile_id": "alpha-v1", "actor": "agent-A", "namespace": "ns-2", "consumer_kind": "search"}),
3424            &registry,
3425            &token,
3426        )
3427        .await
3428        .unwrap();
3429        pack.dispatch(
3430            "brain.bind",
3431            json!({"profile_id": "beta-v1", "actor": "agent-B", "namespace": "ns-1", "consumer_kind": "recall"}),
3432            &registry,
3433            &token,
3434        )
3435        .await
3436        .unwrap();
3437
3438        // Filter by profile_id + namespace: should return exactly 1 row.
3439        let r1 = pack
3440            .dispatch(
3441                "brain.bindings",
3442                json!({"profile_id": "balanced-recall-v1", "namespace": "ns-1"}),
3443                &registry,
3444                &token,
3445            )
3446            .await
3447            .unwrap();
3448        assert_eq!(
3449            r1["count"],
3450            json!(1u64),
3451            "AND filter profile_id+namespace must return 1 row"
3452        );
3453        assert_eq!(r1["bindings"][0]["profile_id"], json!("balanced-recall-v1"));
3454        assert_eq!(r1["bindings"][0]["namespace"], json!("ns-1"));
3455
3456        // Filter by actor + consumer_kind: agent-A+search → only alpha-v1 row.
3457        let r2 = pack
3458            .dispatch(
3459                "brain.bindings",
3460                json!({"actor": "agent-A", "consumer_kind": "search"}),
3461                &registry,
3462                &token,
3463            )
3464            .await
3465            .unwrap();
3466        assert_eq!(
3467            r2["count"],
3468            json!(1u64),
3469            "AND filter actor+consumer_kind must return 1 row"
3470        );
3471        assert_eq!(r2["bindings"][0]["profile_id"], json!("alpha-v1"));
3472
3473        // Zero-row combination: agent-B + consumer_kind=search (no such binding).
3474        let r3 = pack
3475            .dispatch(
3476                "brain.bindings",
3477                json!({"actor": "agent-B", "consumer_kind": "search"}),
3478                &registry,
3479                &token,
3480            )
3481            .await
3482            .unwrap();
3483        assert_eq!(
3484            r3["count"],
3485            json!(0u64),
3486            "AND filter with no matches must return count=0"
3487        );
3488        assert_eq!(r3["bindings"], json!([]));
3489    }
3490
3491    // Round-2 fix 2: user-created profile has real posterior state that reset mutates.
3492    // Round-3 strengthening: emit feedback first to move posteriors, then assert all three
3493    // posterior alpha/beta values return to their ADR-032 priors after reset.
3494    #[tokio::test]
3495    async fn r2_user_profile_reset_mutates_posteriors() {
3496        let (pack, rt) = make_pack();
3497        let registry = empty_registry();
3498        let token = rt.authorize(Namespace::local()).unwrap();
3499
3500        let target = create_test_entity(&rt, &token).await;
3501
3502        pack.dispatch(
3503            "brain.create_profile",
3504            json!({"name": "custom-v1", "consumer_kind": "recall"}),
3505            &registry,
3506            &token,
3507        )
3508        .await
3509        .unwrap();
3510        pack.dispatch(
3511            "brain.activate",
3512            json!({"profile_id": "custom-v1"}),
3513            &registry,
3514            &token,
3515        )
3516        .await
3517        .unwrap();
3518
3519        // Emit feedback to custom-v1 so its salience posterior diverges from prior.
3520        // brain.feedback with signal="useful" → salience.update_success() (fold.rs:54).
3521        pack.dispatch(
3522            "brain.feedback",
3523            json!({"target_id": target, "signal": "useful", "served_by_profile_id": "custom-v1"}),
3524            &registry,
3525            &token,
3526        )
3527        .await
3528        .unwrap();
3529
3530        // Confirm salience.alpha increased above the prior (2.0).
3531        let mutated = pack
3532            .dispatch(
3533                "brain.profile",
3534                json!({"profile_id": "custom-v1"}),
3535                &registry,
3536                &token,
3537            )
3538            .await
3539            .unwrap();
3540        let salience_alpha_before = mutated["state_snapshot"]["salience"]["alpha"]
3541            .as_f64()
3542            .expect("state_snapshot.salience.alpha must be a number");
3543        assert!(
3544            salience_alpha_before > 2.0,
3545            "r3 fix 2: feedback must have moved salience alpha above prior 2.0; got {salience_alpha_before}"
3546        );
3547        let epoch_before = mutated["exploration_epoch"].as_u64().unwrap();
3548
3549        // Reset custom profile.
3550        let reset_result = pack
3551            .dispatch(
3552                "brain.reset",
3553                json!({"profile_id": "custom-v1"}),
3554                &registry,
3555                &token,
3556            )
3557            .await
3558            .unwrap();
3559        assert_eq!(reset_result["reset"], json!(true));
3560        assert_eq!(reset_result["profile_id"], json!("custom-v1"));
3561
3562        // Epoch must increment.
3563        let after = pack
3564            .dispatch(
3565                "brain.profile",
3566                json!({"profile_id": "custom-v1"}),
3567                &registry,
3568                &token,
3569            )
3570            .await
3571            .unwrap();
3572        let epoch_after = after["exploration_epoch"].as_u64().unwrap();
3573        assert!(
3574            epoch_after > epoch_before,
3575            "r3 fix 2: reset must increment exploration_epoch on user-created profile; before={epoch_before} after={epoch_after}"
3576        );
3577
3578        // All three posteriors must return exactly to ADR-032 priors:
3579        //   relevance  = Beta(7, 3)
3580        //   salience   = Beta(2, 8)
3581        //   temporal   = Beta(1, 9)
3582        let snap = &after["state_snapshot"];
3583        assert!(
3584            !snap.is_null(),
3585            "r3 fix 2: state_snapshot must be non-null after reset"
3586        );
3587
3588        let rel_alpha = snap["relevance"]["alpha"]
3589            .as_f64()
3590            .expect("relevance.alpha");
3591        let rel_beta = snap["relevance"]["beta"].as_f64().expect("relevance.beta");
3592        assert!(
3593            (rel_alpha - 7.0).abs() < 1e-9 && (rel_beta - 3.0).abs() < 1e-9,
3594            "r3 fix 2: relevance must be Beta(7,3) after reset; got ({rel_alpha},{rel_beta})"
3595        );
3596
3597        let sal_alpha = snap["salience"]["alpha"].as_f64().expect("salience.alpha");
3598        let sal_beta = snap["salience"]["beta"].as_f64().expect("salience.beta");
3599        assert!(
3600            (sal_alpha - 2.0).abs() < 1e-9 && (sal_beta - 8.0).abs() < 1e-9,
3601            "r3 fix 2: salience must be Beta(2,8) after reset; got ({sal_alpha},{sal_beta})"
3602        );
3603
3604        let tmp_alpha = snap["temporal"]["alpha"].as_f64().expect("temporal.alpha");
3605        let tmp_beta = snap["temporal"]["beta"].as_f64().expect("temporal.beta");
3606        assert!(
3607            (tmp_alpha - 1.0).abs() < 1e-9 && (tmp_beta - 9.0).abs() < 1e-9,
3608            "r3 fix 2: temporal must be Beta(1,9) after reset; got ({tmp_alpha},{tmp_beta})"
3609        );
3610    }
3611
3612    // Round-2 fix 2: feedback routes to the user-created profile's own state.
3613    #[tokio::test]
3614    async fn r2_user_profile_feedback_routes_to_profile_state() {
3615        let (pack, rt) = make_pack();
3616        let registry = empty_registry();
3617        let token = rt.authorize(Namespace::local()).unwrap();
3618
3619        let target = create_test_entity(&rt, &token).await;
3620
3621        pack.dispatch(
3622            "brain.create_profile",
3623            json!({"name": "custom-v1", "consumer_kind": "recall"}),
3624            &registry,
3625            &token,
3626        )
3627        .await
3628        .unwrap();
3629        pack.dispatch(
3630            "brain.activate",
3631            json!({"profile_id": "custom-v1"}),
3632            &registry,
3633            &token,
3634        )
3635        .await
3636        .unwrap();
3637
3638        let before = pack
3639            .dispatch(
3640                "brain.profile",
3641                json!({"profile_id": "custom-v1"}),
3642                &registry,
3643                &token,
3644            )
3645            .await
3646            .unwrap();
3647        let events_before = before["total_events"].as_u64().unwrap();
3648
3649        pack.dispatch(
3650            "brain.feedback",
3651            json!({"target_id": target, "signal": "useful", "served_by_profile_id": "custom-v1"}),
3652            &registry,
3653            &token,
3654        )
3655        .await
3656        .unwrap();
3657
3658        let after = pack
3659            .dispatch(
3660                "brain.profile",
3661                json!({"profile_id": "custom-v1"}),
3662                &registry,
3663                &token,
3664            )
3665            .await
3666            .unwrap();
3667        let events_after = after["total_events"].as_u64().unwrap();
3668        assert!(
3669            events_after > events_before,
3670            "r2 fix 2: feedback routed to custom profile must increment its total_events; before={events_before} after={events_after}"
3671        );
3672    }
3673
3674    // H4: brain.profile accepts profile_id (canonical) and id (alias).
3675    #[tokio::test]
3676    async fn w4_h4_profile_accepts_profile_id_and_id_alias() {
3677        let (pack, rt) = make_pack();
3678        let registry = empty_registry();
3679        let token = rt.authorize(Namespace::local()).unwrap();
3680
3681        // Canonical arg.
3682        let r1 = pack
3683            .dispatch(
3684                "brain.profile",
3685                json!({"profile_id": "balanced-recall-v1"}),
3686                &registry,
3687                &token,
3688            )
3689            .await
3690            .unwrap();
3691        assert_eq!(r1["id"], json!("balanced-recall-v1"));
3692
3693        // Legacy alias.
3694        let r2 = pack
3695            .dispatch(
3696                "brain.profile",
3697                json!({"id": "balanced-recall-v1"}),
3698                &registry,
3699                &token,
3700            )
3701            .await
3702            .unwrap();
3703        assert_eq!(r2["id"], json!("balanced-recall-v1"));
3704    }
3705
3706    // ── Round-3 regression tests ──────────────────────────────────────────────
3707
3708    // R3-1: archiving balanced-recall-v1 then calling brain.feedback without
3709    // served_by_profile_id must return InvalidInput and must NOT append an event.
3710    #[tokio::test]
3711    async fn r3_feedback_default_profile_archived_rejected() {
3712        let (pack, rt) = make_pack();
3713        let registry = empty_registry();
3714        let token = rt.authorize(Namespace::local()).unwrap();
3715
3716        let target = create_test_entity(&rt, &token).await;
3717
3718        // balanced-recall-v1 starts Active; must deactivate before archiving (lifecycle rule).
3719        pack.dispatch(
3720            "brain.deactivate",
3721            json!({"profile_id": "balanced-recall-v1"}),
3722            &registry,
3723            &token,
3724        )
3725        .await
3726        .unwrap();
3727
3728        // Archive the default profile.
3729        pack.dispatch(
3730            "brain.archive",
3731            json!({"profile_id": "balanced-recall-v1"}),
3732            &registry,
3733            &token,
3734        )
3735        .await
3736        .unwrap();
3737
3738        // Baseline: total_events on default profile before the attempted feedback.
3739        // Also capture brain.events count so we catch any FeedbackExplicit row that
3740        // sneaks past the lifecycle check via a reordered append → fold sequence.
3741        let snap_before = pack
3742            .dispatch(
3743                "brain.profile",
3744                json!({"profile_id": "balanced-recall-v1"}),
3745                &registry,
3746                &token,
3747            )
3748            .await
3749            .unwrap();
3750        let events_before = snap_before["total_events"].as_u64().unwrap_or(0);
3751
3752        let log_before = pack
3753            .dispatch("brain.events", json!({"limit": 1000}), &registry, &token)
3754            .await
3755            .unwrap();
3756        let log_count_before = log_before["events"]
3757            .as_array()
3758            .map(|a| a.len())
3759            .unwrap_or(0);
3760
3761        // feedback without served_by_profile_id must be rejected.
3762        let err = pack
3763            .dispatch(
3764                "brain.feedback",
3765                json!({"target_id": target, "signal": "useful"}),
3766                &registry,
3767                &token,
3768            )
3769            .await
3770            .unwrap_err();
3771        match &err {
3772            RuntimeError::InvalidInput(msg) => {
3773                assert!(
3774                    msg.contains("archived"),
3775                    "r3-1: error must mention 'archived'; got: {msg}"
3776                );
3777            }
3778            other => panic!("r3-1: expected InvalidInput(archived), got {other:?}"),
3779        }
3780
3781        // No state mutation: total_events must be unchanged.
3782        let snap_after = pack
3783            .dispatch(
3784                "brain.profile",
3785                json!({"profile_id": "balanced-recall-v1"}),
3786                &registry,
3787                &token,
3788            )
3789            .await
3790            .unwrap();
3791        let events_after = snap_after["total_events"].as_u64().unwrap_or(0);
3792        assert_eq!(
3793            events_after, events_before,
3794            "r3-1: archived default profile must not have events appended; before={events_before} after={events_after}"
3795        );
3796
3797        // Defense-in-depth: also verify nothing landed in the event log itself.
3798        // A future reorder that appends FeedbackExplicit before the lifecycle check
3799        // but skips the fold would leave total_events unchanged but still write to
3800        // the log. This assertion catches that class.
3801        let log_after = pack
3802            .dispatch("brain.events", json!({"limit": 1000}), &registry, &token)
3803            .await
3804            .unwrap();
3805        let log_count_after = log_after["events"].as_array().map(|a| a.len()).unwrap_or(0);
3806        assert_eq!(
3807            log_count_after, log_count_before,
3808            "r3-1: rejected feedback must not append a FeedbackExplicit event; before={log_count_before} after={log_count_after}"
3809        );
3810    }
3811
3812    // R3-3: brain.create_profile profile-id grammar enforcement.
3813    #[tokio::test]
3814    async fn r3_create_profile_id_grammar_enforced() {
3815        let (pack, rt) = make_pack();
3816        let registry = empty_registry();
3817        let token = rt.authorize(Namespace::local()).unwrap();
3818
3819        // Whitespace-only name must be rejected.
3820        let err = pack
3821            .dispatch(
3822                "brain.create_profile",
3823                json!({"name": "   ", "consumer_kind": "recall"}),
3824                &registry,
3825                &token,
3826            )
3827            .await
3828            .unwrap_err();
3829        assert!(
3830            matches!(err, RuntimeError::InvalidInput(_)),
3831            "r3-3: whitespace-only name must return InvalidInput; got {err:?}"
3832        );
3833
3834        // Leading/trailing space: trimmed name "my-profile" must be accepted.
3835        pack.dispatch(
3836            "brain.create_profile",
3837            json!({"name": "  my-profile  ", "consumer_kind": "recall"}),
3838            &registry,
3839            &token,
3840        )
3841        .await
3842        .expect("r3-3: name with leading/trailing spaces should be accepted after trim");
3843
3844        // Dot in name must be rejected.
3845        let err = pack
3846            .dispatch(
3847                "brain.create_profile",
3848                json!({"name": "bad.profile", "consumer_kind": "recall"}),
3849                &registry,
3850                &token,
3851            )
3852            .await
3853            .unwrap_err();
3854        assert!(
3855            matches!(err, RuntimeError::InvalidInput(_)),
3856            "r3-3: dot in name must return InvalidInput; got {err:?}"
3857        );
3858
3859        // Underscore in name must be rejected.
3860        let err = pack
3861            .dispatch(
3862                "brain.create_profile",
3863                json!({"name": "bad_profile", "consumer_kind": "recall"}),
3864                &registry,
3865                &token,
3866            )
3867            .await
3868            .unwrap_err();
3869        assert!(
3870            matches!(err, RuntimeError::InvalidInput(_)),
3871            "r3-3: underscore in name must return InvalidInput; got {err:?}"
3872        );
3873
3874        // Asterisk in name must be rejected.
3875        let err = pack
3876            .dispatch(
3877                "brain.create_profile",
3878                json!({"name": "*", "consumer_kind": "recall"}),
3879                &registry,
3880                &token,
3881            )
3882            .await
3883            .unwrap_err();
3884        assert!(
3885            matches!(err, RuntimeError::InvalidInput(_)),
3886            "r3-3: asterisk name must return InvalidInput; got {err:?}"
3887        );
3888
3889        // Valid alphanumeric-hyphen name must succeed.
3890        pack.dispatch(
3891            "brain.create_profile",
3892            json!({"name": "valid-profile-123", "consumer_kind": "recall"}),
3893            &registry,
3894            &token,
3895        )
3896        .await
3897        .expect("r3-3: valid alphanumeric-hyphen name must succeed");
3898    }
3899
3900    // #289: feedback event must record a non-zero duration_us.
3901    #[tokio::test]
3902    async fn test_289_feedback_event_records_nonzero_duration() {
3903        let (pack, rt) = make_pack();
3904        let registry = empty_registry();
3905        let token = rt.authorize(Namespace::local()).unwrap();
3906        let target = create_test_entity(&rt, &token).await;
3907
3908        let result = pack
3909            .dispatch(
3910                "brain.feedback",
3911                json!({"target_id": target, "signal": "useful"}),
3912                &registry,
3913                &token,
3914            )
3915            .await
3916            .unwrap();
3917        let event_id = result["event_id"].as_str().unwrap().to_string();
3918
3919        let log = pack
3920            .dispatch("brain.events", json!({"limit": 100}), &registry, &token)
3921            .await
3922            .unwrap();
3923        let event = log["events"]
3924            .as_array()
3925            .unwrap()
3926            .iter()
3927            .find(|e| e["id"].as_str() == Some(event_id.as_str()))
3928            .expect("#289: feedback event must appear in brain.events");
3929
3930        assert!(
3931            event["duration_us"].as_i64().unwrap() > 0,
3932            "#289: feedback event duration_us must be non-zero, got {}",
3933            event["duration_us"]
3934        );
3935    }
3936
3937    // ── #517: brain.auto_feedback ─────────────────────────────────────────────
3938
3939    #[tokio::test]
3940    async fn brain_auto_feedback_emits_implicit_positive_for_first_result() {
3941        let (pack, rt) = make_pack();
3942        let registry = empty_registry();
3943        let token = rt.authorize(Namespace::local()).unwrap();
3944        let target = create_test_entity(&rt, &token).await;
3945
3946        let result = pack
3947            .dispatch(
3948                "brain.auto_feedback",
3949                json!({
3950                    "query": "recall calibration target",
3951                    "results": [{ "note_id": target }]
3952                }),
3953                &registry,
3954                &token,
3955            )
3956            .await
3957            .expect("auto_feedback succeeds");
3958
3959        assert_eq!(result["emitted"], json!(true), "emitted must be true");
3960        assert_eq!(
3961            result["signal"],
3962            json!("implicit_positive"),
3963            "default signal must be implicit_positive"
3964        );
3965        let returned_target_id = result["target_id"].as_str().unwrap_or("");
3966        assert_eq!(
3967            returned_target_id.len(),
3968            36,
3969            "target_id in auto_feedback response must be full 36-char UUID"
3970        );
3971        assert_eq!(
3972            returned_target_id, target,
3973            "target_id must match the created entity"
3974        );
3975        assert_eq!(
3976            pack.snapshot().balanced_recall.total_events,
3977            1,
3978            "auto_feedback must increment total_events"
3979        );
3980    }
3981
3982    #[tokio::test]
3983    async fn brain_auto_feedback_empty_results_returns_no_emit() {
3984        let (pack, rt) = make_pack();
3985        let registry = empty_registry();
3986        let token = rt.authorize(Namespace::local()).unwrap();
3987
3988        let result = pack
3989            .dispatch(
3990                "brain.auto_feedback",
3991                json!({
3992                    "query": "empty recall results",
3993                    "results": []
3994                }),
3995                &registry,
3996                &token,
3997            )
3998            .await
3999            .expect("auto_feedback with empty results succeeds");
4000
4001        assert_eq!(result["emitted"], json!(false));
4002        assert_eq!(result["reason"], json!("no_results"));
4003    }
4004
4005    #[tokio::test]
4006    async fn brain_auto_feedback_accepts_short_note_id_prefix() {
4007        let (pack, rt) = make_pack();
4008        let registry = empty_registry();
4009        let token = rt.authorize(Namespace::local()).unwrap();
4010        let target = create_test_entity(&rt, &token).await;
4011        // Use 8-char prefix as Agent mode would return from memory.recall.
4012        let prefix = &target[..8];
4013
4014        let result = pack
4015            .dispatch(
4016                "brain.auto_feedback",
4017                json!({
4018                    "query": "prefix resolution test",
4019                    "results": [{ "note_id": prefix }]
4020                }),
4021                &registry,
4022                &token,
4023            )
4024            .await
4025            .expect("auto_feedback with 8-char prefix succeeds");
4026
4027        assert_eq!(result["emitted"], json!(true));
4028        assert_eq!(result["target_id"].as_str().unwrap_or("").len(), 36);
4029    }
4030}
4031
4032#[cfg(test)]
4033mod help_tests {
4034    use super::*;
4035
4036    fn find_handler(name: &str) -> &'static HandlerDef {
4037        BRAIN_HANDLERS
4038            .iter()
4039            .find(|h| h.name == name)
4040            .unwrap_or_else(|| panic!("handler {name:?} not found in BRAIN_HANDLERS"))
4041    }
4042
4043    #[test]
4044    fn brain_feedback_params_non_empty_and_has_target_and_signal() {
4045        let h = find_handler("brain.feedback");
4046        assert!(!h.params.is_empty(), "brain.feedback must have params");
4047        assert!(
4048            h.params.iter().any(|p| p.name == "target_id" && p.required),
4049            "brain.feedback must have required target_id param"
4050        );
4051        assert!(
4052            h.params.iter().any(|p| p.name == "signal" && p.required),
4053            "brain.feedback must have required signal param"
4054        );
4055        assert!(
4056            h.params.iter().any(|p| p.name == "served_by_profile_id"),
4057            "brain.feedback must document served_by_profile_id"
4058        );
4059    }
4060
4061    #[test]
4062    fn brain_auto_feedback_handler_is_declared() {
4063        let h = find_handler("brain.auto_feedback");
4064        assert!(
4065            h.params.iter().any(|p| p.name == "query" && p.required),
4066            "brain.auto_feedback must have required query param"
4067        );
4068        assert!(
4069            h.params.iter().any(|p| p.name == "results" && p.required),
4070            "brain.auto_feedback must have required results param"
4071        );
4072    }
4073
4074    #[test]
4075    fn brain_profile_params_has_required_profile_id() {
4076        let h = find_handler("brain.profile");
4077        assert!(!h.params.is_empty(), "brain.profile must have params");
4078        // H4: canonical param is now profile_id; id is accepted as a runtime alias.
4079        assert!(
4080            h.params
4081                .iter()
4082                .any(|p| p.name == "profile_id" && p.required),
4083            "brain.profile must have required profile_id param (H4 fix)"
4084        );
4085    }
4086
4087    #[test]
4088    fn brain_profiles_params_has_lifecycle_filter() {
4089        let h = find_handler("brain.profiles");
4090        assert!(!h.params.is_empty(), "brain.profiles must have params");
4091        assert!(
4092            h.params.iter().any(|p| p.name == "lifecycle"),
4093            "brain.profiles must document lifecycle filter param"
4094        );
4095    }
4096
4097    #[test]
4098    fn brain_resolve_params_has_consumer_kind_required() {
4099        let h = find_handler("brain.resolve");
4100        assert!(!h.params.is_empty(), "brain.resolve must have params");
4101        assert!(
4102            h.params
4103                .iter()
4104                .any(|p| p.name == "consumer_kind" && p.required),
4105            "brain.resolve must have required consumer_kind"
4106        );
4107        assert!(
4108            h.params.iter().any(|p| p.name == "actor"),
4109            "brain.resolve must document optional actor"
4110        );
4111        assert!(
4112            h.params.iter().any(|p| p.name == "namespace"),
4113            "brain.resolve must document optional namespace"
4114        );
4115    }
4116
4117    #[test]
4118    fn brain_bind_params_has_required_profile_id_and_optionals() {
4119        let h = find_handler("brain.bind");
4120        assert!(!h.params.is_empty(), "brain.bind must have params");
4121        assert!(
4122            h.params
4123                .iter()
4124                .any(|p| p.name == "profile_id" && p.required),
4125            "brain.bind must have required profile_id"
4126        );
4127        assert!(
4128            h.params.iter().any(|p| p.name == "actor"),
4129            "brain.bind must document actor"
4130        );
4131        assert!(
4132            h.params.iter().any(|p| p.name == "namespace"),
4133            "brain.bind must document namespace"
4134        );
4135        assert!(
4136            h.params.iter().any(|p| p.name == "consumer_kind"),
4137            "brain.bind must document consumer_kind"
4138        );
4139        assert!(
4140            h.params.iter().any(|p| p.name == "priority"),
4141            "brain.bind must document priority"
4142        );
4143    }
4144
4145    #[test]
4146    fn brain_unbind_params_non_empty_all_optional() {
4147        let h = find_handler("brain.unbind");
4148        assert!(!h.params.is_empty(), "brain.unbind must have params");
4149        assert!(
4150            h.params.iter().all(|p| !p.required),
4151            "brain.unbind params must all be optional (filter semantics)"
4152        );
4153        assert!(
4154            h.params.iter().any(|p| p.name == "profile_id"),
4155            "brain.unbind must document profile_id filter"
4156        );
4157        assert!(
4158            h.params.iter().any(|p| p.name == "actor"),
4159            "brain.unbind must document actor filter"
4160        );
4161    }
4162
4163    #[test]
4164    fn brain_activate_deactivate_archive_each_have_profile_id() {
4165        for verb in ["brain.activate", "brain.deactivate", "brain.archive"] {
4166            let h = find_handler(verb);
4167            assert!(!h.params.is_empty(), "{verb} must have params");
4168            assert!(
4169                h.params
4170                    .iter()
4171                    .any(|p| p.name == "profile_id" && p.required),
4172                "{verb} must have required profile_id param"
4173            );
4174        }
4175    }
4176
4177    #[test]
4178    fn brain_reset_params_has_optional_profile_id() {
4179        let h = find_handler("brain.reset");
4180        assert!(!h.params.is_empty(), "brain.reset must have params");
4181        assert!(
4182            h.params
4183                .iter()
4184                .any(|p| p.name == "profile_id" && !p.required),
4185            "brain.reset profile_id must be optional (defaults to balanced-recall-v1)"
4186        );
4187    }
4188
4189    #[test]
4190    fn brain_config_params_has_parameter() {
4191        let h = find_handler("brain.config");
4192        assert!(
4193            !h.params.is_empty(),
4194            "brain.config must document the parameter arg"
4195        );
4196        assert!(
4197            h.params
4198                .iter()
4199                .any(|p| p.name == "parameter" && !p.required),
4200            "brain.config parameter must be optional"
4201        );
4202    }
4203
4204    #[test]
4205    fn brain_events_params_has_limit() {
4206        let h = find_handler("brain.events");
4207        assert!(
4208            !h.params.is_empty(),
4209            "brain.events must document the limit arg"
4210        );
4211        assert!(
4212            h.params.iter().any(|p| p.name == "limit" && !p.required),
4213            "brain.events limit must be optional"
4214        );
4215    }
4216
4217    #[test]
4218    fn brain_emit_params_non_empty_with_target_and_signal() {
4219        let h = find_handler("brain.emit");
4220        assert!(
4221            !h.params.is_empty(),
4222            "brain.emit must have params (mirrors brain.feedback)"
4223        );
4224        assert!(
4225            h.params.iter().any(|p| p.name == "target_id" && p.required),
4226            "brain.emit must have required target_id"
4227        );
4228        assert!(
4229            h.params.iter().any(|p| p.name == "signal" && p.required),
4230            "brain.emit must have required signal"
4231        );
4232    }
4233
4234    #[test]
4235    fn brain_bindings_params_all_optional() {
4236        let h = find_handler("brain.bindings");
4237        assert!(
4238            h.params.iter().all(|p| !p.required),
4239            "brain.bindings: all params must be optional filter args"
4240        );
4241        assert!(
4242            h.params.iter().any(|p| p.name == "profile_id"),
4243            "brain.bindings must document profile_id filter"
4244        );
4245        assert!(
4246            h.params.iter().any(|p| p.name == "consumer_kind"),
4247            "brain.bindings must document consumer_kind filter"
4248        );
4249    }
4250
4251    #[test]
4252    fn brain_create_profile_params_has_required_name() {
4253        let h = find_handler("brain.create_profile");
4254        assert!(
4255            !h.params.is_empty(),
4256            "brain.create_profile must have params"
4257        );
4258        assert!(
4259            h.params.iter().any(|p| p.name == "name" && p.required),
4260            "brain.create_profile must have required name param"
4261        );
4262        assert!(
4263            h.params
4264                .iter()
4265                .any(|p| p.name == "consumer_kind" && !p.required),
4266            "brain.create_profile consumer_kind must be optional"
4267        );
4268    }
4269
4270    // ── Regression: schema-aware namespace strip (codex round-2 H1) ──────────
4271    //
4272    // brain.bind / brain.resolve / brain.unbind / brain.bindings declare
4273    // `namespace` as a *business* parameter in their HandlerDef.params.  The
4274    // VerbRegistry dispatch path must NOT strip `namespace` from those verbs
4275    // even though it strips it as a transport routing key from all other verbs.
4276    //
4277    // These tests go through VerbRegistry::dispatch (not pack.dispatch) to
4278    // exercise the actual strip logic in pack.rs.
4279
4280    /// Build a VerbRegistry with kg + brain packs, returning the registry and
4281    /// an owned BrainPack snapshot handle.  We need a reference to the brain
4282    /// state after dispatch — the registry owns the pack, so we verify via a
4283    /// second dispatch (brain.bindings) rather than peeking at internal state.
4284    fn make_brain_registry() -> (VerbRegistry, KhiveRuntime) {
4285        use khive_pack_kg::KgPack;
4286        use khive_runtime::VerbRegistryBuilder;
4287        let rt = KhiveRuntime::memory().expect("in-memory runtime for brain registry");
4288        let mut builder = VerbRegistryBuilder::new();
4289        builder.register(KgPack::new(rt.clone()));
4290        builder.register(BrainPack::new(rt.clone()));
4291        let registry = builder.build().expect("kg+brain registry builds");
4292        (registry, rt)
4293    }
4294
4295    /// brain.bind via VerbRegistry must store the caller-supplied namespace,
4296    /// not default to "*".  Regression for the blanket-strip bug (codex H1).
4297    #[tokio::test]
4298    async fn r2_h1_bind_via_registry_preserves_namespace() {
4299        let (registry, _rt) = make_brain_registry();
4300
4301        // Bind with a specific namespace.
4302        let result = registry
4303            .dispatch(
4304                "brain.bind",
4305                json!({
4306                    "profile_id": "balanced-recall-v1",
4307                    "actor": "alice",
4308                    "namespace": "team-a",
4309                    "consumer_kind": "recall",
4310                }),
4311            )
4312            .await
4313            .expect("brain.bind must succeed");
4314        assert_eq!(
4315            result["namespace"],
4316            json!("team-a"),
4317            "brain.bind response must echo the caller-supplied namespace"
4318        );
4319
4320        // Verify via brain.bindings: the stored row must have namespace=team-a.
4321        let bindings = registry
4322            .dispatch(
4323                "brain.bindings",
4324                json!({
4325                    "profile_id": "balanced-recall-v1",
4326                    "namespace": "team-a",
4327                }),
4328            )
4329            .await
4330            .expect("brain.bindings must succeed");
4331        assert_eq!(
4332            bindings["count"],
4333            json!(1u64),
4334            "must find exactly one binding for namespace=team-a"
4335        );
4336        assert_eq!(
4337            bindings["bindings"][0]["namespace"],
4338            json!("team-a"),
4339            "stored binding namespace must be team-a, not wildcard"
4340        );
4341    }
4342
4343    /// brain.resolve via VerbRegistry must use the caller-supplied namespace to
4344    /// match the binding stored by brain.bind.  Regression for codex H1.
4345    #[tokio::test]
4346    async fn r2_h1_resolve_via_registry_uses_namespace() {
4347        let (registry, _rt) = make_brain_registry();
4348
4349        // Store a binding scoped to team-a.
4350        registry
4351            .dispatch(
4352                "brain.bind",
4353                json!({
4354                    "profile_id": "balanced-recall-v1",
4355                    "actor": "alice",
4356                    "namespace": "team-a",
4357                    "consumer_kind": "recall",
4358                }),
4359            )
4360            .await
4361            .expect("brain.bind team-a");
4362
4363        // Resolve for alice / team-a / recall — must find balanced-recall-v1.
4364        let resolved = registry
4365            .dispatch(
4366                "brain.resolve",
4367                json!({
4368                    "actor": "alice",
4369                    "namespace": "team-a",
4370                    "consumer_kind": "recall",
4371                }),
4372            )
4373            .await
4374            .expect("brain.resolve must succeed for team-a");
4375        assert_eq!(
4376            resolved["resolved_profile_id"],
4377            json!("balanced-recall-v1"),
4378            "resolve must return the profile bound for team-a"
4379        );
4380    }
4381
4382    /// brain.unbind via VerbRegistry must use the caller-supplied namespace to
4383    /// remove only the matching binding.  Regression for codex H1.
4384    #[tokio::test]
4385    async fn r2_h1_unbind_via_registry_uses_namespace() {
4386        let (registry, _rt) = make_brain_registry();
4387
4388        // Two bindings: team-a and team-b.
4389        registry
4390            .dispatch(
4391                "brain.bind",
4392                json!({
4393                    "profile_id": "balanced-recall-v1",
4394                    "actor": "alice",
4395                    "namespace": "team-a",
4396                    "consumer_kind": "recall",
4397                }),
4398            )
4399            .await
4400            .expect("bind team-a");
4401        registry
4402            .dispatch(
4403                "brain.bind",
4404                json!({
4405                    "profile_id": "balanced-recall-v1",
4406                    "actor": "alice",
4407                    "namespace": "team-b",
4408                    "consumer_kind": "recall",
4409                }),
4410            )
4411            .await
4412            .expect("bind team-b");
4413
4414        // Unbind only team-a.
4415        let unbound = registry
4416            .dispatch(
4417                "brain.unbind",
4418                json!({
4419                    "actor": "alice",
4420                    "namespace": "team-a",
4421                }),
4422            )
4423            .await
4424            .expect("unbind team-a");
4425        assert_eq!(
4426            unbound["unbound"],
4427            json!(1u64),
4428            "must remove exactly one binding (team-a)"
4429        );
4430
4431        // team-b must survive.
4432        let remaining = registry
4433            .dispatch(
4434                "brain.bindings",
4435                json!({
4436                    "actor": "alice",
4437                    "namespace": "team-b",
4438                }),
4439            )
4440            .await
4441            .expect("bindings after unbind");
4442        assert_eq!(
4443            remaining["count"],
4444            json!(1u64),
4445            "team-b binding must survive the team-a unbind"
4446        );
4447    }
4448}