Skip to content

feat(test): write down the safety invariants and test them as invariants - #71

Open
mkoushni wants to merge 10 commits into
praxis-proxy:mainfrom
mkoushni:feat/test-safety-invariants
Open

feat(test): write down the safety invariants and test them as invariants#71
mkoushni wants to merge 10 commits into
praxis-proxy:mainfrom
mkoushni:feat/test-safety-invariants

Conversation

@mkoushni

@mkoushni mkoushni commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Write the fail-closed promise down as testable claims in docs/safety-invariants.md (linked from CONTRIBUTING) and drive them with a reusable fault-injection plugin and PDP resolver.
  • Contain panics in serial, transform, and audit the same way concurrent already did: route through on_error instead of unwinding execute(). Halt vs continue still follows can_block; that difference is documented with a reason.
  • Containment costs a payload clone per plugin per hook (clone_boxed into tokio::spawn). Audit used to take &payload and spawn nothing; it is no longer free. The spawn also carries the timeout and abort-on-drop cancellation, so this is a trade rather than a regression. If the clone matters for CMF payloads, catch_unwind over AssertUnwindSafe would contain panics without the clone or the spawn, at the cost of losing timeout and cancellation on those phases — measure before changing.
  • Table-driven catalog: every plugin phase × {panic, error, timeout} under on_error: fail, plus ignore/disable for serial, transform, and audit. A sequential panic under ignore still runs later audit; under fail it does not.
  • Malformed config covers unknown key, misspelled top-level block, and unparseable YAML. Omitted on_error in YAML is Fail. Missing subject.id pins the differential allowlist row (Cedar dispatch error, CEL eval error, OPA default deny). Malformed policy pins Cedar dispatch / CEL compile / OPA compile-or-dispatch.
  • PDP {panic, error, timeout} cells drive the evaluator wrapper (FaultPdp), not the Cedar/CEL/OPA resolvers themselves. Real dialects are covered for missing attribute and malformed policy. A new phase or shipped dialect fails the build until a cell is added.
  • ppe-apl-core requires a Tokio runtime: tokio::spawn panics outside one, so a host driving the evaluator on a non-Tokio executor unwinds out of evaluate_effects on every Effect::Pdp instead of failing closed.

Closes #24

Test plan

  • cargo test -p praxis-policy-core --test safety_invariants
  • cargo test -p praxis-policy-apl-core --test safety_invariants
  • cargo test -p praxis-policy-pdp-diff safety

The fail-closed promise lived in comments and per-seam judgment. Codify
it as a catalog and contain panics in every awaited phase the way
concurrent already did.

Fixes praxis-proxy#24

Signed-off-by: mkoushni <mkoushni@redhat.com>
A one-plugin catalog could not fail if a contained serial panic still
skipped later audit, and several cells only asserted "not allow". Name
the decision per cell and cover ignore/disable outside concurrent.

Signed-off-by: mkoushni <mkoushni@redhat.com>

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review: feat(test): write down the safety invariants and test them as invariants

Strong work. The containment model is sound: serial, transform, and audit panics now route through on_error the same way concurrent already did. The catalog approach -- exhaustive matches that fail the build when a phase or dialect is added -- is exactly the right mechanism for these invariants. The fault-injection infrastructure is well-factored and the test matrix is thorough.

Two findings, both medium.

Severity File Finding
Medium executor.rs ContainedOutcome::Lost non-blocking paths skip OnError::Disable
Medium plugin.rs is_dispatch_phase takes self by value; all other PluginMode methods take &self

Comment thread crates/ppe-core/src/executor.rs
Comment thread crates/ppe-core/src/plugin.rs Outdated
A non-blocking Lost outcome recorded the error but skipped the Disable
circuit breaker that Error, Timeout, and Panic already trip. Match the
other PluginMode predicates on the receiver too.

Signed-off-by: mkoushni <mkoushni@redhat.com>
@araujof araujof added the tests label Sep 3, 2026
@araujof araujof moved this from Backlog to In progress in Praxis Policy Engine (PPE) Sep 3, 2026
@araujof araujof added this to the 0.1.2 milestone Sep 3, 2026

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PR Review

Both previous findings (Lost/Disable gap and is_dispatch_phase receiver) are addressed. One new finding.

Severity File Finding
Medium evaluator.rs evaluate_pdp_contained leaks spawned tasks on timeout

Comment thread crates/ppe-apl-core/src/evaluator.rs
Keep the safety-invariants changelog under Unreleased; take 0.2.0 wording
for assertions. Fold the unreadable-result tests into FaultHandler.

Signed-off-by: mkoushni <mkoushni@redhat.com>
Wrapping timeout around the JoinHandle detached the spawned task, so a
resolver that never returned kept running. Put the budget inside the
spawn, matching invoke_contained, so dropping the future cancels it.

Signed-off-by: mkoushni <mkoushni@redhat.com>
@mkoushni
mkoushni requested a review from a team September 6, 2026 09:37
Signed-off-by: mkoushni <mkoushni@redhat.com>
Signed-off-by: mkoushni <mkoushni@redhat.com>
@araujof araujof self-assigned this Sep 9, 2026
@araujof araujof moved this from In progress to Review in Praxis Policy Engine (PPE) Sep 9, 2026
@shaneutt
shaneutt self-requested a review September 9, 2026 00:32
Keep the safety-invariants changelog under Unreleased alongside the
PPE documentation entry from praxis-proxy#82.

