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, ¶ms, 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}