feat(interceptors): ordered before-chains + cycle guard + ctx plumbing (§9.4 M1+M2)

M1: introduces a clone-cheap InterceptorCtx { interceptors, self_engine,
limits } threaded into every SDK register fn (kv/docs/files/queue/pubsub/http)
so the non-KV services carry the hook seam (unused until M7-M11); KvHandle
collapses its three fields into one ictx.

M2: replaces the nearest-only resolver with ordered before/after chains. The
trait becomes resolve(cx, service, op) -> InterceptorChain { before, after }
(migration 0074 adds a phase column + phase-aware unique indexes). The before
-chain runs ancestor->app (depth DESC) so a group compliance guard can't be
bypassed by a descendant; single-marker behavior is byte-identical to before.
An identity cycle guard (thread-local visited-set keyed by script_id) denies a
detected cycle, alongside the existing binary re-entrancy break. Fail-closed
verdict preserved (allow only on #{ allowed: true }; a Dangling entry or a
missing engine back-ref denies); app_id still derives from cx.app_id only.

after-chains resolve but stay unused until M3. Pinned by two new interceptor
journeys (ancestor->app chain ordering; self-referential no-recurse).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-15 22:54:51 +02:00
parent eae2ee08f1
commit be0c618672
15 changed files with 623 additions and 200 deletions

View File

@@ -20,35 +20,54 @@ use async_trait::async_trait;
use crate::{ResolvedScript, SdkCallCx};
/// Outcome of resolving the before-op interceptor for a `(service, op)`.
///
/// The three arms encode a **fail-closed** contract: a registered-but-broken
/// hook DENIES rather than silently allowing (only [`None`](Self::None) — no
/// marker at all — allows without running anything).
/// One resolved, runnable interceptor: a script **sealed to the owner that
/// declared the marker** (its lexical `import` origin), plus its per-hook
/// timeout. `timeout_ms` is threaded through the chain now but unused until
/// M5 — always `None` for the M2 slice.
#[derive(Debug, Clone)]
pub enum InterceptorResolution {
/// No marker registered for `(service, op)` on the chain — allow, no work.
None,
/// A marker IS registered but its sealed script is missing or disabled.
/// The caller must DENY (a mis-declared guard must never fail open).
/// Carries the script name for the error message.
pub struct ResolvedInterceptor {
pub script: Box<ResolvedScript>,
pub timeout_ms: Option<u32>,
}
/// One entry in an interceptor chain. A **fail-closed** contract: a
/// registered-but-broken hook is a [`Dangling`](Self::Dangling) entry the
/// caller must DENY on — it is never dropped in favour of a farther, weaker
/// marker.
#[derive(Debug, Clone)]
pub enum InterceptorEntry {
/// Run this script as a before/after hook.
Run(ResolvedInterceptor),
/// A marker exists but its sealed script is missing/disabled → the caller
/// DENIES (a mis-declared guard must never fail open). Carries the script
/// name for the error message.
Dangling(String),
/// Run this script (sealed to the declaring owner) as the before-op hook.
Run(Box<ResolvedScript>),
}
/// The ordered before + after interceptors guarding a `(service, op)` for the
/// calling app. `before` runs ancestor→app (an outer/group guard runs first,
/// so a group denial cannot be bypassed by a descendant); `after` runs
/// app→ancestor. An empty chain means un-hooked (allow, no work). `after` is
/// populated but UNUSED until §9.4 M3.
#[derive(Debug, Clone, Default)]
pub struct InterceptorChain {
pub before: Vec<InterceptorEntry>,
pub after: Vec<InterceptorEntry>,
}
#[async_trait]
pub trait InterceptorService: Send + Sync {
/// Resolve the interceptor guarding `(service, op)` for the calling app —
/// the nearest marker on `cx.app_id`'s chain, its script sealed to the
/// declaring owner. `Err` is a backend failure (the caller fails closed:
/// turns it into an operation error rather than silently allowing).
async fn resolve_before(
/// Resolve the before + after interceptors guarding `(service, op)` for the
/// calling app — every marker visible on `cx.app_id`'s chain, each script
/// sealed to the owner that declared it, ordered per phase. `Err` is a
/// backend failure (the caller fails closed: turns it into an operation
/// error rather than silently allowing).
async fn resolve(
&self,
cx: &SdkCallCx,
service: &str,
op: &str,
) -> Result<InterceptorResolution, String>;
) -> Result<InterceptorChain, String>;
}
/// Default: nothing is ever intercepted. The shape every non-picloud `Services`
@@ -59,12 +78,12 @@ pub struct NoopInterceptorService;
#[async_trait]
impl InterceptorService for NoopInterceptorService {
async fn resolve_before(
async fn resolve(
&self,
_cx: &SdkCallCx,
_service: &str,
_op: &str,
) -> Result<InterceptorResolution, String> {
Ok(InterceptorResolution::None)
) -> Result<InterceptorChain, String> {
Ok(InterceptorChain::default())
}
}

View File

@@ -87,7 +87,10 @@ pub use ids::{
pub use inbox::{
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
};
pub use interceptor::{InterceptorResolution, InterceptorService, NoopInterceptorService};
pub use interceptor::{
InterceptorChain, InterceptorEntry, InterceptorService, NoopInterceptorService,
ResolvedInterceptor,
};
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
pub use log_sink::{ExecutionLogSink, LogSinkError};