Signed-off-by: mkoushni <mkoushni@redhat.com>

@araujof araujof left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for this PR!

A few findings and suggestions to address before merging:

  • make publish-dry fails because ppe-core uses a versioned workspace self-dependency. Use the same path-only self-dependency already used by ppe-apl-core.

  • A transform panic can let the original, unredacted payload continue through the pipeline. Either stop processing when on_error is Fail, or document and test this behavior explicitly.

  • The spawned plugin and PDP tasks outlive a dropped request. Give both join handles abort-on-drop ownership so timeout, disconnect, and shutdown cancel the work.

  • A contained serial or transform panic removes the plugin's existing local_state. Preserve and restore the previous state when the task returns no context.

  • InjectedFailure::all() omits WrongType, so the matrix never checks it. Add it and correct the audit expectation, since audit results are intentionally discarded.

A dropped request left plugin and PDP work running, and a serial panic
cleared the plugin's prior local_state. Path-only self-dep so publish-dry
packages; the catalog now drives WrongType.

Signed-off-by: mkoushni <mkoushni@redhat.com>
@mkoushni

mkoushni commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

make publish-dry — ppe-core now uses a path-only self-dev-dependency, same as ppe-apl-core. cargo package -p praxis-policy-core succeeds.

Transform panic + original payload — Transform still cannot halt (on_error: fail is not an enforcement point). That is documented: the original unredacted payload continues, payload_modified stays false, and the failure is recorded. Pinned by a_transform_fail_panic_keeps_the_original_payload.

Abort-on-drop — Contained plugin tasks use AbortOnDropHandle; PDP tasks abort if the request future is dropped, so timeout/disconnect/shutdown cancel the work.

local_state — Serial/transform snapshot context into the task instead of taking it. A contained panic leaves the previous map in the table. Pinned by a_contained_serial_panic_keeps_prior_local_state.

WrongType — InjectedFailure::all() includes it. Audit discards results, so Audit × WrongType is Allow, not a recorded error.

@terylt terylt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nice work! My review uncovered three small items.

1. Say what the containment costs

crates/ppe-core/src/executor.rs, run_serial_phase and run_ref_phase

Every entry now does payload.clone_boxed() plus a task spawn. Audit used to take
&payload and spawn nothing, so a hook with N sequential + M transform + K audit
plugins went from zero clones to N+M+K per request.

The spawn is load-bearing, since it carries the timeout and the abort-on-drop
cancellation as well as the panic containment, so this is a trade rather than a
regression. But the trade is currently unstated. Add a line to the PR body and to
the module comment saying containment costs a payload clone per plugin per hook,
and that audit is no longer free.

If the clone turns out to matter for CMF payloads, catch_unwind over
AssertUnwindSafe contains panics without the clone or the spawn, at the cost of
losing the timeout and cancellation on those phases. Worth measuring before
changing anything.

2. Fix or drop the coverage test

crates/ppe-core/tests/safety_invariants.rs, plugin_fault_catalog_covers_every_dispatch_mode

dispatch_modes() is all_plugin_modes().filter(PluginMode::is_dispatch_phase),
so asserting modes.contains(&mode) == mode.is_dispatch_phase() restates the
filter and cannot fail. The exhaustive match in all_plugin_modes() is what
actually guards I12, and it does so at compile time.

Either delete the test and point the I12 doc line at the exhaustive match, or
replace it with something the filter does not already guarantee, for example that
every mode in dispatch_modes() has a non-Allow cell for at least one injected
failure.

3. Document the Tokio requirement

crates/ppe-apl-core

tokio moved from a dev-dependency to a dependency, which is the right call and
is already explained in Cargo.toml. The gap is the failure shape: tokio::spawn
panics outside a runtime, so a host driving the evaluator on a non-Tokio executor
unwinds out of evaluate_effects on every Effect::Pdp instead of failing
closed. One line in the crate-level docs saying a Tokio runtime is required.

Audit is no longer a free &payload path. The I12 catalog test restated
the dispatch-mode filter; replace it with a non-Allow cell check.

Signed-off-by: mkoushni <mkoushni@redhat.com>
@mkoushni

mkoushni commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author
  1. Containment cost — The executor module comment now says containment costs a payload clone per plugin per hook, and that audit is no longer free. The spawn is load-bearing for timeout and abort-on-drop. run_ref_phase and invoke_contained say the same. The PR body records the trade and the catch_unwind alternative (measure before changing).

  2. I12 test — Replaced the vacuous modes.contains == is_dispatch_phase() check with plugin_fault_catalog_every_dispatch_mode_has_a_non_allow_cell: every dispatched mode has at least one injected failure that is not Allow. I12 now points at the exhaustive match as the compile-time guard.

  3. Tokio requirement — Crate-level docs on ppe-apl-core state that a Tokio runtime is required: tokio::spawn panics outside one, so a host without a runtime unwinds out of evaluate_effects on every Effect::Pdp instead of failing closed.

@terylt
terylt self-requested a review September 9, 2026 16:41

@terylt terylt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

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

Projects

Status: Review

Development

Successfully merging this pull request may close these issues.

feat(test): write down the safety invariants and test them as invariants

4 participants