Auditing

SPDX-License-Identifier: Apache-2.0 Copyright (c) 2026 Praxis Contributors –>

Versions marked “overview” do not contain this page. Selecting one opens that version’s documentation overview.

On this page

PPE decides who may call which tool and what comes back. Auditing is the record of those decisions, and of the irreversible acts its plugins perform to carry them out.

There are two records and they answer different questions. A decision says how the pipeline ruled on one request: which plugins ran, what each did, and whether the request was allowed or blocked. An effect says what a plugin did to the outside world, such as minting a token at an IdP. Effects outlive the request and cannot be undone, which is why they are recorded separately and before the fact rather than after it.

Both are off until configured.

What consumes them is a sink: a plugin the engine hands each record to, which decides what the record looks like and where it goes. The engine builds the records and owns when they are emitted; a sink only serializes. PPE ships one, audit-logger, and a deployment needing a different format or destination writes its own.

What the engine promises

A verdict that was reached is emitted, and the record says what the caller was told. Emission happens once per invocation, at the engine’s return points rather than in a pipeline phase, so allow, deny and modify all produce one record and nobody has to reason about which phase runs before which deny.

The engine’s return points, specifically, and not the executor’s. The pipeline is not the last thing that can change a verdict: the assertions: contract runs after it and can refuse a request the pipeline allowed. A record emitted before that would report a request as forwarded that the caller was refused, which is worse than no record because it reads as evidence. The same holds at the other end: a request denied before the pipeline starts, because no route resolved for it, still produces a record. Those are the requests most worth having one for.

When an effect log is configured, a completed act has a durable record of its intent. The record is fsynced before the act happens, and if it cannot be written the act does not happen. That is what makes a crash recoverable: an intent with no outcome means the act may or may not have occurred, which is a question the participant can be asked: the external system the act was performed against, such as the IdP that would have minted the token.

Durable here means the record was written to the local filesystem and fsynced before the logging append returns, so it survives the process dying and the machine losing power. The append that creates the log also syncs the directory holding it, because a file’s own fsync does not make the directory entry naming it durable, and a crash in that window would otherwise leave no file at all.

It does not mean the record is replicated, and it is only as good as the storage underneath: a device that acknowledges a flush before the data is on stable media will lose records a crash should have kept. Nothing above that layer can detect it.

A plugin behaves the same whether or not auditing is on. There is no capability a plugin needs and no setting that makes a working deployment stop working. Turning auditing off costs the record, not the behavior.

A sink observes and does nothing else, and the engine enforces that rather than trusting it. The extensions a sink is handed are filtered against the capabilities its own plugins: entry declares, exactly as a hook plugin’s are: a sink that declares nothing sees the ungated slots. One that wants headers asks for read_headers; one that wants inbound tokens asks for read_inbound_credentials. A sink needs read_query to receive the raw HTTP query. A sink never receives the host’s HTTP transport without perform_http, and the view it gets refuses effects outright, so a sink watching a plugin mint a token cannot write records under that plugin’s name.

What the engine does not promise is that a record reached storage before the response left. Sinks are awaited, but a sink that buffers internally before writing to a network destination is past the point the engine can see.

Turning it on

The reference audit-logger runs as a sink when it lists no hooks:

plugins:
  - name: audit
    kind: audit/logger
    mode: audit
    capabilities:
      - read_subject           # sub / roles / teams
      - read_client            # client_id / client_name
      - read_labels            # the taint diff
      - read_delegated_tokens  # which audience got which scopes
    config:
      destination: stderr      # or `tracing`
      source: gateway-eu-1     # optional, stamped on every record

The reference ocsf-audit sink is wired the same way and emits each decision as an OCSF API Activity event instead: the verdict as security_control, the steps, span, taint, content digests and stream stamps under unmapped, and with chain: true an attestation whose fingerprint binds the record to its predecessor. A signing key adds a DSSE signature over the same bytes, which a verifier checks offline with the public key. It renders decisions only: on_effect keeps its ignoring default, so effects do not become OCSF events. It writes each record through the exporters in praxis_policy_core::audit::export: exporters: lists any of stderr, stdout, an append-only file, and tracing, and every record goes to each one byte for byte. Its README under reference/plugins/ocsf-audit/ documents the record, the exporters, the verifier rule and the sink’s known deviations from the published schema.

plugins:
  - name: ocsf-audit
    kind: audit/ocsf
    mode: audit
    capabilities:
      - read_subject
      - read_agent
      - read_delegation
      - read_labels
      - read_workload
    config:
      exporters:
        - { kind: file, path: /var/log/praxis/ocsf.ndjson, fsync: batch }
      chain: true
      signing: dsse
      signing_key_pem_path: /etc/praxis/ocsf-signing.pem
      signing_key_id: ocsf-2026-10
      authority_uid: org-f3576cf6

