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

@@ -172,9 +172,9 @@ fn register_set(engine: &mut RhaiEngine) {
"set",
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
// §9.4 before-op interceptor (allow/deny). A denial errors here and
// the write below never runs.
super::interceptor::run_before(
// §9.4 before-op interceptor (allow/deny + M4 data-transform). A
// denial errors here; a returned `data` rewrites the written value.
let write_val = super::interceptor::run_before(
&handle.ictx,
&handle.cx,
"kv",
@@ -182,11 +182,24 @@ fn register_set(engine: &mut RhaiEngine) {
&handle.collection,
key,
Some(&json),
)?;
)?
.unwrap_or(json);
let written = write_val.clone();
let h = handle.clone();
block_on("kv", async move {
h.service.set(&h.cx, &h.collection, key, json).await
})
h.service.set(&h.cx, &h.collection, key, write_val).await
})?;
// §9.4 M3 after-hook (observe/audit; the write already committed).
super::interceptor::run_after(
&handle.ictx,
&handle.cx,
"kv",
"set",
&handle.collection,
key,
Some(&written),
serde_json::Value::Null,
)
},
);
}
@@ -228,6 +241,7 @@ fn register_delete(engine: &mut RhaiEngine) {
engine.register_fn(
"delete",
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
// Delete carries no value, so the before-hook never transforms.
super::interceptor::run_before(
&handle.ictx,
&handle.cx,
@@ -238,9 +252,20 @@ fn register_delete(engine: &mut RhaiEngine) {
None,
)?;
let h = handle.clone();
block_on("kv", async move {
let was_present = block_on("kv", async move {
h.service.delete(&h.cx, &h.collection, key).await
})
})?;
super::interceptor::run_after(
&handle.ictx,
&handle.cx,
"kv",
"delete",
&handle.collection,
key,
None,
serde_json::Value::Bool(was_present),
)?;
Ok(was_present)
},
);
}
@@ -252,7 +277,7 @@ fn group_run_before(
op: &'static str,
key: &str,
value: Option<&serde_json::Value>,
) -> Result<(), Box<EvalAltResult>> {
) -> Result<Option<serde_json::Value>, Box<EvalAltResult>> {
super::interceptor::run_before(
&handle.ictx,
&handle.cx,
@@ -264,6 +289,25 @@ fn group_run_before(
)
}
fn group_run_after(
handle: &GroupKvHandle,
op: &'static str,
key: &str,
value: Option<&serde_json::Value>,
result: serde_json::Value,
) -> Result<(), Box<EvalAltResult>> {
super::interceptor::run_after(
&handle.ictx,
&handle.cx,
"kv",
op,
&handle.collection,
key,
value,
result,
)
}
fn register_list(engine: &mut RhaiEngine) {
// Zero-arg form — full page, no cursor.
engine.register_fn(
@@ -330,12 +374,15 @@ fn register_group_set(engine: &mut RhaiEngine) {
"set",
|handle: &mut GroupKvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
// §9.4 before-op interceptor also guards shared-collection writes.
group_run_before(handle, "set", key, Some(&json))?;
// §9.4 before-op interceptor also guards shared-collection writes
// (allow/deny + M4 data-transform).
let write_val = group_run_before(handle, "set", key, Some(&json))?.unwrap_or(json);
let written = write_val.clone();
let h = handle.clone();
block_on("kv", async move {
h.service.set(&h.cx, &h.collection, key, json).await
})
h.service.set(&h.cx, &h.collection, key, write_val).await
})?;
group_run_after(handle, "set", key, Some(&written), serde_json::Value::Null)
},
);
}
@@ -376,9 +423,17 @@ fn register_group_delete(engine: &mut RhaiEngine) {
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
group_run_before(handle, "delete", key, None)?;
let h = handle.clone();
block_on("kv", async move {
let was_present = block_on("kv", async move {
h.service.delete(&h.cx, &h.collection, key).await
})
})?;
group_run_after(
handle,
"delete",
key,
None,
serde_json::Value::Bool(was_present),
)?;
Ok(was_present)
},
);
}