Skip to content

Commit 6156d04

Browse files
committed
feat: added sequence numbering to events, documented auditing await behavior and audit bytes canonicalization.
Signed-off-by: Teryl Taylor <terylt@ibm.com>
1 parent a43c876 commit 6156d04

8 files changed

Lines changed: 379 additions & 6 deletions

File tree

builtins/plugins/audit-logger/src/logger.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,16 @@ impl AuditLogger {
278278
json!({ "input_hash": input_hash, "output_hash": output_hash }),
279279
);
280280
}
281+
282+
// Stream identity + sequences. `stream_seq` is gap-free within the
283+
// decision stream (a consumer proves none was dropped); the global
284+
// `emission_seq` orders this record against the effect records a
285+
// consumer merges into the same chain.
286+
if let Some(stream_seq) = decisions.stream_seq() {
287+
map.insert("stream_id".into(), json!(decisions.stream_id()));
288+
map.insert("stream_seq".into(), json!(stream_seq));
289+
map.insert("emission_seq".into(), json!(decisions.emission_seq()));
290+
}
281291
}
282292
record
283293
}
@@ -299,6 +309,9 @@ impl AuditLogger {
299309
"state": format!("{:?}", effect.state),
300310
"caused_by": effect.plugin_name,
301311
"details": effect.details,
312+
"stream_id": effect.stream_id,
313+
"stream_seq": effect.stream_seq,
314+
"emission_seq": effect.emission_seq,
302315
}),
303316
);
304317
}
@@ -478,6 +491,19 @@ mod tests {
478491
assert!(record["content"]["output_hash"].is_null());
479492
}
480493

