@@ -396,6 +396,45 @@ impl Executor {
396396 ) ;
397397 }
398398
399+ /// Emit a single allow decision record for an invocation that resolved to
400+ /// zero plugins, keeping the audit stream dense at one record per
401+ /// invocation. **Cheap no-op when no audit sink is attached** — an
402+ /// unaudited host pays only a length check, building no record and
403+ /// consuming no sequence number. The manager calls this at its zero-plugin
404+ /// short-circuits (which return before reaching `execute`), and `execute`
405+ /// calls it for a direct empty invocation. Captures the same span /
406+ /// input-label / input-hash provenance a normal run records at entry, then
407+ /// stamps and emits.
408+ pub ( crate ) async fn emit_empty_allow (
409+ & self ,
410+ payload : & dyn PluginPayload ,
411+ extensions : & Extensions ,
412+ ) {
413+ if self . audit_handlers . is_empty ( ) {
414+ return ;
415+ }
416+ let mut decisions = DecisionLog :: new ( ) ;
417+ let request = extensions. request . as_ref ( ) ;
418+ decisions. set_span ( crate :: decision:: Span :: for_request (
419+ request. and_then ( |r| r. trace_id . as_deref ( ) ) ,
420+ request. and_then ( |r| r. span_id . as_deref ( ) ) ,
421+ ) ) ;
422+ if let Some ( sec) = extensions. security . as_ref ( ) {
423+ let mut labels: Vec < String > = sec. labels . iter ( ) . cloned ( ) . collect ( ) ;
424+ labels. sort_unstable ( ) ;
425+ decisions. set_input_labels ( labels) ;
426+ }
427+ if self . config . capture_content_provenance {
428+ let hash = payload
429+ . audit_bytes ( )
430+ . map ( |b| crate :: hooks:: payload:: content_hash ( & b) ) ;
431+ decisions. set_input_hash ( hash) ;
432+ }
433+ decisions. finalize ( Verdict :: Allow ) ;
434+ self . stamp_decision_stream ( & mut decisions) ;
435+ self . emit_audit ( payload, extensions, & decisions) . await ;
436+ }
437+
399438 async fn emit_audit (
400439 & self ,
401440 payload : & dyn PluginPayload ,
@@ -465,7 +504,15 @@ impl Executor {
465504 ) -> ( PipelineResult , BackgroundTasks ) {
466505 let mut ctx_table = context_table. unwrap_or_default ( ) ;
467506
507+ // A hook that resolves to zero plugins is a normal case (nothing is
508+ // configured for this entity). It still emits exactly one allow record
509+ // so the audit stream stays dense at one record per invocation — but
510+ // `emit_empty_allow` is a no-op when no sink is attached, so an
511+ // unaudited host pays nothing. (The manager short-circuits most
512+ // zero-plugin invocations before reaching here and calls
513+ // `emit_empty_allow` itself; this covers a direct `execute(&[], …)`.)
468514 if entries. is_empty ( ) {
515+ self . emit_empty_allow ( & * payload, & extensions) . await ;
469516 return (
470517 PipelineResult :: allowed_with ( payload, extensions, ctx_table) ,
471518 BackgroundTasks :: empty ( ) ,
@@ -705,13 +752,40 @@ impl Executor {
705752 } ) ) ;
706753 }
707754
708- // Execute with timeout — handler borrows payload, gets filtered extensions
755+ // Execute with timeout — handler borrows payload, gets filtered
756+ // extensions. Contain a panic the same way the concurrent phase
757+ // does (`catch_unwind`): a panic between `begin_effect` and
758+ // `complete_effect` would otherwise unwind the whole request
759+ // future. Collapsing it into a `PluginError` lets `on_error`
760+ // decide and keeps the pipeline's bookkeeping intact; the orphaned
761+ // WAL entry is left for recovery to reconcile as `unknown` rather
762+ // than crashing the request.
763+ use futures:: FutureExt ;
709764 let timeout_dur = Duration :: from_secs ( self . config . timeout_seconds ) ;
710765 let result = timeout (
711766 timeout_dur,
712- entry. handler . invoke ( & * * payload, & filtered, & mut ctx) ,
767+ std:: panic:: AssertUnwindSafe ( entry. handler . invoke ( & * * payload, & filtered, & mut ctx) )
768+ . catch_unwind ( ) ,
713769 )
714- . await ;
770+ . await
771+ . map ( |caught| {
772+ caught. unwrap_or_else ( |panic| {
773+ let msg = panic
774+ . downcast_ref :: < & ' static str > ( )
775+ . map ( |s| s. to_string ( ) )
776+ . or_else ( || panic. downcast_ref :: < String > ( ) . cloned ( ) )
777+ . unwrap_or_else ( || "unknown panic" . to_string ( ) ) ;
778+ error ! ( "{} plugin '{}' panicked: {}" , phase_label, plugin_name, msg) ;
779+ Err ( Box :: new ( crate :: error:: PluginError :: Execution {
780+ plugin_name : plugin_name. to_string ( ) ,
781+ message : format ! ( "task panicked: {msg}" ) ,
782+ source : None ,
783+ code : Some ( "panic" . into ( ) ) ,
784+ details : std:: collections:: HashMap :: new ( ) ,
785+ proto_error_code : None ,
786+ } ) )
787+ } )
788+ } ) ;
715789
716790 match result {
717791 Ok ( Ok ( result_box) ) => {
@@ -724,8 +798,21 @@ impl Executor {
724798 }
725799 }
726800
801+ // A block signalled from a non-blocking phase
802+ // (Transform): suppressed by the phase contract
803+ // (can_modify, not can_block), but recorded as the
804+ // plugin's actual intent — never a plain allow.
805+ // Enforcement is unchanged (the pipeline proceeds);
806+ // this plugin's modifications are skipped, since it
807+ // asked to stop rather than shape.
808+ let deny_ignored =
809+ !erased. continue_processing && !can_block && erased. violation . is_some ( ) ;
810+ if deny_ignored {
811+ action = PluginAction :: DenyIgnored ;
812+ }
813+
727814 // Accept modifications
728- if can_modify {
815+ if can_modify && !deny_ignored {
729816 if let Some ( mp) = erased. modified_payload {
730817 * payload = mp;
731818 action = PluginAction :: ModifiedPayload ;
@@ -832,12 +919,26 @@ impl Executor {
832919 // If extract failed or no modifications — payload unchanged
833920 } ,
834921 Ok ( Err ( e) ) => {
922+ // A contained panic (from the `catch_unwind` above) carries
923+ // code "panic". Surface it with the same "plugin_panic"
924+ // violation code the concurrent phase uses, so a host or
925+ // sink can distinguish a panic from an ordinary plugin error
926+ // by code, regardless of which phase it happened in.
927+ let is_panic = matches ! (
928+ e. as_ref( ) ,
929+ crate :: error:: PluginError :: Execution { code: Some ( c) , .. }
930+ if c. as_str( ) == "panic"
931+ ) ;
835932 error ! ( "{} plugin '{}' failed: {}" , phase_label, plugin_name, e) ;
836933 action = PluginAction :: Error ( e. to_string ( ) ) ;
837934 match on_error {
838935 OnError :: Fail if can_block => {
839936 let mut v = crate :: error:: PluginViolation :: new (
840- "plugin_error" ,
937+ if is_panic {
938+ "plugin_panic"
939+ } else {
940+ "plugin_error"
941+ } ,
841942 format ! ( "Plugin '{}' failed: {}" , plugin_name, e) ,
842943 ) ;
843944 v. plugin_name = Some ( plugin_name. to_string ( ) ) ;
@@ -1141,8 +1242,9 @@ impl Executor {
11411242 } ,
11421243 BranchOutcome :: TimedOut => PluginAction :: Error ( "timed out" . to_string ( ) ) ,
11431244 BranchOutcome :: Panicked ( s) => PluginAction :: Error ( format ! ( "panicked: {s}" ) ) ,
1144- // Cancelled because another branch short-circuited the phase.
1145- BranchOutcome :: Aborted => PluginAction :: Error ( "aborted" . to_string ( ) ) ,
1245+ // Cancelled because another branch short-circuited the phase —
1246+ // an intentional abort, recorded as such rather than an error.
1247+ BranchOutcome :: Aborted => PluginAction :: Aborted ,
11461248 } ;
11471249 decisions. record ( plugin_name, entry. plugin_ref . trusted_config ( ) . mode , action) ;
11481250
0 commit comments