From 861acf5425ec52b1f1c2677cd8368f657aca3d97 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 17:19:04 +0000 Subject: [PATCH 1/2] runtime: charge the caller's quota on a guard-deny The submission charge moves ahead of the guard verdict at the venue registry, so a denied egress spends one unit exactly as an accepted submit does: a module spamming denied egress exhausts its own budget instead of looping free once the guard turns enforcing. A regression test pins the rate limit on a repeated-deny loop. --- .../nexum-runtime/src/host/venue_registry.rs | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index 57e029e3..d150d206 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -405,12 +405,15 @@ impl VenueRegistry { /// re-invoking the adapter. /// /// Charging is deliberately asymmetric across the two stages. Once the - /// guard admits the header the submission is charged before the adapter - /// call, so a forwarded submission spends one unit regardless of the - /// venue's outcome (the adapter did the work, and a transient venue - /// outage must not become a free retry loop). A derive-stage venue error - /// that is not a decode failure is the venue's fault, not the caller's, - /// so it is left uncharged and the caller may retry. + /// header derives, the submission is charged before the guard verdict + /// and the adapter call, so a denied egress spends one unit exactly as + /// an accepted submit does (a guard that turns enforcing must not hand + /// the caller a free retry loop) and a forwarded submission spends that + /// same unit regardless of the venue's outcome (the adapter did the + /// work, and a transient venue outage must not become a free retry + /// loop). A derive-stage venue error that is not a decode failure is the + /// venue's fault, not the caller's, so it is left uncharged and the + /// caller may retry. pub async fn submit( &self, caller: &str, @@ -443,6 +446,10 @@ impl VenueRegistry { venue, header: &header, }; + // Charge ahead of the verdict: a denied egress consumes one unit of + // the caller's budget exactly as an accepted submit does, so any + // deny return path is already charged. + self.charge(caller); // Advisory-only checkpoint: a deny is logged, never enforced. if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { warn!( @@ -452,8 +459,6 @@ impl VenueRegistry { "egress guard would deny - advisory-only, submission proceeds", ); } - // A forwarded submission consumes one unit of the caller's budget. - self.charge(caller); let outcome = adapter.submit(&body).await?; // An accepted receipt goes under status watch so subscribers see // its transitions; requires-signing has no receipt to watch yet. @@ -972,6 +977,39 @@ mod tests { assert_eq!(calls.submit.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn repeated_guard_denies_exhaust_the_caller_quota() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(2, Duration::from_secs(3600)); + let registry = registry_with( + quota, + Some(Arc::new(DenyGuard)), + StubAdapter::new(calls.clone()), + ); + + // Each denied submit spends exactly one unit: the second is still + // admitted, so a deny is never double-charged. + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + // The deny loop is rate-limited at the gate, not free. + assert!(matches!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::RateLimited(_)) + )); + assert_eq!(calls.derive.load(Ordering::SeqCst), 2); + assert_eq!(calls.submit.load(Ordering::SeqCst), 2); + } + #[tokio::test] async fn quote_reaches_the_adapter_without_header_or_guard() { let calls = Arc::new(StubCalls::default()); From a494361203e0a0b5d9cc62a42ebcfd50bbf1bd43 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 17:25:51 +0000 Subject: [PATCH 2/2] runtime: tersen the submit charge docs --- .../nexum-runtime/src/host/venue_registry.rs | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index d150d206..cfb24ec6 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -404,16 +404,10 @@ impl VenueRegistry { /// budget and is stopped at the gate on the next call rather than /// re-invoking the adapter. /// - /// Charging is deliberately asymmetric across the two stages. Once the - /// header derives, the submission is charged before the guard verdict - /// and the adapter call, so a denied egress spends one unit exactly as - /// an accepted submit does (a guard that turns enforcing must not hand - /// the caller a free retry loop) and a forwarded submission spends that - /// same unit regardless of the venue's outcome (the adapter did the - /// work, and a transient venue outage must not become a free retry - /// loop). A derive-stage venue error that is not a decode failure is the - /// venue's fault, not the caller's, so it is left uncharged and the - /// caller may retry. + /// Charged once the header derives, ahead of the guard and adapter, so + /// a deny (when enforcing) or a venue outage is never a free retry. + /// Derive-stage venue errors other than a decode failure are left + /// uncharged and retryable. pub async fn submit( &self, caller: &str, @@ -446,9 +440,7 @@ impl VenueRegistry { venue, header: &header, }; - // Charge ahead of the verdict: a denied egress consumes one unit of - // the caller's budget exactly as an accepted submit does, so any - // deny return path is already charged. + // Charge before the guard so an enforcing deny stays non-free. self.charge(caller); // Advisory-only checkpoint: a deny is logged, never enforced. if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) {