A sink declares what it reads, like any other plugin. The extensions it is handed are filtered against its own capabilities:, so a sink that declares nothing still runs, still emits a record per invocation, and still chains and signs — with the gated fields simply absent. Nothing fails, and that is the problem: a reader of the resulting record cannot tell “no delegation happened” from “the sink was not permitted to see the delegation.” Absence of evidence and evidence of absence are the same bytes.

So work out which slots a sink reads and grant exactly those. agent, delegation, http, raw_credentials and the security sub-fields — subject, client, workload, labels — each need their grant. A verdict, the ordered plugin actions, the entity called and the stream position need none, so a sink that records only those is complete with no capabilities at all.

Any plugin becomes a sink by overriding Plugin::as_audit_handler. A sink has two entry points. handle receives every decision. on_effect receives every effect, and has a default that ignores them, so a sink that only cares about verdicts implements nothing extra.

Effect records need a log:

engine_settings:
  effect_log_path: /var/lib/praxis/effects.ndjson
  effect_log_compaction_threshold: 1024   # optional; 0 disables auto-compaction

With a path set, intent is durably recorded before a plugin acts. Without one, effects still reach a sink’s on_effect, but nothing survives a restart, so an act interrupted by a crash leaves nothing behind to follow up.

The file holds one JSON record per line and is compacted in place: once an effect has both an intent and an outcome the pair is dropped, so it tracks what is in flight rather than growing without bound.

A crash can interrupt an append part-way through its write, leaving a final line with no newline and possibly cut mid-character. Recovery treats that one line as the interrupted append it is and discards it: it was never acknowledged, so the act it guarded never proceeded. A line that will not parse anywhere else is corruption, and recovery refuses rather than silently dropping a record that accounts for an irreversible act.

Getting that wrong costs more than an unbounded file. A recovery that cannot complete never runs reconciliation either, and since reconciliation results are emitted to the sinks, the stream a consumer is verifying either falls quiet on those transitions or carries an intent that was in fact settled. So the tolerance for a torn tail is load-bearing for the evidence, not only for compaction.

The durable record carries no stream position. Positions are stamped when a record is handed to the sinks, which is the stream they describe; the log is the write-ahead record of what was attempted, and recovery re-stamps whatever it resolves into the live stream anyway. Stamping earlier meant a refused write — fail-closed, so the act correctly did not happen — still spent a sequence number that nothing was ever emitted under, and a gap has to be read as something far worse than the disk error it was.

What reconciliation concludes is emitted to the sinks. The resolving record is appended and compacted away inside the same sweep, so the log is not where anyone reads it — without the emit, an effect that spent a restart unaccounted for would vanish with nobody told which way it went.

A host that wires plugins in code calls PolicyEngine::set_effect_log before initialize. That install does not survive load_config, which rebuilds the executor from the config it is given, so a log that has to outlive a reload belongs in config.

A record says which plugins ran and how the pipeline ruled. It does not say whether any of them changed the request on the way through. A content hash answers that: a digest of the payload taken at entry and again at the end. Equal digests mean nothing altered the content, different ones mean something did, and a reader learns which without the audit trail holding either version.

Withholding the content is the point rather than a limitation. A redaction plugin exists to take content out of a request, and writing that content into an audit record puts it straight back. A digest says the content changed, or did not, and nothing else about it.

A plain hash would undo that for short content. Anyone holding a record can hash a guess and compare, so a templated message with a redacted SSN in it is a billion guesses away from recovery. The digest is therefore an HMAC-SHA256 under an operator secret, named from the secrets: block:

engine_settings:
  capture_content_provenance: true
  content_provenance_key: provenance_key
secrets:
  providers:
    local: { kind: file, base_dir: /etc/ppe }
  values:
    provenance_key: { provider: local, ref: provenance.key }

The key has to be at least 32 bytes. The engine takes both digests, at entry and at emission, and puts them on the decision log, so a sink reads them and never holds the key.

Setting up the key

The key is an ordinary declared secret, so everything in the secrets: block applies. Generate one with enough entropy and keep it wherever the deployment keeps credentials:

openssl rand -base64 48 > /etc/ppe/provenance.key

The key is the file’s text, less one trailing newline, so the base64 output is used as written. In Kubernetes that is a Secret mounted as a volume, with base_dir pointing at the mount.

An environment variable works too, and needs no file:

engine_settings:
  capture_content_provenance: true
  content_provenance_key: provenance_key
secrets:
  providers:
    shell: { kind: env }
  values:
    provenance_key: { provider: shell, ref: PPE_PROVENANCE_KEY }

The environment is fixed when the process starts, so an env key only rotates with a restart. A file key rotates when the host calls refresh_secrets after the file changes, and the new key id shows up in the next record.

The host has to make the provider kinds available. file and env need no dependencies, but nothing registers them on its own: before initialize, the host installs SecretProviderRegistry::with_builtin_backends() with PolicyEngine::set_secret_providers. Without it, initialize fails naming the kind it could not find. A host embedding PPE through Praxis gets whatever Praxis registers.

Every engine that writes into one audit stream should share a key. Otherwise the same content digests differently on each, and the replay signal is lost across them.

Every digest names its scheme and key: hmac-sha256:<key_id>:<hex>. The key id is a fingerprint of the key, stable across restarts and different after a rotation. Two digests are comparable only when their key ids match; a reader seeing different ids knows the key changed, not the content. Rotating the bytes behind provenance_key takes effect on the next refresh_secrets. Naming a different value takes a restart, because secrets are read once at startup, and a reload that names a value nobody resolved is refused.

The key is deployment-wide rather than drawn per record. Within one record either would show whether the pipeline altered the content, but a deployment key also gives equal digests for equal content across requests, which is how a reader spots the same payload replayed. A per-record salt would need no key management and would lose that.

For development, content_provenance_key: unkeyed records plain SHA-256 as sha256:<hex> and logs a warning at every load. It has to be written out: provenance switched on with no key is a load error, so nobody ends up with guessable digests by leaving a line out.

Only payloads that opt in are digested, which today means the CMF MessagePayload. Anything else records no digest rather than a misleading one. It is off by default because hashing sits on the request path.

One collector may gather records from several processes. Naming the stream keeps them countable apart:

engine_settings:
  audit_stream_namespace: gw-1

Stream ids become gw-1:decision and gw-1:effect. An empty or whitespace value is refused at load, since it would compose :decision, which is neither a bare label nor an attributable one.

The epoch has no YAML key on purpose. It defaults to the executor’s boot time, which advances on its own and is correct with no configuration. A host that needs to set it does so in code, and takes on the invariant: the value has to be strictly larger on every load, reloads included, because a reload builds a fresh executor with the counters back at zero. Repeat an epoch and this generation’s records collide with the last one’s, and a reader can no longer tell a restart from records that went missing. A file cannot satisfy that, which is why the key does not exist. PPE warns when the epoch fails to advance across a reload, but still loads: the records are worth having even when the completeness claim is compromised.

The records

A decision:

{
  "ts": "2026-09-06T10:15:00.123Z",
  "plugin": "audit",
  "verdict": { "deny": {
    "code": "not_permitted",
    "reason": "no grant covers this tool",
    "plugin": "cedar-pdp"
  }},
  "decision_steps": [
    { "plugin": "identity-jwt", "phase": "Sequential", "action": "allowed" },
    { "plugin": "cedar-pdp", "phase": "Sequential", "action": "denied",
      "detail": { "code": "not_permitted", "reason": "no grant covers this tool" } }
  ],
  "span": {
    "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
    "span_id": "3c9f7d21e4075c68",
    "parent_span_id": "71e5093fd0c41822"
  },
  "taint": { "input": ["PII"], "final": ["CONFIDENTIAL", "PII"] }
}

decision_steps is in execution order. action is one of allowed, denied, deny_ignored, modified_payload, modified_extensions, aborted or error; the ones with something to say also carry detail.

deny_ignored means a plugin in a phase that cannot block asked to stop and was overruled. It is not an allow, and a consumer mapping actions onto a disposition that reads it as one will report the opposite of what the plugin asked for. The verdict names nothing in that case, so the step is the only place the objection survives.

span is W3C trace context, the same identifiers a tracing system uses to stitch one end-to-end request back together out of the hops that served it. The trace_id is shared by everything belonging to that request, the span_id names this interception, and the parent_span_id names the call that caused it. A reader with records from several hops can rebuild the order they happened in. PPE stamps these but does not propagate trace context downstream, so chaining across hops needs the host to carry it.

taint tracks security labels, the markers plugins attach to a request to record what kind of data it is carrying, such as PII. Labels only accumulate, never disappear, so the difference between the labels at entry and the labels at the end is exactly what this node added.

content appears only with provenance enabled, holding input_hash and output_hash, both read from the decision log.

epoch, stream_id, stream_seq and emission_seq place the record in the audit stream, and they answer two different questions.

stream_seq counts records within one stream and never skips, so a reader who sees 4 then 6 knows record 5 was lost rather than never written. That is the claim a tamper-evident consumer needs, and it only holds within one epoch: the counters restart with each executor, and epoch is the process generation, so a smaller sequence under a larger epoch is a restart rather than a gap.

emission_seq is shared between decisions and effects. It says only what order things happened in, so that a reader merging both streams can put them back in sequence. Reading one stream on its own it looks full of holes, and those holes are the other stream’s records, not losses. Use stream_seq to check for loss and emission_seq to order, never the reverse.

stream_id is the stream a record belongs to, decision or effect, prefixed by the namespace when one is configured. The type suffix always survives, so a consumer recovering it splits on the last colon rather than the first, since a namespace may contain one.

A transition that recovery settled rides the effect stream like any other effect record, dense with them and stamped from the live process, not the one that crashed. It is not interleaved onto decision, and it does not get a counter of its own: stream_seq is dense per (epoch, stream_id), so a per-record-kind counter would break exactly the check it exists for.

An effect is its own event:

{
  "event": "effect",
  "effect_kind": "token_mint",
  "effect_state": "confirmed",
  "effect_key": "9f1c...",
  "effect_plugin": "oauth-delegator",
  "effect_details": { "audience": "https://hr.example.com", "scope": "read:compensation" }
}

effect_plugin is stamped from the plugin the executor was running, not from anything the plugin supplied, so a record cannot claim to come from somewhere it did not.

Effects

An effect moves through four states.

StateMeaning
preparedIntent recorded. Nothing external has happened yet.
confirmedThe act completed.
rejectedThe act provably did not happen.
unknownNobody can say. Reconcile it.

unknown is the one that matters. A call that timed out may still have landed at the participant with the answer lost coming back, so recording it as rejected asserts no token was minted when nothing checked. The OAuth delegator records every failed mint as unknown, including a 4xx with an OAuth error body. The transport cannot verify that the authorization server sent the response: a proxy may answer after the token was minted. A 2xx whose body is not a token response is uncertain too. A reconciler can record rejected only after checking the effect key against an authoritative participant.

Which phases may act

Only sequential and transform. A plugin in any other mode is refused, with a violation naming the plugin, the mode and the key to change.

This is not about auditing and does not switch off with it. A concurrent branch is cancelled when another branch short-circuits the phase, audit is read-only by contract, and fire_and_forget runs after the verdict is returned. An act in any of them happens for work the pipeline discarded, and an external act cannot be discarded. A delegator configured in concurrent mode was minting a real credential and having the result thrown away.

The same refusal applies to a copy of a plugin’s extensions. Effects are performed on the &Extensions a handler was given. A clone is detached from the invocation that authorised it, and without the refusal a plugin could clone its way around the phase rule.

Recovery

A restart leaves whatever was in flight when the process stopped: intents whose outcome was never written. Settling those is reconciliation, and it means asking the participant that would have performed the act whether it did, using the key the intent recorded. Only the participant knows.

PolicyEngine::initialize sweeps the log once, before traffic. Completed effects are compacted away and the rest are reconciled with an external service that can answer for the participant, if one is configured.

Calling recover_effects again while serving is safe. From the log, an intent whose act is still running looks the same as one a crash orphaned, and reconciling it would race the plugin: a ledger that has not seen the mint yet says rejected, then the plugin records confirmed for the same key. So the engine tracks the keys it has an act open on, from just before the intent is written until just after the outcome is, and a sweep skips them. They come back among the unresolved and the next sweep picks them up. A plugin that times out, panics or is cancelled mid-act releases its key when the invocation ends, so its orphaned intent is reconciled like any other.

In practice there is nothing to configure. The question an orphaned intent raises is “did this mint land at the IdP before we died”, and an OAuth IdP has no endpoint that takes a mint key and reports whether that token was issued, so the question has nowhere to go. Nor is there a standard place to put the key on the way out: neither RFC 6749 nor RFC 8693 gives the token endpoint an idempotency or client-reference parameter. The OAuth delegator’s idempotency_header: names one for an IdP that honours a vendor-specific header, and is unset by default so nothing invented reaches an IdP that never asked for it.

The reconciler PPE ships by default connects to nothing. It records that the effect is unresolved, and leaves it unknown for an operator to chase.

Whatever a reconciler does settle reaches the audit sinks as its own event. The resolving record is compacted out of the log in the same sweep that writes it, so the emit is where that answer surfaces.

A host with an authoritative issuance ledger, one that does know what was minted, implements EffectReconciler and calls PolicyEngine::recover_effects_with. The reconciler reads the self-describing record and looks up EffectRecord::key, so it is specific to the participant at most, never to the plugin.

EffectRecord::key must be unique per attempt. Recovery resolves keys across the whole file, so a key reused across retries lets an earlier attempt’s outcome mask a later attempt’s orphaned intent, and the one act nobody can account for is the one skipped.

Writing a sink

use std::sync::Arc;
use async_trait::async_trait;
use praxis_policy_core::audit::AuditHandler;
use praxis_policy_core::decision::{DecisionLog, Verdict};
use praxis_policy_core::effect::EffectRecord;
use praxis_policy_core::hooks::payload::{Extensions, PluginPayload};
use praxis_policy_core::plugin::{Plugin, PluginConfig};

struct MySink { cfg: PluginConfig }

#[async_trait]
impl AuditHandler for MySink {
    async fn handle(
        &self,
        payload: &dyn PluginPayload,
        extensions: &Extensions,
        decisions: &DecisionLog,
    ) {
        let blocked = matches!(decisions.verdict(), Some(Verdict::Deny(_)));
        let _ = (payload, extensions, blocked);
    }

    // Optional. Effects only reach a sink that wants them.
    async fn on_effect(&self, _effect: &EffectRecord, _extensions: &Extensions) {}

    fn name(&self) -> &str { &self.cfg.name }
}

impl Plugin for MySink {
    fn config(&self) -> &PluginConfig { &self.cfg }

    fn as_audit_handler(self: Arc<Self>) -> Option<Arc<dyn AuditHandler>> {
        Some(self)
    }
}

handle runs once per pipeline invocation. on_effect runs once per lifecycle transition, so a completed mint calls it twice, once for the intent and once for the outcome, and a consumer counting mints should count terminal states rather than calls.

A sink fires for every hook family, so payload is not always a CMF MessagePayload. Downcast and handle the case where it is not.

The executor awaits every sink before returning the pipeline result, so each sink has returned before the caller gets its answer. That is not durability: a sink that buffers, or writes to a stream nobody fsyncs, can still lose a record to a crash, and only the effect log promises otherwise. The cost is that sink latency is request latency. A sink writing to a network destination should hand off to its own queue rather than block. Each call is bounded by the audit timeout (100 ms by default, set with engine_settings.audit_timeout_milliseconds), and its panics are contained; a sink that fails is logged and skipped, because the verdict is already decided and a lost record does not justify failing the request.

Performing an effect

use praxis_policy_core::effect::EffectRecord;

let intent = EffectRecord::prepared("token_mint", "exchange for hr-api", fresh_key())
    .with_detail("audience", "https://hr.example.com");

let token = ext.perform_effect(&intent, || async {
    idp.mint().await          // the irreversible act
}).await?;

perform_effect brackets the act so the protocol cannot be skipped or reordered. If the intent cannot be recorded the act never runs. On success the effect is confirmed, on failure unknown. A plugin that can tell a refusal from a lost answer should call begin_effect and complete_effect directly and classify the outcome itself, as the OAuth delegator does.

What it costs

SettingCost per invocation
Nothing configuredA length check.
A sink attachedA span, so two UUIDs and two allocations, plus the taint labels, plus the sink’s work.
effect_log_pathOne fsync per lifecycle transition. Effects are rare.
capture_content_provenanceTwo keyed digests of the payload, at entry and at emission.

Provenance is built only when a sink will read it. Decision steps and the verdict are recorded either way, since the phases record them as they run.

When something is wrong

A denied request produces no record. Check the logger is in sink mode with no hooks: listed; with hooks listed it is a post-hook observer and never runs on the deny path.

delegation.effects_not_permitted, or an effect_phase_not_permitted error. The plugin is in a mode that cannot perform irreversible acts. Set its mode: to sequential or transform.

effect_extensions_detached. The plugin called perform_effect on a copy of its extensions instead of the &Extensions passed to handle.

delegation.effect_log_failed. The phase is fine but the record could not be written; check the path is writable. The delegation is denied rather than minting a credential nothing accounts for.

Recovery reports unresolved effects at startup. Expected with the default reconciler, which cannot confirm anything and leaves them unknown. Each names its key and the plugin that caused it. Resolving them means asking the participant, or installing a reconciler that can.

See also

  • assertions.md for what the engine puts on the wire
  • crates/ppe-core/src/audit.rs for the sink trait
  • crates/ppe-core/src/decision.rs for the decision log
  • crates/ppe-core/src/effect.rs for effect records, the log and recovery