Browse Source

#18159: Handle malformed paths and absent data in conditions (Pre-release QA) (#22911)

Distinguish absent values from malformed or unwalkable paths during
condition evaluation.

Preserve valid empty-list traversal, support changes in snapshot shape,
and reject snapshot attributes which are missing from both snapshots.

Normalize absent job payloads and ensure conditioned rules fail closed
when no payload is available. Add regression coverage and streamline the
related documentation and comments.
Jeremy Stretch 1 week ago
parent
commit
ca76dacbf3

+ 34 - 2
docs/reference/conditions.md

@@ -49,6 +49,13 @@ The following condition will evaluate as true:
 }
 ```
 
+!!! note "Missing keys and absent data"
+    A condition which references a key that does not exist in the data being evaluated fails closed: the condition set evaluates as false, and (for an [event rule](../features/event-rules.md)) an error is logged to `netbox.event_rules` so that a typo does not silently disable the rule.
+
+    Where the data is absent altogether rather than merely missing the referenced key — an event rule evaluating a job which recorded no data, say — the condition does not match. Such an absence is a normal property of the event rather than a mistake, so it is not treated as an error and does not affect the evaluation of the other conditions in the set. Because there is nothing to compare against, the condition does not match whatever the operator, and remains a non-match when `negate` is set: no rule fires on data an event never carried.
+
+    A snapshot which does not exist for the event type is the exception: it resolves to `null`, so that a condition can distinguish (for example) a newly created object from an updated one. See [below](#snapshot-conditions-event-rules).
+
 ### Examples
 
 `name` equals "foo":
@@ -111,6 +118,8 @@ Fire only when `status` changes (to any value):
 }
 ```
 
+An attribute which resolves in neither snapshot — a misspelling, an attribute the object type does not have, or an event which recorded no snapshots at all — leaves nothing to compare. The condition fails closed (the rule does not fire) and an error is logged to `netbox.event_rules`. This holds regardless of `negate`: negating a condition whose attribute cannot be resolved does not turn it into a match.
+
 ### Combining with Standard Conditions
 
 The canonical use case — fire only when `status` changes **to** `active` — combines a standard value check with the `changed` operator:
@@ -142,10 +151,33 @@ You can also read pre- or post-change values directly using the `snapshots.prech
 ```
 
 !!! warning "Snapshot serialization format"
-    Snapshot data uses the **model serializer format**, not the REST API format. Choice fields such as `status` are stored as raw strings (e.g. `"active"`) rather than nested objects (e.g. `{"value": "active", "label": "Active"}`). Use `attr: "snapshots.prechange.status"` — not `"snapshots.prechange.status.value"` — when referencing snapshot attributes. The `changed`/`unchanged` operators compare the same format on both sides, so they are not affected by this distinction.
+    Snapshot data uses the **model serializer format**, not the REST API format. Choice fields such as `status` are stored as raw strings (e.g. `"active"`) rather than nested objects (e.g. `{"value": "active", "label": "Active"}`). Use `status` — not `status.value` — when referencing a snapshot attribute, both in `snapshots.prechange.*`/`snapshots.postchange.*` paths and with the `changed`/`unchanged` operators. A `.value` suffix cannot be resolved against a snapshot: the condition fails closed (the rule does not fire) and an error is logged to `netbox.event_rules`.
 
 !!! note "Snapshot availability"
-    Snapshots are only populated for update and delete events. For create events, `prechange` is `null` — conditions using the `changed` operator on a create event evaluate to `true` (the field transitioned from non-existent to its initial value), while conditions using `snapshots.prechange.*` paths evaluate to `false`. For delete events, `postchange` is `null` — the `changed` operator evaluates to `true` for any attribute present in the prechange snapshot, and `unchanged` evaluates to `false`.
+    For create events, `prechange` is `null`. The `changed` operator evaluates to `true` for any attribute present in the postchange snapshot (each field transitioned from non-existent to its initial value), and a `snapshots.prechange.*` path resolves to `null` — so it matches a condition testing for `null` and fails any other comparison.
+
+    For delete events, `postchange` is `null`. The `changed` operator evaluates to `true` for any attribute present in the prechange snapshot, `unchanged` evaluates to `false`, and a `snapshots.postchange.*` path resolves to `null`.
+
+    An absent snapshot excuses only the absence of the data, not a path which does not describe it. The attribute path is checked against the opposite snapshot, and fails closed (the rule does not fire, and an error is logged) if it cannot be resolved there — whether because it cannot be resolved against a snapshot at all, such as the `.value` suffix above, or because the attribute is misspelled or unknown. Otherwise a typo would resolve to `null` and fire the rule on every create or delete.
+
+    To test only whether a snapshot is absent, reference the snapshot itself rather than an attribute of it: `{"attr": "snapshots.prechange", "value": null}`.
+
+Because an absent snapshot resolves to `null` rather than raising an error — matching a condition testing for `null` and failing any other comparison, whichever operator is used — a snapshot path can be combined safely with other conditions in a rule that also fires on create or delete. For example, this rule fires when a site is created, or when a site whose status was previously `planned` is updated:
+
+```json
+{
+  "or": [
+    {
+      "attr": "snapshots.prechange.status",
+      "value": null
+    },
+    {
+      "attr": "snapshots.prechange.status",
+      "value": "planned"
+    }
+  ]
+}
+```
 
 ## Condition Sets
 

+ 183 - 47
netbox/extras/conditions.py

