Description
PipelineResult.decision_log is public and has a full accessor set (steps, verdict, span, input_labels, input_hash, and the four stream fields), so an in-process Rust host reaches the whole record today. None of the decision types carry serde derives, so the record cannot leave the process.
Is the decision log a host-facing API that crosses process and language boundaries, or an internal record handed to in-process sinks?
- If yes, step recording stays unconditional and the per-plugin
String allocation stops being an exception to the zero-overhead-when-off rule and becomes the price of a documented feature.
- If no, step recording should be gated on a sink being attached, and that aggregate cannot exist.
Serialize, deliberately not Deserialize
DecisionLog's fields are private behind accessors, and the invariants live in the methods: finalize sets the verdict once at a return point, and set_stream stamps sequence numbers that must be dense per (epoch, stream_id). A derived Deserialize bypasses all of it and hands any caller a log with an arbitrary verdict and arbitrary sequence numbers, which is precisely what the stream density contract exists to make impossible.
Producers need Serialize. Consumers deserialize in their own language against their own types, as the Go and Python bindings already do for every other wire type. A Rust Deserialize would serve round-trip tests and forgery, so derive Serialize only and let the round-trip test assert against a JSON value.
Wire contract
Five types: DecisionLog, DecisionStep, PluginAction, Verdict, Span. PluginViolation and PluginMode already derive both, and PluginMode is already rename_all = "snake_case", so the field-level work is small.
Security
Verdict::Deny, PluginAction::Denied, and PluginAction::DenyIgnored each carry a PluginViolation, including its details map.
Exporting that to the host is not the concern: the host is the operator, not the untrusted party, and it already reads PipelineResult.violation. Blanket-serializing a decision log into a telemetry span is the concern, and CPEX's issue #178 lists PluginViolation.details as excluded from telemetry for exactly this reason. The filtering belongs at span construction in the consumer, not at the serialization boundary here. State it in the docs so it is not solved later by trimming the record.
Acceptance criteria
- The five decision types derive
Serialize, with a representation chosen deliberately rather than taken as serde's default, and documented as a wire contract.
- No
Deserialize on DecisionLog. A round-trip test asserts the serialized shape, not a Rust round trip.
- The four stream fields survive serialization, so a consumer can check density without re-deriving it.
- The auditing docs say that
violation.details rides in a serialized log and that a consumer putting the log into telemetry must filter at that point.
- Whether step recording stays unconditional is settled and written down, either way.
Coordination
The out-of-tree OCSF sink reads DecisionStep and Verdict and is pinned at 5b76fa6. Agree the enum representation with its owner before anything lands; a change afterwards is a breaking wire change for the first and only consumer.
Land the metrics issue first if both are going ahead. It adds a field to DecisionStep, and doing it after this one stamps the serialized shape and then immediately changes it.
Description
PipelineResult.decision_logis public and has a full accessor set (steps,verdict,span,input_labels,input_hash, and the four stream fields), so an in-process Rust host reaches the whole record today. None of the decision types carry serde derives, so the record cannot leave the process.Is the decision log a host-facing API that crosses process and language boundaries, or an internal record handed to in-process sinks?
Stringallocation stops being an exception to the zero-overhead-when-off rule and becomes the price of a documented feature.Serialize, deliberately not Deserialize
DecisionLog's fields are private behind accessors, and the invariants live in the methods:finalizesets the verdict once at a return point, andset_streamstamps sequence numbers that must be dense per(epoch, stream_id). A derivedDeserializebypasses all of it and hands any caller a log with an arbitrary verdict and arbitrary sequence numbers, which is precisely what the stream density contract exists to make impossible.Producers need
Serialize. Consumers deserialize in their own language against their own types, as the Go and Python bindings already do for every other wire type. A RustDeserializewould serve round-trip tests and forgery, so deriveSerializeonly and let the round-trip test assert against a JSON value.Wire contract
Five types:
DecisionLog,DecisionStep,PluginAction,Verdict,Span.PluginViolationandPluginModealready derive both, andPluginModeis alreadyrename_all = "snake_case", so the field-level work is small.Security
Verdict::Deny,PluginAction::Denied, andPluginAction::DenyIgnoredeach carry aPluginViolation, including itsdetailsmap.Exporting that to the host is not the concern: the host is the operator, not the untrusted party, and it already reads
PipelineResult.violation. Blanket-serializing a decision log into a telemetry span is the concern, and CPEX's issue #178 listsPluginViolation.detailsas excluded from telemetry for exactly this reason. The filtering belongs at span construction in the consumer, not at the serialization boundary here. State it in the docs so it is not solved later by trimming the record.Acceptance criteria
Serialize, with a representation chosen deliberately rather than taken as serde's default, and documented as a wire contract.DeserializeonDecisionLog. A round-trip test asserts the serialized shape, not a Rust round trip.violation.detailsrides in a serialized log and that a consumer putting the log into telemetry must filter at that point.Coordination
The out-of-tree OCSF sink reads
DecisionStepandVerdictand is pinned at5b76fa6. Agree the enum representation with its owner before anything lands; a change afterwards is a breaking wire change for the first and only consumer.Land the metrics issue first if both are going ahead. It adds a field to
DecisionStep, and doing it after this one stamps the serialized shape and then immediately changes it.