Current Behavior
When violations_as_exceptions=True is set on an invoke_hook() call and a plugin denies the invocation, CPEX raises PluginViolationError immediately without appending a ControlExecutionRecord for the denying plugin to PluginResult.executions (see cpex/framework/manager.py:680-682).
As a result, PluginResult.executions only contains records for plugins that ran before the denial. The denying plugin itself has no record. ContextForge consumers cannot identify which specific control blocked the invocation from telemetry — only that some control in the chain denied it.
Expected Behavior
PluginResult.executions should contain a ControlExecutionRecord for the denying plugin even when violations_as_exceptions=True. The record should be appended before the exception is raised, with:
status = completed (or an appropriate terminal status)
effective_allow = false
- All other standard identity and duration fields populated
Steps to Reproduce
- Configure a CPEX plugin chain with at least two plugins where the second plugin denies the invocation.
- Call
invoke_hook() with violations_as_exceptions=True.
- Catch the raised
PluginViolationError.
- Inspect
PluginResult.executions — only the first plugin's record is present. The denying plugin has no record.
Logs / Error Output
No stack trace. The gap is silent — PluginResult.executions returns an incomplete list with no indication that a record is missing. ContextForge documents this inline:
Note: when violations_as_exceptions=True, CPEX raises PluginViolationError
before appending a ControlExecutionRecord for the denying plugin to the
executions list (see cpex/framework/manager.py:680-682 — "propagate
immediately, no record needed"). This means _ctl_acc may only contain
records from earlier plugins in the chain that ran before the denial.
Environment
Additional Context
ContextForge works around this via ControlTelemetryAccumulator.mark_denied(hook="pre/post"), which correctly sets cpex.control.result.allowed=false on the emitted summary span. However, downstream consumers cannot determine which control denied the invocation from telemetry alone.
The fix belongs in cpex/framework/manager.py around line 680-682 — the ControlExecutionRecord for the denying plugin should be appended to executions before the exception is raised, not skipped. Related: #130.
Current Behavior
When
violations_as_exceptions=Trueis set on aninvoke_hook()call and a plugin denies the invocation, CPEX raisesPluginViolationErrorimmediately without appending aControlExecutionRecordfor the denying plugin toPluginResult.executions(seecpex/framework/manager.py:680-682).As a result,
PluginResult.executionsonly contains records for plugins that ran before the denial. The denying plugin itself has no record. ContextForge consumers cannot identify which specific control blocked the invocation from telemetry — only that some control in the chain denied it.Expected Behavior
PluginResult.executionsshould contain aControlExecutionRecordfor the denying plugin even whenviolations_as_exceptions=True. The record should be appended before the exception is raised, with:status = completed(or an appropriate terminal status)effective_allow = falseSteps to Reproduce
invoke_hook()withviolations_as_exceptions=True.PluginViolationError.PluginResult.executions— only the first plugin's record is present. The denying plugin has no record.Logs / Error Output
No stack trace. The gap is silent —
PluginResult.executionsreturns an incomplete list with no indication that a record is missing. ContextForge documents this inline:Note: when violations_as_exceptions=True, CPEX raises PluginViolationError
before appending a ControlExecutionRecord for the denying plugin to the
executions list (see cpex/framework/manager.py:680-682 — "propagate
immediately, no record needed"). This means _ctl_acc may only contain
records from earlier plugins in the chain that ran before the denial.
Environment
tool_service.py:6628-6635, PR feat: consume Cpex control execution records IBM/mcp-context-forge#6003Additional Context
ContextForge works around this via
ControlTelemetryAccumulator.mark_denied(hook="pre/post"), which correctly setscpex.control.result.allowed=falseon the emitted summary span. However, downstream consumers cannot determine which control denied the invocation from telemetry alone.The fix belongs in
cpex/framework/manager.pyaround line 680-682 — theControlExecutionRecordfor the denying plugin should be appended toexecutionsbefore the exception is raised, not skipped. Related: #130.