7 Commits

Author SHA1 Message Date
MechaCat02
0dd03af0c6 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>
2026-07-16 20:44:42 +02:00
MechaCat02
ef16d48a8c 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>
2026-07-16 20:37:18 +02:00
MechaCat02
8b70d52d43 feat(interceptors): per-interceptor wall-clock timeout (§9.4 M5)
A [[interceptors]] marker can set timeout_ms (migration 0075, CHECK > 0); a
runaway guard (loop {}) is interrupted and the op DENIED (fail closed) within
budget rather than hanging the write path. The effective deadline is
min(caller-remaining, now + timeout) — a hook can only tighten, never extend,
its caller's deadline. NULL uses PICLOUD_INTERCEPTOR_TIMEOUT_MS (default 5s).

Wiring: run_resolved_blocking splits into a core + a _with_timeout variant that
computes the effective deadline from engine::ambient_deadline() and runs via
execute_ast_with_deadline; run_one_hook passes the marker's timeout_ms (or the
env default). timeout_ms threaded end to end — manifest, plan, BundleInterceptor,
the reconcile diff (part of the mutable body: a timeout change is an Update),
insert_interceptor_tx, resolve_chain/list_for_owner/list_on_app_chain +
SealedInterceptor/InterceptorMarker, and interceptor_service → ResolvedInterceptor.
Schema snapshot re-blessed. Pinned by a journey: a loop{} guard with
timeout_ms=100 is denied and its write does not persist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 20:18:03 +02:00
MechaCat02
5592f1c97e 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>
2026-07-16 19:55:44 +02:00
MechaCat02
be0c618672 feat(interceptors): ordered before-chains + cycle guard + ctx plumbing (§9.4 M1+M2)
M1: introduces a clone-cheap InterceptorCtx { interceptors, self_engine,
limits } threaded into every SDK register fn (kv/docs/files/queue/pubsub/http)
so the non-KV services carry the hook seam (unused until M7-M11); KvHandle
collapses its three fields into one ictx.

M2: replaces the nearest-only resolver with ordered before/after chains. The
trait becomes resolve(cx, service, op) -> InterceptorChain { before, after }
(migration 0074 adds a phase column + phase-aware unique indexes). The before
-chain runs ancestor->app (depth DESC) so a group compliance guard can't be
bypassed by a descendant; single-marker behavior is byte-identical to before.
An identity cycle guard (thread-local visited-set keyed by script_id) denies a
detected cycle, alongside the existing binary re-entrancy break. Fail-closed
verdict preserved (allow only on #{ allowed: true }; a Dangling entry or a
missing engine back-ref denies); app_id still derives from cx.app_id only.

after-chains resolve but stay unused until M3. Pinned by two new interceptor
journeys (ancestor->app chain ordering; self-referential no-recurse).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 22:54:51 +02:00
MechaCat02
d08df88df5 fix(interceptors): harden the §9.4 slice — seal, fail-closed, re-entrancy, shared coverage
An end-to-end audit of the (unmerged) interceptor slice surfaced seven ways a
KV allow/deny guard could be silently defeated or misbehave. Close all of them:

1. Shared-collection bypass — `kv::shared_collection(...).set/delete` skipped
   the hook entirely, so any `(kv, set/delete)` guard was circumvented by
   choosing the shared handle. `GroupKvHandle` now runs the same before-op hook.

2. Seal to the declaring owner — the marker resolved nearest-owner-wins but its
   script name was then re-resolved on the CALLER's chain, so a descendant app
   could shadow a group's mandatory guard with a same-named local script.
   `resolve_before` now returns the script sealed to the owner that declared the
   marker (one LEFT JOIN in manager-core), so the executor never re-resolves by
   name. A descendant can only override with its OWN explicit marker.

3. Re-entrancy — an "allow + audit-log" guard that itself wrote KV re-triggered
   itself to the depth cap and then DENIED the original write (plus ~8x
   resolve/compile/execute amplification). A thread-local guard makes a write
   performed by an interceptor bypass interception.

4. Fail-closed verdict — only `#{ allowed: false }` denied; a bare bool, a
   typo'd key, a non-bool, or a bare unit all ALLOWED. Now allow ONLY on an
   explicit `#{ allowed: true }`; every other shape denies.

5/6. Fail-closed edges — a dangling (missing/disabled) interceptor script and a
   missing engine back-reference now DENY instead of allowing.

7. AppInvoke coupling — resolution no longer routes through `invoke.resolve`, so
   installing a guard no longer denies writes to principals who hold KV-write
   but not AppInvoke.

The seam (`InterceptorService::resolve_before`) now returns an
`InterceptorResolution { None | Dangling | Run(ResolvedScript) }`; the executor
runs the sealed script straight through the shared `run_resolved_blocking` core
and drops its `InvokeService` dependency. executor-core stays Postgres-free.
No migration change (0073 unchanged).

Pinned by three new journeys in tests/interceptors.rs: the seal (a same-named
app script does NOT shadow the group's guard), shared-collection coverage, and
the fail-closed verdict. Full journey suite 157/157.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:20:20 +02:00
MechaCat02
2fc9476f9e 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>
2026-07-13 20:44:50 +02:00