Skip to content

Commit a2ad129

Browse files
committed
refactor(callcenter): unify on AuditSink trait per OQ-7-2 (drop UnifiedAuditSink shim)
Sprint-7 meta-review CC-7-1 fix: UnifiedBridge::audit_sink was typed Arc<dyn UnifiedAuditSink> (old D-SDR-4 placeholder trait) while sprint-7 W6 production sinks impl Arc<dyn AuditSink>. W6 shipped orphaned. Per OQ-7-2 decision (locked 2026-05-13 EPIPHANIES): **full migrate, no adapter** — CLAUDE.md "no abstractions beyond what the task requires". Migration: * crates/lance-graph-callcenter/src/audit_sink/mod.rs — add NoopAuditSink (Default impl AuditSink). Update doc comment so the canonical trait surface is here, not unified_audit. * crates/lance-graph-callcenter/src/unified_audit.rs — drop UnifiedAuditSink trait + NoopUnifiedAuditSink struct. Leave a one-paragraph migration note. Doc-comment references updated. * crates/lance-graph-callcenter/src/unified_bridge.rs — audit_sink field retyped to Arc<dyn AuditSink>. Constructor uses NoopAuditSink. with_audit_chain / with_audit_chain_resume builders take Arc<dyn AuditSink>. emit_audit() now `let _ = sink.emit(event);` (move + best-effort discard of Result — failures must not block the authorize hot path). * crates/lance-graph-callcenter/src/lib.rs — drop the NoopUnifiedAuditSink / UnifiedAuditSink re-exports. * crates/lance-graph-callcenter/src/cognitive_bridge_gate.rs — test RecordingSink retyped to impl AuditSink (3 methods). * crates/lance-graph-consumer-conformance/src/harness.rs — RecordingSink retyped to impl AuditSink. Conformance imports audit_sink::{AuditSink, AuditError, MerkleRoot}. OQ-7-3 also locked: new UnifiedBridge::with_jsonl_audit(super_domain, salt, base_path) ergonomic constructor for MedCare-rs sprint-2 item 5's "JSONL primary + optional Lance projection" pattern. Default remains NoopAuditSink (explicit opt-in to durable sinks — no silent disk writes). Verification: * cargo clippy --workspace --tests --no-deps -- -D warnings exits 0 * cargo test -p lance-graph-callcenter -p lance-graph-consumer-conformance passes (all 8 conformance assertions + cognitive_bridge_gate + unified_audit tests, including noop_sink_swallows_events). * Full workspace test running in background. UnifiedAuditEvent::canonical_bytes byte layout unchanged (still 26 bytes) — emit() takes the event by value but doesn't change the wire representation. W6 sinks' prev_merkle field is recorded outside canonical_bytes (per W6 design).
1 parent 7861743 commit a2ad129

6 files changed

Lines changed: 102 additions & 68 deletions

File tree

crates/lance-graph-callcenter/src/audit_sink/mod.rs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
//! D-SDR-4b audit sink infrastructure.
1+
//! D-SDR-4b audit sink infrastructure — the canonical sink trait.
22
//!
3-
//! Defines the `AuditSink` trait and `AuditError` enum used by both
4-
//! `LanceAuditSink` (columnar) and `JsonlAuditSink` (plain text) as
5-
//! production persistence sinks for `UnifiedAuditEvent`.
3+
//! Defines the `AuditSink` trait, `AuditError` enum, and `NoopAuditSink`
4+
//! used by `UnifiedBridge` and the production sinks (`LanceAuditSink`
5+
//! columnar, `JsonlAuditSink` plain text).
66
//!
7-
//! The legacy `UnifiedAuditSink` in `unified_audit.rs` takes `&UnifiedAuditEvent`
8-
//! and returns `()`. This module introduces `AuditSink` as the D-SDR-4b
9-
//! production interface: `emit()` returns `Result<_, AuditError>`, and
10-
//! `flush()` + `checkpoint()` provide durability guarantees.
7+
//! Per OQ-7-2 (locked 2026-05-13): this is the ONLY audit sink trait.
8+
//! The earlier `UnifiedAuditSink` shim from D-SDR-4 was migrated to this
9+
//! interface in sprint-7. `emit()` returns `Result<_, AuditError>` and
10+
//! moves the event (not `&event`); `flush()` + `checkpoint()` provide
11+
//! durability guarantees that the legacy trait lacked.
1112
1213
use crate::unified_audit::UnifiedAuditEvent;
1314