@@ -1,10 +1,9 @@
-import functools
-import operator
 import re
 
 from django.utils.translation import gettext as _
 
 __all__ = (
+    'AbsentData',
     'Condition',
     'ConditionSet',
     'InvalidCondition',
@@ -13,13 +12,65 @@ __all__ = (
 AND = 'and'
 OR = 'or'
 
-# Sentinel for a snapshot attribute that could not be resolved (missing key or
-# null snapshot).  Using a unique object ensures that two independently
-# unresolvable values compare equal to each other, which is the correct
-# semantics for the 'unchanged' operator when neither snapshot has the field.
+# Prefix identifying a condition attribute that reads an event's pre- or post-change snapshot directly, e.g.
+# 'snapshots.prechange.status'.
+SNAPSHOT_PREFIX = 'snapshots.'
+
+# Maps each snapshot to its counterpart
+OPPOSITE_SNAPSHOT = {
+    'prechange': 'postchange',
+    'postchange': 'prechange',
+}
+
+# Sentinel for a snapshot attribute that could not be resolved (missing key or null snapshot)
 _MISSING = object()
 
 
+class AbsentData(dict):
+    """
+    An empty dict standing in for an event payload which cannot be evaluated: one which is
+    absent (a job which recorded no data) or unusable (a payload which is not a dict at all).
+    """
+    def copy(self):
+        # dict.copy() would return a plain dict, silently discarding the marker.
+        return AbsentData(self)
+
+
+def walk_path(obj, keys, empty_list_is_absent=False):
+    """
+    Walk a sequence of keys through obj, returning _MISSING if a key is absent or null along the way.
+
+    Raises TypeError if the path descends into a value which cannot be indexed by key (e.g. a
+    REST API-style 'status.value' applied to a snapshot, where status is the raw string
+    "active"). Walkability follows from the value's type: an empty string is as unwalkable as any
+    other scalar, not an absent key.
+    """
+    for key in keys:
+        if obj is None:
+            return _MISSING
+        if isinstance(obj, list):
+            if not obj and empty_list_is_absent:
+                # An empty list yields no evidence either way
+                return _MISSING
+            values = []
+            for item in obj:
+                if item is None:
+                    return _MISSING
+                if not isinstance(item, dict):
+                    raise TypeError(f"cannot resolve '{key}' within {type(item).__name__}")
+                if key not in item:
+                    return _MISSING
+                values.append(item[key])
+            obj = values
+        elif isinstance(obj, dict):
+            if key not in obj:
+                return _MISSING
+            obj = obj[key]
+        else:
+            raise TypeError(f"cannot resolve '{key}' within {type(obj).__name__}")
+    return obj
+
+
 def is_ruleset(data):
     """
     Determine whether the given dictionary looks like a rule set.
@@ -78,7 +129,7 @@ class Condition:
                 raise ValueError(_(
                     "The '{op}' operator compares snapshots and does not accept a value."
                 ).format(op=op))
-            if attr.startswith('snapshots.'):
+            if attr.startswith(SNAPSHOT_PREFIX):
                 raise ValueError(_(
                     "The '{op}' operator resolves '{attr}' within each snapshot dict, not the "
                     "top-level condition context. Use the bare attribute name (e.g. 'status') "
@@ -106,36 +157,117 @@ class Condition:
         missing keys, or when an intermediate value can't be indexed by key (e.g. a
         REST API-style path like 'status.value' applied to a raw snapshot value).
         """
-        def _get(obj, key):
-            if isinstance(obj, list):
-                return [operator.getitem(item or {}, key) for item in obj]
-            return operator.getitem(obj or {}, key)
-
         try:
-            return functools.reduce(_get, self.attr.split('.'), data)
-        except KeyError:
-            raise InvalidCondition(f"Invalid key path: {self.attr}")
+            value = walk_path(data, self.attr.split('.'))
         except TypeError as e:
             raise InvalidCondition(f"Invalid key path: {self.attr} ({e})")
+        if value is _MISSING:
+            raise InvalidCondition(f"Invalid key path: {self.attr}")
+        return value
 
-    def _resolve_snapshot_attr(self, snapshot):
+    def _references_absent_payload(self, data):
         """
-        Walk self.attr through a snapshot dict, returning _MISSING on any miss.
-        Snapshots use the model serializer format (raw field values), not the REST
-        API format, so e.g. status is stored as "active" not {"value": "active"}.
+        Return True if self.attr references an attribute of a payload which is absent
+        altogether (AbsentData), as opposed to one which is present but lacks the attribute.
         """
-        if snapshot is None:
-            return _MISSING
-        try:
-            obj = snapshot
-            for key in self.attr.split('.'):
-                if isinstance(obj, list):
-                    obj = [operator.getitem(item or {}, key) for item in obj]
-                else:
-                    obj = operator.getitem(obj or {}, key)
-            return obj
-        except (KeyError, TypeError):
-            return _MISSING
+        return isinstance(data, AbsentData) and self.attr.split('.')[0] not in data
+
+    def _references_absent_snapshot(self, data):
+        """
+        Return True if self.attr is a direct snapshot path (snapshots.prechange.* or
+        snapshots.postchange.*) whose snapshot is null and whose remaining path the opposite
+        snapshot resolves. Create events have no prechange snapshot, delete events no
+        postchange snapshot.
+
+        Unlike an absent payload, such a reference resolves to null: the snapshot's absence is
+        itself meaningful (the object did not exist before, or does not after), and validating
+        the path below shows the reference to describe the data.
+        """
+        if not self.attr.startswith(SNAPSHOT_PREFIX):
+            return False
+        snapshots = data.get('snapshots') if isinstance(data, dict) else None
+        if type(snapshots) is not dict:
+            return False
+        which, _sep, remainder = self.attr[len(SNAPSHOT_PREFIX):].partition('.')
+        if which not in OPPOSITE_SNAPSHOT:
+            # Anything other than prechange or postchange names no snapshot the event could have
+            # recorded, so the path does not describe the data
+            return False
+        if which not in snapshots or snapshots[which] is not None:
+            return False
+
+        # The referenced snapshot is null, which excuses only data the event would otherwise
+        # have carried, never a path which does not describe the data. Validate the remainder
+        # against the opposite snapshot so that such a path fails closed here exactly as it does
+        # when both snapshots are present; otherwise a typo would resolve to null and fire the
+        # rule on every create or delete, with nothing logged.
+        other = snapshots.get(OPPOSITE_SNAPSHOT[which])
+        if remainder and other is not None:
+            try:
+                value = walk_path(other, remainder.split('.'), empty_list_is_absent=True)
+            except TypeError:
+                return False
+            if value is _MISSING:
+                return False
+
+        # Nothing to validate against: with the opposite snapshot absent too, the event carries
+        # no data anywhere for the path to be checked. Testing for the absent snapshot itself
+        # (snapshots.prechange, no remainder) lands here too.
+        return True
+
+    def _resolve_snapshot_attrs(self, snapshots):
+        """
+        Walk self.attr through the prechange and postchange snapshots, returning the two
+        resolved values, with _MISSING for a snapshot which is absent, lacks the attribute, or
+        cannot be walked by the path.
+
+        Raises InvalidCondition if the attribute resolves in neither snapshot, leaving nothing
+        to compare: a misspelling, an unwalkable path, or an event which recorded no snapshots.
+        The unresolved state must be reported rather than compared, since any boolean it
+        returned would become a match under negate.
+
+        A path which resolves in only one snapshot describes a real difference between them (a
+        JSON attribute whose value changed shape, say), so the unresolved side counts as missing
+        and the comparison proceeds: raising would report as unchanged an attribute which
+        demonstrably changed. Only a snapshot yielding a value excuses the other side; one
+        resolving to nothing is no evidence that the path describes the data.
+        """
+        keys = self.attr.split('.')
+        values = []
+        errors = []
+        available = False
+        resolved = False
+
+        for which in ('prechange', 'postchange'):
+            snapshot = snapshots.get(which)
+            if snapshot is None:
+                # Absent snapshot (normal for create and delete events): nothing to resolve
+                values.append(_MISSING)
+                continue
+            available = True
+            try:
+                value = walk_path(snapshot, keys, empty_list_is_absent=True)
+            except TypeError as e:
+                values.append(_MISSING)
+                errors.append(e)
+            else:
+                values.append(value)
+                resolved = resolved or value is not _MISSING
+
+        if not available:
+            # Neither snapshot was recorded, so the attribute itself is not in question
+            raise InvalidCondition(
+                f"No snapshot data available for '{self.op}' operator: {self.attr}. "
+                f"Snapshot operators are only meaningful on update and delete events."
+            )
+        if not resolved:
+            reason = f" ({errors[0]})" if errors else ""
+            raise InvalidCondition(
+                f"Invalid key path for '{self.op}' operator: {self.attr}{reason}. The attribute resolves in neither "
+                f"snapshot. Note that snapshots store raw field values, so choice fields have no '.value' suffix."
+            )
+
+        return values
 
     def eval(self, data):
         """
@@ -143,7 +275,7 @@ class Condition:
         """
         if self.op in self.SNAPSHOT_OPERATORS:
             snapshots = data.get('snapshots') if isinstance(data, dict) else None
-            if snapshots is None:
+            if type(snapshots) is not dict:
                 raise InvalidCondition(
                     f"No snapshot data available for '{self.op}' operator. "
                     f"Snapshot operators are only meaningful on update and delete events."
@@ -151,11 +283,26 @@ class Condition:
             result = self.eval_func(snapshots)
             return not result if self.negate else result
 
-        value = self._resolve_attr(data)
+        if self._references_absent_payload(data):
+            # No payload to evaluate, so the condition cannot be satisfied. Negation is not
+            # applied: it inverts the result of a comparison, and none took place - inverting
+            # would fire the rule on an event which carried nothing to match against. Nor is
+            # this an invalid condition: a job which records no data is routine, and logging it
+            # per rule per event would bury the malformed conditions worth acting on.
+            return False
+
+        absent = self._references_absent_snapshot(data)
+        value = None if absent else self._resolve_attr(data)
         try:
             result = self.eval_func(value)
         except TypeError as e:
-            raise InvalidCondition(f"Invalid data type at '{self.attr}' for '{self.op}' evaluation: {e}")
+            if not absent:
+                raise InvalidCondition(f"Invalid data type at '{self.attr}' for '{self.op}' evaluation: {e}")
+            # An absent snapshot resolves to null, which satisfies only a comparison against
+            # null: contains, regex and the numeric comparisons raise TypeError on None. That is
+            # a non-match, not a malformed condition, so report False (subject to negation
+            # below) rather than aborting the condition set.
+            result = False
 
         if self.negate:
             return not result
@@ -197,24 +344,13 @@ class Condition:
         return re.match(self.value, value) is not None
 
     # Snapshot comparison operators
-    # These resolve self.attr in both the prechange and postchange snapshots and
-    # compare the resulting values.  _MISSING is used when a snapshot is absent
-    # or does not contain the attribute.
-    #
-    # Fail-closed semantics:
-    #   changed:   False when attr is absent from both snapshots (field never existed)
-    #   unchanged: False when attr is absent from both snapshots (avoids silent pass on typos)
 
     def eval_changed(self, snapshots):
-        pre = self._resolve_snapshot_attr(snapshots.get('prechange'))
-        post = self._resolve_snapshot_attr(snapshots.get('postchange'))
+        pre, post = self._resolve_snapshot_attrs(snapshots)
         return pre != post
 
     def eval_unchanged(self, snapshots):
-        pre = self._resolve_snapshot_attr(snapshots.get('prechange'))
-        post = self._resolve_snapshot_attr(snapshots.get('postchange'))
-        if pre is _MISSING and post is _MISSING:
-            return False
+        pre, post = self._resolve_snapshot_attrs(snapshots)
         return pre == post
 
 

+ 23 - 9
netbox/extras/events.py

@@ -11,6 +11,7 @@ from netbox.models.features import has_feature
 from utilities.api import get_serializer_for_model
 from utilities.serialization import serialize_object
 
+from .conditions import AbsentData
 from .models import EventRule
 
 logger = logging.getLogger('netbox.events_processor')
@@ -161,24 +162,37 @@ def process_event_rules(event_rules, object_type, event):
 
     Notes on event sources:
     - Object change events (created/updated/deleted) are enqueued via enqueue_event()
-      during an HTTP request. These events include a request object.
+      during an HTTP request. These events include a request object, and their payload is
+      always the serialized object.
     - Job lifecycle events (JOB_STARTED/JOB_COMPLETED) are emitted by job_start/job_end
       signal handlers and may not include a request context. Consumers must not assume
-      that a request is always present.
+      that a request is always present. Their payload is the job's `data` field, which is
+      nullable and (for a job which sets it directly) not guaranteed to be a dict.
     """
+    if not event_rules:
+        return
 
     # Normalize object_type onto the event context so that an action's enqueue() can always read
     # event_context['object_type']: job-lifecycle events pass it only as this parameter.
     event['object_type'] = object_type
 
+    # Normalize the event payload to a dict or AbsentData once for all rules.
+    data = event['data']
+    if not isinstance(data, dict):
+        if data is not None:
+            logger.warning(
+                _('Ignoring invalid data payload on {event_type} event (got {data_type})').format(
+                    event_type=event['event_type'],
+                    data_type=type(data).__name__,
+                )
+            )
+        data = AbsentData()
+
     for event_rule in event_rules:
 
-        # Evaluate event rule conditions (if any).
-        # Snapshots are merged into the condition context so conditions can
-        # reference snapshots.prechange.<attr> and snapshots.postchange.<attr>
-        # using the standard dot-path syntax, and so the 'changed'/'unchanged'
-        # operators can access pre/post values.
-        condition_data = {**event['data'], 'snapshots': event.get('snapshots')}
+        # Merge snapshots and evaluate event rule conditions (if any).
+        condition_data = data.copy()
+        condition_data['snapshots'] = event.get('snapshots')
         if not event_rule.eval_conditions(condition_data):
             continue
 
@@ -201,7 +215,7 @@ def process_event_rules(event_rules, object_type, event):
 
         # Merge rule-specific action_data with the event payload.
         # Copy to avoid mutating the rule's stored action_data dict.
-        event_data = {**action_data, **event['data']}
+        event_data = {**action_data, **data}
 
         action = event_rule.action_provider
         if action is None:

+ 472 - 13
netbox/extras/tests/test_conditions.py

@@ -4,7 +4,7 @@ from django.test import TestCase
 from core.events import *
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
-from extras.conditions import Condition, ConditionSet, InvalidCondition
+from extras.conditions import AbsentData, Condition, ConditionSet, InvalidCondition
 from extras.events import serialize_for_event
 from extras.forms import EventRuleForm
 from extras.models import EventRule, Webhook
@@ -53,6 +53,24 @@ class ConditionTestCase(TestCase):
         with self.assertRaises(InvalidCondition):
             c.eval({'x': {'y': {'a': 1}}})
 
+    def test_nested_within_list(self):
+        c = Condition('tags.slug', 'exempt', 'contains')
+        self.assertTrue(c.eval({'tags': [{'slug': 'exempt'}, {'slug': 'other'}]}))
+        self.assertFalse(c.eval({'tags': [{'slug': 'other'}]}))
+
+    def test_nested_within_empty_list(self):
+        """
+        Descending into an empty list resolves to an empty list, not an absent attribute: an
+        object with no tags is a legitimate non-match for the documented 'tags.slug contains'
+        condition, not a malformed path. Raising here would abort the whole condition set,
+        which for an 'or' set means a matching sibling condition never gets evaluated.
+        """
+        self.assertFalse(Condition('tags.slug', 'exempt', 'contains').eval({'tags': []}))
+        self.assertTrue(Condition('tags.slug', 'exempt', 'contains', negate=True).eval({'tags': []}))
+        self.assertTrue(Condition('tags.slug', [], 'eq').eval({'tags': []}))
+        # The list is carried through the remainder of the path, just as a populated one is
+        self.assertFalse(Condition('tags.parent.slug', 'exempt', 'contains').eval({'tags': []}))
+
     #
     # Operator tests
     #
@@ -236,6 +254,28 @@ class ConditionSetTestCase(TestCase):
         self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9}))
         self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3}))
 
+    def test_untagged_object_does_not_veto_sibling_conditions(self):
+        """
+        The documented "status is active and primary_ip4 is defined, or the exempt tag is
+        applied" example, evaluated for an object with no tags at all. The tag condition is a
+        plain non-match: it must not abort the set before its sibling is reached, whichever
+        order the two are listed in.
+        """
+        tag_rule = {'attr': 'tags.slug', 'value': 'exempt', 'op': 'contains'}
+        status_rule = {'and': [
+            {'attr': 'status.value', 'value': 'active'},
+            {'attr': 'primary_ip4', 'value': None, 'negate': True},
+        ]}
+        data = {'status': {'value': 'active'}, 'primary_ip4': {'address': '192.0.2.1/32'}, 'tags': []}
+
+        self.assertTrue(ConditionSet({'or': [tag_rule, status_rule]}).eval(data))
+        self.assertTrue(ConditionSet({'or': [status_rule, tag_rule]}).eval(data))
+
+        # Neither condition matches: still False rather than an error
+        self.assertFalse(ConditionSet({'or': [tag_rule, status_rule]}).eval({
+            'status': {'value': 'planned'}, 'primary_ip4': None, 'tags': []
+        }))
+
     def test_event_rule_conditions_without_logic_operator(self):
         """
         Test evaluation of EventRule conditions without logic operator.
@@ -323,6 +363,87 @@ class ConditionSetTestCase(TestCase):
         self.assertFalse(form.is_valid())
 
 
+class AbsentDataTestCase(TestCase):
+    """
+    Tests for conditions evaluated against an AbsentData payload, i.e. event data which is
+    absent altogether (a job which recorded no data) rather than merely lacking the
+    referenced attribute.
+    """
+
+    def _absent_data(self, **kwargs):
+        """Return an absent payload as produced by process_event_rules()."""
+        data = AbsentData()
+        data['snapshots'] = kwargs.get('snapshots')
+        return data
+
+    def test_absent_data_is_a_non_match(self):
+        """
+        An absent payload carries nothing to match against, so a reference to any attribute of
+        it is a non-match rather than a resolved null. Matching would enqueue the rule's action
+        on an event which recorded no data at all.
+        """
+        data = self._absent_data()
+        self.assertFalse(Condition('status', value=None).eval(data))
+        self.assertFalse(Condition('status', value='completed').eval(data))
+        # A nested path is equally unresolvable, and equally not an error
+        self.assertFalse(Condition('output.result', value='x').eval(data))
+        self.assertFalse(Condition('output.result', value=None).eval(data))
+
+    def test_absent_data_is_a_non_match_for_every_operator(self):
+        data = self._absent_data()
+        for op, value in (
+            ('eq', None), ('eq', 'foo'), ('in', ['foo']), ('contains', 'foo'), ('regex', '^foo'),
+            ('gt', 1), ('gte', 1), ('lt', 1), ('lte', 1),
+        ):
+            with self.subTest(op=op, value=value):
+                self.assertFalse(Condition('status', value=value, op=op).eval(data))
+
+    def test_negate_cannot_turn_an_absent_payload_into_a_match(self):
+        """
+        Negation inverts the result of a comparison, and against an absent payload no
+        comparison takes place. Inverting the non-match would make 'negate' a fail-open switch,
+        firing the rule on an empty payload - for a misspelled attribute as readily as a real
+        one, since neither resolves.
+        """
+        data = self._absent_data()
+        for attr in ('status', 'stauts', 'output.result'):
+            for value in (None, 'completed'):
+                with self.subTest(attr=attr, value=value):
+                    self.assertFalse(Condition(attr, value=value, negate=True).eval(data))
+        self.assertFalse(Condition('status', value='foo', op='contains', negate=True).eval(data))
+
+    def test_absent_data_does_not_veto_sibling_conditions(self):
+        """
+        An absent payload must not abort evaluation of the whole condition set: the other
+        conditions, including a snapshot path drawn from the surrounding context, are still
+        evaluated on their own merits.
+        """
+        data = self._absent_data(snapshots={'prechange': {'status': 'planned'}, 'postchange': None})
+        ruleset = {'or': [
+            {'attr': 'status', 'value': 'foo', 'op': 'regex'},
+            {'attr': 'snapshots.prechange.status', 'value': 'planned'},
+        ]}
+        self.assertTrue(ConditionSet(ruleset).eval(data))
+        self.assertFalse(ConditionSet({'and': list(ruleset['or'])}).eval(data))
+
+    def test_present_data_missing_attr_still_fails_closed(self):
+        """
+        Only data which is absent altogether resolves to None; a payload which is present
+        but lacks the attribute remains a fail-closed error, so that a typo is logged.
+        """
+        with self.assertRaises(InvalidCondition):
+            Condition('status', value='completed').eval({'other': 1})
+
+    def test_snapshot_path_against_absent_data(self):
+        """
+        The absent-payload marker must not short-circuit a snapshot path: the snapshots key
+        is part of the evaluation context, not of the payload.
+        """
+        data = self._absent_data(snapshots={'prechange': {'status': 'planned'}, 'postchange': None})
+        self.assertTrue(Condition('snapshots.prechange.status', value='planned').eval(data))
+        self.assertTrue(Condition('snapshots.postchange.status', value=None).eval(data))
+
+
 class SnapshotConditionTestCase(TestCase):
     """
     Tests for snapshot-aware conditions: the 'changed'/'unchanged' operators and
@@ -395,25 +516,143 @@ class SnapshotConditionTestCase(TestCase):
         }
         self.assertTrue(c.eval({'snapshots': snapshots}))
 
-    def test_changed_false_when_both_snapshots_missing_attr(self):
-        # If neither snapshot has the attr, nothing changed
+    def test_changed_raises_when_both_snapshots_missing_attr(self):
+        # An attr absent from both snapshots leaves nothing to compare: report it rather than
+        # returning a non-match, which negate would turn into a match
         c = Condition('nonexistent', op='changed')
         snapshots = {
             'prechange': {'status': 'active'},
             'postchange': {'status': 'active'},
         }
-        self.assertFalse(c.eval({'snapshots': snapshots}))
+        with self.assertRaises(InvalidCondition):
+            c.eval({'snapshots': snapshots})
 
-    def test_changed_false_when_path_traverses_scalar(self):
-        # Snapshot choice fields are raw strings, not nested dicts. A path like
-        # 'status.value' hits a TypeError when traversing into the string; both
-        # sides resolve to _MISSING and the operator returns False (no change).
+    def test_changed_raises_when_path_traverses_scalar(self):
+        # Snapshot choice fields are raw strings, not nested dicts. A REST API-style path
+        # like 'status.value' cannot be walked at all, which is a malformed condition
+        # rather than an absent attribute: it must raise so that the mistake is logged,
+        # not silently evaluate False on every event.
         c = Condition('status.value', op='changed')
         snapshots = {
             'prechange': {'status': 'planned'},
             'postchange': {'status': 'active'},
         }
-        self.assertFalse(c.eval({'snapshots': snapshots}))
+        with self.assertRaises(InvalidCondition):
+            c.eval({'snapshots': snapshots})
+
+    def test_changed_raises_when_path_traverses_falsy_scalar(self):
+        # Walkability is a property of the value's type, not its truthiness: an empty string
+        # is exactly as unwalkable as a populated one, and must not be mistaken for an absent
+        # attribute. (description and comments default to an empty string on most models, so
+        # this is the common case rather than an edge case.)
+        c = Condition('description.value', op='changed')
+        snapshots = {
+            'prechange': {'description': ''},
+            'postchange': {'description': 'foo'},
+        }
+        with self.assertRaises(InvalidCondition):
+            c.eval({'snapshots': snapshots})
+
+    def test_unchanged_raises_when_path_traverses_scalar(self):
+        c = Condition('status.value', op='unchanged')
+        snapshots = {
+            'prechange': {'status': 'active'},
+            'postchange': {'status': 'active'},
+        }
+        with self.assertRaises(InvalidCondition):
+            c.eval({'snapshots': snapshots})
+
+    def test_changed_raises_when_path_traverses_scalar_in_list(self):
+        # Snapshot list fields hold raw values (e.g. tag names), so a path descending
+        # into a list element is equally unwalkable.
+        c = Condition('tags.name', op='changed')
+        snapshots = {
+            'prechange': {'tags': ['Alpha']},
+            'postchange': {'tags': ['Alpha', 'Beta']},
+        }
+        with self.assertRaises(InvalidCondition):
+            c.eval({'snapshots': snapshots})
+
+    def test_changed_raises_when_counterpart_snapshot_resolves_to_nothing(self):
+        """
+        Only a snapshot which actually yields a value excuses an unwalkable path in the
+        other. A snapshot which merely resolves to nothing - an empty list, an absent key -
+        is no evidence that the path is well-formed, so the malformed condition must still
+        be reported rather than evaluating (and possibly firing) until the data fills in.
+        """
+        for snapshots in (
+            # Tagging a previously untagged object: the likely first evaluation of the rule
+            {'prechange': {'tags': []}, 'postchange': {'tags': ['Alpha']}},
+            {'prechange': {'tags': ['Alpha']}, 'postchange': {'tags': []}},
+        ):
+            with self.subTest(snapshots=snapshots):
+                with self.assertRaises(InvalidCondition):
+                    Condition('tags.name', op='changed').eval({'snapshots': snapshots})
+
+        with self.assertRaises(InvalidCondition):
+            Condition('status.value', op='changed').eval({
+                'snapshots': {'prechange': {}, 'postchange': {'status': 'active'}}
+            })
+
+    def test_changed_when_path_is_walkable_in_only_one_snapshot(self):
+        """
+        A path which resolves in one snapshot but not the other is not malformed: it
+        describes a real difference between them, such as a JSON attribute whose value
+        changed shape. The unwalkable side counts as missing and the comparison proceeds,
+        rather than raising and reporting the attribute as unchanged.
+        """
+        snapshots = {
+            'prechange': {'custom_fields': {'blob': 'legacy'}},
+            'postchange': {'custom_fields': {'blob': {'key': 1}}},
+        }
+        reversed_snapshots = {'prechange': snapshots['postchange'], 'postchange': snapshots['prechange']}
+        self.assertTrue(Condition('custom_fields.blob.key', op='changed').eval({'snapshots': snapshots}))
+        self.assertTrue(Condition('custom_fields.blob.key', op='changed').eval({'snapshots': reversed_snapshots}))
+        self.assertFalse(Condition('custom_fields.blob.key', op='unchanged').eval({'snapshots': snapshots}))
+
+    def test_changed_raises_when_only_available_snapshot_traverses_scalar(self):
+        """
+        On create and delete events only one snapshot is available, so an unwalkable path is
+        unwalkable everywhere it can be evaluated: still malformed, and still raises.
+        """
+        with self.assertRaises(InvalidCondition):
+            Condition('status.value', op='changed').eval({
+                'snapshots': {'prechange': None, 'postchange': {'status': 'active'}}
+            })
+        with self.assertRaises(InvalidCondition):
+            Condition('status.value', op='changed').eval({
+                'snapshots': {'prechange': {'status': 'active'}, 'postchange': None}
+            })
+
+    def test_changed_raises_when_no_snapshot_is_available(self):
+        """
+        With neither snapshot available there is nothing to compare, whether the path is
+        malformed or not. Reporting the condition is the only way to fail closed: a boolean
+        would be a verdict on data the event never carried, and negate would turn it into a
+        match.
+        """
+        snapshots = {'prechange': None, 'postchange': None}
+        for attr, op, negate in (
+            ('status', 'changed', False),
+            ('status', 'changed', True),
+            ('status', 'unchanged', True),
+            ('status.value', 'changed', False),
+            ('status.value', 'unchanged', False),
+        ):
+            with self.subTest(attr=attr, op=op, negate=negate):
+                with self.assertRaises(InvalidCondition):
+                    Condition(attr, op=op, negate=negate).eval({'snapshots': snapshots})
+
+    def test_changed_raises_when_attr_resolves_in_neither_snapshot(self):
+        # An attribute absent from both snapshots is indistinguishable from a typo, so it
+        # cannot be reported as an ordinary non-match
+        c = Condition('nonexistent', op='changed')
+        snapshots = {
+            'prechange': {'status': 'planned'},
+            'postchange': {'status': 'active', 'description': 'x'},
+        }
+        with self.assertRaises(InvalidCondition):
+            c.eval({'snapshots': snapshots})
 
     def test_changed_negated(self):
         c = Condition('status', op='changed', negate=True)
@@ -422,12 +661,42 @@ class SnapshotConditionTestCase(TestCase):
             'postchange': {'status': 'active'},
         }
         self.assertFalse(c.eval({'snapshots': snapshots}))
+        self.assertTrue(c.eval({'snapshots': {
+            'prechange': {'status': 'active'},
+            'postchange': {'status': 'active'},
+        }}))
+
+    def test_negate_cannot_turn_an_unresolved_attr_into_a_match(self):
+        """
+        A misspelled attribute must not fire the rule, whichever operator it is used with and
+        whether or not the condition is negated. Returning False for the unresolved state
+        would make 'negate' a fail-open switch.
+        """
+        snapshots = {'prechange': {'status': 'planned'}, 'postchange': {'status': 'active'}}
+        for op in ('changed', 'unchanged'):
+            for negate in (False, True):
+                with self.subTest(op=op, negate=negate):
+                    with self.assertRaises(InvalidCondition):
+                        Condition('statsu', op=op, negate=negate).eval({'snapshots': snapshots})
 
     def test_changed_raises_when_no_snapshots(self):
         c = Condition('status', op='changed')
         with self.assertRaises(InvalidCondition):
             c.eval({'status': {'value': 'active'}})
 
+    def test_changed_raises_when_snapshots_is_not_a_dict(self):
+        """
+        A snapshots value which is not a dict holds no snapshot to compare. It must be reported
+        as an invalid condition, matching a direct snapshot path against the same data, rather
+        than raising an uncaught AttributeError which would abort event processing entirely.
+        """
+        for snapshots in ('oops', ['prechange'], 42):
+            with self.subTest(snapshots=snapshots):
+                with self.assertRaises(InvalidCondition):
+                    Condition('status', op='changed').eval({'snapshots': snapshots})
+                with self.assertRaises(InvalidCondition):
+                    Condition('snapshots.prechange.status', value='active').eval({'snapshots': snapshots})
+
     #
     # 'unchanged' operator
     #
@@ -448,15 +717,16 @@ class SnapshotConditionTestCase(TestCase):
         }
         self.assertFalse(c.eval({'snapshots': snapshots}))
 
-    def test_unchanged_false_when_both_snapshots_missing_attr(self):
-        # Fail-closed: a typo or non-existent attr resolves to _MISSING on both
-        # sides; unchanged must return False rather than silently passing.
+    def test_unchanged_raises_when_both_snapshots_missing_attr(self):
+        # Fail-closed: a typo or non-existent attr resolves on neither side, so 'unchanged'
+        # must report it rather than silently passing (or, negated, matching)
         c = Condition('statsu', op='unchanged')
         snapshots = {
             'prechange': {'status': 'active'},
             'postchange': {'status': 'active'},
         }
-        self.assertFalse(c.eval({'snapshots': snapshots}))
+        with self.assertRaises(InvalidCondition):
+            c.eval({'snapshots': snapshots})
 
     #
     # Direct snapshot path access (snapshots.prechange.* / snapshots.postchange.*)
@@ -492,6 +762,152 @@ class SnapshotConditionTestCase(TestCase):
         with self.assertRaises(InvalidCondition):
             c.eval({'snapshots': snapshots})
 
+    def test_snapshot_path_resolves_to_none_when_prechange_absent(self):
+        """
+        On a create event there is no prechange snapshot. That is a property of the event,
+        not a malformed condition, so the path resolves to None rather than raising.
+        """
+        snapshots = {'prechange': None, 'postchange': {'status': 'active'}}
+        self.assertFalse(Condition('snapshots.prechange.status', value='planned').eval({'snapshots': snapshots}))
+        self.assertTrue(Condition('snapshots.prechange.status', value=None).eval({'snapshots': snapshots}))
+        self.assertTrue(
+            Condition('snapshots.prechange.status', value='planned', negate=True).eval({'snapshots': snapshots})
+        )
+
+    def test_snapshot_path_resolves_to_none_when_postchange_absent(self):
+        """As above, for the postchange snapshot on a delete event."""
+        snapshots = {'prechange': {'status': 'active'}, 'postchange': None}
+        self.assertFalse(Condition('snapshots.postchange.status', value='active').eval({'snapshots': snapshots}))
+        self.assertTrue(Condition('snapshots.postchange.status', value=None).eval({'snapshots': snapshots}))
+
+    def test_absent_snapshot_is_a_non_match_for_every_operator(self):
+        """
+        An absent snapshot resolves to None, which satisfies only a comparison against null.
+        Operators which raise a TypeError on None must report a plain non-match rather than
+        aborting evaluation, so that the guarantee holds for all operators and not just
+        those which happen to tolerate None.
+        """
+        data = {'snapshots': {'prechange': None, 'postchange': {'description': 'foo'}}}
+        attr = 'snapshots.prechange.description'
+        for op, value in (('contains', 'foo'), ('regex', '^foo'), ('gt', 1), ('gte', 1), ('lt', 1), ('lte', 1)):
+            with self.subTest(op=op):
+                self.assertFalse(Condition(attr, value=value, op=op).eval(data))
+                self.assertTrue(Condition(attr, value=value, op=op, negate=True).eval(data))
+
+    def test_absent_snapshot_does_not_veto_sibling_conditions(self):
+        """
+        Regression: an absent prechange snapshot must not abort evaluation of the whole
+        condition set, which would suppress a sibling condition that does match. The
+        result must also not depend on the order of the conditions, nor on which operator
+        the snapshot condition uses.
+        """
+        data = {'name': 'Site 1', 'snapshots': {'prechange': None, 'postchange': {'status': 'active'}}}
+        name_rule = {'attr': 'name', 'value': 'Site 1'}
+        for snapshot_rule in (
+            {'attr': 'snapshots.prechange.status', 'value': 'planned'},
+            {'attr': 'snapshots.prechange.status', 'value': 'plan', 'op': 'contains'},
+            {'attr': 'snapshots.prechange.status', 'value': '^plan', 'op': 'regex'},
+        ):
+            with self.subTest(op=snapshot_rule.get('op', 'eq')):
+                self.assertTrue(ConditionSet({'or': [snapshot_rule, name_rule]}).eval(data))
+                self.assertTrue(ConditionSet({'or': [name_rule, snapshot_rule]}).eval(data))
+
+    def test_snapshot_path_typo_still_fails_closed(self):
+        """
+        A path naming a snapshot that exists but lacks the attribute is a genuine typo and
+        must still raise, so that it is logged rather than silently evaluating.
+        """
+        snapshots = {'prechange': {'status': 'planned'}, 'postchange': {'status': 'active'}}
+        with self.assertRaises(InvalidCondition):
+            Condition('snapshots.prechange.stauts', value='planned').eval({'snapshots': snapshots})
+        with self.assertRaises(InvalidCondition):
+            Condition('snapshots.bogus.status', value='planned').eval({'snapshots': snapshots})
+
+        # A misnamed snapshot which happens to be null names no snapshot the event could have
+        # recorded, so it has no absence to excuse it: it must fail closed like any other typo,
+        # rather than resolving to null and firing the rule
+        with self.assertRaises(InvalidCondition):
+            Condition('snapshots.bogus.status', value=None).eval({'snapshots': {'bogus': None}})
+
+    def test_absent_snapshot_path_traversing_scalar_still_fails_closed(self):
+        """
+        An absent snapshot excuses only the absence of the data, not a path which cannot be
+        walked at all. A REST API-style 'status.value' must fail closed on create and delete
+        events exactly as it does on updates, rather than resolving to null (which would fire
+        the rule on every create) with nothing logged.
+        """
+        create = {'snapshots': {'prechange': None, 'postchange': {'status': 'active', 'tags': ['Alpha']}}}
+        with self.assertRaises(InvalidCondition):
+            Condition('snapshots.prechange.status.value', value='active').eval(create)
+        with self.assertRaises(InvalidCondition):
+            # A test for null must not escape the check either
+            Condition('snapshots.prechange.status.value', value=None).eval(create)
+        with self.assertRaises(InvalidCondition):
+            # Snapshot list fields hold raw values, so descending into an element is
+            # equally unwalkable
+            Condition('snapshots.prechange.tags.name', value='Alpha').eval(create)
+
+        delete = {'snapshots': {'prechange': {'status': 'active'}, 'postchange': None}}
+        with self.assertRaises(InvalidCondition):
+            Condition('snapshots.postchange.status.value', value='active').eval(delete)
+
+        # An empty string is exactly as unwalkable as a populated one, so the check must not
+        # turn on the truthiness of the value in the opposite snapshot
+        blank = {'snapshots': {'prechange': None, 'postchange': {'description': ''}}}
+        with self.assertRaises(InvalidCondition):
+            Condition('snapshots.prechange.description.value', value=None).eval(blank)
+
+    def test_absent_snapshot_path_unknown_to_opposite_snapshot_fails_closed(self):
+        """
+        An absent snapshot excuses only a path the opposite snapshot shows to describe the
+        data. A path which resolves to nothing there either is not shown to describe it, so it
+        must fail closed rather than resolving to null - which would let an unknown path fire
+        the rule on every create or delete, with nothing logged, even though the same path
+        raises on an update event. Testing for the absent snapshot itself remains available
+        as 'snapshots.prechange' with no remainder.
+        """
+        create = {'snapshots': {'prechange': None, 'postchange': {'custom_fields': {'cf1': 'x'}, 'tags': []}}}
+        with self.assertRaises(InvalidCondition):
+            Condition('snapshots.prechange.nonexistent.attr', value=None).eval(create)
+        with self.assertRaises(InvalidCondition):
+            Condition('snapshots.prechange.custom_fields.cf2', value=None).eval(create)
+        # An empty list holds no element in which to find 'name', so it cannot show the path
+        # to describe the data any more than an absent key can
+        with self.assertRaises(InvalidCondition):
+            Condition('snapshots.prechange.tags.name', value=None).eval(create)
+
+        delete = {'snapshots': {'prechange': {'status': 'active'}, 'postchange': None}}
+        with self.assertRaises(InvalidCondition):
+            Condition('snapshots.postchange.nonexistent.attr', value=None).eval(delete)
+
+    def test_absent_snapshot_path_resolves_to_none_when_shape_is_valid(self):
+        """
+        A nested path which the opposite snapshot resolves is a genuine absence, so it
+        resolves to null. So is the absent snapshot itself, and a path which cannot be checked
+        at all because the opposite snapshot is null too: the event then carries no data
+        anywhere to check against, so treating it as absent is the only alternative to logging
+        an error for every rule on every such event.
+        """
+        create = {'snapshots': {'prechange': None, 'postchange': {'custom_fields': {'cf1': 'x'}, 'tags': []}}}
+        self.assertTrue(Condition('snapshots.prechange.custom_fields.cf1', value=None).eval(create))
+        self.assertTrue(Condition('snapshots.prechange', value=None).eval(create))
+        # A resolved value is a resolved value, whatever its own shape
+        self.assertTrue(Condition('snapshots.prechange.tags', value=None).eval(create))
+
+        both_absent = {'snapshots': {'prechange': None, 'postchange': None}}
+        self.assertTrue(Condition('snapshots.prechange.status.value', value=None).eval(both_absent))
+        self.assertTrue(Condition('snapshots.prechange.nonexistent.attr', value=None).eval(both_absent))
+
+    def test_snapshot_path_without_snapshot_context_fails_closed(self):
+        """
+        A snapshot path used where there is no snapshot context at all (e.g. a job event)
+        is a mismatched rule and must fail closed rather than resolving to None.
+        """
+        with self.assertRaises(InvalidCondition):
+            Condition('snapshots.prechange.status', value='planned').eval({'snapshots': None})
+        with self.assertRaises(InvalidCondition):
+            Condition('snapshots.prechange.status', value='planned').eval({'name': 'Site 1'})
+
     #
     # EventRule.eval_conditions integration
     #
@@ -567,3 +983,46 @@ class SnapshotConditionTestCase(TestCase):
             'postchange': {'status': SiteStatusChoices.STATUS_ACTIVE},
         })
         self.assertFalse(event_rule.eval_conditions(data))
+
+    def test_event_rule_snapshot_path_rest_api_style_attr_on_create_is_logged(self):
+        """
+        The same mistake must behave identically on a create event, where the prechange
+        snapshot is absent: fail closed and log, rather than resolving to null and firing
+        the rule for every object created.
+        """
+        event_rule = EventRule(
+            name='Was planned (REST-style mistake)',
+            event_types=[OBJECT_CREATED, OBJECT_UPDATED],
+            conditions={
+                'attr': 'snapshots.prechange.status.value',
+                'value': None,
+            }
+        )
+        site = Site.objects.create(name='Site 6', slug='site-6', status=SiteStatusChoices.STATUS_ACTIVE)
+        data = self._make_condition_data(site, {
+            'prechange': None,
+            'postchange': {'status': SiteStatusChoices.STATUS_ACTIVE},
+        })
+        with self.assertLogs('netbox.event_rules', level='ERROR') as cm:
+            self.assertFalse(event_rule.eval_conditions(data))
+        self.assertIn('snapshots.prechange.status.value', cm.output[0])
+
+    def test_event_rule_changed_operator_rest_api_style_attr_is_logged(self):
+        """
+        The same REST API-style mistake made with a snapshot operator must also fail
+        closed *and* be logged. Silently evaluating False would leave the rule dead with
+        no indication of why, even though the watched attribute really did change.
+        """
+        event_rule = EventRule(
+            name='Activated (REST-style mistake)',
+            event_types=[OBJECT_UPDATED],
+            conditions={'attr': 'status.value', 'op': 'changed'}
+        )
+        site = Site.objects.create(name='Site 5', slug='site-5', status=SiteStatusChoices.STATUS_ACTIVE)
+        data = self._make_condition_data(site, {
+            'prechange': {'status': SiteStatusChoices.STATUS_PLANNED},
+            'postchange': {'status': SiteStatusChoices.STATUS_ACTIVE},
+        })
+        with self.assertLogs('netbox.event_rules', level='ERROR') as cm:
+            self.assertFalse(event_rule.eval_conditions(data))
+        self.assertIn('status.value', cm.output[0])

+ 116 - 0
netbox/extras/tests/test_event_rules.py

@@ -604,6 +604,122 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
         self.assertEqual(job.kwargs['object_type'], script_type)
         self.assertNotIn('request', job.kwargs)
 
+    def _job_event_rule(self, conditions=None):
+        webhook = Webhook.objects.get(name='Webhook 1')
+        event_rule = EventRule.objects.create(
+            name='Event Rule Job Completed',
+            event_types=[JOB_COMPLETED],
+            action_type=EventRuleActionChoices.WEBHOOK,
+            action_object_type=ObjectType.objects.get_for_model(Webhook),
+            action_object_id=webhook.pk,
+            conditions=conditions,
+        )
+        event_rule.object_types.set([ObjectType.objects.get_for_model(Script)])
+        return event_rule
+
+    def test_job_event_with_null_data(self):
+        """
+        Job.data is nullable, and a job which recorded no data is entirely routine. Event
+        processing must handle it rather than raising while merging the payload.
+        """
+        script_type = ObjectType.objects.get_for_model(Script)
+        self._job_event_rule()
+        process_job_end_event_rules(Mock(object_type=script_type, data=None, user=self.user))
+        self.assertEqual(self.queue.count, 1)
+        self.assertEqual(self.queue.jobs[0].kwargs['data'], {})
+
+    def test_job_event_with_null_data_and_conditions(self):
+        """
+        A condition referencing an attribute of a null payload is a non-match rather than an
+        error: the rule is skipped without logging, since a job which recorded no data is
+        routine rather than a misconfigured rule.
+        """
+        script_type = ObjectType.objects.get_for_model(Script)
+        self._job_event_rule(conditions={'attr': 'status', 'value': 'completed'})
+        with self.assertNoLogs('netbox.event_rules', level='ERROR'):
+            process_job_end_event_rules(Mock(object_type=script_type, data=None, user=self.user))
+        self.assertEqual(self.queue.count, 0)
+
+    def test_job_event_with_null_data_does_not_satisfy_conditions(self):
+        """
+        A null payload must not satisfy a conditioned rule, however the condition is phrased:
+        there is no data to evaluate, so nothing may enqueue the rule's action. A test for null
+        and a negated test are the two phrasings which would otherwise match.
+        """
+        script_type = ObjectType.objects.get_for_model(Script)
+        for conditions in (
+            {'attr': 'status', 'value': None},
+            {'attr': 'status', 'value': 'completed', 'negate': True},
+        ):
+            with self.subTest(conditions=conditions):
+                event_rule = self._job_event_rule(conditions=conditions)
+                with self.assertNoLogs('netbox.event_rules', level='ERROR'):
+                    process_job_end_event_rules(Mock(object_type=script_type, data=None, user=self.user))
+                self.assertEqual(self.queue.count, 0)
+                event_rule.delete()
+
+    def test_job_event_with_non_dict_data(self):
+        """
+        A payload which is neither null nor a dict is unexpected: log it, but continue
+        processing rather than aborting the batch.
+        """
+        script_type = ObjectType.objects.get_for_model(Script)
+        self._job_event_rule()
+        for payload in ([1, 2], 'a string', 42):
+            self.queue.empty()
+            with self.assertLogs('netbox.events_processor', level='WARNING') as cm:
+                process_job_end_event_rules(Mock(object_type=script_type, data=payload, user=self.user))
+            self.assertIn(type(payload).__name__, cm.output[0])
+            self.assertEqual(self.queue.count, 1)
+            self.assertEqual(self.queue.jobs[0].kwargs['data'], {})
+
+    def test_job_event_with_non_dict_data_and_conditions(self):
+        """
+        An invalid payload is no more evaluable than an absent one, so a conditioned rule must
+        fail closed for it — including for the phrasings which a payload normalized to an empty
+        dict would otherwise satisfy. The invalid payload is still reported once for the event,
+        as the anomaly it is.
+        """
+        script_type = ObjectType.objects.get_for_model(Script)
+        for conditions in (
+            {'attr': 'status', 'value': None},
+            {'attr': 'status', 'value': 'completed', 'negate': True},
+            {'attr': 'status', 'value': 'completed'},
+        ):
+            event_rule = self._job_event_rule(conditions=conditions)
+            for payload in ([1, 2], 'a string', 42):
+                with self.subTest(conditions=conditions, payload=payload):
+                    self.queue.empty()
+                    with self.assertLogs('netbox.events_processor', level='WARNING') as cm:
+                        process_job_end_event_rules(
+                            Mock(object_type=script_type, data=payload, user=self.user)
+                        )
+                    self.assertIn(type(payload).__name__, cm.output[0])
+                    self.assertEqual(self.queue.count, 0)
+            event_rule.delete()
+
+    def test_no_matching_rules_leaves_payload_unserialized(self):
+        """
+        Normalizing the payload must not defeat EventContext's lazy serialization: an
+        event with no applicable rules should never have its payload materialized.
+        """
+        request = RequestFactory().get('/')
+        request.id = uuid.uuid4()
+        request.user = self.user
+        site = Site.objects.create(name='Site Lazy', slug='site-lazy')
+
+        queue = {}
+        enqueue_event(queue, site, request, OBJECT_UPDATED)
+        event = queue[f'dcim.site:{site.pk}']
+        self.assertNotIn('data', event.data)
+
+        process_event_rules(
+            event_rules=EventRule.objects.none(),
+            object_type=ObjectType.objects.get_for_model(Site),
+            event=event,
+        )
+        self.assertNotIn('data', event.data)
+
     def test_duplicate_enqueue_refreshes_lazy_payload(self):
         """
         When the same object is enqueued more than once in a single request,