Repository: PrefectHQ/prefect

Issue:
Add the ability to filter events (& create triggers) based on not having certain labels
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar request and didn't find it.
- [X] I searched the Prefect documentation for this feature.

### Prefect Version

2.x

### Describe the current behavior

Currently there is no way to filter events (and create triggers) based on them not having a certain property.  E.g. All runs from this flow that did not have the tag "auto-scheduled". 

### Describe the proposed behavior

Add a way to filter based on not having a certain label

### Example Use

_No response_

### Additional context

@chrisguidry @zzstoatzz - apologies for the delay.  Does this capture our discussion/your comments on Tuesday correctly?


Discussion:

Resolving patch:
diff --git a/src/prefect/events/schemas/events.py b/src/prefect/events/schemas/events.py
--- a/src/prefect/events/schemas/events.py
+++ b/src/prefect/events/schemas/events.py
@@ -192,15 +192,22 @@ class Config:
 
 def matches(expected: str, value: Optional[str]) -> bool:
     """Returns true if the given value matches the expected string, which may
-    include wildcards"""
+    include a a negation prefix ("!this-value") or a wildcard suffix
+    ("any-value-starting-with*")"""
     if value is None:
         return False
 
-    # TODO: handle wildcards/globs better than this
+    positive = True
+    if expected.startswith("!"):
+        expected = expected[1:]
+        positive = False
+
     if expected.endswith("*"):
-        return value.startswith(expected[:-1])
+        match = value.startswith(expected[:-1])
+    else:
+        match = value == expected
 
-    return value == expected
+    return match if positive else not match
 
 
 class ResourceSpecification(PrefectBaseModel):
diff --git a/src/prefect/server/events/filters.py b/src/prefect/server/events/filters.py
--- a/src/prefect/server/events/filters.py
+++ b/src/prefect/server/events/filters.py
@@ -1,4 +1,5 @@
 import sys
+from dataclasses import dataclass, field
 from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, cast
 from uuid import UUID
 
@@ -205,15 +206,29 @@ def build_where_clauses(
         return filters
 
 
-def _partition_by_wildcards(names: List[str]) -> Tuple[List[str], List[str]]:
-    """Partition a list of names into those with wildcards and those without"""
-    without_wildcards, with_wildcards = [], []
-    for name in names:
-        if name.endswith("*"):
-            with_wildcards.append(name.strip("*"))
-        else:
-            without_wildcards.append(name)
-    return without_wildcards, with_wildcards
+@dataclass
+class LabelSet:
+    simple: List[str] = field(default_factory=list)
+    prefixes: List[str] = field(default_factory=list)
+
+
+@dataclass
+class LabelOperations:
+    values: List[str]
+    positive: LabelSet = field(default_factory=LabelSet)
+    negative: LabelSet = field(default_factory=LabelSet)
+
+    def __post_init__(self):
+        for value in self.values:
+            label_set = self.positive
+            if value.startswith("!"):
+                label_set = self.negative
+                value = value[1:]
+
+            if value.endswith("*"):
+                label_set.prefixes.append(value.rstrip("*"))
+            else:
+                label_set.simple.append(value)
 
 
 class EventResourceFilter(EventDataFilter):
@@ -282,27 +297,37 @@ def build_where_clauses(
             # On the event_resources table, resource_id is unpacked
             # into a column, so we should search for it there
             if resource_ids := labels.pop("prefect.resource.id", None):
-                simple, prefixes = _partition_by_wildcards(resource_ids)
-                if simple:
-                    label_filters.append(db.EventResource.resource_id.in_(simple))
-                for prefix in prefixes:
-                    label_filters.append(
-                        db.EventResource.resource_id.startswith(prefix)
-                    )
+                label_ops = LabelOperations(resource_ids)
+
+                resource_id_column = db.EventResource.resource_id
+
+                if values := label_ops.positive.simple:
+                    label_filters.append(resource_id_column.in_(values))
+                if values := label_ops.negative.simple:
+                    label_filters.append(resource_id_column.not_in(values))
+                for prefix in label_ops.positive.prefixes:
+                    label_filters.append(resource_id_column.startswith(prefix))
+                for prefix in label_ops.negative.prefixes:
+                    label_filters.append(sa.not_(resource_id_column.startswith(prefix)))
 
             if labels:
                 for _, (label, values) in enumerate(labels.items()):
-                    simple, prefixes = _partition_by_wildcards(values)
-                    if simple:
-                        label_filters.append(
-                            json_extract(db.EventResource.resource, label).in_(simple)
-                        )
-                    for prefix in prefixes:
-                        label_filters.append(
-                            json_extract(db.EventResource.resource, label).startswith(
-                                prefix
-                            )
-                        )
+                    label_ops = LabelOperations(values)
+
+                    label_column = json_extract(db.EventResource.resource, label)
+
+                    # With negative labels, the resource _must_ have the label
+                    if label_ops.negative.simple or label_ops.negative.prefixes:
+                        label_filters.append(label_column.is_not(None))
+
+                    if values := label_ops.positive.simple:
+                        label_filters.append(label_column.in_(values))
+                    if values := label_ops.negative.simple:
+                        label_filters.append(label_column.notin_(values))
+                    for prefix in label_ops.positive.prefixes:
+                        label_filters.append(label_column.startswith(prefix))
+                    for prefix in label_ops.negative.prefixes:
+                        label_filters.append(sa.not_(label_column.startswith(prefix)))
 
             assert self._top_level_filter
             filters.append(
@@ -358,36 +383,45 @@ def build_where_clauses(
             )
 
         if self.labels:
-            label_filters: List["ColumnElement[bool]"] = []
+            label_filters: List[ColumnElement[bool]] = []
             labels = self.labels.deepcopy()
 
             # On the event_resources table, resource_id and resource_role are unpacked
             # into columns, so we should search there for them
             if resource_ids := labels.pop("prefect.resource.id", None):
-                simple, prefixes = _partition_by_wildcards(resource_ids)
-                if simple:
-                    label_filters.append(db.EventResource.resource_id.in_(simple))
-                for prefix in prefixes:
-                    label_filters.append(
-                        db.EventResource.resource_id.startswith(prefix)
-                    )
+                label_ops = LabelOperations(resource_ids)
+
+                resource_id_column = db.EventResource.resource_id
+
+                if values := label_ops.positive.simple:
+                    label_filters.append(resource_id_column.in_(values))
+                if values := label_ops.negative.simple:
+                    label_filters.append(resource_id_column.notin_(values))
+                for prefix in label_ops.positive.prefixes:
+                    label_filters.append(resource_id_column.startswith(prefix))
+                for prefix in label_ops.negative.prefixes:
+                    label_filters.append(sa.not_(resource_id_column.startswith(prefix)))
 
             if roles := labels.pop("prefect.resource.role", None):
                 label_filters.append(db.EventResource.resource_role.in_(roles))
 
             if labels:
                 for _, (label, values) in enumerate(labels.items()):
-                    simple, prefixes = _partition_by_wildcards(values)
-                    if simple:
-                        label_filters.append(
-                            json_extract(db.EventResource.resource, label).in_(simple)
-                        )
-                    for prefix in prefixes:
-                        label_filters.append(
-                            json_extract(db.EventResource.resource, label).startswith(
-                                prefix
-                            )
-                        )
+                    label_ops = LabelOperations(values)
+
+                    label_column = json_extract(db.EventResource.resource, label)
+
+                    if label_ops.negative.simple or label_ops.negative.prefixes:
+                        label_filters.append(label_column.is_not(None))
+
+                    if values := label_ops.positive.simple:
+                        label_filters.append(label_column.in_(values))
+                    if values := label_ops.negative.simple:
+                        label_filters.append(label_column.notin_(values))
+                    for prefix in label_ops.positive.prefixes:
+                        label_filters.append(label_column.startswith(prefix))
+                    for prefix in label_ops.negative.prefixes:
+                        label_filters.append(sa.not_(label_column.startswith(prefix)))
 
             filters.append(sa.and_(*label_filters))
 
@@ -469,30 +503,39 @@ def build_where_clauses(
             # On the event_resources table, resource_id and resource_role are unpacked
             # into columns, so we should search there for them
             if resource_ids := labels.pop("prefect.resource.id", None):
-                simple, prefixes = _partition_by_wildcards(resource_ids)
-                if simple:
-                    label_filters.append(db.EventResource.resource_id.in_(simple))
-                for prefix in prefixes:
-                    label_filters.append(
-                        db.EventResource.resource_id.startswith(prefix)
-                    )
+                label_ops = LabelOperations(resource_ids)
+
+                resource_id_column = db.EventResource.resource_id
+
+                if values := label_ops.positive.simple:
+                    label_filters.append(resource_id_column.in_(values))
+                if values := label_ops.negative.simple:
+                    label_filters.append(resource_id_column.notin_(values))
+                for prefix in label_ops.positive.prefixes:
+                    label_filters.append(resource_id_column.startswith(prefix))
+                for prefix in label_ops.negative.prefixes:
+                    label_filters.append(sa.not_(resource_id_column.startswith(prefix)))
 
             if roles := labels.pop("prefect.resource.role", None):
                 label_filters.append(db.EventResource.resource_role.in_(roles))
 
             if labels:
                 for _, (label, values) in enumerate(labels.items()):
-                    simple, prefixes = _partition_by_wildcards(values)
-                    if simple:
-                        label_filters.append(
-                            json_extract(db.EventResource.resource, label).in_(simple)
-                        )
-                    for prefix in prefixes:
-                        label_filters.append(
-                            json_extract(db.EventResource.resource, label).startswith(
-                                prefix
-                            )
-                        )
+                    label_ops = LabelOperations(values)
+
+                    label_column = json_extract(db.EventResource.resource, label)
+
+                    if label_ops.negative.simple or label_ops.negative.prefixes:
+                        label_filters.append(label_column.is_not(None))
+
+                    if values := label_ops.positive.simple:
+                        label_filters.append(label_column.in_(values))
+                    if values := label_ops.negative.simple:
+                        label_filters.append(label_column.notin_(values))
+                    for prefix in label_ops.positive.prefixes:
+                        label_filters.append(label_column.startswith(prefix))
+                    for prefix in label_ops.negative.prefixes:
+                        label_filters.append(sa.not_(label_column.startswith(prefix)))
 
             filters.append(sa.and_(*label_filters))
 
diff --git a/src/prefect/server/events/schemas/events.py b/src/prefect/server/events/schemas/events.py
--- a/src/prefect/server/events/schemas/events.py
+++ b/src/prefect/server/events/schemas/events.py
@@ -218,15 +218,22 @@ def without_id_and_role(resource: Resource) -> Dict[str, str]:
 
 def matches(expected: str, value: Optional[str]) -> bool:
     """Returns true if the given value matches the expected string, which may
