Skip to content

Constraints

Scopes name what an agent is allowed to do. A constraint rides alongside a scope and names when, where, or how much — geofence, time-of-day, speed cap, amount cap, rate cap. The verifier evaluates every constraint against the runtime VerifierContext you supply.

Fail-closed by design:

  • If the constraint’s value is outside the allowed rangeconstraint_denied.
  • If the runtime context required to decide is not suppliedconstraint_unverifiable.
  • If the constraint’s type is not recognized by this SDK build → constraint_unknown.

In other words: the verifier never falls back to “allow because I’m not sure.”

Seven first-class types are built into every SDK. They are byte-for-byte identical across Go, TypeScript, Python, and Rust — the canonical wire-format fixtures cover all seven.

typeWhat it boundsRequired VerifierContext
geo_circlePosition inside a haversine-radius circlecurrent_lat, current_lon
geo_polygonPosition inside a polygon (≥3 vertices)current_lat, current_lon
geo_bboxPosition inside a lat/lon (+ optional alt) boxcurrent_lat, current_lon (+ current_alt_m if set)
time_windowLocal wall-clock time in [start, end](none — verifier clock is enough)
max_speed_mpsCurrent velocity ≤ max_mpscurrent_speed_mps
max_amountRequested amount ≤ max_amount in currencyrequested_amount, requested_currency
max_ratecount invocations per rolling window_srate-counter callback

Unknown types are rejected (constraint_unknown). New types require either a SPEC bump or an extension ConstraintEvaluator (SPEC §17.7) — see Provider architecture.

{
"type": "geo_circle",
"lat": 37.7749,
"lon": -122.4194,
"radius_m": 500
}

Haversine distance on WGS-84. Valid only when the agent is within radius_m meters of (lat, lon).

ctx := ratify.VerifierContext{
HasLocation: true,
CurrentLat: 37.7751, // ~30 m from center
CurrentLon: -122.4190,
}
result := ratify.Verify(bundle, ratify.VerifyOptions{
RequiredScope: "drone:deliver",
Context: ctx,
})

If HasLocation == false the result is constraint_unverifiable — not denied, unverifiable.

{
"type": "geo_polygon",
"points": [
[37.7755, -122.4200],
[37.7755, -122.4180],
[37.7745, -122.4180],
[37.7745, -122.4200]
]
}

points is a list of [lat, lon] pairs. At least 3 vertices required; winding order is irrelevant. Inclusion is tested with ray-casting in equirectangular projection.

v1 limitation (fail-closed): polygons whose longitude span exceeds 180° (i.e., that cross the anti-meridian) are rejected with constraint_denied rather than silently returning a wrong answer. For very large or anti-meridian-crossing regions, model the boundary with a sequence of sub-polygons or use a ConstraintEvaluator extension (geodesic semantics are planned for v2).

{
"type": "geo_bbox",
"min_lat": 37.7700,
"max_lat": 37.7800,
"min_lon": -122.4250,
"max_lon": -122.4150,
"min_alt_m": 0,
"max_alt_m": 120
}

Rectangular bounding box. Altitude bounds are only enforced if either min_alt_m or max_alt_m is non-zero — set both to zero to ignore altitude entirely.

geo_bbox is the only geo type with anti-meridian semantics built in: if min_lon > max_lon, the box is interpreted as wrapping the 180° meridian. For example, {"min_lon": 170, "max_lon": -170} means “from 170°E eastward across the date line to 170°W.”

{
"type": "time_window",
"tz": "America/Los_Angeles",
"start": "06:00",
"end": "22:00"
}

Local wall-clock window in the IANA-named timezone tz. Inclusive at both ends. No VerifierContext input is required — the verifier uses its own clock.

Wrapping is supported: start: "22:00", end: "06:00" means “10 PM through 6 AM the next day.”

# Cert says "weekdays 6am–10pm Pacific"; the verifier's own clock decides
result = verify_bundle(
bundle,
VerifyOptions(required_scope="physical:move"),
)

Times are validated as HH:MM 24-hour clock. Malformed values fail closed.

{
"type": "max_speed_mps",
"max_mps": 5.0
}

The agent’s current velocity must be ≤ max_mps meters per second (SI units — no mph hidden conversions).

