Skip to content

Commit a178cf9

Browse files
committed
fixes: added epoch and updated documentation around sequencing.
Signed-off-by: Teryl Taylor <terylt@ibm.com>
1 parent 6156d04 commit a178cf9

6 files changed

Lines changed: 126 additions & 52 deletions

File tree

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ impl AuditLogger {
284284
// `emission_seq` orders this record against the effect records a
285285
// consumer merges into the same chain.
286286
if let Some(stream_seq) = decisions.stream_seq() {
287+
map.insert("epoch".into(), json!(decisions.epoch()));
287288
map.insert("stream_id".into(), json!(decisions.stream_id()));
288289
map.insert("stream_seq".into(), json!(stream_seq));
289290
map.insert("emission_seq".into(), json!(decisions.emission_seq()));
@@ -309,6 +310,7 @@ impl AuditLogger {
309310
"state": format!("{:?}", effect.state),
310311
"caused_by": effect.plugin_name,
311312
"details": effect.details,
313+
"epoch": effect.epoch,
312314
"stream_id": effect.stream_id,
313315
"stream_seq": effect.stream_seq,
314316
"emission_seq": effect.emission_seq,
@@ -495,11 +497,12 @@ mod tests {
495497
fn decision_record_includes_stream_and_sequences() {
496498
let plugin = AuditLogger::new(cfg()).unwrap();
497499
let mut log = DecisionLog::new();
498-
log.set_stream("dec-abc".into(), 7, 42);
500+
log.set_stream(1_700_000_000, "decision".into(), 7, 42);
499501
log.finalize(Verdict::Allow);
500502

501503
let record = plugin.build_decision_record(None, &Extensions::default(), &log);
502-
assert_eq!(record["stream_id"], "dec-abc");
504+
assert_eq!(record["epoch"], 1_700_000_000u64);
505+
assert_eq!(record["stream_id"], "decision");
503506
assert_eq!(record["stream_seq"], 7);
504507
assert_eq!(record["emission_seq"], 42);
505508
}

crates/cpex-core/src/decision.rs

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ pub struct DecisionLog {
122122
span: Option<Span>,
123123
input_labels: Vec<String>,
124124
input_hash: Option<String>,
125+
epoch: Option<u64>,
125126
stream_id: Option<String>,
126127
stream_seq: Option<u64>,
127128
emission_seq: Option<u64>,
@@ -190,30 +191,60 @@ impl DecisionLog {
190191
self.input_hash.as_deref()
191192
}
192193

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) {
194+
/// Stamp the audit-stream identity + sequence numbers, assigned by the
195+
/// executor at emission. The two counters are **distinct claims** — don't
196+
/// use one for the other's job:
197+
///
198+
/// - `epoch` — the executor's boot time (Unix nanoseconds), captured once
199+
/// at startup. It scopes the counters so a verifier tells a *counter
200+
/// reset* (new, larger epoch) from *records lost* (a gap within an
201+
/// epoch); being ordered, `(epoch, emission_seq)` is a total order across
202+
/// restarts, computable from the record alone. Cross-epoch tail-loss is
203+
/// not provable from the counters alone — that is what a durable sink
204+
/// (the ledger) is for.
205+
/// - `stream_id` — the per-type stream (`"decision"`), the entry-type a
206+
/// merged consumer keys on.
207+
/// - `stream_seq` — a **completeness** claim. Dense (gap-free) within
208+
/// `(epoch, stream_id)`; a gap means a record was dropped.
209+
/// - `emission_seq` — an **ordering** claim *only*. Monotonic across all
210+
/// streams within the epoch (decisions and effects share it) for
211+
/// reconstructing interleaved order. A single-stream consumer sees it
212+
/// *sparse* by design — the gaps are the other stream's records, never a
213+
/// loss signal.
214+
pub fn set_stream(
215+
&mut self,
216+
epoch: u64,
217+
stream_id: String,
218+
stream_seq: u64,
219+
emission_seq: u64,
220+
) {
221+
self.epoch = Some(epoch);
200222
self.stream_id = Some(stream_id);
201223
self.stream_seq = Some(stream_seq);
202224
self.emission_seq = Some(emission_seq);
203225
}
204226

205-
/// The decision stream this record belongs to (scopes `stream_seq`).
227+
/// The executor boot epoch (Unix nanoseconds) this record was emitted in.
228+
/// Orderable, so a new/larger value marks a restart — a reset is
229+
/// distinguishable from a loss, and it extends `emission_seq` to a total
230+
/// order across restarts.
231+
pub fn epoch(&self) -> Option<u64> {
232+
self.epoch
233+
}
234+
235+
/// The per-type stream this record belongs to (scopes `stream_seq`).
206236
pub fn stream_id(&self) -> Option<&str> {
207237
self.stream_id.as_deref()
208238
}
209239

210-
/// Monotonic, gap-free sequence within the decision stream — completeness.
240+
/// **Completeness** counter — dense within `(epoch, stream_id)`; a gap is a
241+
/// dropped record.
211242
pub fn stream_seq(&self) -> Option<u64> {
212243
self.stream_seq
213244
}
214245

215-
/// Global monotonic sequence across decisions and effects — interleaved
216-
/// order.
246+
/// **Ordering** counter — monotonic across decisions and effects within the
247+
/// epoch. Sparse for a single-stream consumer by design; not a loss signal.
217248
pub fn emission_seq(&self) -> Option<u64> {
218249
self.emission_seq
219250
}
@@ -339,11 +370,13 @@ mod tests {
339370
#[test]
340371
fn stream_and_sequences_stamp_and_read_back() {
341372
let mut log = DecisionLog::new();
373+
assert!(log.epoch().is_none());
342374
assert!(log.stream_id().is_none());
343375
assert!(log.stream_seq().is_none());
344376
assert!(log.emission_seq().is_none());
345-
log.set_stream("dec-abc".into(), 7, 42);
346-
assert_eq!(log.stream_id(), Some("dec-abc"));
377+
log.set_stream(1_700_000_000, "decision".into(), 7, 42);
378+
assert_eq!(log.epoch(), Some(1_700_000_000));
379+
assert_eq!(log.stream_id(), Some("decision"));
347380
assert_eq!(log.stream_seq(), Some(7));
348381
assert_eq!(log.emission_seq(), Some(42));
349382
}

crates/cpex-core/src/effect.rs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,23 @@ 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`).
66+
/// The executor's boot time (Unix nanoseconds), scoping the sequences so a
67+
/// verifier tells a counter reset (new, larger epoch) from records lost.
68+
/// Ordered, so `(epoch, emission_seq)` totally orders records across
69+
/// restarts. Stamped at emission.
70+
#[serde(default, skip_serializing_if = "Option::is_none")]
71+
pub epoch: Option<u64>,
72+
/// The per-type stream this record belongs to (`"effect"`). Stamped at
73+
/// emission.
6874
#[serde(default, skip_serializing_if = "Option::is_none")]
6975
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.
76+
/// **Completeness** counter — dense within `(epoch, stream_id)`; a gap means
77+
/// an effect record was dropped. Stamped at emission.
7278
#[serde(default, skip_serializing_if = "Option::is_none")]
7379
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.
80+
/// **Ordering** counter — monotonic across decisions and effects within the
81+
/// epoch, for interleaved order. Sparse for an effects-only consumer by
82+
/// design; not a loss signal. Stamped at emission.
7783
#[serde(default, skip_serializing_if = "Option::is_none")]
7884
pub emission_seq: Option<u64>,
7985
}
@@ -93,6 +99,7 @@ impl EffectRecord {
9399
state: EffectState::Prepared,
94100
details: HashMap::new(),
95101
plugin_name: None,
102+
epoch: None,
96103
stream_id: None,
97104
stream_seq: None,
98105
emission_seq: None,

crates/cpex-core/src/executor.rs

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -311,14 +311,15 @@ pub struct Executor {
311311
/// `plugin_settings.effect_log_path` or programmatically. Opt-in.
312312
effect_log: Option<Arc<dyn DurableEffectLog>>,
313313

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>,
314+
/// Audit stream identity + counters. `epoch` is the executor's boot time
315+
/// (Unix nanos), captured once — it scopes the counters so a restart is
316+
/// distinguishable from a loss and orders records across restarts. Each
317+
/// record carries its per-type counter (`decision_seq` / `effect_seq`,
318+
/// gap-free → completeness) and the shared `emission_seq` (global across
319+
/// both → interleaved order). The counters are `Arc` so copy-on-write
320+
/// snapshot mutations stay on the same stream.
321+
epoch: u64,
320322
decision_seq: Arc<AtomicU64>,
321-
effect_stream_id: Arc<str>,
322323
effect_seq: Arc<AtomicU64>,
323324
emission_seq: Arc<AtomicU64>,
324325
}
@@ -330,9 +331,14 @@ impl Executor {
330331
config,
331332
audit_handlers: Vec::new(),
332333
effect_log: None,
333-
decision_stream_id: Arc::from(format!("dec-{}", uuid::Uuid::new_v4().simple())),
334+
// Boot time in Unix nanoseconds — an orderable epoch that needs no
335+
// persistence. A new executor (restart or config reload) gets a
336+
// larger value, so a verifier tells a reset from a loss.
337+
epoch: std::time::SystemTime::now()
338+
.duration_since(std::time::UNIX_EPOCH)
339+
.map(|d| d.as_nanos() as u64)
340+
.unwrap_or(0),
334341
decision_seq: Arc::new(AtomicU64::new(0)),
335-
effect_stream_id: Arc::from(format!("eff-{}", uuid::Uuid::new_v4().simple())),
336342
effect_seq: Arc::new(AtomicU64::new(0)),
337343
emission_seq: Arc::new(AtomicU64::new(0)),
338344
}
@@ -383,7 +389,8 @@ impl Executor {
383389
/// `PipelineResult.decision_log`.
384390
fn stamp_decision_stream(&self, decisions: &mut DecisionLog) {
385391
decisions.set_stream(
386-
self.decision_stream_id.to_string(),
392+
self.epoch,
393+
"decision".to_string(),
387394
self.decision_seq.fetch_add(1, Ordering::Relaxed),
388395
self.emission_seq.fetch_add(1, Ordering::Relaxed),
389396
);
@@ -692,7 +699,7 @@ impl Executor {
692699
// The configured WAL (opt-in). `None` → ordering-only, not
693700
// fail-closed; `Some` → durable-before-fanout, fail-closed.
694701
durable: self.effect_log.clone(),
695-
stream_id: self.effect_stream_id.clone(),
702+
epoch: self.epoch,
696703
stream_seq: self.effect_seq.clone(),
697704
emission_seq: self.emission_seq.clone(),
698705
}));
@@ -1344,10 +1351,10 @@ struct AuditEffectEmitter {
13441351
/// before fanning out and fails closed if that write fails. `None` until
13451352
/// slice 3b wires a real WAL — then emit is ordering-only.
13461353
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>,
1354+
/// Boot epoch + counters (shared with the executor). Each emitted record is
1355+
/// stamped with `epoch`, `stream_seq` (gap-free within the effect stream),
1356+
/// and the global `emission_seq` (interleaved order vs decisions).
1357+
epoch: u64,
13511358
stream_seq: Arc<AtomicU64>,
13521359
emission_seq: Arc<AtomicU64>,
13531360
}
@@ -1373,7 +1380,8 @@ impl EffectEmitter for AuditEffectEmitter {
13731380
// counter across decisions and effects (interleaved order).
13741381
let mut stamped = effect.clone();
13751382
stamped.plugin_name = Some(self.plugin_name.clone());
1376-
stamped.stream_id = Some(self.stream_id.to_string());
1383+
stamped.epoch = Some(self.epoch);
1384+
stamped.stream_id = Some("effect".to_string());
13771385
stamped.stream_seq = Some(self.stream_seq.fetch_add(1, Ordering::Relaxed));
13781386
stamped.emission_seq = Some(self.emission_seq.fetch_add(1, Ordering::Relaxed));
13791387

@@ -1597,7 +1605,7 @@ mod tests {
15971605
plugin_name: "delegator".into(),
15981606
timeout: Duration::from_secs(5),
15991607
durable: Some(Arc::new(FailingLog)),
1600-
stream_id: Arc::from("eff-test"),
1608+
epoch: 0,
16011609
stream_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
16021610
emission_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
16031611
};
@@ -1616,7 +1624,7 @@ mod tests {
16161624
plugin_name: "delegator".into(),
16171625
timeout: Duration::from_secs(5),
16181626
durable: Some(Arc::new(OkLog)),
1619-
stream_id: Arc::from("eff-test"),
1627+
epoch: 0,
16201628
stream_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
16211629
emission_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
16221630
};

crates/cpex-core/src/manager.rs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2585,16 +2585,8 @@ plugins:
25852585
assert_eq!(l[0].0, "effect:Prepared");
25862586
assert_eq!(l[2].0, "decision");
25872587
// Distinct per-type streams.
2588-
assert!(
2589-
l[0].1.starts_with("eff-"),
2590-
"effect stream id; got {}",
2591-
l[0].1
2592-
);
2593-
assert!(
2594-
l[2].1.starts_with("dec-"),
2595-
"decision stream id; got {}",
2596-
l[2].1
2597-
);
2588+
assert_eq!(l[0].1, "effect", "effect stream id");
2589+
assert_eq!(l[2].1, "decision", "decision stream id");
25982590
}
25992591

26002592
/// `on_effect` fires on the `begin_effect` (prepared) leg — a sink observes

docs/content/docs/auditing.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,8 @@ line per effect:
135135
],
136136
"span": { "trace_id": "…", "span_id": "…", "parent_span_id": "…" },
137137
"taint": { "input": ["PII"], "final": ["PII", "secret"] },
138-
"content": { "input_hash": "sha256:…", "output_hash": "sha256:…" }
138+
"content": { "input_hash": "sha256:…", "output_hash": "sha256:…" },
139+
"epoch": 1723680000000000000, "stream_id": "decision", "stream_seq": 413, "emission_seq": 913
139140
}
140141
141142
// effect
@@ -144,7 +145,8 @@ line per effect:
144145
"effect": {
145146
"kind": "token_mint", "state": "confirmed", "key": "…",
146147
"caused_by": "oauth-delegator",
147-
"details": { "audience": "workday-api", "scope": "read_compensation" }
148+
"details": { "audience": "workday-api", "scope": "read_compensation" },
149+
"epoch": 1723680000000000000, "stream_id": "effect", "stream_seq": 7, "emission_seq": 912
148150
}
149151
}
150152
```
@@ -153,6 +155,35 @@ Fields appear only when present: `span` always; `taint` when labels exist;
153155
`content` only when content provenance is enabled; `subject` when a subject is
154156
resolved.
155157

158+
### Sequence numbers — completeness vs. order
159+
160+
Four fields — `epoch`, `stream_id`, `stream_seq`, `emission_seq` — let a
161+
downstream store prove properties about the stream it received. Two are
162+
**claims** a verifier checks; two **scope** those claims. Don't use one claim
163+
for the other's job:
164+
165+
- `stream_seq` is a **completeness** claim. It is dense (gap-free) within its
166+
`(epoch, stream_id)`. **A gap means a record was dropped** — a consumer of one
167+
stream can prove nothing was silently lost.
168+
- `emission_seq` is an **ordering** claim only. It is monotonic across *both*
169+
streams within an epoch, so a consumer that merges decisions and effects can
170+
reconstruct their interleave (an effect emits during a request, so it carries
171+
a lower `emission_seq` than the decision that closed the request). **A
172+
single-stream consumer sees it sparse by design — the gaps are the other
173+
stream's records, not a loss.** Do not detect loss from `emission_seq`.
174+
- `stream_id` scopes `stream_seq` — it names the per-type stream, `"decision"`
175+
or `"effect"` (the entry-type a merged consumer keys on). Decisions and
176+
effects each get their own dense counter, so a consumer of just one still has
177+
gap-free completeness.
178+
- `epoch` scopes both counters. It is the executor's boot time (Unix
179+
nanoseconds), so a *new, larger* value marks a restart: `stream_seq` proves
180+
completeness within an epoch, and across a restart the epoch changes, so a
181+
verifier tells a **counter reset from records lost** — and `(epoch,
182+
emission_seq)` is a total order across restarts. Detecting loss of the *tail*
183+
of a previous epoch (a crash between emit and persist) is not possible from
184+
the counters alone — that is what a durable sink (an append-only ledger) is
185+
for.
186+
156187
### Destinations
157188

158189
| `destination` | Behaviour |

0 commit comments

Comments
 (0)