-    include wildcards"""
+    include a a negation prefix ("!this-value") or a wildcard suffix
+    ("any-value-starting-with*")"""
     if value is None:
         return False
 
-    # TODO: handle wildcards/globs better than this
+    positive = True
+    if expected.startswith("!"):
+        expected = expected[1:]
+        positive = False
+
     if expected.endswith("*"):
-        return value.startswith(expected[:-1])
+        match = value.startswith(expected[:-1])
+    else:
+        match = value == expected
 
-    return value == expected
+    return match if positive else not match
 
 
 class ResourceSpecification(PrefectBaseModel):


Test patch:
diff --git a/tests/events/client/test_resource_schema.py b/tests/events/client/test_resource_schema.py
--- a/tests/events/client/test_resource_schema.py
+++ b/tests/events/client/test_resource_schema.py
@@ -664,3 +664,209 @@ def test_resource_specification_deepcopy():
     assert specification["prefect.resource.id"] == copy["prefect.resource.id"]
     assert specification["but-also"] == copy["but-also"]
     assert specification["but-also"] is not copy["but-also"]
+
+
+@pytest.fixture
+def specification_with_single_label():
+    return ResourceSpecification.parse_obj(
+        {
+            "only-a-negative": ["!nah"],
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "resource_labels",
+    [
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "yes",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "woohoo!",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "!nah",  # it's not not wrong
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "nah, chief",  # it's not not wrong
+        },
+    ],
+)
+def test_resource_specification_single_negative_label_values_includes(
+    specification_with_single_label: ResourceSpecification,
+    resource_labels: Dict[str, str],
+):
+    resource = Resource.parse_obj(resource_labels)
+    assert specification_with_single_label.includes([resource])
+
+
+@pytest.mark.parametrize(
+    "resource_labels",
+    [
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "nah",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "does-not-have-the-label": "this ain't it, chief",
+        },
+    ],
+)
+def test_resource_specification_single_negative_label_values_excludes(
+    specification_with_single_label: ResourceSpecification,
+    resource_labels: Dict[str, str],
+):
+    resource = Resource.parse_obj(resource_labels)
+    assert not specification_with_single_label.includes([resource])
+
+
+@pytest.fixture
+def specification_with_negated_wildcard():
+    return ResourceSpecification.parse_obj(
+        {
+            "only-a-negative": ["!nah*"],
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "resource_labels",
+    [
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "yes",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "woohoo!",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "!nah",  # it's not not wrong
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "is it? nah",
+        },
+    ],
+)
+def test_resource_specification_single_negated_wildcard_includes(
+    specification_with_negated_wildcard: ResourceSpecification,
+    resource_labels: Dict[str, str],
+):
+    resource = Resource.parse_obj(resource_labels)
+    assert specification_with_negated_wildcard.includes([resource])
+
+
+@pytest.mark.parametrize(
+    "resource_labels",
+    [
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "nah",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "nah, chief",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "nah, pal",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "does-not-have-the-label": "this ain't it, chief",
+        },
+    ],
+)
+def test_resource_specification_single_negated_wildcard_excludes(
+    specification_with_negated_wildcard: ResourceSpecification,
+    resource_labels: Dict[str, str],
+):
+    resource = Resource.parse_obj(resource_labels)
+    assert not specification_with_negated_wildcard.includes([resource])
+
+
+@pytest.fixture
+def specification_with_multiple_labels():
+    return ResourceSpecification.parse_obj(
+        {
+            "some-label": ["this-value", "!that-value", "other-value"],
+            "another-label": ["yes", "!no", "maybe"],
+            "only-a-negative": ["!nah"],
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "resource_labels",
+    [
+        {
+            "prefect.resource.id": "anything",
+            "some-label": "this-value",
+            "another-label": "yes",
+            "only-a-negative": "yes",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "some-label": "other-value",
+            "another-label": "maybe",
+            "only-a-negative": "woohoo!",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "totally-other": "no",  # forbidden value, but in a different label
+            "some-label": "this-value",
+            "another-label": "yes",
+            "only-a-negative": "!nah",  # it's not not wrong
+        },
+    ],
+)
+def test_resource_specification_multiple_negative_label_values_includes(
+    specification_with_multiple_labels: ResourceSpecification,
+    resource_labels: Dict[str, str],
+):
+    resource = Resource.parse_obj(resource_labels)
+    assert specification_with_multiple_labels.includes([resource])
+
+
+@pytest.mark.parametrize(
+    "resource_labels",
+    [
+        {
+            "prefect.resource.id": "anything",
+            "some-label": "that-value",  # forbidden
+            "another-label": "yes",
+            "only-a-negative": "yes",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "some-label": "that-value",  # forbidden
+            "another-label": "maybe",
+            "only-a-negative": "yes",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "some-label": "this-value",
+            "another-label": "yes",
+            "only-a-negative": "nah",  # forbidden
+        },
+        {
+            "prefect.resource.id": "anything",
+            "some-label": "that-value",  # forbidden
+            "another-label": "no",  # forbidden
+            "only-a-negative": "nah",  # forbidden
+        },
+    ],
+)
+def test_resource_specification_multiple_negative_label_values_excludes(
+    specification_with_multiple_labels: ResourceSpecification,
+    resource_labels: Dict[str, str],
+):
+    resource = Resource.parse_obj(resource_labels)
+    assert not specification_with_multiple_labels.includes([resource])
diff --git a/tests/events/server/test_events_queries.py b/tests/events/server/test_events_queries.py
--- a/tests/events/server/test_events_queries.py
+++ b/tests/events/server/test_events_queries.py
@@ -680,6 +680,63 @@ async def test_querying_by_any_resource_labels(
         )
 
 
+async def test_querying_by_any_negative_resource_labels(
+    events_query_session: AsyncSession,
+    full_occurred_range: EventOccurredFilter,
+    all_events: List[ReceivedEvent],
+) -> None:
+    assert any(
+        resource.get("hello") == "world"
+        for event in all_events
+        for resource in event.involved_resources
+    )
+
+    events, _, _ = await query_events(
+        session=events_query_session,
+        filter=EventFilter(
+            occurred=full_occurred_range,
+            any_resource=EventAnyResourceFilter(labels={"hello": "!world"}),
+        ),
+    )
+
+    assert events
+    assert_events_ordered_descending(events)
+    for event in events:
+        with_hello_label = [r for r in event.involved_resources if r.get("hello")]
+        assert with_hello_label, repr(event)
+        assert any(r["hello"] != "world" for r in with_hello_label), repr(event)
+
+
+async def test_querying_by_any_negative_prefix(
+    events_query_session: AsyncSession,
+    full_occurred_range: EventOccurredFilter,
+    all_events: List[ReceivedEvent],
+) -> None:
+    assert any(
+        resource.get("hello") and resource["hello"].startswith("wor")
+        for event in all_events
+        for resource in event.involved_resources
+    )
+
+    events, _, _ = await query_events(
+        session=events_query_session,
+        filter=EventFilter(
+            occurred=full_occurred_range,
+            any_resource=EventAnyResourceFilter(labels={"hello": "!wor*"}),
+        ),
+    )
+
+    assert events
+    assert_events_ordered_descending(events)
+    for event in events:
+        all_resources = [event.resource] + event.related
+        with_hello_label = [r for r in all_resources if r.get("hello")]
+        assert with_hello_label, repr(event)
+        assert any(not r["hello"].startswith("wor") for r in with_hello_label), repr(
+            event
+        )
+
+
 async def test_querying_by_any_multiple_resource_labels(
     events_query_session: AsyncSession,
     full_occurred_range: EventOccurredFilter,
@@ -874,6 +931,53 @@ async def test_querying_by_resource_labels(
         assert event.resource["hello"] == "world"
 
 
+async def test_querying_by_negative_resource_labels(
+    events_query_session: AsyncSession,
+    full_occurred_range: EventOccurredFilter,
+    all_events: List[ReceivedEvent],
+) -> None:
+    assert any(event.resource.get("hello") == "world" for event in all_events)
+
+    events, _, _ = await query_events(
+        session=events_query_session,
+        filter=EventFilter(
+            occurred=full_occurred_range,
+            resource=EventResourceFilter(labels={"hello": "!world"}),
+        ),
+    )
+
+    assert events
+    assert_events_ordered_descending(events)
+    for event in events:
+        assert event.resource["hello"]
+        assert event.resource["hello"] != "world"
+
+
+async def test_querying_by_negative_resource_prefix(
+    events_query_session: AsyncSession,
+    full_occurred_range: EventOccurredFilter,
+    all_events: List[ReceivedEvent],
+) -> None:
+    assert any(
+        event.resource.get("hello") and event.resource["hello"].startswith("world")
+        for event in all_events
+    )
+
+    events, _, _ = await query_events(
+        session=events_query_session,
+        filter=EventFilter(
+            occurred=full_occurred_range,
+            resource=EventResourceFilter(labels={"hello": "!wor*"}),
+        ),
+    )
+
+    assert events
+    assert_events_ordered_descending(events)
+    for event in events:
+        assert event.resource["hello"]
+        assert not event.resource["hello"].startswith("wor")
+
+
 async def test_querying_by_resource_wildcards(
     events_query_session: AsyncSession,
     full_occurred_range: EventOccurredFilter,
@@ -1024,6 +1128,64 @@ async def test_querying_by_related_labels(
         )
 
 
+async def test_querying_by_related_negative_labels(
+    events_query_session: AsyncSession,
+    full_occurred_range: EventOccurredFilter,
+    all_events: List[ReceivedEvent],
+) -> None:
+    assert any(
+        resource.get("hello") == "world"
+        for event in all_events
+        for resource in event.related
+    )
+
+    events, _, _ = await query_events(
+        session=events_query_session,
+        filter=EventFilter(
+            occurred=full_occurred_range,
+            related=EventRelatedFilter(labels={"hello": "!world"}),
+        ),
+    )
+
+    assert events
+    assert_events_ordered_descending(events)
+    for event in events:
+        assert event.related
+        with_hello_label = [r for r in event.related if r.get("hello")]
+        assert with_hello_label, repr(event)
+        assert any(r["hello"] != "world" for r in with_hello_label), repr(event)
+
+
+async def test_querying_by_related_negative_prefix(
+    events_query_session: AsyncSession,
+    full_occurred_range: EventOccurredFilter,
+    all_events: List[ReceivedEvent],
+) -> None:
+    assert any(
+        resource.get("hello") and resource["hello"].startswith("world")
+        for event in all_events
+        for resource in event.related
+    )
+
+    events, _, _ = await query_events(
+        session=events_query_session,
+        filter=EventFilter(
+            occurred=full_occurred_range,
+            related=EventRelatedFilter(labels={"hello": "!wor*"}),
+        ),
+    )
+
+    assert events
+    assert_events_ordered_descending(events)
+    for event in events:
+        assert event.related
+        with_hello_label = [r for r in event.related if r.get("hello")]
+        assert with_hello_label, repr(event)
+        assert any(not r["hello"].startswith("wor") for r in with_hello_label), repr(
+            event
+        )
+
+
 async def test_querying_by_related_labels_with_resource_ids_as_labels(
     events_query_session: AsyncSession,
     full_occurred_range: EventOccurredFilter,
diff --git a/tests/events/server/test_resource_schema.py b/tests/events/server/test_resource_schema.py
--- a/tests/events/server/test_resource_schema.py
+++ b/tests/events/server/test_resource_schema.py
@@ -670,3 +670,209 @@ def test_resource_specification_deepcopy():
     assert specification["prefect.resource.id"] == copy["prefect.resource.id"]
     assert specification["but-also"] == copy["but-also"]
     assert specification["but-also"] is not copy["but-also"]
+
+
+@pytest.fixture
+def specification_with_single_label():
+    return ResourceSpecification.parse_obj(
+        {
+            "only-a-negative": ["!nah"],
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "resource_labels",
+    [
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "yes",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "woohoo!",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "!nah",  # it's not not wrong
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "nah, chief",  # it's not not wrong
+        },
+    ],
+)
+def test_resource_specification_single_negative_label_values_includes(
+    specification_with_single_label: ResourceSpecification,
+    resource_labels: Dict[str, str],
+):
+    resource = Resource.parse_obj(resource_labels)
+    assert specification_with_single_label.includes([resource])
+
+
+@pytest.mark.parametrize(
+    "resource_labels",
+    [
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "nah",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "does-not-have-the-label": "this ain't it, chief",
+        },
+    ],
+)
+def test_resource_specification_single_negative_label_values_excludes(
+    specification_with_single_label: ResourceSpecification,
+    resource_labels: Dict[str, str],
+):
+    resource = Resource.parse_obj(resource_labels)
+    assert not specification_with_single_label.includes([resource])
+
+
+@pytest.fixture
+def specification_with_negated_wildcard():
+    return ResourceSpecification.parse_obj(
+        {
+            "only-a-negative": ["!nah*"],
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "resource_labels",
+    [
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "yes",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "woohoo!",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "!nah",  # it's not not wrong
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "is it? nah",
+        },
+    ],
+)
+def test_resource_specification_single_negated_wildcard_includes(
+    specification_with_negated_wildcard: ResourceSpecification,
+    resource_labels: Dict[str, str],
+):
+    resource = Resource.parse_obj(resource_labels)
+    assert specification_with_negated_wildcard.includes([resource])
+
+
+@pytest.mark.parametrize(
+    "resource_labels",
+    [
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "nah",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "nah, chief",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "only-a-negative": "nah, pal",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "does-not-have-the-label": "this ain't it, chief",
+        },
+    ],
+)
+def test_resource_specification_single_negated_wildcard_excludes(
+    specification_with_negated_wildcard: ResourceSpecification,
+    resource_labels: Dict[str, str],
+):
+    resource = Resource.parse_obj(resource_labels)
+    assert not specification_with_negated_wildcard.includes([resource])
+
+
+@pytest.fixture
+def specification_with_multiple_labels():
+    return ResourceSpecification.parse_obj(
+        {
+            "some-label": ["this-value", "!that-value", "other-value"],
+            "another-label": ["yes", "!no", "maybe"],
+            "only-a-negative": ["!nah"],
+        }
+    )
+
+
+@pytest.mark.parametrize(
+    "resource_labels",
+    [
+        {
+            "prefect.resource.id": "anything",
+            "some-label": "this-value",
+            "another-label": "yes",
+            "only-a-negative": "yes",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "some-label": "other-value",
+            "another-label": "maybe",
+            "only-a-negative": "woohoo!",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "totally-other": "no",  # forbidden value, but in a different label
+            "some-label": "this-value",
+            "another-label": "yes",
+            "only-a-negative": "!nah",  # it's not not wrong
+        },
+    ],
+)
+def test_resource_specification_multiple_negative_label_values_includes(
+    specification_with_multiple_labels: ResourceSpecification,
+    resource_labels: Dict[str, str],
+):
+    resource = Resource.parse_obj(resource_labels)
+    assert specification_with_multiple_labels.includes([resource])
+
+
+@pytest.mark.parametrize(
+    "resource_labels",
+    [
+        {
+            "prefect.resource.id": "anything",
+            "some-label": "that-value",  # forbidden
+            "another-label": "yes",
+            "only-a-negative": "yes",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "some-label": "that-value",  # forbidden
+            "another-label": "maybe",
+            "only-a-negative": "yes",
+        },
+        {
+            "prefect.resource.id": "anything",
+            "some-label": "this-value",
+            "another-label": "yes",
+            "only-a-negative": "nah",  # forbidden
+        },
+        {
+            "prefect.resource.id": "anything",
+            "some-label": "that-value",  # forbidden
+            "another-label": "no",  # forbidden
+            "only-a-negative": "nah",  # forbidden
+        },
+    ],
+)
+def test_resource_specification_multiple_negative_label_values_excludes(
+    specification_with_multiple_labels: ResourceSpecification,
+    resource_labels: Dict[str, str],
+):
+    resource = Resource.parse_obj(resource_labels)
+    assert not specification_with_multiple_labels.includes([resource])