Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/nexum-runtime/src/host/impls/venue_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
//! thin delegation to the shared
//! [`VenueRegistry`](crate::host::venue_registry) carried in the store; the
//! registry owns the venue resolution, per-adapter serialisation, guard
//! seam, and quota. The caller identity the registry meters against is this
//! store's module namespace.
//! seam (advisory-only for now), and quota. The caller identity the registry
//! meters against is this store's module namespace.

use crate::bindings::client::Host;
use crate::bindings::{IntentStatus, Quotation, SubmitOutcome, VenueError};
Expand Down
45 changes: 29 additions & 16 deletions crates/nexum-runtime/src/host/venue_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
//! A module's `client::submit(venue, body)` reaches the host here. The
//! registry resolves the venue id to the one installed adapter that answers
//! for it, then drives a fixed sequence against that adapter: derive the
//! header, run the guard interposition seam on it, and only then submit.
//! header, run the guard interposition seam on it (advisory-only for now:
//! see [`EgressGuard`]), and only then submit.
//! Status and cancel are pass-throughs; they are not submissions, so they
//! skip the header, the guard, and the quota.
//!
Expand Down Expand Up @@ -103,10 +104,13 @@ impl Default for SubmitQuota {
}

/// The guard interposition seam. The registry runs this on the
/// adapter-derived header after `derive-header` and before `submit`. The
/// shipped policy is the unit guard, which allows every egress; the
/// egress-guard epic replaces the installed policy with the real
/// facts-plus-analysers pipeline without the registry changing shape.
/// adapter-derived header after `derive-header` and before `submit`.
///
/// Advisory-only: the checkpoint is not yet enforcing. A `Deny` verdict is
/// logged as a would-deny and the submission proceeds. The shipped policy is
/// the unit guard, which allows every egress; the egress-guard epic installs
/// the real facts-plus-analysers pipeline and turns the verdict enforcing,
/// without the registry changing shape.
pub trait EgressGuard: Send + Sync {
/// Decide whether the derived header may proceed to the adapter's submit.
fn check(&self, ctx: &GuardContext<'_>) -> GuardVerdict;
Expand Down Expand Up @@ -135,7 +139,8 @@ pub struct GuardContext<'a> {
pub enum GuardVerdict {
/// Forward the submission to the adapter.
Allow,
/// Refuse the egress with an operator-facing reason.
/// Refuse the egress with an operator-facing reason. Logged, not
/// enforced, while the seam is advisory-only.
Deny(String),
}

Expand Down Expand Up @@ -393,7 +398,8 @@ impl VenueRegistry {

/// Submit an opaque body to `venue` on behalf of `caller`: resolve the
/// adapter, gate on the caller's quota, derive the header, run the guard
/// seam, then forward to the adapter. A decode failure is charged to the
/// seam (advisory-only: a deny logs and the submission proceeds), then
/// forward to the adapter. A decode failure is charged to the
/// caller before returning, so a caller feeding garbage exhausts its own
/// budget and is stopped at the gate on the next call rather than
/// re-invoking the adapter.
Expand Down Expand Up @@ -437,8 +443,14 @@ impl VenueRegistry {
venue,
header: &header,
};
// Advisory-only checkpoint: a deny is logged, never enforced.
if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) {
return Err(VenueError::Denied(reason));
warn!(
caller,
venue = %venue,
reason,
"egress guard would deny - advisory-only, submission proceeds",
);
}
// A forwarded submission consumes one unit of the caller's budget.
self.charge(caller);
Expand Down Expand Up @@ -651,7 +663,8 @@ impl VenueRegistryBuilder {
}

/// Override the guard policy. The egress-guard epic wires the real
/// pipeline through here; tests inject a denying policy to prove the seam.
/// pipeline through here; tests inject a denying policy to prove the
/// advisory seam.
pub fn with_guard(mut self, guard: Arc<dyn EgressGuard>) -> Self {
self.guard = guard;
self
Expand Down Expand Up @@ -939,24 +952,24 @@ mod tests {
}

#[tokio::test]
async fn guard_deny_blocks_submit_after_deriving_the_header() {
async fn guard_deny_is_advisory_and_does_not_block_submit() {
let calls = Arc::new(StubCalls::default());
let registry = registry_with(
SubmitQuota::default(),
Some(Arc::new(DenyGuard)),
StubAdapter::new(calls.clone()),
);

let err = registry
let outcome = registry
.submit("mod-a", &cow(), b"body".to_vec())
.await
.expect_err("guard denies");
.expect("advisory deny does not block");

assert!(matches!(err, VenueError::Denied(reason) if reason.contains("test policy")));
// The seam runs on the derived header, then blocks: derive ran, submit
// did not.
// The seam runs on the derived header but only logs: derive ran and
// the submission still reached the adapter.
assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"receipt"));
assert_eq!(calls.derive.load(Ordering::SeqCst), 1);
assert_eq!(calls.submit.load(Ordering::SeqCst), 0);
assert_eq!(calls.submit.load(Ordering::SeqCst), 1);
}

#[tokio::test]
Expand Down
3 changes: 2 additions & 1 deletion crates/nexum-venue-sdk/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ pub trait VenueAdapter {
/// Project an opaque intent body onto the stable header guard
/// policy runs on. Must be a pure derivation: no transport, no side
/// effects, so the host can inspect a header before deciding to
/// submit.
/// submit. The host's guard checkpoint is advisory-only until the
/// egress-guard epic lands: a would-deny is logged, not enforced.
fn derive_header(body: Vec<u8>) -> Result<IntentHeader, VenueError>;

/// Price an opaque intent body: an indicative quotation, not an
Expand Down
Loading