let mut ctx = VerifierContext::default();
ctx.current_speed_mps = Some(3.2); // ~7 mph
let result = verify_bundle(&bundle, &VerifyOptions {
required_scope: "robot:operate".into(),
context: ctx,
..VerifyOptions::default()
});

Without current_speed_mps, the result is constraint_unverifiable.

{
"type": "max_amount",
"max_amount": 500.0,
"currency": "USD"
}

The requested amount must be ≤ max_amount and the currency must match exactly (ISO 4217 codes; case-sensitive on the wire). A USD constraint will deny a EUR request with constraint_denied: currency mismatch.

const result = await verifyBundle(bundle, {
required_scope: "payment:execute",
context: {
requested_amount: 75.0,
requested_currency: "USD",
},
});

The protocol does no FX conversion. If you grant max_amount: 500 USD and the agent presents a EUR payment, the cert is denied — even if 75 EUR < 500 USD by today’s rate. This is intentional: FX feeds are out of scope for an authorization protocol.

{
"type": "max_rate",
"count": 10,
"window_s": 3600
}

At most count exercises of this specific cert (matched by cert_id) within a rolling window_s seconds. Both must be positive integers — malformed values fail closed.

max_rate requires you to supply a callback that answers “how many times has this cert been used in the last N seconds?”:

ctx := ratify.VerifierContext{
InvocationsInWindow: func(certID string, windowS int64) int {
return myRateLimiter.Count(certID, windowS)
},
}

This is the one constraint that needs persistent state across verifications — the rest are pure functions of (constraint, context, now). Most implementations back the counter with Redis or a database keyed on (cert_id, window_bucket).

Without the callback, the result is constraint_unverifiable.

The full set of fields the seven built-in constraints can read:

FieldTypeRead by
current_lat, current_lonfloat64geo_circle, geo_polygon, geo_bbox
current_alt_mfloat64geo_bbox (when altitude bounds set)
current_speed_mpsfloat64max_speed_mps
requested_amountfloat64max_amount
requested_currencystring (ISO 4217)max_amount
invocations_in_window(cert_id, window_s) -> intcallbackmax_rate

In Go and Rust, presence is signaled by boolean flags (HasLocation, HasSpeed, HasAmount); in TypeScript and Python, by leaving the field undefined / None. The canonical context hash (used inside PolicyVerdict, SPEC §17.6) treats “flag unset” and “field is None” identically across SDKs — so a verdict signed by a Go verifier is consumable by a Rust verifier on the exact same context.

A sub-delegation chain compounds constraints — every cert’s constraints must pass.

Alice → Agent-A constraint: geo_bbox (warehouse footprint)
Agent-A → Agent-B constraint: time_window (06:00–08:00 PT)
Agent-B's bundle is valid iff:
✓ inside the warehouse (Alice's constraint)
✓ at 6 AM–8 AM PT (Agent-A's constraint)

Constraints never weaken down the chain. If you want a wider envelope downstream, the upstream cert has to grant it explicitly.

Outcomeidentity_statuserror_reason prefix
All constraints satisfiedauthorized_agent(empty)
A value is out of rangeconstraint_deniedconstraint[i] (geo_circle): outside allowed radius: 612.3m > 500.0m
A required input is missingconstraint_unverifiableconstraint[i] (max_speed_mps): no current speed in context
The type is unknown to this SDKconstraint_unknownconstraint[i] (foo): constraint_unknown: unknown constraint type "foo"

error_reason always carries the cert chain index and the constraint type, so logs are machine-parsable.

If you need a constraint vocabulary the v1 set doesn’t cover (e.g., org_membership, device_attestation), register a ConstraintEvaluator in VerifyOptions.ConstraintEvaluators:

opts := ratify.VerifyOptions{
RequiredScope: "internal:do_thing",
ConstraintEvaluators: map[string]ratify.ConstraintEvaluator{
"org_membership": myOrgMembershipEvaluator,
},
}

The built-in seven always win — registering a geo_circle evaluator is a no-op. Unknown types are routed to your registry; if no entry matches, the verifier returns constraint_unknown (fail-closed).

See Provider architecture for the full evaluator interface and lifecycle.