| title | Evaluation Semantics |
|---|---|
| sidebar_label | Evaluation Semantics |
| sidebar_position | 3 |
| description | Deterministic evaluation behavior, predicate matching rules, and output semantics. |
This covers how Stave evaluates controls over observations and how results are produced.
Given the same:
- control files
- observation files
- CLI flags (including
--max-unsafe) --nowvalue
Stave produces identical output.
--now controls evaluation time for duration-based logic. For reproducible
CI runs, always set --now explicitly.
Observation snapshots are evaluated in ascending captured_at order.
- Duration checks use elapsed time across ordered snapshots.
- Recurrence checks count unsafe exposure windows in the configured window.
Each evaluated (control, asset) pair yields one decision row when
explain-all output is enabled.
Decision values:
VIOLATIONPASSINCONCLUSIVENOT_APPLICABLESKIPPED
The output summary aggregates violations and asset-level totals.
Control predicates defined in unsafe_predicate are compiled to
CEL (Common Expression Language)
expressions and evaluated by the cel-go runtime. This provides:
- Type-safe expression evaluation
- Thread-safe compiled program caching
- Deterministic results across platforms
The compilation pipeline:
- YAML
unsafe_predicaterules are parsed intopolicy.UnsafePredicate - The CEL compiler translates each predicate into a CEL expression
- Compiled programs are cached by expression string for reuse
- At evaluation time, asset properties are bound as CEL variables
all: logical AND — every rule must match for the predicate to be trueany: logical OR — at least one rule must match
Nested combinators are supported (e.g., any containing all blocks).
Field references use dot-separated paths into asset properties:
properties.storage.access.public_read
The CEL environment resolves these paths against the flattened asset property map at evaluation time.
Controls can reference dynamic values via value_from_param:
unsafe_predicate:
any:
- field: properties.storage.tags.data-classification
op: in
value_from_param: sensitive_classifications
params:
sensitive_classifications:
- phi
- piiParameters are resolved from the control's params map before CEL
compilation.
Common predicate patterns are available as named aliases (e.g.,
s3.is_public_readable, s3.has_full_control_public). Aliases expand to
full unsafe_predicate blocks at load time. See stave controls aliases
to list available aliases.
Supported operators in ctrl.v1:
| Operator | Description |
|---|---|
eq |
Equal (exact match) |
ne |
Not equal |
gt |
Greater than |
lt |
Less than |
gte |
Greater than or equal |
lte |
Less than or equal |
in |
Value is in a list |
missing |
Field does not exist |
present |
Field exists |
contains |
String/list contains value |
any_match |
Any element in list matches |
neq_field |
Not equal to another field's value |
not_in_field |
Value not in another field's list |
list_empty |
List field is empty |
not_subset_of_field |
Not a subset of another field's list |
any_in_field |
List field has at least one element also in another field's list (complement of not_subset_of_field) |
Important behavior for control authors:
- Missing fields do not satisfy
eq false— only explicitly setfalsetriggerseq false. - Missing fields can satisfy
ne <value>— absence counts as "not equal." missingandpresentare explicit existence checks.
Use explicit predicates for absent/optional data to avoid accidental matches.
Stave's predicate operators split into two camps when the field they test is absent. Knowing which camp matters when an extractor drops a field — different operators interpret the absence differently.
| Operator | Missing-field interpretation | Reason |
|---|---|---|
eq |
Field absent → rule does NOT match (fail-open) | Cannot equal a value that was never set |
ne |
Field absent → rule MATCHES (fail-closed) | The author wrote ne true because that is the safety property; absence means "we cannot prove safety" |
gt/lt/gte/lte |
Field absent → does NOT match (fail-open) | A comparison against a missing number is undefined; "no signal" is not a violation |
in |
Field absent → does NOT match (fail-open) | Cannot be in a list when no value exists |
contains |
Field absent → does NOT match (fail-open) | Same logic as in |
missing |
Field absent → matches when value: true (explicit) |
This is the test for absence |
present |
Field absent → matches when value: false (explicit) |
This is the test for presence |
list_empty |
Field absent → matches (treats absence as empty) | "Empty" includes "not there" |
*_field operators |
Either side absent → does NOT match (fail-open) | Cross-field comparisons need both to be present |
The asymmetry between eq and ne is intentional. A control
author writes ne true for properties that must be true to be safe
(encryption_enabled, require_tls). If the extractor drops that
field, a fail-open ne would silently pass the asset, which is the
opposite of what the author asked for. Fail-closed ne matches the
operator's safety reading: "I assert the field is NOT this value;
if there is no field, my assertion is unverified."
If you need ne to fail open (treat missing as pass), pair it with
an explicit present check:
all:
- field: properties.encryption_enabled
op: present
value: true
- field: properties.encryption_enabled
op: ne
value: trueThe present clause guards the ne so missing fields short-circuit
the rule before ne's fail-closed semantics fire.
Evaluation output uses schema version out.v0.1 in the schema_version
field.
See: