First-class decision & effect auditing (the audit seam) - #166
Conversation
…iting of denies. Signed-off-by: Teryl Taylor <terylt@ibm.com>
Signed-off-by: Teryl Taylor <terylt@ibm.com>
Brings in the identity work (5 commits) before building effect auditing, which touches delegation/identity. Only executor.rs conflicted: dev's `payload_modified` flag and the audit seam's `decisions` log each appended a trailing parameter to the phase functions — kept both, ordered decisions then payload_modified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t.begin_effect, fail-closed durability. Signed-off-by: Teryl Taylor <terylt@ibm.com>
… recovery + reconciliation seam. Signed-off-by: Teryl Taylor <terylt@ibm.com>
Signed-off-by: Teryl Taylor <terylt@ibm.com>
…ction. Signed-off-by: Teryl Taylor <terylt@ibm.com>
…mint effect-audit. Signed-off-by: Teryl Taylor <terylt@ibm.com>
|
Reviewed from the perspective of the OCSF audit plugin that will consume this seam. Overall: this is the right architecture, and I would like to put three of its decisions on the record before suggesting anything — verdict-point emission (completeness by construction — observation-only sinks finally see denials), DecisionLog kept out of PluginContext (the observation-only contract becomes type-enforced rather than promised), and Err → unknown on effects (the honest crash semantics; a timed-out action may have landed). Those three are what make this an enforcement record rather than an enforcement log. A few suggestions, all non-blocking:
One design fact worth pinning in the docs (from our Slack thread): AuditHandler::handle is awaited at the verdict return point, not fire-and-forget. That's the right default for an evidence seam — a crash can't lose a verdict that was emitted, and sinks don't need drop-detection machinery for the steady state. Two consequences worth a doc note: (a) it's now a contract consumers will design around — our chain relies on it, so a future "optimization" to fire-and-forget would be a silent semantics break, and it'd be good if a comment on the trait said so; (b) sink latency sits on the request path, so handler implementations need to stay cheap — ours is serialize + hash + append, and anything slower (network sinks, say) probably belongs behind an internal queue on the handler's side of the boundary, which might also be worth a sentence in the trait docs. Happy to be the guinea-pig consumer: our plugin is a near-twin of audit-logger, so I'll port it against this seam as soon as the PR settles and report anything that doesn't match intent? |
|
Reviewing from the downstream persistence side and building the append-only ledger @terylt that we talked about. That would be the durable sink for these decision and effect records. +1 on the monotonic sequence number and audit_bytes() canonicalization asks. We need both: the sequence number lets us prove completeness of an exported stream (no gaps), and byte-stable serialization lets content hashes mean "same input" across runs rather than "same serialization attempt." The Err → unknown crash semantics on effects are exactly right for our use case. An immutable ledger can serve as the Happy to collaborate on wiring up the integration once this settles. The ledger already has an OCSF adapter; |
|
Glad the sequence + canonicalization asks line up with what the ledger needs — two independent consumers wanting the same semantics is a good sign for the seam. Would be great to compare notes on the OCSF shape: our plugin emits the 6003/ai_operation form with a DSSE-signed fingerprint chain, and if your adapter and our emitter agree on the event shape, evidence becomes portable across both sinks by construction. Happy to share the reference bundle. |
Signed-off-by: Teryl Taylor <terylt@ibm.com>
|
Here's our OCSF adapter. It's thin by design: adapters/ocsf/ The mapping is straightforward: raw OCSF JSON goes in as content with content_type: "application/ocsf+json", keyed by We don't have 6003/ai_operation yet. If your emitter's event shape lands as OCSF JSONL, adding it is a one-line class Happy to share the full sample events and field mapping if useful, or just compare a sample 6003 event against what |
…ehavior and audit bytes canonicalization. Signed-off-by: Teryl Taylor <terylt@ibm.com>
|
Thanks @Levaj2000 and @jkershawrh — Thank you for the feedback! I made the suggested changes. Let me know if you like how the sequencing is done or would prefer a different mechanism. Not sure if I over designed it. Status on the four items: 2 — prepared-leg delivery test. Done. There's now a test that asserts a sink observes the 3 — 4 — 1 — sequence numbers. Built, and this is the one I'd like your input on. The design is two per-type streams plus a global order counter. Every decision and effect record now carries:
The reason we didn't use one shared counter for completeness: a consumer that receives only one record type would see phantom gaps. Concretely — if the OCSF side routes decisions to
So the question for you:
Happy to change the shape — it's cheap either way (a couple of atomics + fields). We leaned two-streams-plus-global because it degrades gracefully for both "merge everything" and "consume one type," but if you'd both rather a single counter (or a different stream scoping), say so and we'll match it before this lands. And yes to porting against it — the seam is stable enough now that a guinea-pig consumer would flush out anything that doesn't match intent faster than we can guess at it. Note, I also added some auditing documentation to the PR today. |
|
@jkershawrh, Great — and the call's on the calendar for Friday, thanks @terylt for setting it up. Taking you up on the sample exchange ahead of that. Attached zipped are two real 6003/ai_operation events from our emitter, an Invoke Tool and a Completion chained to it, plus a field-mapping doc against your adapter's conventions. They're the merged ocsf-schema#1661 shape on OCSF 1.9.0, emitted as JSONL, so your one-line class map entry should be exactly that. Two things worth calling out from the mapping doc: agent_id should come from ai_agent.uid, not metadata.uid. In ai_operation events, metadata.uid is the record id — it's what the next record's prev_event.uid points at — so the OpenShell-style metadata.uid → agent_id mapping would give you one "agent" per event. correlation_id maps cleanly from metadata.correlation_uid. Byte-for-byte content preservation means our chain is verifiable from your stored entries alone. Strip fingerprint/signatures and the two unmapped.signature_* extras, JCS-canonicalize (RFC 8785), SHA-256, compare — no knowledge of our crate required. The mapping doc has the full recipe, including how unmapped.signature_b64/signature_key_id can populate your V3 writer_signature/signer_key_reference so both integrity layers cover the same bytes. Which is also why I want to underline that there are now two independent consumers needing the same thing from audit_bytes(): our fingerprints commit to canonical bytes of the event, and your ledger's envelope commits to whatever bytes arrive. One canonical-serialization guarantee at the emitter keeps hashes comparable for every downstream consumer — happy to help spec that if useful. On Teryl's sequencing question — from where we sit, streams mapping to separate entry_type chains (your parallel-chains scaling model) with emission_seq as the cross-chain interleave key seems like the natural fit, but that one's yours to call. Thanks, sir. |
|
@terylt, very nicely done! This matches what I had in mind, and the dual-stream split is the right call in my opinion — the phantom-gap rationale is exactly why. From the serializer side, (stream_id, stream_seq) maps directly onto our chain scoping: each stream becomes its own fingerprint chain (and, downstream, its own ledger entry_type), so within-stream gap detection falls out of the dense counter, and emission_seq gives us the cross-stream interleave for reconstructing total order — decision-before-effect causality without merging the chains. Two things I think are worth pinning down in the docs so verifiers don't misuse one counter for the other's job: Separate the claims. stream_seq is a completeness claim (dense within its stream — a gap means a missing record); emission_seq is an ordering claim only (a single-stream consumer will legitimately see it sparse). Stating that explicitly prevents someone from "detecting loss" off emission_seq gaps. Restart semantics. For completeness verification to survive a crash, the counters need to be either durable across restarts or epoch-scoped with the epoch visible in the record (so (epoch, stream_seq) is monotonic and a verifier can distinguish "counter reset" from "records lost"). Given the Err→unknown crash semantics elsewhere in this PR, an explicit epoch/boot id feels consistent — works either way as long as it's stated. And assuming both counters land inside audit_bytes(), they're covered by the content hashes — which is where this connects back to the canonicalization thread: sequence integrity and byte stability together are what make the downstream evidence chain verifiable end-to-end. good stuff- Jeff |
|
Thanks both. The dual-counter design is what the ledger wants. stream_seq per type maps to our per-entry_type chains for gap detection on ingest; emission_seq goes into the entry body as metadata for cross-chain causal ordering. No changes needed from the ledger side. @Levaj2000 - Jeff's two counter-semantics points are worth pinning:
On the sample data. Thanks Jeff. Will fix agent_id to pull from ai_agent.uid instead of metadata.uid, and map correlation_uid → source_id. The signature_b64 / signature_key_id → V3 writer_signature path is noted and will prototype in the adapter. The byte-preservation point ties it together: if audit_bytes() is what both Jeff's fingerprint chain and the ledger's entry hash commit to, we get end-to-end verification from two independent integrity layers covering the same canonical bytes. Happy to help spec that boundary if useful. Ready to port against this shape. See you Friday. |
Summary
CPEX could not audit its own enforcement. An observation-only plugin — the
reference
audit-logger, or an OCSF emitter — only ever sees allowedpost-hook traffic; a blocked call, an approval rejection, a delegation failure,
or an injection-stop produced no audit record at all. And irreversible
external actions a plugin causes (a token mint, an approval grant) were not
recorded crash-safely — a process that died between "about to mint" and "minted"
left no trace.
This PR makes auditing first-class in the executor. The core owns a decision
record and emits it at every verdict; any audit sink consumes it. Irreversible
effects are recorded write-ahead, crash-safe, and reconcilable. Each decision
carries the provenance needed to reconstruct a causal graph (span + taint +
content hash). The OAuth delegator is wired as the first real consumer.
Everything is opt-in — no behavior changes unless the operator configures a
sink, an effect WAL, or content provenance.
What's included
Decision auditing.
AuditHookfamily, auto-attached by thePluginManager, fired at theexecutor's verdict return points (not a pipeline phase) — so allow, deny,
and modify all produce a record. "Which phase, before or after which deny"
stops being a question.
DecisionLog— executor-owned, handed only to audit sinks, never placedon
PluginContext(the thing that records must not be able to change what itrecords). Carries the ordered plugin steps and the terminal verdict.
Effect auditing (irreversible external actions).
emit_effectcallsext.begin_effectto durably record intent before theact (fail-closed — no durable record, no act) and
ext.complete_effectto record the outcome (
confirmed/rejected/unknown).FileEffectLog— a durable WAL (append +fsync, serialized againstconcurrent writers, self-compacting past a configurable threshold).
PluginManager::recover_effectscompacts completed effectsand reconciles crash-orphaned ones against the issuing participant via an
EffectReconcilerseam. The default (LogUnknownsReconciler) logs and leavesthem
unknown— correct for any participant with no lookup-by-key.Extensions::perform_effectbrackets the whole protocol so a caller cannotskip, reorder, or forget it.
Provenance on the decision node (for downstream causal-graph reconstruction).
DecisionLog::span()carries a W3Ctrace_id/span_id/parent_span_id(child-span model: a fresh span whoseparent is the request's span), set by the executor at pipeline entry.
diffs them against the final labels to show the taint this node added.
PluginPayload::audit_bytes()(per-type opt-invia
impl_plugin_payload!(_, audit_serialize)) feeds asha256:content ref.The executor hashes the payload at entry behind
capture_content_provenance;the sink hashes the output lazily. Only digests are kept, never content —
provenance without re-spilling the data a PII scanner exists to redact.
First real consumer.
cpex-plugin-delegator-oauthbrackets both mint legs — the workloadclient_assertionbase-token mint and the RFC 8693 exchange — withbegin_effect/complete_effect, mapping a successful exchange toconfirmed,a definitive IdP rejection to
rejected, and a timeout/unreachable IdP tounknown(reconciled later, never assumed minted).Reference sink.
audit-loggernow renders the verdict, ordered steps,span,taint, and(when enabled)
contenthashes.Notable design decisions
phase; emitting at the executor's return points is complete by construction.
DecisionLogreaching an audit handlerbut never
PluginContextis a real security property; anAuditHookfamily(not a manager special-case) makes "sees verdicts, cannot influence them"
type-level.
Err → unknown, notrejected. A failed mint may still have landed at theparticipant, so recovery reconciles it rather than assuming it didn't happen.
The delegator, which knows a clean 4xx from a timeout, maps precisely.
self-describing
EffectRecordand does a keyed ledger lookup — participant-specific at most, and today no participant offers one, so the default suffices.
Opt-in / compatibility
PluginPayload::audit_bytes()has adefault (
None);AuditHook/effect emitter/WAL/hashing all engage only whenconfigured (
effect_log_path,capture_content_provenance,emit_effect).plugin_settings:effect_log_path,effect_log_compaction_threshold,capture_content_provenance— all defaultto off.
Testing
cpex-core,audit-logger, anddelegator-oauth:verdict-point emit, capability gating, WAL durability / concurrent-append
integrity / recovery+compaction / reconciliation,
perform_effectstatemapping, span child-model, taint delta, content-hash gating, and the OAuth
delegator emitting
prepared → confirmed/rejectedagainst a mock IdP.cargo fmtclean,cargo clippy --workspace --all-targetsclean, fullworkspace test green.
Out of scope (follow-ups)
ocsf-auditmappingspan()/on_effectinto OCSFtrace/spanand theAuthentication class.
docs/effect-ledger-integration-note.md; would makeunknowntruly resolvableand, if it sits in the mint path, deliver v2 structural enforcement too.