Skip to main content
A stage is a unit of behavior attached to a VPoint. It comes in two forms:
  • A CEL stage — a small expression, pushed from a test through the coordinator and scoped to your session. This is the portable kind: it crosses process and language boundaries, so it’s what multi-service tests use.
  • A local stage — an ordinary Python or TypeScript callback attached in-process, with no coordinator, no session, and no CEL. This is the cheap kind: it only affects the process that attached it, which is exactly right for unit and single-service tests.
Both run in the same pipeline and obey the same rules about overriding and passing through. Start local when the code under test and the stage live in the same process; reach for CEL when they don’t.

The pipeline

Every VPoint call runs through a fixed sequence of anchors — slots where stages can attach:
This is the complete anchor set, and the original function always runs after the last anchor. A stage that produces a value short-circuits the rest of the pipeline; a stage that passes through lets execution continue. With no stages attached, the call takes a fast path straight to the default plan — the original function, exactly as written.

The three CEL stage patterns

Almost every coordinator-backed test uses one of three shapes. Inside a CEL expression, args.<param> reads the call’s inputs and invoke() executes the next stage or the real function. Override — replace the result entirely. The real function never runs:
Conditional override — intercept some calls, pass the rest through:
Probe — observe without changing behavior. sample() captures a value for the test to assert on and returns it unchanged:
The captured sample streams back to your test session, where you assert on it:

Validated against the schema

CEL stages compile against the VPoint’s schema. Type names, field names, and required fields are checked, and ChargeResult { txn: ... } is reported as a validation issue if the field is txnId — but that report is advisory: the insert succeeds and the sync barrier still passes. This is why inspecting the schema first (via the MCP tools or your SDK’s build-time output) beats guessing — a wrong field name costs you a silently inert stage rather than a failed insert. Key rules:
  • Inputs are read only through args.<param> — a bare parameter name is rejected at parse time.
  • Type names match the schema exactly and are case-sensitive; use the simple name (ChargeResult, not pb.ChargeResult).
  • Without invoke(), the expression fully replaces the function’s result.
  • ctx.<fn>(...) calls context functions your service registered.
The full expression language — operators, optionals, list predicates, let bindings, and current limitations — is in the CEL reference.

Local stages

The Python and TypeScript SDKs can attach a stage in-process, as a plain callback. Nothing is published, so there is no coordinator to run, no session to create, and no sync barrier to wait on — and the callback is ordinary code with a debugger and a stack trace, rather than a CEL string:
The contract is the same in both runtimes:
  • The callback receives a context with ctx.args (the VPoint’s named arguments, as the caller’s live objects), ctx.self (the receiver, for a class-based VPoint), and ctx.vp_lookup_name / ctx.vpLookupName.
  • Python takes argument names from the function signature. TypeScript erases them, so ctx.args is keyed arg0, arg1, … unless varianz-scan has run or the VPoint declares params explicitly.
  • Return the exported CONTINUE token to fall through to the next stage or the original function. Return anything else — including None/undefined — to override the result.
  • Overrides are converted against the VPoint’s declared return type; a mistyped override fails the call rather than handing the caller the wrong type. This is the callback contract, and for callback stages it holds. For CEL stages it is only partial in Python and in Go — see the warning below.
  • Callbacks must be synchronous. An async def / a returned Promise fails the call instead of being silently un-awaited.
  • Raising propagates to the caller unchanged, which is how you inject failures.
  • Attaching returns a stage handle with set_enabled/setEnabled, is_enabled/isEnabled, and remove. Scope one to a block with Python’s with or Node 23+‘s using; either way it’s removed on exit.
Per-language detail: Python SDK and TypeScript SDK, including the typed helpers the build-time scanners generate. Go and Java/Kotlin do not have a local-stage API — use CEL stages there.
A CEL stage result is not always converted against the declared return type. Callback stages convert in full and fail the call on a mismatch; CEL stages do not, and the gaps differ by runtime.Python. str, float and int returns are converted correctly — a CEL 42 on a VPoint declared -> str arrives as "42", and a CEL string on one declared -> float fails the call rather than handing over a str. Three cases are still unconverted and silent: a VPoint declared -> bool converts nothing at all; a list or map result leaks into a scalar-declared return; and a scalar result leaks into a container-declared return. The only signal is a validation warning at session teardown.Go. A CEL float or bool where the VPoint declares string is coerced, not rejected: 42.9 arrives as "42.9" and true as "true", with err == nil. The same holds for a struct field. Whether this should coerce or fail is an open product question, not a decided bug.Assert on the type as well as the value in tests whose CEL override matters, and prefer a callback stage where the type is load-bearing.
Local stages are not session-scoped: they apply to every call in that process, session or not. That’s the point in a unit test, and the reason not to attach one in a shared or production process. Session isolation, cross-service delivery, and samples are the CEL path.