Summary
PluginMode.TRANSFORM (the backing mode for operator-set permissive) logs violations at WARNING level when a plugin returns continue_processing=False, but the suppressed violation is never surfaced in PluginResult.violation. The field is always None on the result returned by invoke_hook() when no SEQUENTIAL or CONCURRENT plugin has halted the pipeline.
Reproduction path
In framework/manager.py, when the pipeline completes without a halt:
# line ~541-553 (v0.1.3)
return (
PluginResult(
continue_processing=True,
modified_payload=current_payload,
...
violation=None, # ← always None, even if TRANSFORM plugins suppressed a violation
...
),
...
)
Meanwhile _run_serial_phase with allow_blocking=False (TRANSFORM) logs the violation and discards the halt:
# ~line 873-880
logger.warning(
"%s plugin %s returned continue_processing=False on hook %s%s; "
"pipeline continues (blocking suppressed)",
...
)
# halt result is discarded — violation never propagated upward
A caller doing result.violation is not None after invoke_hook() in permissive mode will always see None, even when a plugin fired a rate-limit, PII, or other policy violation.
Why it matters
- Observability gap: permissive mode is the natural staging strategy for "log but don't enforce yet". With violations dropped from
PluginResult, downstream metrics, audit trails, and structured logs lose them. The only remaining signal is a WARNING log line — fine for humans, useless for programmatic consumers.
- Test brittleness: assertions like
assert result.violation is not None under permissive mode silently pass incorrectly.
- Partial workaround exists: the
PluginResult.executions list does carry per-plugin records with effective_allow=False for suppressed plugins, so callers willing to walk that list can recover the data — but this is non-obvious and undocumented as the intended access pattern.
Operator mapping (in ContextForge gateway)
| Operator mode |
PluginMode |
Violation surfaced? |
enforce |
SEQUENTIAL |
✅ yes |
permissive |
TRANSFORM |
❌ no (this bug) |
disabled |
DISABLED |
n/a |
Proposed fix (preferred)
In PluginManager.invoke_hook(), accumulate any suppressed violations from the TRANSFORM phase and populate PluginResult.violation (or a new suppressed_violation field) on the final result. The key invariant: suppressing the raise/halt should be independent of suppressing observability.
Option A — reuse violation field, change semantics to "last violation encountered (may or may not have halted)":
return (
PluginResult(
continue_processing=True,
violation=suppressed_violation, # populated from TRANSFORM phase
...
),
...
)
Option B — add suppressed_violation: Optional[PluginViolation] = None to preserve existing semantics of violation ("raised → halt") while exposing non-halting ones separately.
Option C — document PluginResult.executions as the intended access path for permissive-mode observability and provide a helper to extract suppressed violations from it.
Version
cpex 0.1.3
Cross-reference
Filed in the ContextForge gateway repo as IBM/mcp-context-forge#4710.
Summary
PluginMode.TRANSFORM(the backing mode for operator-setpermissive) logs violations at WARNING level when a plugin returnscontinue_processing=False, but the suppressed violation is never surfaced inPluginResult.violation. The field is alwaysNoneon the result returned byinvoke_hook()when no SEQUENTIAL or CONCURRENT plugin has halted the pipeline.Reproduction path
In
framework/manager.py, when the pipeline completes without a halt:Meanwhile
_run_serial_phasewithallow_blocking=False(TRANSFORM) logs the violation and discards the halt:A caller doing
result.violation is not Noneafterinvoke_hook()in permissive mode will always seeNone, even when a plugin fired a rate-limit, PII, or other policy violation.Why it matters
PluginResult, downstream metrics, audit trails, and structured logs lose them. The only remaining signal is a WARNING log line — fine for humans, useless for programmatic consumers.assert result.violation is not Noneunder permissive mode silently pass incorrectly.PluginResult.executionslist does carry per-plugin records witheffective_allow=Falsefor suppressed plugins, so callers willing to walk that list can recover the data — but this is non-obvious and undocumented as the intended access pattern.Operator mapping (in ContextForge gateway)
enforceSEQUENTIALpermissiveTRANSFORMdisabledDISABLEDProposed fix (preferred)
In
PluginManager.invoke_hook(), accumulate any suppressed violations from the TRANSFORM phase and populatePluginResult.violation(or a newsuppressed_violationfield) on the final result. The key invariant: suppressing the raise/halt should be independent of suppressing observability.Option A — reuse
violationfield, change semantics to "last violation encountered (may or may not have halted)":Option B — add
suppressed_violation: Optional[PluginViolation] = Noneto preserve existing semantics ofviolation("raised → halt") while exposing non-halting ones separately.Option C — document
PluginResult.executionsas the intended access path for permissive-mode observability and provide a helper to extract suppressed violations from it.Version
cpex 0.1.3
Cross-reference
Filed in the ContextForge gateway repo as IBM/mcp-context-forge#4710.