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:
MechaCat02
2026-07-16 20:37:18 +02:00
parent faf04174c4
commit ef16d48a8c
8 changed files with 564 additions and 112 deletions

View File

@@ -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 M7M11).
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" {