@@ -54,6 +55,26 @@ pub trait AuditSink: Send + Sync {
5455
fn checkpoint(&self) -> Result<(), AuditError>;
5556
}
5657

58+
/// No-op sink — discards every event. Default for `UnifiedBridge::new()`
59+
/// when `super_domain.audit_required = false` (no compliance regime requires
60+
/// audit), and for tests. Per OQ-7-3 (locked 2026-05-13): silent default;
61+
/// explicit opt-in to durable sinks via `UnifiedBridge::with_jsonl_audit()` /
62+
/// `with_audit_chain()`.
63+
#[derive(Clone, Copy, Debug, Default)]
64+
pub struct NoopAuditSink;
65+
66+
impl AuditSink for NoopAuditSink {
67+
fn emit(&self, _event: UnifiedAuditEvent) -> Result<(), AuditError> {
68+
Ok(())
69+
}
70+
fn flush(&self) -> Result<MerkleRoot, AuditError> {
71+
Ok(0)
72+
}
73+
fn checkpoint(&self) -> Result<(), AuditError> {
74+
Ok(())
75+
}
76+
}
77+
5778
pub mod composite;
5879

5980
#[cfg(feature = "jsonl")]

crates/lance-graph-callcenter/src/cognitive_bridge_gate.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,8 @@ mod tests {
195195
use lance_graph_rbac::policy::smb_policy;
196196

197197
use crate::unified_bridge::{TenantId, UnifiedBridge};
198-
use crate::unified_audit::{UnifiedAuditEvent, UnifiedAuditSink};
198+
use crate::audit_sink::{AuditError, AuditSink, MerkleRoot};
199+
use crate::unified_audit::UnifiedAuditEvent;
199200
use crate::super_domain::SuperDomain as SD;
200201
use thinking_engine::bridge_gate::{CognitiveBridgeGate, CognitiveOpKind, CognitiveAuthResult};
201202

@@ -230,10 +231,13 @@ mod tests {
230231
impl RecordingSink {
231232
fn count(&self) -> usize { self.events.lock().unwrap().len() }
232233
}
233-
impl UnifiedAuditSink for RecordingSink {
234-
fn emit(&self, event: &UnifiedAuditEvent) {
235-
self.events.lock().unwrap().push(*event);
234+
impl AuditSink for RecordingSink {
235+
fn emit(&self, event: UnifiedAuditEvent) -> Result<(), AuditError> {
236+
self.events.lock().unwrap().push(event);
237+
Ok(())
236238
}
239+
fn flush(&self) -> Result<MerkleRoot, AuditError> { Ok(0) }
240+
fn checkpoint(&self) -> Result<(), AuditError> { Ok(()) }
237241
}
238242

239243
// ── Chinese-wall tests ───────────────────────────────────────────────────

crates/lance-graph-callcenter/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ pub use cognitive_bridge_gate::UnifiedBridgeGate;
187187
pub mod unified_audit;
188188
pub use unified_audit::{
189189
verify_chain, AuditChain, AuditMerkleRoot, AuthDecision, AuthOp, HydrationRefreshAudit,
190-
NoopUnifiedAuditSink, UnifiedAuditEvent, UnifiedAuditSink,
190+
UnifiedAuditEvent,
191191
};
192192

193193
// D-SDR-4b — Production audit sinks: LanceAuditSink (columnar) and

crates/lance-graph-callcenter/src/unified_audit.rs

Lines changed: 11 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//!
44
//! Each `UnifiedBridge::authorize()` call that materially gates access
55
//! (Deny / Escalate / Audit-required Allow) emits one `UnifiedAuditEvent`
6-
//! through a `UnifiedAuditSink`. Events form a chain: the merkle root of
6+
//! through an `AuditSink` (see `crate::audit_sink`). Events form a chain: the merkle root of
77
//! event N includes the merkle root of event N-1 plus a per-super-domain
88
//! `merkle_salt` (§13.4 hard-lock — cross-domain audit logs are
99
//! unlinkable). Tampering with any past event is detectable by chain
@@ -24,9 +24,9 @@
2424
//! durable record (JSON Lines / Lance dataset / no-op)
2525
//! ```
2626
//!
27-
//! D-SDR-4 scope: type system + chain mechanics + `NoopUnifiedAuditSink`
27+
//! D-SDR-4 scope: type system + chain mechanics + sink trait
2828
//! reference impl + tamper detection helper. Production sinks
29-
//! (`JsonLinesUnifiedAuditSink`, `LanceUnifiedAuditSink`) are D-SDR-4b.
29+
//! (`JsonlAuditSink`, `LanceAuditSink` in `crate::audit_sink`) are D-SDR-4b/sprint-7.
3030
//! Wiring into `UnifiedBridge::authorize()` is D-SDR-5.
3131
//!
3232
//! ## Separate from `crate::audit`
@@ -301,34 +301,10 @@ impl HydrationRefreshAudit {
301301
}
302302
}
303303

304-
// ═══════════════════════════════════════════════════════════════════════════
305-
// UnifiedAuditSink — pluggable persistence
306-
// ═══════════════════════════════════════════════════════════════════════════
307-
308-
/// Sink trait — pluggable persistence backend. Implementations:
309-
/// - `NoopUnifiedAuditSink` (this module) — discards events; default for
310-
/// tests and `audit_required = false` policies.
311-
/// - `JsonLinesUnifiedAuditSink` (D-SDR-4b) — appends to a JSONL file.
312-
/// - `LanceUnifiedAuditSink` (D-SDR-4b) — appends to a Lance dataset,
313-
/// indexed by `(tenant, super_domain, ts_unix_ms)`.
314-
pub trait UnifiedAuditSink: Send + Sync
315-
{
316-
/// Persist one event. **Must not block on I/O** for >1ms — the
317-
/// authorize() hot path calls this synchronously. Production sinks
318-
/// buffer asynchronously and flush on a separate task.
319-
fn emit(&self, event: &UnifiedAuditEvent);
320-
}
321-
322-
/// No-op sink — discards every event. Use as the default sink when
323-
/// `super_domain.audit_required = false` (no compliance regime requires
324-
/// audit), or in tests.
325-
#[derive(Clone, Copy, Debug, Default)]
326-
pub struct NoopUnifiedAuditSink;
327-
328-
impl UnifiedAuditSink for NoopUnifiedAuditSink
329-
{
330-
fn emit(&self, _event: &UnifiedAuditEvent) {}
331-
}
304+
// The audit sink trait + NoopAuditSink moved to crate::audit_sink in
305+
// sprint-7 (OQ-7-2 locked 2026-05-13). UnifiedAuditSink and
306+
// NoopUnifiedAuditSink were the D-SDR-4 placeholders; production
307+
// consumers use `AuditSink` from `crate::audit_sink` directly.
332308

333309
// ═══════════════════════════════════════════════════════════════════════════
334310
// Chain verification — tamper detection
@@ -494,12 +470,12 @@ mod tests
494470
}
495471

496472
#[test]
497-
fn noop_sink_swallows_events()
498-
{
499-
let sink = NoopUnifiedAuditSink;
473+
fn noop_sink_swallows_events() {
474+
use crate::audit_sink::{AuditSink, NoopAuditSink};
475+
let sink = NoopAuditSink;
500476
let mut chain = AuditChain::new(SuperDomain::Healthcare, 0xC0FFEE);
501477
let ev = chain.advance(fresh_event());
502-
sink.emit(&ev); // doesn't panic, doesn't observe — by design
478+
sink.emit(ev).expect("noop never errors"); // doesn't observe — by design
503479
}
504480

505481
#[test]

crates/lance-graph-callcenter/src/unified_bridge.rs

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ use lance_graph_rbac::policy::{Operation, Policy};
4747

4848
use crate::super_domain::SuperDomain;
4949
use crate::unified_audit::{
50-
AuditChain, AuditMerkleRoot, AuthDecision, AuthOp, NoopUnifiedAuditSink, UnifiedAuditEvent,
51-
UnifiedAuditSink,
50+
AuditChain, AuditMerkleRoot, AuthDecision, AuthOp, UnifiedAuditEvent,
5251
};
52+
use crate::audit_sink::{AuditSink, NoopAuditSink};
5353

5454
/// Extract the canonical ontology entity type name from a resolved
5555
/// [`MappingRow`], for use as the [`Policy::evaluate`] key.
@@ -249,9 +249,9 @@ pub struct UnifiedBridge<B: NamespaceBridge> {
249249
tenant: TenantId,
250250
/// Audit sink — every `authorize_*` call that reaches the policy
251251
/// evaluation step emits one `UnifiedAuditEvent`. Default is
252-
/// `NoopUnifiedAuditSink` (zero overhead, no persistence). Swap via
252+
/// `NoopAuditSink` (zero overhead, no persistence). Swap via
253253
/// [`Self::with_audit_chain`].
254-
audit_sink: Arc<dyn UnifiedAuditSink>,
254+
audit_sink: Arc<dyn AuditSink>,
255255
/// Merkle-chained audit advancer. Holds the prior event's root +
256256
/// per-super-domain salt so each new event chains off it. Mutex
257257
/// guards the `last_root` advance under concurrent `authorize_*`
@@ -262,7 +262,7 @@ pub struct UnifiedBridge<B: NamespaceBridge> {
262262
impl<B: NamespaceBridge> UnifiedBridge<B> {
263263
/// Construct a new unified bridge.
264264
///
265-
/// Defaults audit to `NoopUnifiedAuditSink` + a chain anchored at
265+
/// Defaults audit to `NoopAuditSink` + a chain anchored at
266266
/// `SuperDomain::Unknown` with salt 0. Call
267267
/// [`Self::with_audit_chain`] to swap in a real sink + the
268268
/// super-domain-specific salt before authorization traffic starts.
@@ -278,20 +278,20 @@ impl<B: NamespaceBridge> UnifiedBridge<B> {
278278
actor_role,
279279
actor_role_hash: fnv1a_str(actor_role),
280280
tenant,
281-
audit_sink: Arc::new(NoopUnifiedAuditSink),
281+
audit_sink: Arc::new(NoopAuditSink),
282282
audit_chain: Mutex::new(AuditChain::new(SuperDomain::Unknown, 0)),
283283
}
284284
}
285285

286-
/// Builder: swap in a real `UnifiedAuditSink` + the super-domain's
286+
/// Builder: swap in a real `AuditSink` + the super-domain's
287287
/// `merkle_salt` (§13.4 — cross-domain audit logs unlinkable). Resets
288288
/// the chain to GENESIS; pass a resume root via
289289
/// [`Self::with_audit_chain_resume`] if continuing a persisted chain.
290290
pub fn with_audit_chain(
291291
mut self,
292292
super_domain: SuperDomain,
293293
salt: u64,
294-
sink: Arc<dyn UnifiedAuditSink>,
294+
sink: Arc<dyn AuditSink>,
295295
) -> Self {
296296
self.audit_sink = sink;
297297
self.audit_chain = Mutex::new(AuditChain::new(super_domain, salt));
@@ -306,13 +306,30 @@ impl<B: NamespaceBridge> UnifiedBridge<B> {
306306
super_domain: SuperDomain,
307307
salt: u64,
308308
last_root: AuditMerkleRoot,
309-
sink: Arc<dyn UnifiedAuditSink>,
309+
sink: Arc<dyn AuditSink>,
310310
) -> Self {
311311
self.audit_sink = sink;
312312
self.audit_chain = Mutex::new(AuditChain::resume(super_domain, salt, last_root));
313313
self
314314
}
315315

316+
/// Ergonomic constructor: wire a `JsonlAuditSink` at `base_path` as
317+
/// the primary audit destination. Per OQ-7-3 (locked 2026-05-13):
318+
/// `new()` defaults to `NoopAuditSink`; this constructor is the
319+
/// explicit opt-in for the production "JSONL primary + optional Lance
320+
/// projection" pattern (MedCare-rs sprint-2 item 5). Only available
321+
/// when the `jsonl` feature is enabled.
322+
#[cfg(feature = "jsonl")]
323+
pub fn with_jsonl_audit(
324+
self,
325+
super_domain: SuperDomain,
326+
salt: u64,
327+
base_path: impl Into<std::path::PathBuf>,
328+
) -> std::io::Result<Self> {
329+
let sink = Arc::new(crate::audit_sink::JsonlAuditSink::new(base_path.into())?);
330+
Ok(self.with_audit_chain(super_domain, salt, sink))
331+
}
332+
316333
/// Returns the underlying namespace bridge.
317334
pub fn bridge(&self) -> &B {
318335
&self.bridge
@@ -350,7 +367,7 @@ impl<B: NamespaceBridge> UnifiedBridge<B> {
350367
/// from policy authorship.
351368
///
352369
/// On policy evaluation reaching, one `UnifiedAuditEvent` is emitted
353-
/// through the configured `UnifiedAuditSink` carrying tenant +
370+
/// through the configured `AuditSink` carrying tenant +
354371
/// super-domain + owl + decision. **`BridgeError` short-circuits
355372
/// before audit** — bad input names aren't auth decisions, they're
356373
/// invalid requests (D-SDR-5 minimum; revisit if probing detection
@@ -428,7 +445,10 @@ impl<B: NamespaceBridge> UnifiedBridge<B> {
428445
};
429446
let stamped = chain.advance(event);
430447
drop(chain);
431-
self.audit_sink.emit(&stamped);
448+
// Best-effort: audit emission failures must not block the authorize
449+
// hot path. Sinks are responsible for their own buffering/backpressure
450+
// (see audit_sink::{JsonlAuditSink, LanceAuditSink} BestEffort mode).
451+
let _ = self.audit_sink.emit(stamped);
432452
}
433453
}
434454

@@ -821,9 +841,16 @@ mod tests {
821841
}
822842
}
823843

824-
impl UnifiedAuditSink for RecordingSink {
825-
fn emit(&self, event: &UnifiedAuditEvent) {
826-
self.events.lock().unwrap().push(*event);
844+
impl AuditSink for RecordingSink {
845+
fn emit(&self, event: UnifiedAuditEvent) -> Result<(), crate::audit_sink::AuditError> {
846+
self.events.lock().unwrap().push(event);
847+
Ok(())
848+
}
849+
fn flush(&self) -> Result<crate::audit_sink::MerkleRoot, crate::audit_sink::AuditError> {
850+
Ok(0)
851+
}
852+
fn checkpoint(&self) -> Result<(), crate::audit_sink::AuditError> {
853+
Ok(())
827854
}
828855
}
829856

crates/lance-graph-consumer-conformance/src/harness.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,8 @@
2222
use std::sync::Arc;
2323

2424
use lance_graph_callcenter::super_domain::SuperDomain;
25-
use lance_graph_callcenter::unified_audit::{
26-
AuditMerkleRoot, UnifiedAuditEvent, UnifiedAuditSink,
27-
};
25+
use lance_graph_callcenter::audit_sink::{AuditError, AuditSink, MerkleRoot};
26+
use lance_graph_callcenter::unified_audit::{AuditMerkleRoot, UnifiedAuditEvent};
2827
use lance_graph_callcenter::unified_bridge::{TenantId, UnifiedBridge};
2928
use lance_graph_contract::hash::fnv1a_str;
3029
use lance_graph_contract::property::PrefetchDepth;
@@ -59,9 +58,16 @@ impl RecordingSink {
5958
}
6059
}
6160

62-
impl UnifiedAuditSink for RecordingSink {
63-
fn emit(&self, event: &UnifiedAuditEvent) {
64-
self.events.lock().unwrap().push(*event);
61+
impl AuditSink for RecordingSink {
62+
fn emit(&self, event: UnifiedAuditEvent) -> Result<(), AuditError> {
63+
self.events.lock().unwrap().push(event);
64+
Ok(())
65+
}
66+
fn flush(&self) -> Result<MerkleRoot, AuditError> {
67+
Ok(0)
68+
}
69+
fn checkpoint(&self) -> Result<(), AuditError> {
70+
Ok(())
6571
}
6672
}
6773

0 commit comments

Comments
 (0)