feat(interceptors): KV after-hooks + before-hook data transform (§9.4 M3+M4)

M4: a before-interceptor returning #{ allowed: true, data: ... } rewrites the
value actually written (threaded through the chain; each hook sees the prior
transform), size-capped at MAX_JSON_MATERIALIZE_BYTES. Delete never transforms.

M3: an 'after' phase interceptor runs once the write has committed, with the
write's result in its payload. After-hooks observe/deny but CANNOT roll back —
a deny surfaces as an operation error while the write persists (documented at
the call site).

Adds phase authoring end to end: a [[interceptors]] entry gains phase =
before|after (default before), threaded through the plan wire, BundleInterceptor,
validate (phase in {before,after}), the reconcile diff/insert/prune key
(service/op/phase), and the repo (insert/delete/list_for_owner/list_on_app_chain,
+ the marker's phase). run_before now returns the transformed value; run_after is
new; both share one fail-closed per-entry runner. Pinned by two journeys: a
before-hook transforms the stored value, and an after-delete hook sees
result==true, denies, yet the key stays deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-16 19:55:44 +02:00
parent be0c618672
commit 5592f1c97e
7 changed files with 496 additions and 162 deletions

View File

@@ -20,6 +20,8 @@ use crate::config_resolver::CHAIN_LEVELS_CTE;
pub struct InterceptorMarker {
pub service: String,
pub op: String,
/// `before` (allow/deny + transform) or `after` (observe; §9.4 M3).
pub phase: String,
pub script: String,
}
@@ -29,11 +31,11 @@ pub async fn list_for_owner(
pool: &PgPool,
owner: ScriptOwner,
) -> Result<Vec<InterceptorMarker>, sqlx::Error> {
let rows: Vec<(String, String, String)> = match owner {
let rows: Vec<(String, String, String, String)> = match owner {
ScriptOwner::App(a) => {
sqlx::query_as(
"SELECT service, op, interceptor_script FROM interceptors \
WHERE app_id = $1 ORDER BY service, op",
"SELECT service, op, phase, interceptor_script FROM interceptors \
WHERE app_id = $1 ORDER BY service, op, phase",
)
.bind(a.into_inner())
.fetch_all(pool)
@@ -41,8 +43,8 @@ pub async fn list_for_owner(
}
ScriptOwner::Group(g) => {
sqlx::query_as(
"SELECT service, op, interceptor_script FROM interceptors \
WHERE group_id = $1 ORDER BY service, op",
"SELECT service, op, phase, interceptor_script FROM interceptors \
WHERE group_id = $1 ORDER BY service, op, phase",
)
.bind(g.into_inner())
.fetch_all(pool)
@@ -51,9 +53,10 @@ pub async fn list_for_owner(
};
Ok(rows
.into_iter()
.map(|(service, op, script)| InterceptorMarker {
.map(|(service, op, phase, script)| InterceptorMarker {
service,
op,
phase,
script,
})
.collect())
@@ -66,21 +69,23 @@ pub async fn list_on_app_chain(
pool: &PgPool,
app_id: AppId,
) -> Result<Vec<InterceptorMarker>, sqlx::Error> {
let rows: Vec<(String, String, String)> = sqlx::query_as(&format!(
let rows: Vec<(String, String, String, String)> = sqlx::query_as(&format!(
"{CHAIN_LEVELS_CTE} \
SELECT DISTINCT ON (i.service, i.op) i.service, i.op, i.interceptor_script \
SELECT DISTINCT ON (i.service, i.op, i.phase) \
i.service, i.op, i.phase, i.interceptor_script \
FROM chain c \
JOIN interceptors i ON (i.app_id = c.app_owner OR i.group_id = c.group_owner) \
ORDER BY i.service, i.op, c.depth ASC",
ORDER BY i.service, i.op, i.phase, c.depth ASC",
))
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(service, op, script)| InterceptorMarker {
.map(|(service, op, phase, script)| InterceptorMarker {
service,
op,
phase,
script,
})
.collect())
@@ -218,37 +223,38 @@ pub async fn insert_interceptor_tx(
owner: ScriptOwner,
service: &str,
op: &str,
phase: &str,
script: &str,
) -> Result<(), sqlx::Error> {
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) \
// The conflict arbiter includes `phase` to match the
// per-(owner, service, op, phase) index (§9.4 M3).
"INSERT INTO interceptors (app_id, service, op, phase, interceptor_script) \
VALUES ($1, $2, $3, $4, $5) \
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())
.bind(service)
.bind(op)
.bind(phase)
.bind(script)
.execute(&mut **tx)
.await?;
}
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) \
"INSERT INTO interceptors (group_id, service, op, phase, interceptor_script) \
VALUES ($1, $2, $3, $4, $5) \
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())
.bind(service)
.bind(op)
.bind(phase)
.bind(script)
.execute(&mut **tx)
.await?;
@@ -257,30 +263,37 @@ pub async fn insert_interceptor_tx(
Ok(())
}
/// Delete a marker at `owner` (by `(service, op)`), in the apply transaction.
/// Used by `--prune` when the manifest stops declaring it.
/// Delete a marker at `owner` (by `(service, op, phase)`), in the apply
/// transaction. Used by `--prune` when the manifest stops declaring it.
pub async fn delete_interceptor_tx(
tx: &mut Transaction<'_, Postgres>,
owner: ScriptOwner,
service: &str,
op: &str,
phase: &str,
) -> Result<(), sqlx::Error> {
match owner {
ScriptOwner::App(a) => {
sqlx::query("DELETE FROM interceptors WHERE app_id = $1 AND service = $2 AND op = $3")
.bind(a.into_inner())
.bind(service)
.bind(op)
.execute(&mut **tx)
.await?;
sqlx::query(
"DELETE FROM interceptors \
WHERE app_id = $1 AND service = $2 AND op = $3 AND phase = $4",
)
.bind(a.into_inner())
.bind(service)
.bind(op)
.bind(phase)
.execute(&mut **tx)
.await?;
}
ScriptOwner::Group(g) => {
sqlx::query(
"DELETE FROM interceptors WHERE group_id = $1 AND service = $2 AND op = $3",
"DELETE FROM interceptors \
WHERE group_id = $1 AND service = $2 AND op = $3 AND phase = $4",
)
.bind(g.into_inner())
.bind(service)
.bind(op)
.bind(phase)
.execute(&mut **tx)
.await?;
}