Context-Aware Validation
Sometimes a rule depends on data that is not on the model — the logged-in user, a tenant configuration, the current time, a feature flag. Hard-coding such values into a constraint is wrong; reading them from a global is worse. iron-monk solves this with an explicit, read-only context mapping: you pass it to validate() (or validate_dict()), and constraints reach into it through a Ctx(...) marker.
Ctx is the twin of Ref: same compilation pipeline, same blueprint clone-per-call semantics, same zero-coercion guarantees. The only difference is where the value comes from — sibling field for Ref, context mapping for Ctx.
Ctx — Pulling Values from Context
from typing import Annotated
from monk import monk, validate
from monk.constraints import Interval, Ctx
@monk
class AgeGate:
age: Annotated[int, Interval(ge=Ctx("min_age"))]
validate(AgeGate(age=21), context={"min_age": 18}) # passes
validate(AgeGate(age=15), context={"min_age": 18}) # raises ValidationError
The context is supplied at the call site, so the same model can be validated against different policies in different requests:
admin_ctx = {"min_age": 13}
public_ctx = {"min_age": 18}
validate(AgeGate(age=15), context=admin_ctx) # passes
validate(AgeGate(age=15), context=public_ctx) # raises
Ctx works anywhere Ref works — inside Interval, Eq, Len, OneOf, Subset, ContainsKeys, MultipleOf, StartsWith, EndsWith, ExactLen, AnyOf, Not, Each, DictOf, etc. You can mix Ref and Ctx on the same field freely:
from monk.constraints import Interval, Ref, Ctx
@monk
class Bid:
floor: int
offer: Annotated[int, Interval(gt=Ref("floor"), le=Ctx("ceiling"))]
validate(Bid(floor=10, offer=50), context={"ceiling": 100})
Ref("floor") is resolved from the sibling field; Ctx("ceiling") is resolved from the call-site mapping.
validate_dict Supports Context Too
Schema-only validation (no instantiation) takes the same context= kwarg:
from monk import monk, validate_dict
from monk.constraints import Eq, Ctx
@monk
class PostSchema:
author_id: Annotated[int, Eq(Ctx("user_id"))]
title: str
validate_dict({"author_id": 5, "title": "Hi"}, PostSchema, context={"user_id": 5})
__monk_validate__ Receives Context
The model-level hook automatically receives the context iff its signature accepts a second positional argument. The arity is detected once at decoration time, so the hot path is a single attribute read.
from monk import monk, validate
@monk
class Post:
author_id: int
body: str
def __monk_validate__(self, context):
if self.author_id != context["user_id"]:
yield ("author_id", "Author must match logged-in user.")
validate(Post(author_id=5, body="..."), context={"user_id": 5}) # passes
A hook that does not declare the parameter still works exactly as before — iron-monk will not pass it:
Errors
There are two failure modes specific to context-aware validation:
| Situation | Outcome |
|---|---|
Ctx("k") is referenced but context= was not provided at the call site. |
MissingContextError is raised — programmer error, surfaced eagerly. |
Ctx("k") is referenced and context= was provided, but "k" is missing from the mapping. |
A normal ValidationError is raised, aggregated alongside other field errors, with code="MissingContextKey". |
The split is intentional: forgetting to pass context entirely is a wiring bug you want loud and immediate, whereas a missing key inside a partial context is a runtime data condition that belongs in the same error envelope as everything else the user typed wrong.
from monk import validate
from monk.exceptions import ValidationError, MissingContextError
# 1. Programmer error
try:
validate(AgeGate(age=21))
except MissingContextError:
... # context= was never threaded through
# 2. Data condition — normal aggregation
try:
validate(AgeGate(age=21), context={})
except ValidationError as e:
assert e.errors[0]["code"] == "MissingContextKey"
Nested @monk Models
Context is forwarded automatically when validation recurses into nested @monk-decorated values, so the inner model sees the same context the outer call passed in:
from typing import Annotated
from monk import monk, validate
from monk.constraints import Interval, Ctx
@monk
class Inner:
n: Annotated[int, Interval(ge=Ctx("min"))]
@monk
class Outer:
inner: Inner
validate(Outer(inner=Inner(n=10)), context={"min": 5}) # forwarded into Inner
Scope and Non-Goals
Ctx deliberately does not support:
@monk-wrapped functions. Function arguments are already explicit. Use a regular argument plusRef("other_arg")if you need cross-argument coupling.- Streams.
validate_streamandvalidate_async_streamoperate on isolated items without sibling context. - Thread-local or implicit context. All context flows through explicit kwargs. There is no
set_context()API. - I/O inside constraints.
Ctxresolves a value from the mapping you already prepared. It will not call your database or fetch a feature flag for you. If you need that, prepare the snapshot at the boundary of your request and pass it in.
These are firm lines: they keep iron-monk predictable, framework-free, and easy to audit.
When to Prefer Which
Ref— the value lives on the same instance being validated.Ctx— the value lives outside the model (request, session, tenant, feature flags, clock).__monk_validate__(self, context)— the rule does not fit a constraint at all and needs Python control flow over both fields and context.