# Interceptor

## Concept

An interceptor pipeline is a state machine that threads an immutable
`Context` value through a list of nodes carrying up to three handlers
each (`enter`, `leave`, `error`), with three traversal modes: forward
on the success path, backward on the success unwind, and error-aware
backward on failure — with explicit support for compensation and
cleared-error transitions.

## Shape

Every interceptor implementation carries these invariants (see
`code-design.md` §Interceptors for the full vocabulary):

- **Three handlers per node.** Each interceptor declares up to three
  handlers: `enter` (success-path forward), `leave` (success-path
  backward), `error` (failure-path backward). Any subset is admissible
  including all-three, just-enter, just-leave, enter-and-error.
- **Three traversals over the same node sequence.** Forward
  (`enter`, positions 1 → n), backward (`leave`, positions n → 1, only
  for nodes whose enter completed), error-aware backward
  (`error`, from failing position back to 1).
- **Immutable Context.** Handlers do not mutate the Context they
  receive; they return new Contexts. The runtime threads them.
- **Context-blind effect functions.** Per `code-design.md` Rule 2
  (Effect-then-interceptor; advisory gate
  `code_design.effect_inline_advisory`), the actual effect work lives
  in a Context-blind function called by the enter handler.
  Interceptors orchestrate Context state; effects do the work.
- **Reusable, dependency-injected interceptors.** Per `code-design.md`
  Rule 3 (Interceptors are reusable and context-agnostic; advisory
  gate `code_design.interceptor_reuse_advisory`), an interceptor
  depends only on the shape of the Context (the field names and types
  it reads and writes), never on which pipeline the Context came
  from. Factory functions inject declared dependencies; no
  module-level singletons.
- **Cleared-error transition.** An error handler that returns a
  Context without the error indicator causes the runtime to flip back
  from error-traversal to leave-traversal at that position.
  Compensation lives here.
- **Compensation-pair admissible.** An interceptor whose leave handler
  does cleanup on the success path and whose error handler does the
  same cleanup plus clears on the failure path is a valid shape —
  same effect, two entry points.

## Forecloses

When a function is designed as an interceptor stack, the following
designs are explicitly *not* the right choice for the same problem:

- **Plain pipeline.** Interceptors carry a Context-threading lifecycle
  the pure pipeline pattern explicitly does not have. If the problem
  has no backward traversal, no error-aware unwind, and no
  compensation needs, choose pipeline — but choosing interceptor
  forecloses the simpler pipeline shape.
- **Inline try/except scattered through procedural code.** The
  failure shape of an interceptor stack is the unified error
  traversal across all nodes from the failing position back to the
  start. Inline try/except mixed into the steps gives the same
  appearance without the same semantics — no compensation pairing,
  no cleared-error transition, no uniformity of unwind.
- **Effect-owning interceptors.** An interceptor whose enter handler
  does the actual IO work directly (without delegating to a
  Context-blind effect function) is not the right shape. The
  effect-then-interceptor split is load-bearing: it lets the effect
  be tested standalone with mocked IO and the interceptor be tested
  with a stub effect. An interceptor that owns the effect collapses
  those two test surfaces into one harder-to-isolate surface.
- **Mutating Context.** A handler that modifies the Context it
  received instead of returning a new one is participating in a
  different abstraction. Mutation breaks the immutability invariant
  and the trajectory-of-values reading of pipeline execution.
- **Pipeline-aware interceptors.** An interceptor that knows which
  pipeline it lives in (reads a pipeline-specific global, imports a
  pipeline-specific singleton) violates the substitutability
  invariant from `code-design.md` §Composability. Per Rule 3, such
  interceptors must be refactored to take their dependencies as
  parameters.

## Judgment probes

These checks verify that a function actually implements the
interceptor pattern:

- **Context-shape declaration.** Can the interceptor's read-set and
  write-set on the Context be enumerated by reading the interceptor
  in isolation? If the read-set is "whatever the Context carries when
  it gets to me," the interceptor is implicitly coupled to specific
  upstream interceptors and is not substitutable.
- **Effect-then-interceptor split present.** For each interceptor
  that does IO work, locate the underlying Context-blind effect
  function. If the IO is inlined in the enter handler with no
  standalone callable, Rule 2's split is missing.
- **Three-traversal completeness.** Does the runtime implement all
  three traversal modes correctly? In particular: does the leave
  traversal skip nodes whose enter did not complete? Does the error
  traversal start from the failing position (not the head)? Does the
  cleared-error transition flip back to leave-traversal at that
  position?
- **Compensation-pair shape audit.** For interceptors that allocate
  external resources (open files, acquired locks, started
  transactions), is there a corresponding compensation pair (leave +
  error both cleaning up) — or is there a single-traversal cleanup
  that will be skipped on the failure path?
- **Reusability across pipelines.** Can this interceptor be
  instantiated in two different pipelines (release flow + migration
  flow + daemon flow per the project's existing usage) without
  modification? If not, Rule 3's reusability constraint is violated.
- **Immutability check.** Search the handlers for any mutation of the
  Context — in-place field assignment, in-place mutation of nested
  collections, language-specific equivalents. Any such mutation
  breaks the invariant; handlers must return new Contexts.

## Render: Python

Python's idiomatic interceptor rendering is the existing project
implementation at `mcp-server/src/forge_manager_mcp/interceptors.py`
(landed via #292): a frozen `Interceptor` dataclass carrying three
optional `Handler` callables, threaded by a runtime over an immutable
frozen `Context` dataclass. Schema-as-data Rule 5 governs the dataclass
construction — `_context_class()` and `_interceptor_class()` are
`@cache`-wrapped factories returning the class. Handlers return a new
Context via `dataclasses.replace`; the runtime maintains the queue /
stack / cleared-error transitions.

```python
from collections.abc import Callable
from dataclasses import dataclass, field, replace
from functools import cache
from typing import Any

@cache
def _context_class() -> type:
    @dataclass(frozen=True, slots=True)
    class Context:
        request: dict = field(default_factory=dict)
        response: dict = field(default_factory=dict)
        queue: tuple = ()
        stack: tuple = ()
        error: BaseException | None = None
    return Context

Context = _context_class()
Handler = Callable[[Any], Any]  # (Context) -> Context

@dataclass(frozen=True, slots=True)
class Interceptor:
    enter: Handler | None = None
    leave: Handler | None = None
    error: Handler | None = None
    name: str | None = None

def _caged(handler: Handler | None, ctx):
    """Run handler with exception capture; None handler is no-op."""
    if handler is None:
        return ctx
    try:
        return handler(ctx)
    except BaseException as exc:  # noqa: BLE001 — capture-to-context
        return replace(ctx, error=exc)

def execute(chain: list, request: dict):
    ctx = Context(request=request, queue=tuple(chain))
    # Forward (enter) traversal
    while ctx.queue and ctx.error is None:
        head, *rest = ctx.queue
        ctx = replace(ctx, queue=tuple(rest), stack=ctx.stack + (head,))
        ctx = _caged(head.enter, ctx)
    # Backward (leave / error) traversal
    while ctx.stack:
        top = ctx.stack[-1]
        ctx = replace(ctx, stack=ctx.stack[:-1])
        if ctx.error is not None:
            ctx = _caged(top.error, ctx)  # cleared-error: handler may clear
        else:
            ctx = _caged(top.leave, ctx)
    return ctx

# Effect-then-interceptor split (Rule 2): the effect is a Context-blind
# function; the interceptor's enter handler wires it to Context fields.
def _post_comment_effect(*, repo: str, body: str, http) -> str:
    """Pure effect: explicit inputs, explicit output, no Context."""
    return http.post(f"/repos/{repo}/comments", json={"body": body})

def make_post_comment_interceptor(http) -> Interceptor:
    def enter(ctx):
        result = _post_comment_effect(
            repo=ctx.request["repo"],
            body=ctx.request["body"],
            http=http,
        )
        return replace(ctx, response={**ctx.response, "comment_id": result})
    return Interceptor(enter=enter, name="post_comment")
```

### Probes

- **factory-produced Context/Interceptor.** Walk module-level
  definitions; flag any `ClassDef` named `Context` or `Interceptor` not
  produced from a `@cache`-decorated factory function (per Rule 5
  `code_design.schema_as_functions`). The existing
  `_context_class()` / `_interceptor_class()` shape is the reference.
- **frozen dataclass shape.** For each Context/Interceptor dataclass
  definition, assert `@dataclass` is called with `frozen=True`. AST: walk
  `decorator_list` for `Call(func=Name("dataclass"))` and check
  `frozen=True` is in the keywords with value `Constant(True)`.
- **handler returns Context, no mutation.** Each handler (`enter`,
  `leave`, `error`) must return a Context. AST: walk handler body for
  `Assign` whose target is an `Attribute` access on the ctx parameter
  (`ctx.field = ...`). Any such assignment violates immutability.
  `replace(ctx, ...)` or fresh `Context(...)` construction is the only
  admissible mutation vehicle.
- **effect-then-interceptor split.** For each `make_<name>_interceptor`
  factory whose returned Interceptor has an `enter` handler that does IO
  work, verify the IO is delegated to a Context-blind effect function
  (one whose signature does not name `Context` or `ctx`). AST: locate
  `Call` nodes inside the enter handler whose targets resolve to
  functions taking no `ctx`-typed parameter.
- **three-traversal completeness.** For each runtime function
  (`execute` / `aexecute`), assert presence of: (a) a forward loop over
  `ctx.queue` with `ctx.error is None` guard, (b) a backward loop over
  `ctx.stack`, (c) a conditional dispatch inside the backward loop on
  `ctx.error is not None` selecting error vs leave. Missing any of the
  three breaks Three-traversal completeness.
- **no module-level state read in handlers.** AST-walk each handler body
  for `Name` loads that resolve to module-level non-callable values
  (anything other than other functions, types, or constants). Any such
  read violates Rule 3 `interceptor_reuse_advisory`.
- **dependency injection at factory boundary.** Each
  `make_<name>_interceptor` factory must accept its dependencies as
  parameters (HTTP client, clock, RNG). AST: a factory whose body
  closes over `Name` loads resolving to `import` targets named in the
  effect-doing set (`httpx`, `requests`, `subprocess`, `time`, `random`)
  violates Rule 10 `injectable_seams`.

### Example

`mcp-server/src/forge_manager_mcp/interceptors.py` —
`_context_class`, `_interceptor_class`, `_caged`, `_enter`, `_leave`,
`execute`, and `aexecute` together are the canonical interceptor runtime
this rendering models. Any new interceptor implementation in
`forge_manager_mcp` imports from this module rather than re-deriving the
runtime.

### Anti-patterns

- **Stateful class with `self.context` mutation.** A class whose
  `enter(self)` / `leave(self)` methods mutate `self.context` is not
  this pattern — it is GoF Observer / Visitor. Violates Immutable Context
  (Forecloses §Mutating Context); the test surface degrades because each
  test must reconstruct the class plus prior mutation history.
- **`enter` handler that calls `requests.post(url)` inline.** The IO
  lives in the handler; no Context-blind effect function exists to test
  standalone. Violates Effect-then-interceptor split (Forecloses
  §Effect-owning interceptors) and Rule 2's
  `effect_inline_advisory`.
- **Singleton interceptor referencing module-level state.** An
  interceptor whose `enter` does `from forge_manager_mcp.config import
  CONFIG; CONFIG.foo` is pipeline-aware — it cannot be instantiated in a
  second pipeline without unwiring the config import (Forecloses
  §Pipeline-aware interceptors; violates Rule 3
  `interceptor_reuse_advisory`).

## Render: Go

Go's idiomatic interceptor rendering is the `net/http` middleware
pattern: a node wraps an inner handler, threading an immutable
`Context` value (Go's `context.Context` for cancellation/deadlines plus
a domain-specific Context struct for handler state) through a chain.
The three-handler shape (enter / leave / error) is rendered as an
explicit `Interceptor` struct rather than the more common
single-function middleware, because middleware-as-a-single-function
collapses the enter/leave distinction into wrapper code that the
runtime cannot inspect node-by-node.

```go
package interceptor

// Context is an immutable value handlers receive and return new
// copies of. Handlers do not mutate the Context they were given.
type Context struct {
	State map[string]any // copy-on-write; handlers produce new maps
	Err   error          // non-nil signals failure rail
}

// Handler is the shape of enter, leave, and error handler bodies.
// Returning a Context with Err set transitions to the error path;
// an error handler that returns a Context with Err == nil performs
// the cleared-error transition (flip back to leave-traversal).
type Handler func(Context) Context

// Interceptor declares up to three handlers. Any subset of fields
// may be nil; the runtime skips nil handlers at the corresponding
// traversal position.
type Interceptor struct {
	Name  string
	Enter Handler
	Leave Handler
	Error Handler
}

// Run executes the three-traversal state machine over a slice of
// interceptors. enterIdx tracks how far the forward traversal got
// (the leave traversal must skip nodes whose Enter did not complete).
func Run(stack []Interceptor, ctx Context) Context {
	enterIdx := -1
	for i, ic := range stack {
		if ic.Enter != nil {
			ctx = ic.Enter(ctx)
		}
		enterIdx = i
		if ctx.Err != nil {
			break
		}
	}
	if ctx.Err != nil {
		for i := enterIdx; i >= 0; i-- {
			if stack[i].Error == nil {
				continue
			}
			ctx = stack[i].Error(ctx)
			if ctx.Err == nil {
				// cleared-error transition: resume leave-traversal
				// from this position back to head.
				for j := i - 1; j >= 0; j-- {
					if stack[j].Leave != nil {
						ctx = stack[j].Leave(ctx)
					}
				}
				return ctx
			}
		}
		return ctx
	}
	for i := enterIdx; i >= 0; i-- {
		if stack[i].Leave != nil {
			ctx = stack[i].Leave(ctx)
		}
	}
	return ctx
}
```

Effect-doing work belongs in Context-blind functions called *from*
`Enter`, not inlined in the handler:

```go
// effect-then-interceptor split: the IO call is a standalone
// Context-blind function; the interceptor orchestrates Context
// state and delegates the effect.
func writeRecord(id string, payload []byte) error { /* IO */ return nil }

func WriteRecordInterceptor(idKey, payloadKey string) Interceptor {
	return Interceptor{
		Name: "write-record",
		Enter: func(ctx Context) Context {
			id, _ := ctx.State[idKey].(string)
			payload, _ := ctx.State[payloadKey].([]byte)
			if err := writeRecord(id, payload); err != nil {
				next := copyState(ctx)
				next.Err = err
				return next
			}
			return ctx
		},
		Error: func(ctx Context) Context {
			// compensation pair: best-effort delete
			id, _ := ctx.State[idKey].(string)
			_ = deleteRecord(id)
			return ctx // does not clear Err
		},
	}
}
```

### Probes

- **Three-handler struct shape (AST).** Walk `*ast.StructType` for the
  declared `Interceptor` (or equivalent) and verify it has at most
  three handler-typed fields. A struct with `Before/After` only is
  middleware-shaped, not interceptor-shaped — the error handler is
  load-bearing for the cleared-error transition.
- **Context immutability (AST).** For each handler body, walk
  `*ast.AssignStmt` and flag any LHS that resolves to a field of the
  incoming `Context` parameter (`ctx.State[...] = ...`). Handlers must
  produce a new Context (typically via a `copyState` helper); in-place
  mutation of the received Context violates the immutability
  invariant.
- **Three-traversal completeness (runtime probe).** Locate the
  runtime's `Run` (or equivalent) function. Inspect its control flow:
  (1) it must track `enterIdx` so leave-traversal skips
  not-yet-entered nodes; (2) the error traversal must iterate from
  `enterIdx`, not from `len(stack) - 1`; (3) the cleared-error
  transition must be present (a handler returning `Err == nil`
  resumes leave-traversal from that position back). A runtime missing
  any of the three is incomplete.
- **Effect-then-interceptor split.** For each interceptor doing IO,
  the IO call (e.g. `http.Get`, `os.Open`, `db.Exec`) must appear in a
  package-level named function called *from* the Enter handler, not as
  a direct call inside the Enter literal. AST probe: walk Enter
  bodies, flag direct calls to known IO packages.
- **Reusability across pipelines.** Each interceptor must be
  constructed by a factory function taking declared dependencies as
  parameters (e.g. `WriteRecordInterceptor(idKey, payloadKey
  string)`). Probe: locate package-level singletons referenced inside
  handler bodies; their presence breaks Rule 3 reusability.
- **Compensation-pair audit.** For each interceptor whose Enter
  allocates a resource (opens file, starts transaction, acquires
  lock), assert that both `Leave` AND `Error` are non-nil and
  reference cleanup logic. Single-traversal cleanup (only `Leave`,
  no `Error`) will be skipped on the failure path.

### Example

`net/http`'s middleware chaining (`http.Handler` wrapped by
`func(http.Handler) http.Handler`) is the universally-recognized Go
interceptor reference, but it conflates enter and leave into a single
wrapper body. For the explicit three-traversal shape, `grpc-go`'s
`UnaryServerInterceptor` chain in `google.golang.org/grpc` is a
closer match — interceptors thread a context value and the runtime
manages the chain.

### Anti-patterns

- **`func(http.Handler) http.Handler` middleware standing in for
  interceptor.** Single-function middleware collapses Enter / Leave /
  Error into one wrapper body; it cannot express the cleared-error
  transition, and compensation pairing has to be hand-coded around
  `defer`. If the design needs the three-handler vocabulary, the
  middleware shape is inadequate.
- **Handler mutating `ctx.State[k] = v` on the received Context.**
  Mutation breaks the trajectory-of-values reading: subsequent
  handlers (or test assertions) cannot recover the pre-handler
  Context. Use `copyState(ctx); next.State[k] = v; return next`.
- **Enter handler doing IO directly.** An Enter body containing
  `http.Get(url)` or `os.WriteFile(...)` collapses the
  effect-then-interceptor split. The effect cannot be tested with a
  mocked IO surface independently of Context threading; the two
  surfaces are entangled.
- **Interceptor importing a pipeline-specific singleton.** A handler
  body that references `mypipeline.SharedDB` (a package-level
  singleton from a specific pipeline's package) ties the interceptor
  to that pipeline. Dependencies must be parameters of the factory
  function, not imports.

## Render: Ada

In Ada, an interceptor is a limited interface declaring three
primitive operations (`Enter`, `Leave`, `On_Error`), each taking and
returning a Context value. The chain is an array of class-wide
access values; the runtime walks it three times (forward enter,
backward leave, error-aware backward). Context is a private type
exported by a separate package; immutability is enforced by the
functional signature — handlers return new Contexts, they do not
mutate `in out` parameters. The cleared-error transition is a record
discriminant on the Context: `Has_Error : Boolean`, and an error
handler flips it from True back to False.

```ada
with Contexts; use Contexts;

package Interceptors is

   --  Limited interface: instances are reusable, dependency-injected.
   --  Each primitive returns a new Context — no mutation.
   type Interceptor is limited interface;

   function Enter    (Self : Interceptor; Ctx : Context) return Context
     is abstract;
   function Leave    (Self : Interceptor; Ctx : Context) return Context
     is abstract;
   function On_Error (Self : Interceptor; Ctx : Context) return Context
     is abstract;

   type Interceptor_Ref is access constant Interceptor'Class;
   type Chain is array (Positive range <>) of Interceptor_Ref;

   --  Three-traversal runtime. Tracks the high-water mark of
   --  successful enters so Leave skips nodes whose Enter never
   --  completed, and so error traversal starts at the failing
   --  position.
   function Run (C : Chain; Initial : Context) return Context
     with Post => (if C'Length = 0 then Run'Result = Initial);

end Interceptors;

package body Interceptors is

   function Run (C : Chain; Initial : Context) return Context is
      Ctx       : Context := Initial;
      Last_Done : Natural := 0;
   begin
      --  Forward (Enter) pass.
      for I in C'Range loop
         Ctx := C (I).all.Enter (Ctx);
         exit when Has_Error (Ctx);
         Last_Done := I;
      end loop;

      if Has_Error (Ctx) then
         --  Error-aware backward pass from failing position.
         for I in reverse C'First .. Last_Done + 1 loop
            Ctx := C (I).all.On_Error (Ctx);
            --  Cleared-error transition: flip back to Leave-traversal.
            exit when not Has_Error (Ctx);
         end loop;
      end if;

      --  Backward (Leave) pass, only over nodes whose Enter completed.
      if not Has_Error (Ctx) then
         for I in reverse C'First .. Last_Done loop
            Ctx := C (I).all.Leave (Ctx);
         end loop;
      end if;

      return Ctx;
   end Run;

end Interceptors;
```

### Probes

- **Interceptor is declared as `limited interface`.** A concrete
  tagged record with no abstract primitives is not the interceptor
  contract surface — substitutability requires the interface. Walk
  `TypeDecl` for `Interceptor`; assert `f_type_def` is an
  `InterfaceTypeDef` with the `limited` keyword.
- **All three primitives return Context.** Each of `Enter`, `Leave`,
  `On_Error` must be a function (not a procedure) whose return type
  is `Context`. A `procedure Enter (Self; Ctx : in out Context)` is
  a mutation-shaped variant that violates the immutability
  invariant. (libadalang: walk the interface's `SubpSpec` list,
  assert each is a `SubpSpec` with `f_subp_kind = ada_subp_kind_function`
  and `f_returns` referring to `Context`.)
- **Context is a private type.** The `Context` type declaration in
  the supporting package must be `private` (or `limited private`),
  so handlers cannot reach inside and assign to fields. Walk the
  Contexts package public part; assert `Context` type's
  `f_type_def` resolves to a `PrivateTypeDef`.
- **No `in out` Context parameter anywhere in the interceptor
  package.** Search subprogram specs for any `ParamSpec` whose
  `f_mode` is `Mode_In_Out` and whose type is `Context`. Any hit
  flags an interceptor that mutates its argument.
- **Run tracks `Last_Done` (or equivalent high-water mark).** The
  Run body must declare a `Natural` (or `Positive` with zero
  sentinel) tracking how many Enters completed before failure.
  Without it the Leave pass cannot correctly skip never-entered
  nodes. Walk the Run body's `ObjectDecl` list; assert at least one
  variable whose initialisation is `0` is referenced by both the
  forward loop's exit / increment and the backward loop's bound.
- **Cleared-error transition exits the error loop.** Inside the
  reverse loop over `On_Error`, the body must contain an
  `exit when not Has_Error (Ctx)` (or equivalent guard) before the
  loop continues. Without this exit the cleared-error transition is
  unimplemented — once an error fires, the system stays on the
  error path even if a handler clears the error indicator.

### Example

AdaCore's GNATCOLL.Refcount + GNATCOLL.Traces patterns (resource
acquisition + structured logging around a transaction) are the
closest in-the-wild Ada examples of the compensation-pair idea, even
though they are not literally interceptor stacks. SPARK examples
under the `spark2014/testsuite/gnatprove/tests/` repository
illustrate the three-traversal style applied to verification
runners.

### Anti-patterns

- **Procedure-shaped handlers with `in out` Context.** Declaring
  `procedure Enter (Self : Interceptor; Ctx : in out Context)` lets
  handlers mutate the Context in place — looks like the same
  contract surface, breaks the immutability invariant entirely.
- **Singleton interceptor packages.** A package
  `Authentication_Interceptor` with a single nullary `Run` procedure
  that reads from `Authentication_State.Singleton` — the
  interceptor cannot be substituted, parametrised, or run twice in
  the same chain. Violates Rule 3's reusability constraint.
- **Effect-owning Enter handler.** An `Enter` body that calls
  `Ada.Text_IO.Put_Line` or opens a file directly — the
  effect-then-interceptor split is missing. The Context-blind
  effect function should live in a separate package, called from
  Enter; the inlined IO collapses the two test surfaces.

## Render: TypeScript

TypeScript's idiomatic interceptor rendering is the Pedestal shape
(sieppari lineage): node objects `{enter?, leave?, error?}` threading a
frozen context through a queue/stack executor. The context is
spread-copied, never property-assigned; the three traversal modes live
in one executor, not in the nodes.

```typescript
type Ctx = Readonly<Record<string, unknown> & { error?: unknown }>;

interface Interceptor {
  enter?: (ctx: Ctx) => Ctx;
  leave?: (ctx: Ctx) => Ctx;
  error?: (ctx: Ctx) => Ctx;
}

const execute = (nodes: ReadonlyArray<Interceptor>, ctx: Ctx): Ctx => {
  const stack: Interceptor[] = [];
  let current = ctx;
  for (const node of nodes) {           // forward
    stack.push(node);
    try {
      current = node.enter ? node.enter(current) : current;
    } catch (e) {
      current = { ...current, error: e };
      break;
    }
  }
  while (stack.length) {                // unwind (leave or error-aware)
    const node = stack.pop()!;
    current = current.error
      ? (node.error ? node.error(current) : current)
      : (node.leave ? node.leave(current) : current);
  }
  return current;
};

const audit: Interceptor = {
  enter: (ctx) => ({ ...ctx, startedAt: performance.now() }),
  leave: (ctx) => ({ ...ctx, elapsed: performance.now() }),
};
```

### Probes

Mechanical (wired in `_TYPESCRIPT_PROBES`):

- **three_handler_shape.** The function's node objects carry at least
  one of the three handler keys (`enter` / `leave` / `error`). A
  function declared interceptor whose text carries none of the three
  keys is not threading the interceptor shape.
- **context_not_mutated.** No property assignment on the context
  parameter (`ctx.x = ...`); the context evolves by spread copy
  (`{ ...ctx, x }`). Mutation breaks the immutable-context invariant
  (Forecloses §Context mutation).

Judgment (prose-only):

- **executor owns traversal.** Forward / unwind / error-aware modes
  live in one executor; nodes never call each other.
- **compensation lives in `leave` / `error`.** Cleanup is a handler,
  not a `finally` scattered across call sites.

### Example

The daemon's HTTP middleware-style request decoration is the natural
consumer; `mcp-server/src/forge_manager_mcp/interceptors.py` documents
the Python executor this mirrors.

### Anti-patterns

- **`ctx.foo = bar` inside a handler.** Context mutation — the
  frozen-context invariant is the pattern's core.
- **Handlers calling the next node directly (Express-style
  `next()`).** That is middleware-as-control-flow; the interceptor
  pattern keeps traversal in the executor so the chain is a value.
