feat(interceptors): hook set_if (CAS) + pic interceptors ls (§9.4 M12)

Closes the §9.4 track:
- set_if (compare-and-swap), per-app and shared, now runs the same (kv, set)
  before/after interceptor as set — otherwise CAS was a silent bypass of a set
  policy. The before-hook can transform the new value; the after-hook sees the
  swapped bool.
- read-only pic interceptors ls --app|--group: app shows the RESOLVED chain
  (every marker guarding its writes, nearest-owner-wins), group its own markers.
  New apply_service::interceptor_report + InterceptorInfo, /apps|groups/{id}/
  interceptors routes (AppRead/GroupScriptsRead), client + cmd mirroring
  extension-points.

CLAUDE.md updated: §9.4 service interceptors are now COMPLETE (M1-M12).
Pinned by a journey: set_if of a guarded key is denied while a free key swaps,
and interceptors ls lists the kv/set guard (12 interceptor journeys green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-16 20:44:42 +02:00
parent ef16d48a8c
commit 0dd03af0c6
9 changed files with 289 additions and 14 deletions

View File

@@ -215,12 +215,38 @@ fn register_set_if(engine: &mut RhaiEngine) {
expected: Dynamic,
new: Dynamic|
-> Result<bool, Box<EvalAltResult>> {
let h = handle.clone();
let exp = expected_from_dynamic(&expected)?;
let new = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
block_on("kv", async move {
h.service.set_if(&h.cx, &h.collection, key, exp, new).await
})
let json = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
// §9.4 M12: CAS is a WRITE, so it runs the `(kv, set)` guard too —
// otherwise `set_if` would be a silent bypass of a `set` policy.
let write_val = super::interceptor::run_before(
&handle.ictx,
&handle.cx,
"kv",
"set",
&handle.collection,
key,
Some(&json),
)?
.unwrap_or(json);
let written = write_val.clone();
let h = handle.clone();
let swapped = block_on("kv", async move {
h.service
.set_if(&h.cx, &h.collection, key, exp, write_val)
.await
})?;
super::interceptor::run_after(
&handle.ictx,
&handle.cx,
"kv",
"set",
&handle.collection,
key,
Some(&written),
serde_json::Value::Bool(swapped),
)?;
Ok(swapped)
},
);
}
@@ -395,12 +421,25 @@ fn register_group_set_if(engine: &mut RhaiEngine) {
expected: Dynamic,
new: Dynamic|
-> Result<bool, Box<EvalAltResult>> {
let h = handle.clone();
let exp = expected_from_dynamic(&expected)?;
let new = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
block_on("kv", async move {
h.service.set_if(&h.cx, &h.collection, key, exp, new).await
})
let json = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
// §9.4 M12: CAS on a shared collection runs the `(kv, set)` guard too.
let write_val = group_run_before(handle, "set", key, Some(&json))?.unwrap_or(json);
let written = write_val.clone();
let h = handle.clone();
let swapped = block_on("kv", async move {
h.service
.set_if(&h.cx, &h.collection, key, exp, write_val)
.await
})?;
group_run_after(
handle,
"set",
key,
Some(&written),
serde_json::Value::Bool(swapped),
)?;
Ok(swapped)
},
);
}