feat(interceptors): §9.4 service interceptors — thin KV allow/deny slice

Smallest honest vertical slice of §9.4: a `[[interceptors]]` block (app OR
group) binds a script to run BEFORE `kv::set`/`delete`; it reads the operation
context (`ctx.request.body`: service, action, collection, key, value, caller
ids) and returns `#{ allowed, reason }` — `allowed == false` denies the op (the
write never runs, the caller gets a runtime error).

Reuses two existing mechanisms rather than inventing new ones:
- Registration mirrors extension points (§5.5): a marker table
  `0073_interceptors.sql` (owner-polymorphic app_id/group_id XOR, keyed
  (service, op) → script), `interceptor_repo` (insert/delete/list + the
  nearest-owner-wins `resolve_before` chain walk), reconciled through the
  declarative apply exactly like `vars` (create/update/delete, prunable).
- Execution reuses the `invoke()` re-entry path: the new `InterceptorService`
  (shared trait + Postgres-backed impl) only RESOLVES the script name (keeping
  executor-core Postgres-free); the executor's `sdk::interceptor::run_before`
  resolves that name and runs it via `run_resolved_blocking` (extracted from
  `invoke_blocking` — shared depth bound + AST cache). An un-hooked write pays
  one indexed `Ok(None)` resolve; no interceptor ⇒ zero overhead.

Nearest-owner-wins so an app overrides a group's interceptor, and a group
interceptor is inherited by every descendant app — the chain walk is the
isolation boundary (a sibling subtree never matches). `validate_bundle_for`
restricts the MVP to `service = "kv"`, `op ∈ {set, delete}`, one marker per
(service, op).

Deferred (documented in §9.4): the `data` transform return, services other
than kv, `after_*` hooks, chaining + circular-dependency guard, the timeout
policy, and a `pic interceptors ls` read surface (needs a server route).

Pinned by `tests/interceptors.rs` (deny blocks the write; allow passes;
group→app inheritance), schema snapshot re-blessed. 154/154 journeys pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 20:44:50 +02:00
parent fcd9451ab1
commit 2fc9476f9e
24 changed files with 1015 additions and 45 deletions

View File

@@ -308,6 +308,16 @@ table: groups
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: interceptors
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NULL
app_id: uuid NULL
service: text NOT NULL
op: text NOT NULL
interceptor_script: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: kv_entries
app_id: uuid NOT NULL
collection: text NOT NULL
@@ -663,6 +673,13 @@ indexes on groups:
groups_pkey: public.groups USING btree (id)
groups_slug_key: public.groups USING btree (slug)
indexes on interceptors:
interceptors_app_id_idx: public.interceptors USING btree (app_id) WHERE (app_id IS NOT NULL)
interceptors_app_uidx: public.interceptors USING btree (app_id, service, op) WHERE (app_id IS NOT NULL)
interceptors_group_id_idx: public.interceptors USING btree (group_id) WHERE (group_id IS NOT NULL)
interceptors_group_uidx: public.interceptors USING btree (group_id, service, op) WHERE (group_id IS NOT NULL)
interceptors_pkey: public.interceptors USING btree (id)
indexes on kv_entries:
idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection)
kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key)
@@ -933,6 +950,12 @@ constraints on groups:
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
[UNIQUE] groups_slug_key: UNIQUE (slug)
constraints on interceptors:
[CHECK] interceptors_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[FOREIGN KEY] interceptors_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] interceptors_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] interceptors_pkey: PRIMARY KEY (id)
constraints on kv_entries:
[FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key)
@@ -1121,3 +1144,4 @@ constraints on workflows:
0070: admin session absolute expiry
0071: workflows
0072: execution source workflow
0073: interceptors