Skip to content

feat: structured control execution records - #134

Open
prakhar-singh1928 wants to merge 2 commits into
mainfrom
feat-execution-records
Open

feat: structured control execution records#134
prakhar-singh1928 wants to merge 2 commits into
mainfrom
feat-execution-records

Conversation

@prakhar-singh1928

Copy link
Copy Markdown
Contributor

Summary

Adds structured control execution records to PipelineResult. Every hook
invocation now returns a Vec<ControlExecutionRecord> — one trusted record per
plugin evaluated — carrying identity, execution health, the effective allow/deny
decision, modification flags, monotonic duration, and bounded diagnostics.
Records are populated entirely from framework-owned state; plugins cannot forge
them. Exposed through the FFI, Go binding, and Python PyO3 binding.

Closes: #130


Changes

Core (crates/cpex-core)

  • New execution_record module: ControlExecutionRecord, ControlExecutionStatus,
    ExecutionSummary helper, string/key cardinality bounds (MAX_STRING_LEN = 256,
    MAX_CONFIG_KEYS = 64), UTF-8-safe truncation
  • executor: populate records across all 5 phases (SEQUENTIAL, TRANSFORM, AUDIT,
    CONCURRENT, FIRE_AND_FORGET) with requested_allow / effective_allow
    distinction, payload_modified, extensions_modified, monotonic duration_ns,
    bounded reason and error_code
  • PipelineResult.executions: Vec<ControlExecutionRecord> + with_executions()

FFI (crates/cpex-ffi)

  • FfiPipelineResult.executions passthrough on both cpex_invoke and
    cpex_invoke_resolved paths

Go (go/cpex)

  • ControlExecutionStatus constants and ControlExecutionRecord struct
  • PipelineResult.Executions and TypedPipelineResult.Executions fields
  • Wire Executions through Invoke[P] struct literal (was silently nil)

Python (bindings/python)

  • PipelineResult.executions getter returns list[dict]
  • _lib.pyi stub updated

Bug fixes

  • Serial phase: deny without a violation object no longer silently swallows
    the denial — synthesize a default violation, always halt the pipeline
  • Concurrent phase: snapshot original_mode before on_error: disable fires
    so records show "concurrent" not "disabled"
  • Go Invoke[P]: Executions was deserialized from the wire but never copied
    into TypedPipelineResult

Checks

  • make lint passes — ✅ lint passed
  • make test passes - ✅
  • CHANGELOG updated (## [Unreleased] section added)

Notes

15 new Rust integration tests cover every executor phase and outcome path:
allow, deny, deny-without-violation, error+ignore, order preservation, audit
phase, concurrent allow/deny, FAF spawn, duration measurement, and
deny-stops-subsequent.

3 new Go tests cover field threading through Invoke[P], msgpack
round-trip for ControlExecutionRecord (all pointer fields), and
PipelineResult.Executions wire round-trip.

Known follow-ups (non-blocking, tracked separately):

  • Disabled / Skipped status variants are declared but not yet emitted
    (disabled plugins are silently excluded by group_by_mode)
  • Serial/ref phase OnError::Disable arm has the same post-disable mode
    snapshot gap that was fixed in the concurrent phase
  • FAF records use status = Completed at spawn time; a dedicated Spawned
    variant would be cleaner

Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>
Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>

@vishu-bh vishu-bh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The structured execution record looks well aligned with the observability use case. Could you please check and clarify the following contract semantics before we consume it in ContextForge?

  1. Trigger semantics: What should count as a control being “triggered”? The record currently distinguishes invocation, matched, applied, and effective_allow=false. Should consumers treat these respectively as executed, condition matched, enforced, and denied?

  2. Stable identity: Is plugin_id guaranteed to remain stable across process restarts, configuration reloads, and multiple replicas? Also, which fields should consumers use for the distinct artifact, control, and binding identities?

  3. Non-standard execution states: Could you check how skipped, disabled, and fire-and-forget controls should be represented? In particular, should fire-and-forget initially use a scheduled status instead of completed, since its final outcome is not yet known when the pipeline returns?

  4. Matched value: matched may currently be None when CPEX cannot determine whether a condition matched. Should matching become an explicit plugin-result field so consumers can reliably distinguish “not matched” from “unknown”? If not, could the expected interpretation of None be documented?

Clarifying these points will help ensure CF derives trigger and enforcement metrics consistently without making assumptions that could produce misleading dashboard counts.

@prakhar-singh1928

Copy link
Copy Markdown
Contributor Author

@vishu-bh to answer your questions

  1. Yes, Exactly
    executed → matched → enforced → denied
    ↑ ↑ ↑ ↑
    status in matched applied applied and
    (completed, ==True ==True !effective_allow
    error,
    timeout)

  2. plugin_id is not stable across process restarts, config reloads, or replicas. It is a Uuid::new_v4() generated at registration time inside PluginManager::initialize() — every process start and every reload produces a new UUID for the same logical plugin. We will not use it for cross-process joins or persistent storage keys.

For the three identity concepts we will use:

Artifact identity → plugin_kind (e.g. "builtin", "wasm://org/guard@1.2")
Control identity → plugin_name — primary grouping key for dashboards and counters
Binding identity → plugin_name + hook_name composite — used for all cross-process and cross-replica correlation

plugin_id will only be used for within-request deduplication within a single invoke_hook call.

  1. Disabled plugins produce zero records. They are silently dropped before any phase runner is called. The Disabled and Skipped status variants exist in the enum but are never written today. If we need "this expected control did not run" observability in CF, we will maintain a baseline list of expected controls per hook in CF config and diff against what appears in result.executions.

Fire-and-forget records use status = Completed at spawn time as an optimistic placeholder. The actual outcome is unknowable when the pipeline returns. duration_ns is 0, requested_allow is None, applied is false. We will identify FAF records by mode == "fire_and_forget" and exclude them from all enforcement metrics. We agree a Scheduled status variant would be cleaner and will file a follow-up CPEX ticket, but will not block CF work on it.

  1. Matched value and None semantics

matched is tri-state:

  • True : condition definitively fired (deny path, or allow with payload/extension modification)
  • False : control ran, condition did not fire (clean allow, no mutation)
  • None : no result was obtained (error or timeout only)

None means unknown, not false. It does not mean the condition did not match. We will only count matched == True in our "condition matched" metric. For "was this control evaluated at all" we will count status in (completed, error, timeout) which is unconditional on match state.

@vishu-bh vishu-bh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @prakhar-singh1928 for clarifying the doubts . It looks a sound approach
LGTM 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: Add structured control execution records for enforcement observability

3 participants