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

@@ -5,7 +5,7 @@
//! interceptor's behaviour is a normal script resolved + run through `invoke()`
//! re-entry. This module holds the read + transactional-write helpers (free
//! functions over `&PgPool` / `&mut Transaction`, keyed by [`ScriptOwner`]),
//! plus the runtime [`resolve_before`] chain walk (nearest-owner-wins).
//! plus the runtime [`resolve_chain`] walk (all markers on the app's chain).
use chrono::{DateTime, Utc};
use picloud_shared::{AppId, ScriptOwner};
@@ -117,55 +117,77 @@ impl SealedInterceptor {
}
}
/// Resolve the interceptor guarding `(service, op)` for a calling app — the
/// nearest MARKER on the app's chain (app, then nearest ancestor group), with
/// its script **sealed to the owner that declared the marker** (a descendant
/// app cannot shadow it with a same-named local script). `None` when un-hooked.
/// The ordered before + after interceptors guarding a `(service, op)` for a
/// calling app, each sealed to its declaring owner (see [`SealedInterceptor`]).
#[derive(Debug, Clone, Default)]
pub struct SealedChain {
/// Ordered ancestor→app (outermost/group guard first).
pub before: Vec<SealedInterceptor>,
/// Ordered app→ancestor.
pub after: Vec<SealedInterceptor>,
}
/// Resolve ALL interceptor markers guarding `(service, op)` for a calling app —
/// every marker visible on the app's chain (app + each ancestor group), each
/// with its script **sealed to the owner that declared the marker** (a
/// descendant app cannot shadow it with a same-named local script). Split by
/// `phase` and ordered: `before` ancestor→app (an outer/group guard runs first,
/// so a group denial cannot be bypassed by a descendant), `after` app→ancestor.
/// **This chain walk is the resolution boundary** — a sibling-subtree app never
/// sees another subtree's interceptor. The script join is `LEFT` so a marker
/// whose script is missing/disabled still returns a row (with `script: None`)
/// rather than silently falling through to a farther, weaker marker.
pub async fn resolve_before(
/// whose script is missing/disabled still returns an entry (with `script:
/// None`) the caller fails closed on, rather than being silently dropped.
///
/// Per the `(owner, service, op, phase)` partial-unique index there is at most
/// one marker per owner per phase, so no per-owner dedup is needed.
pub async fn resolve_chain(
pool: &PgPool,
app_id: AppId,
service: &str,
op: &str,
) -> Result<Option<SealedInterceptor>, sqlx::Error> {
) -> Result<SealedChain, sqlx::Error> {
#[allow(clippy::type_complexity)]
let row: Option<(
Option<Uuid>,
Option<Uuid>,
String,
Option<Uuid>,
Option<String>,
Option<DateTime<Utc>>,
let rows: Vec<(
Option<Uuid>, // marker_app
Option<Uuid>, // marker_group
String, // script_name
String, // phase
i32, // depth (0 = app, larger = ancestor)
Option<Uuid>, // script id
Option<String>, // script source
Option<DateTime<Utc>>, // script updated_at
)> = sqlx::query_as(&format!(
"{CHAIN_LEVELS_CTE}, \
nearest AS ( \
markers AS ( \
SELECT i.interceptor_script AS script_name, \
i.app_id AS marker_app, i.group_id AS marker_group, c.depth AS depth \
i.app_id AS marker_app, i.group_id AS marker_group, \
i.phase AS phase, c.depth AS depth \
FROM chain c \
JOIN interceptors i ON (i.app_id = c.app_owner OR i.group_id = c.group_owner) \
WHERE i.service = $2 AND i.op = $3 \
ORDER BY c.depth ASC LIMIT 1 \
) \
SELECT n.marker_app, n.marker_group, n.script_name, \
SELECT m.marker_app, m.marker_group, m.script_name, m.phase, m.depth, \
s.id, s.source, s.updated_at \
FROM nearest n \
FROM markers m \
LEFT JOIN scripts s ON ( \
(n.marker_app IS NOT NULL AND s.app_id = n.marker_app \
AND s.name = n.script_name AND s.enabled) \
OR (n.marker_group IS NOT NULL AND s.group_id = n.marker_group \
AND s.name = n.script_name AND s.enabled) \
(m.marker_app IS NOT NULL AND s.app_id = m.marker_app \
AND s.name = m.script_name AND s.enabled) \
OR (m.marker_group IS NOT NULL AND s.group_id = m.marker_group \
AND s.name = m.script_name AND s.enabled) \
)",
))
.bind(app_id.into_inner())
.bind(service)
.bind(op)
.fetch_optional(pool)
.fetch_all(pool)
.await?;
Ok(row.map(
|(marker_app, marker_group, script_name, sid, src, upd)| SealedInterceptor {
// Split by phase and order: `before` ancestor→app = depth DESC (larger
// depth = ancestor runs first); `after` app→ancestor = depth ASC.
let mut before: Vec<(i32, SealedInterceptor)> = Vec::new();
let mut after: Vec<(i32, SealedInterceptor)> = Vec::new();
for (marker_app, marker_group, script_name, phase, depth, sid, src, upd) in rows {
let sealed = SealedInterceptor {
marker_app,
marker_group,
script_name,
@@ -173,8 +195,19 @@ pub async fn resolve_before(
(Some(id), Some(source), Some(updated_at)) => Some((id, source, updated_at)),
_ => None,
},
},
))
};
if phase == "after" {
after.push((depth, sealed));
} else {
before.push((depth, sealed));
}
}
before.sort_by(|a, b| b.0.cmp(&a.0)); // depth DESC → ancestor first
after.sort_by(|a, b| a.0.cmp(&b.0)); // depth ASC → app first
Ok(SealedChain {
before: before.into_iter().map(|(_, s)| s).collect(),
after: after.into_iter().map(|(_, s)| s).collect(),
})
}
/// Upsert a marker at `owner` in the apply transaction. A re-apply that changes
@@ -190,9 +223,11 @@ pub async fn insert_interceptor_tx(
match owner {
ScriptOwner::App(a) => {
sqlx::query(
// `phase` defaults to 'before' (0074); the conflict arbiter must
// include it to match the per-(owner, service, op, phase) index.
"INSERT INTO interceptors (app_id, service, op, interceptor_script) \
VALUES ($1, $2, $3, $4) \
ON CONFLICT (app_id, service, op) WHERE app_id IS NOT NULL \
ON CONFLICT (app_id, service, op, phase) WHERE app_id IS NOT NULL \
DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()",
)
.bind(a.into_inner())
@@ -204,9 +239,11 @@ pub async fn insert_interceptor_tx(
}
ScriptOwner::Group(g) => {
sqlx::query(
// `phase` defaults to 'before' (0074); the conflict arbiter must
// include it to match the per-(owner, service, op, phase) index.
"INSERT INTO interceptors (group_id, service, op, interceptor_script) \
VALUES ($1, $2, $3, $4) \
ON CONFLICT (group_id, service, op) WHERE group_id IS NOT NULL \
ON CONFLICT (group_id, service, op, phase) WHERE group_id IS NOT NULL \
DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()",
)
.bind(g.into_inner())