Skip to main content

khive_runtime/pack/
dispatch.rs

1use std::sync::Arc;
2use std::time::Instant;
3
4use khive_gate::{AuditEvent, GateDecision, GateRequest};
5use khive_storage::{Event, EventStore, EventView, SubstrateKind};
6use khive_types::{EventKind, EventOutcome, Namespace, Visibility};
7use serde_json::Value;
8
9use crate::error::{AuditObligationFailure, DispatchError, RuntimeError};
10use crate::runtime::NamespaceToken;
11
12use super::request_identity::edge_endpoint_table;
13#[cfg(doc)]
14use super::IdResolutionMode;
15use super::{
16    append_audit_event_best_effort, build_audit_storage_event, fold_audit_obligation,
17    identifier_resolution_help, link_audit_success_from_result, masked_audit_event,
18    persist_git_digest_receipt, resolution_mode_contract, resolve_explicit_namespace,
19    GitDigestReceiptOutcome, InterceptedDispatchResult, RequestIdentity, VerbRegistry,
20    VerifiedActor,
21};
22
23impl VerbRegistry {
24    /// Return the help schema envelope for a verb.
25    ///
26    /// Walks registered packs for the first matching `HandlerDef` and returns a
27    /// structured JSON envelope. Subhandlers carry `callable_via_mcp: false`.
28    /// Every envelope carries the shared `identifier_resolution` contract.
29    /// `link`'s envelope additionally carries `endpoint_rules` — the composed
30    /// per-relation source/target allowlist (issue #964) — so batch callers can
31    /// defer to the kernel's own table instead of re-implementing it locally.
32    /// Every `uuid`/`array of uuid` parameter description has its
33    /// declared [`IdResolutionMode`]'s contract text appended — the same
34    /// text rendered under `identifier_resolution.resolution_modes` — so the
35    /// full-UUID-vs-short-prefix rule is stated once per mode (in
36    /// `resolution_mode_contract`) and inherited by every matching param,
37    /// instead of restating it per param across every `HandlerDef` in every
38    /// pack. Parameters whose mode is [`IdResolutionMode::NotApplicable`]
39    /// (every non-identifier parameter) are left unchanged.
40    /// Unknown or disabled verbs return `RuntimeError::UnknownVerb`. Full shape documented
41    /// in `docs/protocol.md` §Request Schema.
42    pub fn describe_verb(&self, verb: &str) -> Result<Value, RuntimeError> {
43        if self.is_verb_disabled(verb) {
44            return Err(self.unknown_verb_error(verb));
45        }
46        for pack in self.packs.iter() {
47            for handler in pack.handlers().iter() {
48                if handler.name == verb {
49                    let category = format!("{:?}", handler.category);
50                    let params_arr: Vec<Value> = handler
51                        .params
52                        .iter()
53                        .map(|p| {
54                            let description = match resolution_mode_contract(p.resolution_mode) {
55                                Some(contract) => format!("{} {}", p.description, contract),
56                                None => p.description.to_string(),
57                            };
58                            serde_json::json!({
59                                "name": p.name,
60                                "type": p.param_type,
61                                "required": p.required,
62                                "description": description,
63                            })
64                        })
65                        .collect();
66                    // Subhandlers are not callable via the MCP request surface;
67                    // the help payload must match the behaviour the dispatch
68                    // path enforces so callers reading `help=true` before
69                    // probing see accurate availability.
70                    if matches!(handler.visibility, Visibility::Subhandler) {
71                        return Ok(serde_json::json!({
72                            "verb": verb,
73                            "pack": pack.name(),
74                            "description": handler.description,
75                            "category": category,
76                            "params": params_arr,
77                            "identifier_resolution": identifier_resolution_help(),
78                            "visibility": "internal",
79                            "callable_via_mcp": false,
80                            "note": "This is an internal subhandler. Calling it via the MCP \
81                                     request surface returns permission denied. It can only be \
82                                     invoked by internal runtime callers.",
83                        }));
84                    }
85                    let mut envelope = serde_json::json!({
86                        "verb": verb,
87                        "pack": pack.name(),
88                        "description": handler.description,
89                        "category": category,
90                        "params": params_arr,
91                        "identifier_resolution": identifier_resolution_help(),
92                    });
93                    // A pack that authored its own schema keeps it; every other
94                    // verb gets one derived from the declarations the runtime
95                    // already holds, so a bridged model has a schema to read
96                    // instead of parsing the prose `params[].type`.
97                    if let Some(schema) = pack.input_schema(verb) {
98                        envelope["input_schema"] = schema;
99                    } else {
100                        let described: Vec<(String, String)> = params_arr
101                            .iter()
102                            .map(|p| {
103                                (
104                                    p["name"].as_str().unwrap_or_default().to_string(),
105                                    p["description"].as_str().unwrap_or_default().to_string(),
106                                )
107                            })
108                            .collect();
109                        if let Some(schema) =
110                            crate::input_schema::derive_input_schema(handler.params, &described)
111                        {
112                            envelope["input_schema"] = schema;
113                        }
114                    }
115                    if verb == "link" {
116                        envelope["endpoint_rules"] = Value::Array(edge_endpoint_table(&self.packs));
117                    }
118                    return Ok(envelope);
119                }
120            }
121        }
122        // Verb-visibility handler names, precomputed at build() time (internal
123        // subhandlers are excluded so they are not advertised in the
124        // unknown-verb error).
125        Err(self.unknown_verb_error(verb))
126    }
127
128    /// Check whether the gate permits writes into `ns`.
129    ///
130    /// Performs a gate evaluation with verb `"authorize"` before any background
131    /// loop is spawned (ADR-056 §6).  Returns `Ok(())` when the gate allows the
132    /// namespace, or `Err(RuntimeError::PermissionDenied{..})` when denied.
133    /// Gate errors (implementation failures) are surfaced as
134    /// `RuntimeError::Internal` carrying the stable classified reason; the
135    /// bounded, masked backend detail goes to the server-side log here, since
136    /// callers log the returned error.
137    pub fn authorize_namespace(&self, ns: Namespace) -> Result<(), RuntimeError> {
138        let actor = crate::actor_identity::resolve_actor(self.actor_id.as_deref());
139        let req = GateRequest::new(actor, ns, "authorize", serde_json::Value::Null);
140        match self.gate.check(&req) {
141            Ok(decision) if decision.is_allow() => Ok(()),
142            Ok(GateDecision::Deny { reason }) => {
143                Err(RuntimeError::permission_denied("authorize", reason))
144            }
145            Ok(_) => Err(RuntimeError::permission_denied("authorize", "gate denied")),
146            Err(e) => {
147                tracing::warn!(
148                    error = %crate::secret_gate::bounded_masked_log_text(&e.to_string()),
149                    "authorize_namespace: gate check failed (fail-closed)"
150                );
151                Err(RuntimeError::Internal(format!(
152                    "gate error: {}",
153                    e.wire_reason()
154                )))
155            }
156        }
157    }
158
159    /// Gate and execute an operation handled outside normal pack dispatch.
160    ///
161    /// Multi-backend transports use this to route an operation through a
162    /// coordinator while retaining [`Self::dispatch_with_identity`]'s gate and
163    /// audit lifecycle. Deny is authoritative, gate errors fail closed, and an
164    /// allowed audit is persisted after the intercepted operation resolves so
165    /// its outcome and duration reflect the operation result. Successful
166    /// `git.digest` interception uses the same strict durable-receipt exception
167    /// as normal pack dispatch.
168    pub async fn dispatch_intercepted_with_identity<F, Fut>(
169        &self,
170        verb: &str,
171        params: &Value,
172        identity: Option<&RequestIdentity>,
173        dispatch: F,
174    ) -> Result<Value, RuntimeError>
175    where
176        F: FnOnce(Namespace) -> Fut,
177        Fut: std::future::Future<Output = Result<Value, RuntimeError>>,
178    {
179        self.dispatch_intercepted_with_metadata_with_identity(
180            verb,
181            params,
182            identity,
183            |namespace| async move {
184                dispatch(namespace)
185                    .await
186                    .map(|result| InterceptedDispatchResult::new(result, ()))
187            },
188        )
189        .await
190        .map(|outcome| outcome.result)
191    }
192
193    /// Gate and execute an intercepted operation whose transport needs typed
194    /// metadata in addition to the canonical verb result.
195    ///
196    /// Audit accounting always receives `outcome.result`; `outcome.metadata`
197    /// crosses the dispatch seam unchanged for the transport to place beside
198    /// that result in its own envelope.
199    pub async fn dispatch_intercepted_with_metadata_with_identity<M, F, Fut>(
200        &self,
201        verb: &str,
202        params: &Value,
203        identity: Option<&RequestIdentity>,
204        dispatch: F,
205    ) -> Result<InterceptedDispatchResult<M>, RuntimeError>
206    where
207        F: FnOnce(Namespace) -> Fut,
208        Fut: std::future::Future<Output = Result<InterceptedDispatchResult<M>, RuntimeError>>,
209    {
210        self.dispatch_intercepted_with_metadata_and_disposition(verb, params, identity, dispatch)
211            .await
212            .map_err(DispatchError::into_source)
213    }
214
215    /// Append the `GateDenied` row of a refused dispatch and report what the
216    /// caller may cite: the row's id when it committed, otherwise why not.
217    pub(super) async fn append_gate_denied_row(
218        &self,
219        store: &Arc<dyn EventStore>,
220        event: Event,
221        verb: &str,
222    ) -> crate::error::DenialReceipt {
223        let audit_event_id = event.id;
224        match append_audit_event_best_effort(
225            self.audit_batch.as_ref(),
226            store,
227            event,
228            verb,
229            crate::audit_batch::AuditProducer::GateDenied,
230            false,
231        )
232        .await
233        {
234            Ok(()) => crate::error::DenialReceipt {
235                audit_event_id: Some(audit_event_id),
236                audit_outcome: crate::error::DenialAuditOutcome::Committed,
237            },
238            Err(failure) => crate::error::DenialReceipt {
239                audit_event_id: None,
240                audit_outcome: crate::error::DenialAuditOutcome::NotCommitted(failure.wire_code()),
241            },
242        }
243    }
244
245    /// Execute an intercepted operation while retaining this boundary's failure provenance.
246    /// Successful canonical results and typed metadata are returned unchanged.
247    pub async fn dispatch_intercepted_with_metadata_and_disposition<M, F, Fut>(
248        &self,
249        verb: &str,
250        params: &Value,
251        identity: Option<&RequestIdentity>,
252        dispatch: F,
253    ) -> Result<InterceptedDispatchResult<M>, DispatchError>
254    where
255        F: FnOnce(Namespace) -> Fut,
256        Fut: std::future::Future<Output = Result<InterceptedDispatchResult<M>, RuntimeError>>,
257    {
258        self.dispatch_intercepted_with_token_and_disposition(verb, params, identity, |token| {
259            dispatch(token.gate_namespace().clone())
260        })
261        .await
262    }
263
264    /// Intercept an operation with the sealed caller token minted after the
265    /// gate decision. Coordinated reads retain the resolved actor and their
266    /// existing namespace selection without reconstructing identity from args.
267    pub async fn dispatch_intercepted_with_token_and_disposition<M, F, Fut>(
268        &self,
269        verb: &str,
270        params: &Value,
271        identity: Option<&RequestIdentity>,
272        dispatch: F,
273    ) -> Result<InterceptedDispatchResult<M>, DispatchError>
274    where
275        F: FnOnce(NamespaceToken) -> Fut,
276        Fut: std::future::Future<Output = Result<InterceptedDispatchResult<M>, RuntimeError>>,
277    {
278        if self.is_verb_disabled(verb) {
279            // Use the ordinary unknown-verb path, including attribution and audit,
280            // without invoking an interceptor that could perform the mutation.
281            self.dispatch_with_disposition(verb, params.clone(), identity.cloned())
282                .await?;
283            return Err(DispatchError::before_dispatch(
284                self.unknown_verb_error(verb),
285            ));
286        }
287        let request_id = identity.and_then(|id| id.request_id);
288        let gate_req = self
289            .gate_request_with_identity(verb, params, identity)
290            .map_err(DispatchError::before_dispatch)?;
291        let gate_decision = khive_gate::check_with_mailbox_policy(self.gate.as_ref(), &gate_req);
292        let mut deferred_audit = match gate_decision {
293            Ok(decision) => {
294                let audit = masked_audit_event(&gate_req, &decision, self.gate.impl_name());
295                tracing::info!(
296                    audit_event = %serde_json::to_string(&audit)
297                        .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
298                    "gate.check"
299                );
300                if let GateDecision::Deny { reason } = decision {
301                    let receipt = match &self.event_store {
302                        Some(store) => {
303                            let event = build_audit_storage_event(
304                                &gate_req,
305                                &audit,
306                                EventOutcome::Denied,
307                                Some(crate::cost_unit::base_resource_payload(request_id)),
308                            );
309                            // The dispatch returns `PermissionDenied` below
310                            // whether or not this row commits — a deny never
311                            // reports success — so a commit failure has no
312                            // caller-visible outcome to fold into; the receipt
313                            // on the refusal says whether the row the caller
314                            // could cite exists.
315                            self.append_gate_denied_row(store, event, verb).await
316                        }
317                        None => crate::error::DenialReceipt::no_store(),
318                    };
319                    return Err(DispatchError::before_dispatch(
320                        RuntimeError::PermissionDenied {
321                            verb: verb.to_string(),
322                            reason,
323                            receipt: Box::new(receipt),
324                        },
325                    ));
326                }
327                Some(audit)
328            }
329            Err(err) => {
330                return Err(DispatchError::before_dispatch(
331                    self.gate_unavailable_error(&gate_req, &err, request_id, None)
332                        .await,
333                ));
334            }
335        };
336
337        let started = Instant::now();
338        let token = self.mint_intercepted_read_token(&gate_req, params, identity);
339        let mut result = dispatch(token).await;
340        let domain_succeeded = result.is_ok();
341        let duration_us = started.elapsed().as_micros() as i64;
342        let receipt_outcome = if verb == "git.digest" && result.is_ok() {
343            let resource = result.as_ref().ok().map(|outcome| {
344                crate::cost_unit::resource_payload(
345                    verb,
346                    &gate_req.args,
347                    &outcome.result,
348                    || 0,
349                    request_id,
350                )
351            });
352            // The receipt helper operates on the canonical verb result. Move
353            // that value out temporarily so it can turn receipt failures into
354            // the outer dispatch error without discarding successful typed
355            // transport metadata.
356            let mut receipt_result: Result<Value, RuntimeError> = match result.as_mut() {
357                Ok(outcome) => Ok(std::mem::take(&mut outcome.result)),
358                Err(_) => unreachable!("git.digest receipt path is guarded by result.is_ok()"),
359            };
360            let outcome = persist_git_digest_receipt(
361                self.event_store.as_ref(),
362                self.audit_batch.as_ref(),
363                &gate_req,
364                deferred_audit.as_ref(),
365                &mut receipt_result,
366                duration_us,
367                resource,
368            )
369            .await;
370            match receipt_result {
371                Ok(receipted_result) => {
372                    if let Ok(intercepted) = &mut result {
373                        intercepted.result = receipted_result;
374                    }
375                }
376                Err(error) => result = Err(error),
377            }
378            Some(outcome)
379        } else {
380            None
381        };
382        if receipt_outcome.is_none()
383            || receipt_outcome == Some(GitDigestReceiptOutcome::BuildRejected)
384        {
385            if let Some(audit) = deferred_audit.take() {
386                let audit_outcome = self
387                    .persist_intercepted_audit(
388                        verb,
389                        &gate_req,
390                        audit,
391                        result.as_ref().map(|outcome| &outcome.result),
392                        duration_us,
393                        request_id,
394                    )
395                    .await;
396                result = fold_audit_obligation(result, audit_outcome, |outcome| outcome.result);
397            }
398        }
399        result.map_err(|error| DispatchError::after_handler(error, domain_succeeded))
400    }
401
402    async fn persist_intercepted_audit(
403        &self,
404        verb: &str,
405        gate_req: &GateRequest,
406        audit: AuditEvent,
407        result: Result<&Value, &RuntimeError>,
408        duration_us: i64,
409        request_id: Option<u64>,
410    ) -> Result<(), AuditObligationFailure> {
411        let Some(store) = &self.event_store else {
412            return Ok(());
413        };
414        let event = match result {
415            Ok(value) if verb == "link" && gate_req.args.get("links").is_none() => {
416                let resource = crate::cost_unit::resource_payload(
417                    verb,
418                    &gate_req.args,
419                    value,
420                    || 0,
421                    request_id,
422                );
423                match link_audit_success_from_result(audit.clone(), value) {
424                    Some((edge_id, mut payload)) => {
425                        if let Value::Object(ref mut map) = payload {
426                            map.insert("resource".to_string(), resource);
427                        }
428                        Event::new(
429                            gate_req.namespace.as_str(),
430                            gate_req.verb.as_str(),
431                            EventKind::Audit,
432                            SubstrateKind::Event,
433                            format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
434                        )
435                        .with_outcome(EventOutcome::Success)
436                        .with_target(edge_id)
437                        .with_payload(payload)
438                        .with_payload_schema_version(2)
439                        .with_duration_us(duration_us)
440                    }
441                    None => build_audit_storage_event(
442                        gate_req,
443                        &audit,
444                        EventOutcome::Success,
445                        Some(resource),
446                    )
447                    .with_duration_us(duration_us),
448                }
449            }
450            Ok(value) => build_audit_storage_event(
451                gate_req,
452                &audit,
453                EventOutcome::Success,
454                Some(crate::cost_unit::resource_payload(
455                    verb,
456                    &gate_req.args,
457                    value,
458                    || 0,
459                    request_id,
460                )),
461            )
462            .with_duration_us(duration_us),
463            Err(_) => build_audit_storage_event(
464                gate_req,
465                &audit,
466                EventOutcome::Error,
467                Some(crate::cost_unit::base_resource_payload(request_id)),
468            )
469            .with_duration_us(duration_us),
470        };
471        let producer = if result.is_ok() {
472            crate::audit_batch::AuditProducer::DispatchSucceeded
473        } else {
474            crate::audit_batch::AuditProducer::DispatchFailed
475        };
476        append_audit_event_best_effort(
477            self.audit_batch.as_ref(),
478            store,
479            event,
480            verb,
481            producer,
482            self.admission_degrade_safe(verb),
483        )
484        .await
485    }
486
487    /// A create refusal may reveal its key holder only when the same caller can list it.
488    pub fn allows_note_key_disclosure(
489        &self,
490        token: &NamespaceToken,
491        kind: &str,
492        key: &str,
493    ) -> bool {
494        let request = GateRequest::new(
495            token.actor().clone(),
496            token.namespace().clone(),
497            "list",
498            serde_json::json!({"kind":"note", "note_kind":kind, "key_prefix":key}),
499        );
500        self.gate
501            .check(&request)
502            .is_ok_and(|decision| decision.is_allow())
503    }
504
505    fn mint_intercepted_read_token(
506        &self,
507        gate_req: &GateRequest,
508        params: &Value,
509        identity: Option<&RequestIdentity>,
510    ) -> NamespaceToken {
511        // Preserve the coordinator's existing namespace selection: the gate
512        // namespace is primary, and explicit namespace input stays narrow.
513        let visible = if params.get("namespace").is_some() {
514            Vec::new()
515        } else {
516            let mut visible = match identity {
517                Some(identity) => identity
518                    .visible_namespaces
519                    .iter()
520                    .filter_map(|namespace| Namespace::parse(namespace).ok())
521                    .collect(),
522                None => self.visible_namespaces.clone(),
523            };
524            visible.push(Namespace::local());
525            visible
526        };
527        NamespaceToken::mint_with_visibility(
528            gate_req.namespace.clone(),
529            visible,
530            gate_req.actor.clone(),
531        )
532        .with_gate_namespace(gate_req.namespace.clone())
533        .with_gate_explicit_namespace(
534            params
535                .get("namespace")
536                .and_then(Value::as_str)
537                .map(str::to_owned),
538        )
539        .with_request_id(identity.and_then(|identity| identity.request_id))
540        .with_process_ref(match identity {
541            Some(identity) => identity.process_ref.clone(),
542            None => crate::config::process_ref_from_env(),
543        })
544    }
545
546    fn gate_request_with_identity(
547        &self,
548        verb: &str,
549        params: &Value,
550        identity: Option<&RequestIdentity>,
551    ) -> Result<GateRequest, RuntimeError> {
552        let default_namespace = identity
553            .map(|id| id.namespace.as_str())
554            .unwrap_or(self.default_namespace.as_str());
555        let namespace = resolve_explicit_namespace(params, default_namespace)?;
556        let actor_id = identity
557            .map(|id| id.actor_id.as_deref())
558            .unwrap_or(self.actor_id.as_deref());
559        let actor = crate::actor_identity::resolve_actor(actor_id);
560        // GateRequest.args deliberately captures submitted dispatch arguments.
561        // The handler's canonicalization and kind hooks have not run; a policy
562        // requiring their effective values belongs after that handler work.
563        let req = GateRequest::new(actor, namespace, verb, params.clone());
564        crate::mailbox_view::validate_mailbox_request(&req)?;
565        Ok(req)
566    }
567
568    pub(super) async fn gate_unavailable_error(
569        &self,
570        gate_req: &GateRequest,
571        error: &khive_gate::GateError,
572        request_id: Option<u64>,
573        effective_target: Option<uuid::Uuid>,
574    ) -> RuntimeError {
575        let audit = AuditEvent::gate_unavailable(gate_req, self.gate.impl_name())
576            .with_operation_attribution(
577                khive_storage::operation_context::current_operation_attribution(),
578            );
579        tracing::info!(
580            audit_event = %serde_json::to_string(&audit)
581                .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
582            "gate.check"
583        );
584        tracing::warn!(
585            verb = %gate_req.verb,
586            error = %crate::secret_gate::bounded_masked_log_text(&error.to_string()),
587            "gate check failed (fail-closed)"
588        );
589        if let Some(store) = &self.event_store {
590            let mut event = build_audit_storage_event(
591                gate_req,
592                &audit,
593                EventOutcome::Error,
594                Some(crate::cost_unit::base_resource_payload(request_id)),
595            );
596            if let Some(target) = effective_target {
597                event = event.with_target(target);
598            }
599            let _ = append_audit_event_best_effort(
600                self.audit_batch.as_ref(),
601                store,
602                event,
603                gate_req.verb.as_str(),
604                crate::audit_batch::AuditProducer::GateUnavailable,
605                false,
606            )
607            .await;
608        }
609        RuntimeError::GateUnavailable {
610            verb: gate_req.verb.clone(),
611            // Caller-visible: a stable, classified reason derived from the
612            // `GateError` variant only. `error`'s `Display` text is logged
613            // above (server-side, via `tracing::warn!`) and must never be
614            // interpolated here — a gate backend's error message can embed
615            // connection details, addresses, or credentials.
616            reason: error.wire_reason().to_string(),
617        }
618    }
619
620    /// Dispatch a verb to the first pack that handles it.
621    ///
622    /// Routes through the gate, then invokes the matching pack handler. When
623    /// `params["help"] == true`, short-circuits to `describe_verb` with no side effects.
624    /// Gate errors fail closed. Full dispatch flow documented in `docs/protocol.md`.
625    ///
626    /// Equivalent to `self.dispatch_with_identity(verb, params, None)` — uses
627    /// this registry's construction-baked `default_namespace` / `actor_id` /
628    /// `visible_namespaces`.
629    pub async fn dispatch(&self, verb: &str, params: Value) -> Result<Value, RuntimeError> {
630        self.dispatch_with_identity(verb, params, None).await
631    }
632
633    /// Dispatch a verb, optionally overriding this registry's baked identity
634    /// scalars for exactly this call (ADR-096 Fork 1).
635    ///
636    /// `identity = None` behaves exactly like [`Self::dispatch`]. `identity =
637    /// Some(id)` uses `id.namespace` / `id.actor_id` / `id.visible_namespaces`
638    /// in place of `self.default_namespace` / `self.actor_id` /
639    /// `self.visible_namespaces` for this call's namespace resolution, gate
640    /// request, and token minting. The registry's own fields are never mutated,
641    /// so concurrent calls with different (or no) identity are independent.
642    /// See `docs/api/pack.md#dispatch_with_identity` for why this enables one warm
643    /// registry to serve many attribution identities over a shared backend.
644    pub async fn dispatch_with_identity(
645        &self,
646        verb: &str,
647        params: Value,
648        identity: Option<RequestIdentity>,
649    ) -> Result<Value, RuntimeError> {
650        self.dispatch_with_disposition(verb, params, identity)
651            .await
652            .map_err(DispatchError::into_source)
653    }
654
655    /// Dispatch with provenance for this operation's own domain result.
656    /// Errors returned by a nested dispatch remain handler errors at this boundary.
657    pub async fn dispatch_with_disposition(
658        &self,
659        verb: &str,
660        params: Value,
661        identity: Option<RequestIdentity>,
662    ) -> Result<Value, DispatchError> {
663        // help=true interception: short-circuit before gate/pack.
664        if params.get("help").and_then(Value::as_bool) == Some(true) {
665            let result = match self.describe_verb(verb) {
666                Ok(value) => Ok(value),
667                Err(error) => match self.mounted_verb_catalog().await {
668                    Ok(catalog) => catalog
669                        .into_iter()
670                        .find(|entry| entry["verb"] == verb)
671                        .ok_or(error),
672                    Err(error) => Err(error),
673                },
674            };
675            return result.map_err(DispatchError::before_dispatch);
676        }
677        // Resolve namespace before `params` is moved into pack.dispatch, so the
678        // post-dispatch hook can reference it.
679        //
680        // Absent `namespace` and a present-but-malformed `namespace` are
681        // different cases. A present non-string value (null, number, bool,
682        // array, object) is explicit caller input that failed to parse and
683        // must fail closed, not silently coerce to the default namespace.
684        // Only a genuinely absent key defaults. Shared with the multi-backend
685        // coordinator intercept via `resolve_explicit_namespace` so every MCP
686        // ingress path applies the same fail-closed rule.
687        let explicit_namespace = params.get("namespace").is_some_and(Value::is_string);
688        // The caller-supplied correlation id (khive#948), if any. Read once
689        // here so it is in scope for every audit-append site below,
690        // including the ones that run before pack dispatch is attempted.
691        let request_id: Option<u64> = identity.as_ref().and_then(|id| id.request_id);
692        // Thread the configured actor identity into the gate request so the
693        // gate can distinguish human vs agent callers at the dispatch seam.
694        // Resolved once via the shared actor-identity policy and reused for
695        // token minting below, so the gate's notion of "who is the caller"
696        // and the storage token's notion can never drift apart.
697        let gate_req = self
698            .gate_request_with_identity(verb, &params, identity.as_ref())
699            .map_err(DispatchError::before_dispatch)?;
700        let ns = gate_req.namespace.clone();
701        let resolved_actor = gate_req.actor.clone();
702
703        // Consult the gate.
704        //
705        // - Ok(Allow) → proceed to pack dispatch (tracing + optional EventStore).
706        // - Ok(Deny) → emit audit, persist if store configured, return PermissionDenied.
707        // - Err(_) → emit an outage audit and return GateUnavailable.
708        let gate_decision = khive_gate::check_with_mailbox_policy(self.gate.as_ref(), &gate_req);
709        let (gate_blocked, mut deferred_audit) = match gate_decision {
710            Ok(decision) => {
711                let is_deny = matches!(decision, GateDecision::Deny { .. });
712
713                // Emit audit event via tracing.
714                let audit = masked_audit_event(&gate_req, &decision, self.gate.impl_name());
715                tracing::info!(
716                    audit_event = %serde_json::to_string(&audit)
717                        .unwrap_or_else(|_| "{\"error\":\"serialize\"}".into()),
718                    "gate.check"
719                );
720
721                // Drain any process-lifetime `OnceLock` config locks queued
722                // since the last dispatch and persist them as `ConfigLocked`
723                // events, riding this same audit-persistence gate. The
724                // namespace/actor stamped on these rows are whichever
725                // dispatch happens to observe the queue non-empty first:
726                // an accepted provenance quirk, preferred over threading an
727                // `EventStore` handle into every synchronous
728                // `OnceLock::get_or_init` call site. The verb column is NOT
729                // inherited from that bystander dispatch: a config-lock row
730                // wearing an operation verb pollutes verb-filtered queries
731                // (e.g. per-verb receipt counts), so these rows carry their
732                // own `config.lock` pseudo-verb and remain discoverable by
733                // `EventKind::ConfigLocked`.
734                if let Some(store) = &self.event_store {
735                    if crate::config_ledger::PENDING
736                        .swap(false, std::sync::atomic::Ordering::AcqRel)
737                    {
738                        for (key, value) in crate::config_ledger::drain_config_locked() {
739                            let payload = serde_json::json!({ "key": key, "value": value });
740                            let storage_event = Event::new(
741                                gate_req.namespace.as_str(),
742                                "config.lock",
743                                EventKind::ConfigLocked,
744                                SubstrateKind::Event,
745                                format!("{}:{}", gate_req.actor.kind, gate_req.actor.id),
746                            )
747                            .with_payload(payload);
748                            // ConfigLocked is pure observability: the helper
749                            // never returns `Err` for it, so there is
750                            // nothing to fold.
751                            let _ = append_audit_event_best_effort(
752                                self.audit_batch.as_ref(),
753                                store,
754                                storage_event,
755                                "config.lock",
756                                crate::audit_batch::AuditProducer::ConfigLocked,
757                                false,
758                            )
759                            .await;
760                        }
761                    }
762                }
763
764                // Every Allow-outcome audit row defers its append until pack
765                // dispatch returns, so the row can carry the measured
766                // dispatch time in `duration_us` (persisting before dispatch
767                // ran always recorded the `Event::new` default of 0). A
768                // singleton `link` call (no `links` bulk array) additionally
769                // enriches the deferred row with the created/resolved edge
770                // fields (schema v2) once dispatch resolves. Denied calls
771                // have no dispatch to wait for and keep the immediate v1
772                // append below.
773                //
774                // Accepted trade-off for ordinary verbs: a crash between this
775                // Allow decision and the deferred append loses the audit row.
776                // `git.digest` narrows the caller-visible contract below: it
777                // never returns success until the deferred receipt append is
778                // confirmed, though a process crash can still leave committed
779                // ingest writes with no response and no completed receipt.
780                let defer_audit = !is_deny;
781
782                // Persist to EventStore immediately only for denied calls;
783                // the receipt rides on the refusal so the caller can cite
784                // the row.
785                let reason = if is_deny {
786                    let reason = match decision {
787                        GateDecision::Deny { reason } => reason,
788                        _ => String::new(),
789                    };
790                    let receipt = match &self.event_store {
791                        Some(store) => {
792                            // ADR-103 Decision (a): the closed `work_class` enum
793                            // is stamped on every event, denial included -- only
794                            // `resource.cost_unit` is scoped to a successful
795                            // dispatch by Amendment 1. `base_resource_payload()`
796                            // carries `work_class` alone, no `cost_unit` key.
797                            let storage_event = build_audit_storage_event(
798                                &gate_req,
799                                &audit,
800                                EventOutcome::Denied,
801                                Some(crate::cost_unit::base_resource_payload(request_id)),
802                            );
803                            // This path always returns `PermissionDenied`
804                            // below, so there is no success outcome to fold a
805                            // commit failure into; the receipt says whether
806                            // the row exists.
807                            self.append_gate_denied_row(store, storage_event, verb)
808                                .await
809                        }
810                        None => crate::error::DenialReceipt::no_store(),
811                    };
812                    Some((reason, receipt))
813                } else {
814                    None
815                };
816                let deferred = if defer_audit { Some(audit) } else { None };
817                (reason, deferred)
818            }
819            Err(err) => {
820                return Err(DispatchError::before_dispatch(
821                    self.gate_unavailable_error(&gate_req, &err, request_id, None)
822                        .await,
823                ));
824            }
825        };
826
827        // Hard enforcement: Deny is authoritative.
828        if let Some((reason, receipt)) = gate_blocked {
829            return Err(DispatchError::before_dispatch(
830                RuntimeError::PermissionDenied {
831                    verb: verb.to_string(),
832                    reason,
833                    receipt: Box::new(receipt),
834                },
835            ));
836        }
837
838        // Mint the authorized storage token at the dispatch boundary.
839        //
840        // Writes pin to `local` by default. Actor identity and config
841        // `[actor] id` are attribution and gate-context inputs only: they
842        // never route storage. The explicit `namespace=` request param is a
843        // precise single-namespace escape: the caller deliberately
844        // reads/writes exactly that one set; it is NOT widened by `visible_namespaces`.
845        //
846        // When actor_id is configured, mint a token carrying that actor
847        // label so that comm.inbox applies the to_actor filter for directed delivery.
848        // Otherwise, use ActorRef::anonymous() and inbox falls back to party-line.
849        // `actor_id_str` already reflects the per-request identity override
850        // when supplied (resolved above into `resolved_actor`, mirrored into
851        // the gate request). Reusing the same value here guarantees the
852        // gate's actor and the storage token's actor can never diverge.
853        //
854        // On the default (no explicit `namespace=`) path, the read scope
855        // widens to `['local'] ∪ visible_namespaces` (baked, or the
856        // per-request override). `'local'` is always included
857        // (mint_with_visibility deduplicates). Writes remain pinned to
858        // `'local'`. Per-actor distinctions use view-layer tag filters
859        // (assignee, actor_id, from/to), not namespace partitions. `ns`/
860        // `explicit_namespace` were already validated above: reuse them
861        // instead of re-reading `params["namespace"]` with `as_str()`, which
862        // would silently drop malformed non-string values again.
863        let token = if explicit_namespace {
864            // Explicit escape: precise single-namespace scope, read+write. NOT widened.
865            NamespaceToken::mint_with_visibility(ns.clone(), vec![], resolved_actor)
866        } else {
867            // Default path: write namespace = local; read scope = ['local'] ∪ visible_namespaces.
868            let primary = Namespace::local();
869            let mut extra_visible: Vec<Namespace> = match identity.as_ref() {
870                Some(id) => id
871                    .visible_namespaces
872                    .iter()
873                    .filter_map(|s| match Namespace::parse(s) {
874                        Ok(parsed) => Some(parsed),
875                        Err(e) => {
876                            tracing::warn!(
877                                namespace = %s,
878                                error = %e,
879                                "dispatch_with_identity: skipping invalid visible_namespace \
880                                 entry from per-request identity"
881                            );
882                            None
883                        }
884                    })
885                    .collect(),
886                None => self.visible_namespaces.clone(),
887            };
888            // ADR-007 Rev 4 Rule 3b, applied once at the seam every identity
889            // path shares: a non-`local` actor reads its own namespace by
890            // default (its episodic memories land there), whether the identity
891            // came from the config loader, a daemon frame, a scheduled replay
892            // or an embedding host. Writes stay pinned to `local` (Rule 0).
893            if let Some(actor_namespace) = resolved_actor
894                .binding_id()
895                .filter(|id| *id != Namespace::LOCAL)
896                .and_then(|id| Namespace::parse(id).ok())
897            {
898                extra_visible.push(actor_namespace);
899            }
900            extra_visible.push(Namespace::local()); // 'local' always readable; mint dedups
901            NamespaceToken::mint_with_visibility(primary, extra_visible, resolved_actor)
902        }
903        .with_gate_namespace(ns.clone())
904        .with_gate_explicit_namespace(
905            params
906                .get("namespace")
907                .and_then(Value::as_str)
908                .map(str::to_owned),
909        )
910        .with_request_id(request_id)
911        .with_process_ref(match identity.as_ref() {
912            Some(id) => id.process_ref.clone(),
913            None => crate::config::process_ref_from_env(),
914        });
915
916        for pack in self.packs.iter().filter(|_| !self.is_verb_disabled(verb)) {
917            let handler_def = pack.handlers().iter().find(|v| v.name == verb);
918            let mounted_name = pack.mounted_namespace().and_then(|prefix| {
919                verb.strip_prefix(prefix)
920                    .and_then(|suffix| suffix.strip_prefix('.'))
921            });
922            if handler_def.is_some() || mounted_name.is_some() {
923                let definition = if let Some(name) = mounted_name {
924                    pack.mounted_catalog().await.and_then(|catalog| {
925                        catalog
926                            .into_iter()
927                            .find(|definition| definition.name == name)
928                            .map(Some)
929                            .ok_or_else(|| RuntimeError::UnknownVerb(verb.to_owned()))
930                    })
931                } else {
932                    Ok(None)
933                };
934                // Strip `namespace` from params before forwarding to packs.
935                // The registry has already consumed it to mint the NamespaceToken.
936                //
937                // Exception: if the handler's own `params` schema declares
938                // `"namespace"` as a valid field (e.g. brain.bind, brain.unbind,
939                // brain.bindings, brain.resolve), the field is a *business* argument
940                // — not a transport routing key — and must be passed through
941                // unchanged. Stripping it would silently default the binding to the
942                // "*" wildcard, broadening profile scope across namespaces.
943                let handler_accepts_namespace = handler_def
944                    .is_some_and(|h| h.params.iter().any(|p| p.name == "namespace"))
945                    || definition
946                        .as_ref()
947                        .ok()
948                        .and_then(|value| value.as_ref())
949                        .is_some_and(|definition| {
950                            definition
951                                .input_schema
952                                .get("properties")
953                                .is_some_and(|properties| properties.get("namespace").is_some())
954                        });
955                let params = if !handler_accepts_namespace {
956                    if let Value::Object(mut map) = params {
957                        map.remove("namespace");
958                        Value::Object(map)
959                    } else {
960                        params
961                    }
962                } else {
963                    params
964                };
965                let dispatch_start = Instant::now();
966                let mounted_audit = definition.as_ref().ok().and_then(|v| v.as_ref()).map(|v| {
967                    serde_json::json!({"mount": pack.name(), "effect": v.effect, "generation": v.generation})
968                });
969                let mut result = match definition {
970                    Ok(Some(definition)) => {
971                        pack.dispatch_mounted(&definition, verb, params, self, &token)
972                            .await
973                    }
974                    Ok(None) => pack.dispatch(verb, params, self, &token).await,
975                    Err(error) => Err(error),
976                };
977                let domain_succeeded = result.is_ok();
978                let dispatch_us = dispatch_start.elapsed().as_micros() as i64;
979
980                // Unlike ordinary audit rows, a successful `git.digest`
981                // response is returned only after its complete report has
982                // been durably persisted as a schema-v2 audit receipt. The
983                // receipt helper borrows the deferred audit row so malformed
984                // handler output can still fall back to one generic Error
985                // audit. Handler errors use that same ordinary path below.
986                let git_digest_receipt_outcome = if verb == "git.digest" && result.is_ok() {
987                    let resource = result.as_ref().ok().map(|value| {
988                        crate::cost_unit::resource_payload(
989                            verb,
990                            &gate_req.args,
991                            value,
992                            || pack.registered_embedding_model_names().len() as i64,
993                            request_id,
994                        )
995                    });
996                    Some(
997                        persist_git_digest_receipt(
998                            self.event_store.as_ref(),
999                            self.audit_batch.as_ref(),
1000                            &gate_req,
1001                            deferred_audit.as_ref(),
1002                            &mut result,
1003                            dispatch_us,
1004                            resource,
1005                        )
1006                        .await,
1007                    )
1008                } else {
1009                    None
1010                };
1011
1012                // Append the deferred Allow-outcome audit row now that
1013                // dispatch has resolved, so `duration_us` carries the
1014                // measured `dispatch_us` instead of the `Event::new` default
1015                // of 0. A successful singleton `link` call enriches the row
1016                // with the created/resolved edge (schema v2); anything that
1017                // cannot be enriched, or is not a singleton `link` call,
1018                // falls back to the generic v1 audit shape so no audit row
1019                // is ever dropped for the deferred path.
1020                let needs_generic_audit = git_digest_receipt_outcome.is_none()
1021                    || git_digest_receipt_outcome == Some(GitDigestReceiptOutcome::BuildRejected);
1022                if let (true, Some(audit)) = (needs_generic_audit, deferred_audit.take()) {
1023                    if let Some(store) = &self.event_store {
1024                        let is_link_singleton =
1025                            verb == "link" && gate_req.args.get("links").is_none();
1026                        // Read-only pass over `result` first: every arm below
1027                        // only needs `audit_outcome` afterward, and folding a
1028                        // failure into `result` requires a mutable borrow
1029                        // that cannot coexist with the `&result` match below.
1030                        let audit_outcome: Result<(), AuditObligationFailure> = match &result {
1031                            Ok(ok_val) if is_link_singleton => {
1032                                // ADR-103 Amendment 1: `link` (singleton or
1033                                // bulk) has no embedding-bearing path — edges
1034                                // carry no embedded body — so cost_unit is
1035                                // always base_weight("link") alone. The
1036                                // registered-model closure is never invoked
1037                                // (per_item_weight("link", ..) short-circuits
1038                                // to 0 before `model_count` reads it).
1039                                let resource = crate::cost_unit::resource_payload(
1040                                    verb,
1041                                    &gate_req.args,
1042                                    ok_val,
1043                                    || pack.registered_embedding_model_names().len() as i64,
1044                                    request_id,
1045                                );
1046                                match link_audit_success_from_result(audit.clone(), ok_val) {
1047                                    Some((edge_id, mut payload)) => {
1048                                        if let Value::Object(ref mut map) = payload {
1049                                            map.insert("resource".to_string(), resource);
1050                                        }
1051                                        let storage_event = Event::new(
1052                                            gate_req.namespace.as_str(),
1053                                            gate_req.verb.as_str(),
1054                                            EventKind::Audit,
1055                                            SubstrateKind::Event,
1056                                            format!(
1057                                                "{}:{}",
1058                                                gate_req.actor.kind, gate_req.actor.id
1059                                            ),
1060                                        )
1061                                        .with_outcome(EventOutcome::Success)
1062                                        .with_target(edge_id)
1063                                        .with_payload(payload)
1064                                        .with_payload_schema_version(2)
1065                                        .with_duration_us(dispatch_us);
1066                                        append_audit_event_best_effort(
1067                                            self.audit_batch.as_ref(),
1068                                            store,
1069                                            storage_event,
1070                                            verb,
1071                                            crate::audit_batch::AuditProducer::DispatchSucceeded,
1072                                            self.admission_degrade_safe(verb),
1073                                        )
1074                                        .await
1075                                    }
1076                                    None => {
1077                                        tracing::warn!(
1078                                            verb,
1079                                            "link audit v2 enrichment parse failed; \
1080                                             falling back to v1 audit shape"
1081                                        );
1082                                        let storage_event = build_audit_storage_event(
1083                                            &gate_req,
1084                                            &audit,
1085                                            EventOutcome::Success,
1086                                            Some(resource),
1087                                        )
1088                                        .with_duration_us(dispatch_us);
1089                                        append_audit_event_best_effort(
1090                                            self.audit_batch.as_ref(),
1091                                            store,
1092                                            storage_event,
1093                                            verb,
1094                                            crate::audit_batch::AuditProducer::DispatchSucceeded,
1095                                            self.admission_degrade_safe(verb),
1096                                        )
1097                                        .await
1098                                    }
1099                                }
1100                            }
1101                            _ => {
1102                                // The persisted audit outcome must reflect
1103                                // the dispatch result, not be hardcoded to
1104                                // Success — otherwise a failed dispatch is
1105                                // recorded as successful work and disappears
1106                                // from `outcome=error` queries.
1107                                //
1108                                // ADR-103 Amendment 1: `resource.cost_unit` is
1109                                // computed ONLY on a successful dispatch —
1110                                // there is no handler `Value` to read
1111                                // `item_count` from on an error, and the
1112                                // amendment's "absence has exactly two
1113                                // meanings" rule requires the field be
1114                                // omitted, never defaulted to 0, on an
1115                                // errored dispatch. `work_class` itself is
1116                                // NOT one of those two omission cases
1117                                // (ADR-103 Decision (a) stamps it on every
1118                                // event), so an errored dispatch still gets
1119                                // `resource: {"work_class": "interactive"}`,
1120                                // just with no `cost_unit` key.
1121                                let (outcome, resource) = match &result {
1122                                    Ok(ok_val) => (
1123                                        EventOutcome::Success,
1124                                        Some(crate::cost_unit::resource_payload(
1125                                            verb,
1126                                            &gate_req.args,
1127                                            ok_val,
1128                                            || pack.registered_embedding_model_names().len() as i64,
1129                                            request_id,
1130                                        )),
1131                                    ),
1132                                    Err(_) => (
1133                                        EventOutcome::Error,
1134                                        Some(crate::cost_unit::base_resource_payload(request_id)),
1135                                    ),
1136                                };
1137                                let producer = if result.is_ok() {
1138                                    crate::audit_batch::AuditProducer::DispatchSucceeded
1139                                } else {
1140                                    crate::audit_batch::AuditProducer::DispatchFailed
1141                                };
1142                                let mut storage_event =
1143                                    build_audit_storage_event(&gate_req, &audit, outcome, resource)
1144                                        .with_duration_us(dispatch_us);
1145                                if let Some(metadata) = &mounted_audit {
1146                                    storage_event.payload["mounted_tool"] = metadata.clone();
1147                                }
1148                                append_audit_event_best_effort(
1149                                    self.audit_batch.as_ref(),
1150                                    store,
1151                                    storage_event,
1152                                    verb,
1153                                    producer,
1154                                    self.admission_degrade_safe(verb),
1155                                )
1156                                .await
1157                            }
1158                        };
1159                        // Only a would-be-success dispatch can be flipped by
1160                        // an obligation failure (ADR-133 D2/D3/D4): an
1161                        // already-erroring dispatch (DispatchFailed producer)
1162                        // keeps its original error, matching
1163                        // `fold_audit_obligation`'s contract.
1164                        result =
1165                            fold_audit_obligation(result, audit_outcome, std::convert::identity);
1166                    }
1167                }
1168
1169                // Post-dispatch hook: fires on success, opt-in.
1170                if let (Ok(ref ok_val), Some(hook)) = (&result, &self.dispatch_hook) {
1171                    let mut dispatch_event = Event::new(
1172                        ns.as_str(),
1173                        verb,
1174                        EventKind::Audit,
1175                        SubstrateKind::Event,
1176                        pack.name(),
1177                    )
1178                    .with_outcome(EventOutcome::Success)
1179                    .with_duration_us(dispatch_us);
1180
1181                    // For recall verbs: extract the first result's id as
1182                    // target_id so the brain temporal posterior can observe
1183                    // real hit/miss and latency. Copy the serve-attribution
1184                    // fields from that same hit so the hook credits the profile
1185                    // that actually served instead of always crediting default.
1186                    if verb == "memory.recall" {
1187                        let first_result =
1188                            ok_val.as_array().and_then(|arr| arr.first()).or_else(|| {
1189                                ok_val
1190                                    .get("results")
1191                                    .and_then(Value::as_array)
1192                                    .and_then(|arr| arr.first())
1193                            });
1194                        let first_note_id = first_result
1195                            .and_then(|v| v.get("id"))
1196                            .and_then(|v| v.as_str())
1197                            .and_then(|s| s.parse::<uuid::Uuid>().ok());
1198                        if let Some(note_id) = first_note_id {
1199                            dispatch_event = dispatch_event.with_target(note_id);
1200                        }
1201                        let mut payload = serde_json::Map::new();
1202                        if let Some(profile_id) = first_result
1203                            .and_then(|v| v.get("served_by_profile_id"))
1204                            .and_then(Value::as_str)
1205                        {
1206                            payload.insert(
1207                                "served_by_profile_id".to_string(),
1208                                Value::String(profile_id.to_string()),
1209                            );
1210                        }
1211                        if let Some(attribution) = first_result
1212                            .and_then(|v| v.get("serve_attribution"))
1213                            .and_then(Value::as_str)
1214                        {
1215                            payload.insert(
1216                                "serve_attribution".to_string(),
1217                                Value::String(attribution.to_string()),
1218                            );
1219                        }
1220                        dispatch_event = dispatch_event.with_payload(Value::Object(payload));
1221                        // No first result → target_id stays None (RecallMiss
1222                        // in brain's event interpreter).
1223                    }
1224
1225                    let dispatch_view = EventView {
1226                        event: dispatch_event,
1227                        observations: Vec::new(),
1228                    };
1229                    let hook = Arc::clone(hook);
1230                    hook.on_dispatch(&dispatch_view).await;
1231                }
1232
1233                // Recently-referenced ring admission: only by-id touches admit
1234                // an id. Runs unconditionally (not gated on `dispatch_hook`,
1235                // which is opt-in) because the ring is a core
1236                // dispatch-boundary capability, not an observer.
1237                //
1238                // Keyed on `token.namespace()`, NOT `ns`: `ns` is the
1239                // gate-resolved namespace, which on the default
1240                // (non-explicit) dispatch path can be a non-local
1241                // `default_namespace` (e.g. "foreign") while the storage
1242                // token that actually created/touched the record is pinned
1243                // to `local`. The ring must be keyed on the namespace the
1244                // record actually lives in: the same namespace
1245                // `resolve_reference`'s ring lookup uses: or admission and
1246                // lookup silently diverge on any non-local `default_namespace`
1247                // config.
1248                if let Ok(ref ok_val) = result {
1249                    let admissions = crate::reference_ring::ring_admissions_for(verb, ok_val);
1250                    if !admissions.is_empty() {
1251                        let actor_key = format!("{}:{}", gate_req.actor.kind, gate_req.actor.id);
1252                        for (id, name) in admissions {
1253                            self.reference_ring.admit(
1254                                token.namespace().as_str(),
1255                                &actor_key,
1256                                id,
1257                                name,
1258                            );
1259                        }
1260                    }
1261                }
1262
1263                return result
1264                    .map_err(|error| DispatchError::after_handler(error, domain_succeeded));
1265            }
1266        }
1267
1268        // No pack owns this verb: the gate allowed it, but no dispatch runs.
1269        // Persist the deferred audit row now (duration stays at the
1270        // `Event::new` default of 0 — no dispatch occurred to measure) so an
1271        // allowed-but-unknown verb is never silently dropped from the audit
1272        // trail (matches the "no audit row is ever dropped" contract above).
1273        if let Some(audit) = deferred_audit.take() {
1274            if let Some(store) = &self.event_store {
1275                // Dispatch is about to return `UnknownVerb` below (no pack
1276                // owns this verb), so the persisted outcome must be `Error`,
1277                // not `Success`. `work_class` is still stamped (ADR-103
1278                // Decision (a)); `resource.cost_unit` is omitted, matching
1279                // every other errored-dispatch row.
1280                let storage_event = build_audit_storage_event(
1281                    &gate_req,
1282                    &audit,
1283                    EventOutcome::Error,
1284                    Some(crate::cost_unit::base_resource_payload(request_id)),
1285                );
1286                // Dispatch already returns `UnknownVerb` below regardless, so
1287                // — as with the deny paths above — there is no success
1288                // outcome to fold a commit failure into.
1289                let _ = append_audit_event_best_effort(
1290                    self.audit_batch.as_ref(),
1291                    store,
1292                    storage_event,
1293                    verb,
1294                    crate::audit_batch::AuditProducer::UnknownVerb,
1295                    false,
1296                )
1297                .await;
1298            }
1299        }
1300
1301        // Verb-visibility handler names, precomputed at build() time (internal
1302        // subhandlers are excluded so they are not advertised in the
1303        // unknown-verb error).
1304        Err(DispatchError::before_dispatch(
1305            self.unknown_verb_error(verb),
1306        ))
1307    }
1308
1309    /// Dispatch a verb under an out-of-band verified actor identity.
1310    ///
1311    /// `verified_actor` is a typed [`VerifiedActor`] (constructor rejects blank
1312    /// identifiers) — only code holding a `VerbRegistry` handle can supply it.
1313    /// `dispatch_as` never reads `params["actor"]` to derive the effective actor;
1314    /// individual verbs may still accept an `actor` field for their own documented
1315    /// business semantics, unrelated to the acting principal. Every pack handler
1316    /// that reads "who is calling" resolves it from the `NamespaceToken` the
1317    /// dispatch boundary mints, so `verified_actor` becomes exactly the principal
1318    /// those handlers observe.
1319    ///
1320    /// Equivalent to `dispatch_with_identity(verb, params, Some(identity))` with
1321    /// `identity.actor_id = Some(verified_actor)` and every other identity scalar
1322    /// (namespace, visible namespaces) left at this registry's construction-baked
1323    /// value. [`Self::dispatch`] and [`Self::dispatch_with_identity`] are unaffected.
1324    /// See `docs/api/pack.md#dispatch_as` for the embedding-host use case and the
1325    /// blank-identifier safety rationale.
1326    pub async fn dispatch_as(
1327        &self,
1328        verb: &str,
1329        params: Value,
1330        verified_actor: VerifiedActor,
1331    ) -> Result<Value, RuntimeError> {
1332        let identity = RequestIdentity {
1333            namespace: self.default_namespace.clone(),
1334            actor_id: Some(verified_actor.into_inner()),
1335            visible_namespaces: self
1336                .visible_namespaces
1337                .iter()
1338                .map(|ns| ns.as_str().to_string())
1339                .collect(),
1340            process_ref: crate::config::process_ref_from_env(),
1341            request_id: None,
1342        };
1343        self.dispatch_with_identity(verb, params, Some(identity))
1344            .await
1345    }
1346}