feat(interceptors): extend before/after hooks to docs/files/queue/pubsub/http (§9.4 M7-M11)
Every non-KV mutating op now runs the same before/after interceptor machinery: - docs create/update/delete (+ group) — create/update honor the M4 data transform; - files create/update/delete (+ group) — allow/deny only (the blob is never surfaced to the hook, value = None); - queue enqueue (+ shared) — transform-capable; after reports the message id; - pubsub publish_durable (+ shared topic) — transform-capable; - http request — all verbs funnel through two svc.request sites; collection = method, key = url, body not surfaced. ictx threaded into the free-fn/handle paths that lacked it (http was _ictx; queue/pubsub per-app publish). validate_bundle_for generalized to a per-service allowed-ops map (kv set/delete; docs/files create/update/delete; queue enqueue; pubsub publish; http request) — unknown service/op still rejected. INVARIANT enforced + verified: every allowed (service, op) has a matching runtime hook, so no validated-but-unhooked fail-open. Pinned by a new docs-create deny journey (11 interceptor journeys green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1292,22 +1292,37 @@ impl ApplyService {
|
||||
for w in &bundle.workflows {
|
||||
validate_workflow_definition(&w.name, &w.definition).map_err(ApplyError::Invalid)?;
|
||||
}
|
||||
// §9.4 interceptors (MVP): `service = "kv"`, `op ∈ {set, delete}`,
|
||||
// `phase ∈ {before, after}` (§9.4 M3), authored on app OR group. One
|
||||
// §9.4 interceptors: a per-service allow-list of mutating ops
|
||||
// (`phase ∈ {before, after}`, §9.4 M3), authored on app OR group. One
|
||||
// marker per `(service, op, phase)` — a duplicate would collide on the
|
||||
// reconcile key and the DB's partial-unique index.
|
||||
//
|
||||
// INVARIANT: every `(service, op)` accepted here MUST have a matching
|
||||
// runtime before/after hook in `executor-core::sdk::<service>` — a
|
||||
// validated-but-unhooked op would be a silent fail-open bypass. The
|
||||
// hooks live in kv/docs/files/queue/pubsub/http (§9.4 M7–M11).
|
||||
let allowed_ops: &[(&str, &[&str])] = &[
|
||||
("kv", &["set", "delete"]),
|
||||
("docs", &["create", "update", "delete"]),
|
||||
("files", &["create", "update", "delete"]),
|
||||
("queue", &["enqueue"]),
|
||||
("pubsub", &["publish"]),
|
||||
("http", &["request"]),
|
||||
];
|
||||
let mut seen_hooks: HashSet<(String, String, String)> = HashSet::new();
|
||||
for i in &bundle.interceptors {
|
||||
if i.service != "kv" {
|
||||
let Some((_, ops)) = allowed_ops.iter().find(|(s, _)| *s == i.service) else {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"interceptor service `{}` is not supported — only `kv` (MVP)",
|
||||
"interceptor service `{}` is not supported — one of kv / docs / files / queue / pubsub / http",
|
||||
i.service
|
||||
)));
|
||||
}
|
||||
if i.op != "set" && i.op != "delete" {
|
||||
};
|
||||
if !ops.contains(&i.op.as_str()) {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"interceptor op `{}` is not supported for kv — only `set` / `delete`",
|
||||
i.op
|
||||
"interceptor op `{}` is not supported for `{}` — one of {}",
|
||||
i.op,
|
||||
i.service,
|
||||
ops.join(" / ")
|
||||
)));
|
||||
}
|
||||
if i.phase != "before" && i.phase != "after" {
|
||||
|
||||
Reference in New Issue
Block a user