Description
PluginResult.metadata is documented as plugin telemetry and is part of the public plugin API, but erase_result does not copy it into ErasedResultFields, so anything a plugin puts there is dropped at the erasure boundary and never reaches the executor. PipelineResult.metadata is documented as the aggregate of it and is set to None by both constructors with nothing ever writing it. Both halves of the channel are inert, which is why no plugin in builtins/, reference/, or ppe-apl-runtime sets the field: there has never been any point.
Carry the metadata through erasure, validate it, record it per plugin on DecisionStep, and build the PipelineResult.metadata aggregate from those steps.
The decision log already records what each plugin did. What it cannot record is anything the plugin knows that the executor does not: that a rate limiter was at 99% of quota, that a scanner matched three patterns, that a cache was cold. Those are the facts an operator wants on both the allow and the deny, and today there is nowhere to put them.
The path
plugin sets PluginResult.metadata
-> erase_result carries it into ErasedResultFields
-> executor validates it
-> DecisionStep.metrics, recorded with plugin_name and phase
-> PipelineResult.metadata, aggregated from the steps
Shape
Metrics are bounded boolean and numeric values. The framework cannot tell whether a string is an identity, a credential, or a request value, and a decision record reaches audit sinks and host telemetry, so free-form text is the wrong thing to carry here by default.
Nothing sets the field today, so we are free to put the contract in the type rather than in a runtime validator that silently discards what does not fit. Prefer a bounded map type over Option<serde_json::Value> plus a sanitizer: a plugin author writing a string then gets a compile error instead of an empty map and no explanation. If a validator is used instead, it must log what it drops and name the plugin.
Decide explicitly whether a declared string allow-list is in scope. It is the first thing that will be asked for (CPEX's equivalent work wants a rate limiter backend label), and deciding it now is better than deciding it under deadline.
Aggregate
DecisionStep.metrics is the record of authority. PipelineResult.metadata is built from the steps at the single point the result is assembled, and is never written by a plugin.
It must be keyed by plugin, not flat-merged. A flat merge means two plugins reporting throttled collide with no owner, which destroys the attribution the metrics exist for. The framework knows the plugin name and stamps it, the same rule EffectRecord::plugin_name already follows.
Settle one case: a plugin configured twice appears as two entries under one name. Either key by plugin_id or state a last-wins rule.
Verdict finalization
Fold in the related honesty fix. emit_decision returns early when no sink is attached, before calling finalize, so a host reading PipelineResult.decision_log with auditing off sees fully populated steps and verdict: None. Finalize unconditionally. The verdict is already known from continue_processing and violation, and emit_decision stays the single place that decides it. Keep stamp_decision_stream on the emit path so an unemitted log never spends a sequence number.
Acceptance criteria
PluginResult.metadata set by a plugin reaches DecisionStep for that plugin, in every phase that records a step.
- Values outside the contract do not reach a sink, and what is rejected is visible to the plugin author rather than silently dropped.
PipelineResult.metadata carries the per-plugin aggregate, attributable to the plugin that reported each entry, built only from the recorded steps.
- Metrics are recorded on allow, deny, and modify, not only on deny.
decision_log.verdict is populated whether or not a sink is attached.
- A plugin that sets no metadata behaves exactly as before, and the aggregate is absent rather than empty.
Scope
Six ErasedResultFields construction sites (two in route_handler.rs, four in tests) and eight decisions.record call sites in executor.rs. All are in this workspace: Praxis constructs neither type, so the field can be added directly. CPEX needed a parallel envelope type and a double downcast to avoid breaking out-of-tree AnyHookHandler implementations; we do not have that constraint and should not copy the workaround.
Out of scope
Serde derives on the decision types, and whether step recording stays unconditional. Both belong to the separate question of whether the decision log is a host-facing API, which needs the out-of-tree OCSF sink owner in the loop before an enum representation is stamped.
Note the dependency in that direction: if PipelineResult.metadata is a real host-facing field, step recording has to stay unconditional, because the aggregate loses its source exactly when auditing is off.
Description
PluginResult.metadatais documented as plugin telemetry and is part of the public plugin API, buterase_resultdoes not copy it intoErasedResultFields, so anything a plugin puts there is dropped at the erasure boundary and never reaches the executor.PipelineResult.metadatais documented as the aggregate of it and is set toNoneby both constructors with nothing ever writing it. Both halves of the channel are inert, which is why no plugin inbuiltins/,reference/, orppe-apl-runtimesets the field: there has never been any point.Carry the metadata through erasure, validate it, record it per plugin on
DecisionStep, and build thePipelineResult.metadataaggregate from those steps.The decision log already records what each plugin did. What it cannot record is anything the plugin knows that the executor does not: that a rate limiter was at 99% of quota, that a scanner matched three patterns, that a cache was cold. Those are the facts an operator wants on both the allow and the deny, and today there is nowhere to put them.
The path
Shape
Metrics are bounded boolean and numeric values. The framework cannot tell whether a string is an identity, a credential, or a request value, and a decision record reaches audit sinks and host telemetry, so free-form text is the wrong thing to carry here by default.
Nothing sets the field today, so we are free to put the contract in the type rather than in a runtime validator that silently discards what does not fit. Prefer a bounded map type over
Option<serde_json::Value>plus a sanitizer: a plugin author writing a string then gets a compile error instead of an empty map and no explanation. If a validator is used instead, it must log what it drops and name the plugin.Decide explicitly whether a declared string allow-list is in scope. It is the first thing that will be asked for (CPEX's equivalent work wants a rate limiter
backendlabel), and deciding it now is better than deciding it under deadline.Aggregate
DecisionStep.metricsis the record of authority.PipelineResult.metadatais built from the steps at the single point the result is assembled, and is never written by a plugin.It must be keyed by plugin, not flat-merged. A flat merge means two plugins reporting
throttledcollide with no owner, which destroys the attribution the metrics exist for. The framework knows the plugin name and stamps it, the same ruleEffectRecord::plugin_namealready follows.Settle one case: a plugin configured twice appears as two entries under one
name. Either key byplugin_idor state a last-wins rule.Verdict finalization
Fold in the related honesty fix.
emit_decisionreturns early when no sink is attached, before callingfinalize, so a host readingPipelineResult.decision_logwith auditing off sees fully populatedstepsandverdict: None. Finalize unconditionally. The verdict is already known fromcontinue_processingandviolation, andemit_decisionstays the single place that decides it. Keepstamp_decision_streamon the emit path so an unemitted log never spends a sequence number.Acceptance criteria
PluginResult.metadataset by a plugin reachesDecisionStepfor that plugin, in every phase that records a step.PipelineResult.metadatacarries the per-plugin aggregate, attributable to the plugin that reported each entry, built only from the recorded steps.decision_log.verdictis populated whether or not a sink is attached.Scope
Six
ErasedResultFieldsconstruction sites (two inroute_handler.rs, four in tests) and eightdecisions.recordcall sites inexecutor.rs. All are in this workspace: Praxis constructs neither type, so the field can be added directly. CPEX needed a parallel envelope type and a double downcast to avoid breaking out-of-treeAnyHookHandlerimplementations; we do not have that constraint and should not copy the workaround.Out of scope
Serde derives on the decision types, and whether step recording stays unconditional. Both belong to the separate question of whether the decision log is a host-facing API, which needs the out-of-tree OCSF sink owner in the loop before an enum representation is stamped.
Note the dependency in that direction: if
PipelineResult.metadatais a real host-facing field, step recording has to stay unconditional, because the aggregate loses its source exactly when auditing is off.