feat(interceptors): per-interceptor wall-clock timeout (§9.4 M5)

A [[interceptors]] marker can set timeout_ms (migration 0075, CHECK > 0); a
runaway guard (loop {}) is interrupted and the op DENIED (fail closed) within
budget rather than hanging the write path. The effective deadline is
min(caller-remaining, now + timeout) — a hook can only tighten, never extend,
its caller's deadline. NULL uses PICLOUD_INTERCEPTOR_TIMEOUT_MS (default 5s).

Wiring: run_resolved_blocking splits into a core + a _with_timeout variant that
computes the effective deadline from engine::ambient_deadline() and runs via
execute_ast_with_deadline; run_one_hook passes the marker's timeout_ms (or the
env default). timeout_ms threaded end to end — manifest, plan, BundleInterceptor,
the reconcile diff (part of the mutable body: a timeout change is an Update),
insert_interceptor_tx, resolve_chain/list_for_owner/list_on_app_chain +
SealedInterceptor/InterceptorMarker, and interceptor_service → ResolvedInterceptor.
Schema snapshot re-blessed. Pinned by a journey: a loop{} guard with
timeout_ms=100 is denied and its write does not persist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-16 20:18:03 +02:00
parent 5592f1c97e
commit 8b70d52d43
12 changed files with 244 additions and 47 deletions

View File

@@ -126,6 +126,10 @@ pub struct BundleInterceptor {
#[serde(default = "default_before_phase")]
pub phase: String,
pub script: String,
/// §9.4 M5: per-hook timeout in ms (`None` = instance default). `default`
/// so a pre-M5 CLI (which omits it) still deserializes.
#[serde(default)]
pub timeout_ms: Option<i32>,
}
fn default_before_phase() -> String {
@@ -828,8 +832,8 @@ pub struct CurrentState {
/// v1.2 Workflows owned directly by this node (app-owned; empty for a group).
pub workflows: Vec<crate::workflow_repo::Workflow>,
/// §9.4 interceptor markers declared directly at this node, as
/// `(service, op, phase, script)` tuples.
pub interceptors: Vec<(String, String, String, String)>,
/// `(service, op, phase, script, timeout_ms)` tuples.
pub interceptors: Vec<(String, String, String, String, Option<i32>)>,
}
/// One row of the read-only extension-point report (§5.5).
@@ -1620,6 +1624,7 @@ impl ApplyService {
&bi.op,
&bi.phase,
&bi.script,
bi.timeout_ms,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
@@ -4263,7 +4268,7 @@ impl ApplyService {
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.into_iter()
.map(|m| (m.service, m.op, m.phase, m.script))
.map(|m| (m.service, m.op, m.phase, m.script, m.timeout_ms))
.collect();
Ok(CurrentState {
scripts,
@@ -4352,24 +4357,30 @@ fn compute_diff_with_names(
/// declaring is a `Delete` (applied only under `--prune`).
fn diff_interceptors(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
let key = |service: &str, op: &str, phase: &str| format!("{service}/{op}/{phase}");
let live: HashMap<String, &str> = current
// The reconcile identity is `(service, op, phase)`; the script AND the
// timeout are the mutable body, so a change to either is an Update.
let live: HashMap<String, (&str, Option<i32>)> = current
.interceptors
.iter()
.map(|(s, o, p, script)| (key(s, o, p), script.as_str()))
.map(|(s, o, p, script, timeout)| (key(s, o, p), (script.as_str(), *timeout)))
.collect();
let mut out = Vec::new();
for bi in &bundle.interceptors {
let k = key(&bi.service, &bi.op, &bi.phase);
match live.get(&k) {
Some(cur) if *cur == bi.script => out.push(ResourceChange {
op: Op::NoOp,
key: k,
detail: None,
}),
Some((cur_script, cur_timeout))
if *cur_script == bi.script && *cur_timeout == bi.timeout_ms =>
{
out.push(ResourceChange {
op: Op::NoOp,
key: k,
detail: None,
});
}
Some(_) => out.push(ResourceChange {
op: Op::Update,
key: k,
detail: Some("interceptor script changed".into()),
detail: Some("interceptor script or timeout changed".into()),
}),
None => out.push(ResourceChange {
op: Op::Create,
@@ -4378,7 +4389,7 @@ fn diff_interceptors(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceCha
}),
}
}
for (s, o, p, _) in &current.interceptors {
for (s, o, p, _, _) in &current.interceptors {
let present = bundle
.interceptors
.iter()