494+
#[test]
495+
fn decision_record_includes_stream_and_sequences() {
496+
let plugin = AuditLogger::new(cfg()).unwrap();
497+
let mut log = DecisionLog::new();
498+
log.set_stream("dec-abc".into(), 7, 42);
499+
log.finalize(Verdict::Allow);
500+
501+
let record = plugin.build_decision_record(None, &Extensions::default(), &log);
502+
assert_eq!(record["stream_id"], "dec-abc");
503+
assert_eq!(record["stream_seq"], 7);
504+
assert_eq!(record["emission_seq"], 42);
505+
}
506+
481507
#[test]
482508
fn no_content_field_without_input_hash() {
483509
// Provenance off (input_hash None) → no content field at all.

crates/cpex-core/src/audit.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,21 @@ pub trait AuditHandler: Send + Sync {
3131
/// Observe one finished pipeline invocation. Must not block or mutate
3232
/// anything the pipeline depends on — its return is `()` by design.
3333
///
34+
/// **Awaited at the verdict return point — a stable contract, not
35+
/// fire-and-forget.** The executor `await`s this call *before* it returns
36+
/// the pipeline result. That is deliberate: a crash cannot lose a verdict
37+
/// that was emitted, so downstream evidence chains need no drop-detection
38+
/// for the steady state. Consumers rely on this — a future change to
39+
/// fire-and-forget would be a silent semantics break, so it must not be
40+
/// made lightly.
41+
///
42+
/// The cost of that guarantee is that **sink latency sits on the request
43+
/// path** (bounded per sink by the plugin timeout with panic containment,
44+
/// and sinks run sequentially). Keep `handle` cheap — serialize / hash /
45+
/// append. A slower sink (a network destination, say) should hand off to
46+
/// an internal queue on its own side of this boundary rather than block
47+
/// here.
48+
///
3449
/// * `payload` — the message as it stood at the verdict.
3550
/// * `extensions` — the final extensions (identity, delegation, labels…).
3651
/// * `decisions` — what each plugin did and how the pipeline ruled.

crates/cpex-core/src/decision.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ pub struct DecisionLog {
122122
span: Option<Span>,
123123
input_labels: Vec<String>,
124124
input_hash: Option<String>,
125+
stream_id: Option<String>,
126+
stream_seq: Option<u64>,
127+
emission_seq: Option<u64>,
125128
}
126129

127130
impl DecisionLog {
@@ -187,6 +190,34 @@ impl DecisionLog {
187190
self.input_hash.as_deref()
188191
}
189192

193+
/// Stamp the stream identity and sequence numbers, assigned by the executor
194+
/// at emission. `stream_id` scopes `stream_seq` — a gap-free counter within
195+
/// the *decision* stream, so a consumer of decisions alone can prove none
196+
/// was dropped. `emission_seq` is the *global* counter across decisions and
197+
/// effects alike, so a consumer that merges both streams can reconstruct
198+
/// their interleaved order.
199+
pub fn set_stream(&mut self, stream_id: String, stream_seq: u64, emission_seq: u64) {
200+
self.stream_id = Some(stream_id);
201+
self.stream_seq = Some(stream_seq);
202+
self.emission_seq = Some(emission_seq);
203+
}
204+
205+
/// The decision stream this record belongs to (scopes `stream_seq`).
206+
pub fn stream_id(&self) -> Option<&str> {
207+
self.stream_id.as_deref()
208+
}
209+
210+
/// Monotonic, gap-free sequence within the decision stream — completeness.
211+
pub fn stream_seq(&self) -> Option<u64> {
212+
self.stream_seq
213+
}
214+
215+
/// Global monotonic sequence across decisions and effects — interleaved
216+
/// order.
217+
pub fn emission_seq(&self) -> Option<u64> {
218+
self.emission_seq
219+
}
220+
190221
/// The ordered steps taken this invocation.
191222
pub fn steps(&self) -> &[DecisionStep] {
192223
&self.steps
@@ -304,4 +335,16 @@ mod tests {
304335
log.set_input_hash(Some("sha256:abc".into()));
305336
assert_eq!(log.input_hash(), Some("sha256:abc"));
306337
}
338+
339+
#[test]
340+
fn stream_and_sequences_stamp_and_read_back() {
341+
let mut log = DecisionLog::new();
342+
assert!(log.stream_id().is_none());
343+
assert!(log.stream_seq().is_none());
344+
assert!(log.emission_seq().is_none());
345+
log.set_stream("dec-abc".into(), 7, 42);
346+
assert_eq!(log.stream_id(), Some("dec-abc"));
347+
assert_eq!(log.stream_seq(), Some(7));
348+
assert_eq!(log.emission_seq(), Some(42));
349+
}
307350
}

crates/cpex-core/src/effect.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@ pub struct EffectRecord {
6363
pub details: HashMap<String, serde_json::Value>,
6464
/// Which plugin caused the effect. Set by the framework, not self-reported.
6565
pub plugin_name: Option<String>,
66+
/// The effect stream this record belongs to, stamped by the framework at
67+
/// emission (scopes `stream_seq`).
68+
#[serde(default, skip_serializing_if = "Option::is_none")]
69+
pub stream_id: Option<String>,
70+
/// Monotonic, gap-free sequence within the effect stream — a consumer of
71+
/// effects alone can prove none was dropped. Stamped at emission.
72+
#[serde(default, skip_serializing_if = "Option::is_none")]
73+
pub stream_seq: Option<u64>,
74+
/// Global monotonic sequence across decisions and effects — lets a consumer
75+
/// that merges both streams reconstruct their interleaved order. Stamped at
76+
/// emission.
77+
#[serde(default, skip_serializing_if = "Option::is_none")]
78+
pub emission_seq: Option<u64>,
6679
}
6780

6881
impl EffectRecord {
@@ -80,6 +93,9 @@ impl EffectRecord {
8093
state: EffectState::Prepared,
8194
details: HashMap::new(),
8295
plugin_name: None,
96+
stream_id: None,
97+
stream_seq: None,
98+
emission_seq: None,
8399
}
84100
}
85101

crates/cpex-core/src/executor.rs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
use std::any::Any;
2828
use std::fmt;
29+
use std::sync::atomic::{AtomicU64, Ordering};
2930
use std::sync::Arc;
3031
use std::time::Duration;
3132

@@ -309,6 +310,17 @@ pub struct Executor {
309310
/// `begin_effect` is not crash-safe or fail-closed. Installed from
310311
/// `plugin_settings.effect_log_path` or programmatically. Opt-in.
311312
effect_log: Option<Arc<dyn DurableEffectLog>>,
313+
314+
/// Audit stream identity + counters, fresh per executor lifetime (a new
315+
/// identity on config reload). Each emitted record carries its per-stream
316+
/// counter (`decision_seq` / `effect_seq`, gap-free → completeness) and the
317+
/// shared `emission_seq` (global across both → interleaved order). `Arc`
318+
/// so copy-on-write snapshot mutations stay on the same stream.
319+
decision_stream_id: Arc<str>,
320+
decision_seq: Arc<AtomicU64>,
321+
effect_stream_id: Arc<str>,
322+
effect_seq: Arc<AtomicU64>,
323+
emission_seq: Arc<AtomicU64>,
312324
}
313325

314326
impl Executor {
@@ -318,6 +330,11 @@ impl Executor {
318330
config,
319331
audit_handlers: Vec::new(),
320332
effect_log: None,
333+
decision_stream_id: Arc::from(format!("dec-{}", uuid::Uuid::new_v4().simple())),
334+
decision_seq: Arc::new(AtomicU64::new(0)),
335+
effect_stream_id: Arc::from(format!("eff-{}", uuid::Uuid::new_v4().simple())),
336+
effect_seq: Arc::new(AtomicU64::new(0)),
337+
emission_seq: Arc::new(AtomicU64::new(0)),
321338
}
322339
}
323340

@@ -356,6 +373,22 @@ impl Executor {
356373

357374
/// Invoke every audit sink with the finalized decision, once per pipeline
358375
/// run. Observation-only — the executor ignores whatever they return.
376+
/// Assign this decision's stream identity + sequence numbers. The executor
377+
/// writes its **own** record here — a step distinct from the read-only
378+
/// handoff in [`Self::emit_audit`] (which takes `&DecisionLog`), so a sink
379+
/// never receives anything mutable. `decision_seq` is gap-free within the
380+
/// decision stream (completeness); `emission_seq` is the shared global
381+
/// counter across decisions and effects (interleaved order). Stamped even
382+
/// with no sinks — it's a property of the stream and rides on
383+
/// `PipelineResult.decision_log`.
384+
fn stamp_decision_stream(&self, decisions: &mut DecisionLog) {
385+
decisions.set_stream(
386+
self.decision_stream_id.to_string(),
387+
self.decision_seq.fetch_add(1, Ordering::Relaxed),
388+
self.emission_seq.fetch_add(1, Ordering::Relaxed),
389+
);
390+
}
391+
359392
async fn emit_audit(
360393
&self,
361394
payload: &dyn PluginPayload,
@@ -494,6 +527,7 @@ impl Executor {
494527
.await
495528
{
496529
decisions.finalize(Verdict::Deny(v.clone()));
530+
self.stamp_decision_stream(&mut decisions);
497531
self.emit_audit(&*current_payload, &current_extensions, &decisions)
498532
.await;
499533
return (
@@ -542,6 +576,7 @@ impl Executor {
542576
.await
543577
{
544578
decisions.finalize(Verdict::Deny(violation.clone()));
579+
self.stamp_decision_stream(&mut decisions);
545580
self.emit_audit(&*current_payload, &current_extensions, &decisions)
546581
.await;
547582
return (
@@ -564,6 +599,7 @@ impl Executor {
564599
);
565600

566601
decisions.finalize(Verdict::Allow);
602+
self.stamp_decision_stream(&mut decisions);
567603
self.emit_audit(&*current_payload, &current_extensions, &decisions)
568604
.await;
569605
(
@@ -656,6 +692,9 @@ impl Executor {
656692
// The configured WAL (opt-in). `None` → ordering-only, not
657693
// fail-closed; `Some` → durable-before-fanout, fail-closed.
658694
durable: self.effect_log.clone(),
695+
stream_id: self.effect_stream_id.clone(),
696+
stream_seq: self.effect_seq.clone(),
697+
emission_seq: self.emission_seq.clone(),
659698
}));
660699
}
661700

@@ -1305,6 +1344,12 @@ struct AuditEffectEmitter {
13051344
/// before fanning out and fails closed if that write fails. `None` until
13061345
/// slice 3b wires a real WAL — then emit is ordering-only.
13071346
durable: Option<Arc<dyn DurableEffectLog>>,
1347+
/// Effect stream identity + counters (shared with the executor). Each
1348+
/// emitted record is stamped with `stream_seq` (gap-free within the effect
1349+
/// stream) and the global `emission_seq` (interleaved order vs decisions).
1350+
stream_id: Arc<str>,
1351+
stream_seq: Arc<AtomicU64>,
1352+
emission_seq: Arc<AtomicU64>,
13081353
}
13091354

13101355
impl std::fmt::Debug for AuditEffectEmitter {
@@ -1322,9 +1367,15 @@ impl EffectEmitter for AuditEffectEmitter {
13221367
use futures::FutureExt;
13231368
use std::panic::AssertUnwindSafe;
13241369

1325-
// Stamp the causing plugin — set by the framework, not self-reported.
1370+
// Stamp the causing plugin + stream identity/sequences — all set by the
1371+
// framework, not self-reported. `stream_seq` is gap-free within the
1372+
// effect stream (completeness); `emission_seq` is the shared global
1373+
// counter across decisions and effects (interleaved order).
13261374
let mut stamped = effect.clone();
13271375
stamped.plugin_name = Some(self.plugin_name.clone());
1376+
stamped.stream_id = Some(self.stream_id.to_string());
1377+
stamped.stream_seq = Some(self.stream_seq.fetch_add(1, Ordering::Relaxed));
1378+
stamped.emission_seq = Some(self.emission_seq.fetch_add(1, Ordering::Relaxed));
13281379

13291380
// Write-ahead: durably record BEFORE any observer sees it. Fail
13301381
// closed — if the durable write fails, return Err and do NOT fan out;
@@ -1546,6 +1597,9 @@ mod tests {
15461597
plugin_name: "delegator".into(),
15471598
timeout: Duration::from_secs(5),
15481599
durable: Some(Arc::new(FailingLog)),
1600+
stream_id: Arc::from("eff-test"),
1601+
stream_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
1602+
emission_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
15491603
};
15501604
let res = emitter.emit(&effect, &Extensions::default()).await;
15511605
assert!(res.is_err(), "durable write failed → emit fails closed");
@@ -1562,6 +1616,9 @@ mod tests {
15621616
plugin_name: "delegator".into(),
15631617
timeout: Duration::from_secs(5),
15641618
durable: Some(Arc::new(OkLog)),
1619+
stream_id: Arc::from("eff-test"),
1620+
stream_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
1621+
emission_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
15651622
};
15661623
let res2 = emitter2.emit(&effect, &Extensions::default()).await;
15671624
assert!(res2.is_ok());

crates/cpex-core/src/hooks/payload.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,22 @@ pub trait PluginPayload: Send + Sync + 'static {
9191
/// or `None` for payloads that can't or shouldn't be serialized (the
9292
/// default). The bytes feed a content hash — **only the digest is
9393
/// retained, never the bytes** — so a node's provenance is recorded
94-
/// without re-spilling its (possibly sensitive) content. Must be
95-
/// *canonical* (stable across processes) for the hashes to compare;
96-
/// `impl_plugin_payload!(_, audit_serialize)` derives that via sorted-key
97-
/// JSON. Computed only when content provenance is enabled, so the default
98-
/// keeps the hot path free.
94+
/// without re-spilling its (possibly sensitive) content. Computed only
95+
/// when content provenance is enabled, so the default keeps the hot path
96+
/// free.
97+
///
98+
/// **Byte-stability (what a consumer may assume).**
99+
/// `impl_plugin_payload!(_, audit_serialize)` derives this by round-tripping
100+
/// through `serde_json::Value` — whose `Map` is a `BTreeMap`, so object keys
101+
/// are sorted. Identical content therefore serializes to identical bytes
102+
/// across runs and processes, and **two equal digests mean "same content"
103+
/// within a deployment**. It is *sorted-key JSON, not full RFC 8785 (JCS)*:
104+
/// number formatting follows `serde_json` and is stable within a
105+
/// `serde_json` version but is not guaranteed by a canonicalization spec
106+
/// across toolchains. So treat digest equality as same-content within a
107+
/// build; do not assume cross-toolchain canonicalization. A hand-written
108+
/// `audit_bytes` must preserve this property (a canonical, deterministic
109+
/// encoding) or its hashes will not be comparable.
99110
fn audit_bytes(&self) -> Option<Vec<u8>> {
100111
None
101112
}

0 commit comments

Comments
 (0)