Repository: pantsbuild/pants

Issue:
Explicitly specified protocols for plugins
EDIT: #12966 took a first step in this direction, but did so without changing the user-facing API of `@union`s. This ticket now covers only exposing a new API for unions.

----

Currently, a `@union` is able to consume "whatever `Params` are in scope at its usage site(s)". While this includes the explicitly provided `Param` of the `Get`, it also includes any others (some necessary, some unnecessary). In particular: the `OptionsBootstrapper` / `Environment` used to be necessary implicit `Params` (and likely will be again after #11269), and the `Platform` should be optionally consumed as well. Since there is no bound on this API/interface, when solving the rule graph, we propagate all `Params` that are in scope through the request for a `@union` member.

Both in order to improve our APIs (by making it more explicit what the contract for a `@union` is) and to improve the determinism of combining plugins (see #12889), we should add an explicit interface to `@union`s. It's possible that while doing this we should also change how `@union`s are defined, to avoid increasing the boilerplate for defining a generic interface.

A strawdesign might look like a new rule type shaped very much like `Query` (and possibly they should be merged, because the impact of this interface would be that they could be solved identically: as roots in the `RuleGraph`, rather than as inner nodes), named `Protocol`, `Interface`, `CallableProtocol`, `RuleProtocol`, etc:
```python
@union
class GenericInputType1: pass

# To declare the interface of a `@union`, one of the input types to the protocol would need to be
# a `@union` (or, in future, an actual `Protocol`, á la #12577).
Protocol(ConcreteOutputType, [GenericInputType1, InputType2])
```

When encountering a `Get` involving a `@union` input type (or perhaps explicitly invoking the protocol somehow?), we'd require a corresponding protocol rule, and then rather than registering that `Get` as dependency of the relevant `@rule`, we'd register it as a the equivalent of a root/`Query` in the `RuleGraph` (or, again, merge the concepts).


Discussion:
> we'd require a corresponding protocol rule, and then rather than registering that Get as dependency of the relevant @rule, we'd register it as a the equivalent of a root/Query in the RuleGraph (or, again, merge the concepts).

I'm not following what this means. Is the protocol rule something declared by the "union base" and used by all implementers?

--

> It's possible that while doing this we should also change how @unions are defined, to avoid increasing the boilerplate for defining a generic interface.

I'm much less worried about boilerplate when defining a new union base. That doesn't happen frequently, especially for plugin authors, and is marked as an advanced section in our docs.

Agreed w/ trying to keep boilerplate low for union members. Although, also I encourage figuring out what the ideal API looks like without considering boilerplate. Then we can try to make it more elegant.
> I'm not following what this means. Is the protocol rule something declared by the "union base" and used by all implementers?

Correct.

> Agreed w/ trying to keep boilerplate low for union members. Although, also I encourage figuring out what the ideal API looks like without considering boilerplate. Then we can try to make it more elegant.

This shouldn't affect the cost of defining members at all.
> `Protocol(ConcreteOutputType, [GenericInputType1, InputType2])`

Ah so you're making more explicit in the rule graph the association of `GenericUnionBase -> ConcreteOutputType`? 

What's the `InputType2` thing doing? Restricting what union member rules can do?
> What's the `InputType2` thing doing? Restricting what union member rules can do?

It's guaranteeing that `InputType2` would be in scope for implementers of the protocol... for example, it might be `Platform` (if implementations of the protocol should be able to consume the `Platform`, and it is provided as a param).
Got it. So union member rules can still request whichever arbitrary types they want, like subsystems and TransitiveTargets, it sounds like. Cool
For the API, this speaks to an idea I've been floating since https://github.com/pantsbuild/pants/pull/12577. Stop using the `@union` decorator and instead something like this:

```python
def rules():
   return [UnionBase(ConcreteOutputType, GenericInputType, additional_inputs=[Platform])
```

The class-based decorator doesn't work well because we want _exact_ type ID matching for union bases, not subclassing. This interface solves that, and seems to fit with your proposal?

(Or this to reduce confusion:

```python
def rules():
   return [UnionBase(output=ConcreteOutputType, input=GenericInputType, additional_inputs=[Platform])
```
Yea, that sounds reasonable. It would probably be better to avoid trying to choose a new name while fixing the immediate issue, so I'll stick with that.
Cool. Btw, I think this modeling would make https://github.com/pantsbuild/pants/pull/12577 superfluous. `GenericInputType` is free to be subclassed as before, whether using dataclass vs Protocol vs ABC etc. That is, it can still act as an interface via Python mechanisms. And subclassing would now be 100% safe with the Rules API because the decorator inheritance problme is solved. 

So, no need to enforce new constraints like that you must implement `Protocol`. cc @kaos 


I think these names could be worth the clarity:

```python
def rules():
   return [
       UnionBase(output=GeneratedSources, base=GenerateSourcesRequest),
       UnionMember(base=GenerateSourcesRequest, member=GenerateProtobufSourcesRequest),
   ]
```

Maybeeee require kwargs? Iirc a common confusion is switching the args of `UnionMember` (aka `UnionRule`)
> Maybeeee require kwargs? Iirc a common confusion is switching the args of UnionMember (aka UnionRule)

I think it could be made clearer, if union bases have classmethods to get the required union rules, so you register them in the same fashion as for target plugin fields.

```python
def rules():
  return [
    GenerateSourcesRequest.register_union_member(GeneratedSources),
    GenerateSourcesRequest.register_union_member(GenerateProtobufSourcesRequest),
  ]
```

The limitation that a `Get` for a `@union` value may only consume a single value affected @tdyas today. He's going to use `SessionValues` instead.

Although this seems related to #12946/#7490, I don't think that it actually is: just adjusting the `@union` definition/syntax to support multiple parameters would be sufficient to allow the existing implementation in #12966 to work. Callsites/`Get`s for the relevant unions wouldn't need to change.
Relatedly: allowing abstract methods of a `@union` to be marked `@rule_helpers` as mentioned in https://github.com/pantsbuild/pants/pull/14238#issuecomment-1031068983 would be really, really helpful. Aligning the syntaxes between calling `@rule_helper` methods of a `@union` and requesting an output for a union input (as described above) would be very important.

EDIT: A good concrete example of this would be to have an optional method of `Field` to compute a dynamic default:
```python
class Field:
    @rule_helper
    @staticmethod
    async def compute_default_value() -> Any: ...

class JvmResolveField:
    @rule_helper
    @staticmethod
    async def compute_default_value() -> Any:
        jvm = await Get(JvmSubsystem, ..)
        return jvm.default_resolve
```
Ok: introducing an `EnvironmentMatcher` param to the graph for #13682 (sketched in #16682) is blocked by this issue, because `@union` `Get` callsites now _only_ provide the param that is actually declared, and nothing else that is in scope. That means that plugins won't have access to the `EnvironmentMatcher` without changes to how those callsites are declared.

While it might be possible to adjust basically all `@union` `Get` callsites to explicitly propagate the `EnvironmentMatcher` via #16668 (á la `Get(TestResult, {field_set: TestFieldSet, env_matcher: EnvironmentMatcher})`), that would typically run afoul of the [param consumption constraint](https://github.com/pantsbuild/pants/blob/main/src/rust/engine/rule_graph/README.md#constraints) (basically: in order to propagate the `EnvironmentMatcher`, a `@rule` would need to consume it as a parameter, which would remove it from scope for the `@rule`'s other dependencies).

----

So a few potential designs for this ("allowing an in-scope type to be propagated in a `@union` declaration"):
1. Proceed with a design similar to the original one proposed on this issue (likely with different naming), where consuming a `@union` at a particular callsite gets an out-of-band syntax which allows more types to be propagated.
    * e.g. something like: `Protocol(OutputType, provided=[Input1, Input2], in_scope=[Input3])`, which a `Get` for a `@union` would be matched against to fully define the API, but the `Get` itself didn't have any syntax changes.
2. Further adjust `Get` syntax to support declaring the union protocol inline.
    * e.g. something like: `Get(OutputType, {input1: Input1, input2: Input2}, in_scope=[Input3])`, which would require that `Input3` is in scope
3. Doing "something" to remove the [param consumption constraint](https://github.com/pantsbuild/pants/blob/main/src/rust/engine/rule_graph/README.md#constraints) for this use-case, and then requiring that `Get`s for `@union`s _do_ explicitly re-pass everything that they want to be in scope.
4. Adjusting the `@union` decorator to take the full set of types that must be in scope to use a `@union`.
    * e.g. something like `@union(in_scope=[Input3])` 
    * This one is similar to (1), but declares types for _all_ callsites of the `@union`, rather than on a callsite-by-callsite basis. That might be easier/less-repetitive than essentially duplicating all `Get`s as `ProtocolRule`s/etc.
> 2. Further adjust Get syntax to support declaring the union protocol inline.

What is the desirability of that in non-union use cases?
> > 2. Further adjust Get syntax to support declaring the union protocol inline.
> 
> What is the desirability of that in non-union use cases?

From a usability perspective, I don't think that there would be any reason to do this? The point of bounding what is in scope at a callsite like this is mostly to define a particular API for a plugin consumer. Most `Get`s won't care about doing that for an internal API.

You could maybe try to bound APIs to improve the performance of `@rule` graph solving, but I don't think that that is something that we'd want folks to do in general.

----

Your question gets to a good point though: changes to the `Get` syntax which are exclusively used for `@union`s are a bit strange. I don't think that we necessarily want a special syntax for `@union` callsites. And so that seems like it would recommend one of the other out-of-band API approaches instead, to keep callsites simple and uniform.
> Your question gets to a good point though: changes to the Get syntax which are exclusively used for @unions are a bit strange. 

That's exactly what I was after. Agreed.

Resolving patch:
diff --git a/src/python/pants/engine/internals/scheduler.py b/src/python/pants/engine/internals/scheduler.py
--- a/src/python/pants/engine/internals/scheduler.py
+++ b/src/python/pants/engine/internals/scheduler.py
@@ -57,7 +57,7 @@
     ProcessResultMetadata,
 )
 from pants.engine.rules import Rule, RuleIndex, TaskRule
-from pants.engine.unions import UnionMembership, is_union
+from pants.engine.unions import UnionMembership, is_union, union_in_scope_types
 from pants.option.global_options import (
     LOCAL_STORE_LEASE_TIME_SECS,
     ExecutionOptions,
@@ -666,17 +666,20 @@ def register_task(rule: TaskRule) -> None:
             unions = [t for t in the_get.input_types if is_union(t)]
             if len(unions) == 1:
                 # Register the union by recording a copy of the Get for each union member.
-                # TODO: See #12934: this should involve an explicit interface
-                # soon, rather than one being implicitly created with only the provided Param.
                 union = unions[0]
+                in_scope_types = union_in_scope_types(union)
+                assert in_scope_types is not None
                 for union_member in union_membership.get(union):
-                    native_engine.tasks_add_union(
+                    native_engine.tasks_add_get_union(
                         tasks,
                         the_get.output_type,
                         tuple(union_member if t == union else t for t in the_get.input_types),
+                        in_scope_types,
                     )
             elif len(unions) > 1:
-                raise TypeError("Only one @union may be used in a Get.")
+                raise TypeError(
+                    "Only one @union may be used in a Get, but {the_get} used: {unions}."
+                )
             else:
                 # Otherwise, the Get subject is a "concrete" type, so add a single Get edge.
                 native_engine.tasks_add_get(tasks, the_get.output_type, the_get.input_types)
diff --git a/src/python/pants/engine/unions.py b/src/python/pants/engine/unions.py
--- a/src/python/pants/engine/unions.py
+++ b/src/python/pants/engine/unions.py
@@ -5,14 +5,14 @@
 
 from collections import defaultdict
 from dataclasses import dataclass
-from typing import DefaultDict, Iterable, Mapping, TypeVar
+from typing import DefaultDict, Iterable, Mapping, TypeVar, cast
 
 from pants.util.frozendict import FrozenDict
 from pants.util.meta import frozen_after_init
 from pants.util.ordered_set import FrozenOrderedSet, OrderedSet
 
 
-def union(cls):
+def union(cls: type | None = None, in_scope_types: list[type] | None = None):
     """A class decorator to allow a class to be a union base in the engine's mechanism for
     polymorphism.
 
@@ -24,19 +24,50 @@ def union(cls):
 
     Often, union bases are abstract classes, but they need not be.
 
+    By default, in order to provide a stable extension API, when a `@union` is used in a `Get`,
+    _only_ the provided parameter is available to callees, But in order to expand its API, a
+    `@union` declaration may optionally include additional "in_scope_types", which are types
+    which must already be in scope at callsites where the `@union` is used in a `Get`, and
+    which are propagated to the callee.
+
     See https://www.pantsbuild.org/docs/rules-api-unions.
     """
-    assert isinstance(cls, type)
-    cls._is_union_for = cls
-    return cls
+
+    def decorator(cls):
+        assert isinstance(cls, type)
+        cls._is_union_for = cls
+        # TODO: this should involve an explicit interface soon, rather than one being implicitly
+        # created with only the provided Param.
+        cls._union_in_scope_types = tuple(in_scope_types) if in_scope_types else tuple()
+        return cls
+
+    if isinstance(cls, type):
+        # This was a decorator call, ex: `@union`.
+        return decorator(cls)
+    else:
+        # This was a factory call, ex: `@union(..)`.
+        return decorator
 
 
 def is_union(input_type: type) -> bool:
-    """Return whether or not a type has been annotated with `@union`."""
+    """Return whether or not a type has been annotated with `@union`.
+
+    This function is also implemented in Rust as `engine::externs::is_union`.
+    """
     is_union: bool = input_type == getattr(input_type, "_is_union_for", None)
     return is_union
 
 
+def union_in_scope_types(input_type: type) -> tuple[type, ...] | None:
+    """If the given type is a `@union`, return its declared in-scope types.
+
+    This function is also implemented in Rust as `engine::externs::union_in_scope_types`.
+    """
+    if not is_union(input_type):
+        return None
+    return cast("tuple[type, ...]", getattr(input_type, "_union_in_scope_types"))
+
+
 @dataclass(frozen=True)
 class UnionRule:
     """Specify that an instance of `union_member` can be substituted wherever `union_base` is


Test patch:
diff --git a/src/python/pants/engine/internals/scheduler_test.py b/src/python/pants/engine/internals/scheduler_test.py
--- a/src/python/pants/engine/internals/scheduler_test.py
+++ b/src/python/pants/engine/internals/scheduler_test.py
@@ -126,7 +126,12 @@ def test_strict_equals() -> None:
 # -----------------------------------------------------------------------------------------------
 
 
-@union
+@dataclass(frozen=True)
+class Fuel:
+    pass
+
+
+@union(in_scope_types=[Fuel])
 class Vehicle(ABC):
     @abstractmethod
     def num_wheels(self) -> int:
@@ -144,7 +149,7 @@ def num_wheels(self) -> int:
 
 
 @rule
-def car_num_wheels(car: Car) -> int:
+def car_num_wheels(car: Car, _: Fuel) -> int:
     return car.num_wheels()
 
 
@@ -163,7 +168,7 @@ async def generic_num_wheels(wrapped_vehicle: WrappedVehicle) -> int:
     return await Get(int, Vehicle, wrapped_vehicle.vehicle)
 
 
-def test_union_rules() -> None:
+def test_union_rules_in_scope_via_query() -> None:
     rule_runner = RuleRunner(
         rules=[
             car_num_wheels,
@@ -171,15 +176,15 @@ def test_union_rules() -> None:
             UnionRule(Vehicle, Car),
             UnionRule(Vehicle, Motorcycle),
             generic_num_wheels,
-            QueryRule(int, [WrappedVehicle]),
+            QueryRule(int, [WrappedVehicle, Fuel]),
         ],
     )
-    assert rule_runner.request(int, [WrappedVehicle(Car())]) == 4
-    assert rule_runner.request(int, [WrappedVehicle(Motorcycle())]) == 2
+    assert rule_runner.request(int, [WrappedVehicle(Car()), Fuel()]) == 4
+    assert rule_runner.request(int, [WrappedVehicle(Motorcycle()), Fuel()]) == 2
 
     # Fails due to no union relationship between Vehicle -> str.
     with pytest.raises(ExecutionError) as exc:
-        rule_runner.request(int, [WrappedVehicle("not a vehicle")])  # type: ignore[arg-type]
+        rule_runner.request(int, [WrappedVehicle("not a vehicle"), Fuel()])  # type: ignore[arg-type]
     assert (
         "Invalid Get. Because an input type for `Get(int, Vehicle, not a vehicle)` was "
         "annotated with `@union`, the value for that type should be a member of that union. Did you "
@@ -187,6 +192,31 @@ def test_union_rules() -> None:
     ) in str(exc.value.args[0])
 
 
+def test_union_rules_in_scope_computed() -> None:
+    @rule
+    def fuel_singleton() -> Fuel:
+        return Fuel()
+
+    rule_runner = RuleRunner(
+        rules=[
+            car_num_wheels,
+            motorcycle_num_wheels,
+            UnionRule(Vehicle, Car),
+            UnionRule(Vehicle, Motorcycle),
+            generic_num_wheels,
+            fuel_singleton,
+            QueryRule(int, [WrappedVehicle]),
+        ],
+    )
+    assert rule_runner.request(int, [WrappedVehicle(Car())]) == 4
+    assert rule_runner.request(int, [WrappedVehicle(Motorcycle())]) == 2
+
+
+# -----------------------------------------------------------------------------------------------
+# Test invalid Gets
+# -----------------------------------------------------------------------------------------------
+
+
 def create_outlined_get() -> Get[int]:
     return Get(int, str, "hello")
 
diff --git a/src/rust/engine/rule_graph/src/tests.rs b/src/rust/engine/rule_graph/src/tests.rs
--- a/src/rust/engine/rule_graph/src/tests.rs
+++ b/src/rust/engine/rule_graph/src/tests.rs
@@ -92,7 +92,7 @@ fn nested_single() {
     Rule(
       "a",
       "a_from_b",
-      vec![DependencyKey::new_with_params("b", vec!["c"])]
+      vec![DependencyKey::new("b").provided_params(vec!["c"])]
     ),
     Rule(
       "b",
@@ -113,7 +113,7 @@ fn nested_multiple() {
     Rule(
       "a",
       "a_from_b",
-      vec![DependencyKey::new_with_params("b", vec!["c"])]
+      vec![DependencyKey::new("b").provided_params(vec!["c"])]
     ),
     Rule(
       "b",
@@ -140,7 +140,7 @@ fn self_cycle_simple() {
     "fib",
     vec![
       DependencyKey::new("int"),
-      DependencyKey::new_with_params("Fib", vec!["int"]),
+      DependencyKey::new("Fib").provided_params(vec!["int"]),
     ],
   )];
   let queries = indexset![
@@ -163,9 +163,9 @@ fn self_cycle_with_external_dep() {
       vec![
         DependencyKey::new("int"),
         // We expect this to be a self-cycle.
-        DependencyKey::new_with_params("Thing", vec!["int"]),
+        DependencyKey::new("Thing").provided_params(vec!["int"]),
         // And this to be satisfied by the second rule, even though we already have an int in scope.
-        DependencyKey::new_with_params("int", vec!["ExternalDep"]),
+        DependencyKey::new("int").provided_params(vec!["ExternalDep"]),
       ],
     ),
     Rule(
@@ -187,7 +187,7 @@ fn multiple_provided() {
     Rule(
       "a",
       "a_from_b",
-      vec![DependencyKey::new_with_params("b", vec!["c", "d"])]
+      vec![DependencyKey::new("b").provided_params(vec!["c", "d"])]
     ),
     Rule(
       "b",
@@ -202,6 +202,75 @@ fn multiple_provided() {
   graph.find_root_edges(vec![], "a").unwrap();
 }
 
+#[test]
+fn in_scope_basic() {
+  let rules = indexset![
+    Rule(
+      "a",
+      "a_from_b",
+      vec![DependencyKey::new("b")
+        .provided_params(vec!["c"])
+        .in_scope_params(vec!["d"])]
+    ),
+    Rule("b", "b_from_c_and_d", vec![DependencyKey::new("c")],),
+  ];
+
+  // Valid when the param is in scope at the callsite (even if it isn't consumed).
+  let queries = indexset![Query::new("a", vec!["d"])];
+  let graph = RuleGraph::new(rules.clone(), queries).unwrap();
+  graph.validate_reachability().unwrap();
+  graph.find_root_edges(vec!["d"], "a").unwrap();
+
+  // Invalid otherwise when the param is not in scope.
+  let queries = indexset![Query::new("a", vec![])];
+  RuleGraph::new(rules, queries).err().unwrap();
+}
+
+#[test]
+fn in_scope_filtered() {
+  let rules = indexset![
+    Rule(
+      "a",
+      "a_from_b",
+      vec![DependencyKey::new("b")
+        .provided_params(vec!["c"])
+        .in_scope_params(vec!["d"])]
+    ),
+    Rule(
+      "b",
+      "b_from_c_and_d",
+      vec![DependencyKey::new("c"), DependencyKey::new("e")],
+    ),
+  ];
+
+  // Even though both "d" and "e" are in scope at the Query, only "c" and "d" are in scope
+  // below `a_from_b`.
+  let queries = indexset![Query::new("a", vec!["d", "e"])];
+  RuleGraph::new(rules, queries).err().unwrap();
+}
+
+#[test]
+fn in_scope_computed() {
+  let _logger = env_logger::try_init();
+  let rules = indexset![
+    Rule(
+      "a",
+      "a_from_b",
+      vec![DependencyKey::new("b")
+        .provided_params(vec!["c"])
+        .in_scope_params(vec!["d"])]
+    ),
+    Rule("b", "b_from_c", vec![DependencyKey::new("c")],),
+    Rule("d", "d", vec![],),
+  ];
+
+  // Valid when the `in_scope` param can be computed or is a singleton.
+  let queries = indexset![Query::new("a", vec![])];
+  let graph = RuleGraph::new(rules.clone(), queries).unwrap();
+  graph.validate_reachability().unwrap();
+  graph.find_root_edges(vec![], "a").unwrap();
+}
+
 #[test]
 fn ambiguous_cycle() {
   let _logger = env_logger::try_init();
@@ -210,8 +279,8 @@ fn ambiguous_cycle() {
       "Root",
       "me",
       vec![
-        DependencyKey::new_with_params("ME", vec!["P"]),
-        DependencyKey::new_with_params("ME", vec!["MPP"]),
+        DependencyKey::new("ME").provided_params(vec!["P"]),
+        DependencyKey::new("ME").provided_params(vec!["MPP"]),
       ],
     ),
     Rule("ME", "me", vec![DependencyKey::new("FERR")]),
@@ -240,7 +309,7 @@ fn natural_loop() {
       "a",
       vec![
         DependencyKey::new("D"),
-        DependencyKey::new_with_params("B", vec!["E"])
+        DependencyKey::new("B").provided_params(vec!["E"])
       ],
     ),
     Rule(
@@ -248,7 +317,7 @@ fn natural_loop() {
       "b",
       vec![
         DependencyKey::new("E"),
-        DependencyKey::new_with_params("C", vec!["F"])
+        DependencyKey::new("C").provided_params(vec!["F"])
       ],
     ),
     Rule(
@@ -256,7 +325,7 @@ fn natural_loop() {
       "c",
       vec![
         DependencyKey::new("F"),
-        DependencyKey::new_with_params("A", vec!["D"])
+        DependencyKey::new("A").provided_params(vec!["D"])
       ],
     ),
   ];
@@ -275,8 +344,8 @@ fn multi_path_cycle() {
       "A",
       "sao",
       vec![
-        DependencyKey::new_with_params("AWO", vec!["AS"]),
-        DependencyKey::new_with_params("AWO", vec!["FS"]),
+        DependencyKey::new("AWO").provided_params(vec!["AS"]),
+        DependencyKey::new("AWO").provided_params(vec!["FS"]),
       ],
     ),
     Rule("AWO", "awofs", vec![DependencyKey::new("FS")]),
@@ -301,7 +370,7 @@ fn mutual_recursion() {
       "is_even",
       vec![
         DependencyKey::new("int"),
-        DependencyKey::new_with_params("IsOdd", vec!["int"]),
+        DependencyKey::new("IsOdd").provided_params(vec!["int"]),
       ],
     ),
     Rule(
@@ -309,7 +378,7 @@ fn mutual_recursion() {
       "is_odd",
       vec![
         DependencyKey::new("int"),
-        DependencyKey::new_with_params("IsEven", vec!["int"]),
+        DependencyKey::new("IsEven").provided_params(vec!["int"]),
       ],
     ),
   ];
@@ -331,14 +400,14 @@ fn wide() {
     Rule(
       "Output",
       "one",
-      vec![DependencyKey::new_with_params("Output", vec!["A"])]
+      vec![DependencyKey::new("Output").provided_params(vec!["A"])]
     ),
     Rule(
       "Output",
       "two",
       vec![
         DependencyKey::new("A"),
-        DependencyKey::new_with_params("Output", vec!["B"])
+        DependencyKey::new("Output").provided_params(vec!["B"])
       ],
     ),
     Rule(
@@ -346,7 +415,7 @@ fn wide() {
       "three",
       vec![
         DependencyKey::new("B"),
-        DependencyKey::new_with_params("Output", vec!["C"])
+        DependencyKey::new("Output").provided_params(vec!["C"])
       ],
     ),
     Rule(
@@ -372,7 +441,7 @@ fn reduced_source_roots() {
       "get_optional_source_roots",
       vec![
         DependencyKey::new("SourceRootsRequest"),
-        DependencyKey::new_with_params("OptionalSourceRoot", vec!["SourceRootRequest"]),
+        DependencyKey::new("OptionalSourceRoot").provided_params(vec!["SourceRootRequest"]),
       ],
     ),
     Rule(
@@ -405,7 +474,7 @@ fn reduced_source_roots() {
       "get_source_roots",
       vec![
         DependencyKey::new("SourceRootsRequest"),
-        DependencyKey::new_with_params("OptionalSourceRootsResult", vec!["SourceRootsRequest"]),
+        DependencyKey::new("OptionalSourceRootsResult").provided_params(vec!["SourceRootsRequest"]),
       ],
     ),
     Rule(
@@ -414,8 +483,8 @@ fn reduced_source_roots() {
       vec![
         DependencyKey::new("SourceRootRequest"),
         DependencyKey::new("SourceRootConfig"),
-        DependencyKey::new_with_params("Snapshot", vec!["PathGlobs"]),
-        DependencyKey::new_with_params("OptionalSourceRoot", vec!["SourceRootRequest"]),
+        DependencyKey::new("Snapshot").provided_params(vec!["PathGlobs"]),
+        DependencyKey::new("OptionalSourceRoot").provided_params(vec!["SourceRootRequest"]),
       ],
     ),
     Rule(
@@ -423,12 +492,12 @@ fn reduced_source_roots() {
       "strip_source_roots",
       vec![
         DependencyKey::new("SourceFiles"),
-        DependencyKey::new_with_params("Snapshot", vec!["DigestSubset"]),
-        DependencyKey::new_with_params("SourceRootsResult", vec!["SourceRootsRequest"]),
-        DependencyKey::new_with_params("Snapshot", vec!["RemovePrefix"]),
-        DependencyKey::new_with_params("Digest", vec!["DigestSubset"]),
-        DependencyKey::new_with_params("Digest", vec!["RemovePrefix"]),
-        DependencyKey::new_with_params("Snapshot", vec!["MergeDigests"]),
+        DependencyKey::new("Snapshot").provided_params(vec!["DigestSubset"]),
+        DependencyKey::new("SourceRootsResult").provided_params(vec!["SourceRootsRequest"]),
+        DependencyKey::new("Snapshot").provided_params(vec!["RemovePrefix"]),
+        DependencyKey::new("Digest").provided_params(vec!["DigestSubset"]),
+        DependencyKey::new("Digest").provided_params(vec!["RemovePrefix"]),
+        DependencyKey::new("Snapshot").provided_params(vec!["MergeDigests"]),
       ],
     ),
   ];
@@ -447,7 +516,7 @@ fn reduced_codegen_cycle() {
       "setup_pex_cli_process",
       vec![
         DependencyKey::new("PexCliProcess"),
-        DependencyKey::new_with_params("ProcessResult", vec!["Process"]),
+        DependencyKey::new("ProcessResult").provided_params(vec!["Process"]),
       ],
     ),
     Rule(
@@ -489,25 +558,22 @@ fn full_scale_target() {
       "infer_python_conftest_dependencies",
       vec![
         DependencyKey::new("InferConftestDependencies"),
-        DependencyKey::new_with_params("HydratedSources", vec!["HydrateSourcesRequest"]),
-        DependencyKey::new_with_params("AncestorFiles", vec!["AncestorFilesRequest"]),
-        DependencyKey::new_with_params("Owners", vec!["OwnersRequest"]),
+        DependencyKey::new("HydratedSources").provided_params(vec!["HydrateSourcesRequest"]),
+        DependencyKey::new("AncestorFiles").provided_params(vec!["AncestorFilesRequest"]),
+        DependencyKey::new("Owners").provided_params(vec!["OwnersRequest"]),
       ],
     ),
     Rule(
       "ThirdPartyModuleToAddressMapping",
       "map_third_party_modules_to_addresses",
-      vec![DependencyKey::new_with_params(
-        "Targets",
-        vec!["AddressSpecs"]
-      )],
+      vec![DependencyKey::new("Targets").provided_params(vec!["AddressSpecs"])],
     ),
     Rule(
       "Targets",
       "resolve_targets",
       vec![
         DependencyKey::new("UnexpandedTargets"),
-        DependencyKey::new_with_params("Subtargets", vec!["Address"]),
+        DependencyKey::new("Subtargets").provided_params(vec!["Address"]),
       ],
     ),
     Rule(
@@ -515,9 +581,9 @@ fn full_scale_target() {
       "find_owners",
       vec![
         DependencyKey::new("OwnersRequest"),
-        DependencyKey::new_with_params("Targets", vec!["AddressSpecs"]),
-        DependencyKey::new_with_params("UnexpandedTargets", vec!["AddressSpecs"]),
-        DependencyKey::new_with_params("BuildFileAddress", vec!["Address"]),
+        DependencyKey::new("Targets").provided_params(vec!["AddressSpecs"]),
+        DependencyKey::new("UnexpandedTargets").provided_params(vec!["AddressSpecs"]),
+        DependencyKey::new("BuildFileAddress").provided_params(vec!["Address"]),
       ],
     ),
     Rule(
@@ -525,8 +591,8 @@ fn full_scale_target() {
       "resolve_addresses_with_origins",
       vec![
         DependencyKey::new("Specs"),
-        DependencyKey::new_with_params("AddressesWithOrigins", vec!["AddressSpecs"]),
-        DependencyKey::new_with_params("AddressesWithOrigins", vec!["FilesystemSpecs"]),
+        DependencyKey::new("AddressesWithOrigins").provided_params(vec!["AddressSpecs"]),
+        DependencyKey::new("AddressesWithOrigins").provided_params(vec!["FilesystemSpecs"]),
       ],
     ),
     Rule(
@@ -534,8 +600,8 @@ fn full_scale_target() {
       "infer_python_dependencies",
       vec![
         DependencyKey::new("InferPythonDependencies"),
-        DependencyKey::new_with_params("StrippedSourceFiles", vec!["SourceFilesRequest"]),
-        DependencyKey::new_with_params("PythonModuleOwner", vec!["PythonModule"]),
+        DependencyKey::new("StrippedSourceFiles").provided_params(vec!["SourceFilesRequest"]),
+        DependencyKey::new("PythonModuleOwner").provided_params(vec!["PythonModule"]),
       ],
     ),
     Rule(
@@ -543,9 +609,9 @@ fn full_scale_target() {
       "infer_python_init_dependencies",
       vec![
         DependencyKey::new("InferInitDependencies"),
-        DependencyKey::new_with_params("HydratedSources", vec!["HydrateSourcesRequest"]),
-        DependencyKey::new_with_params("AncestorFiles", vec!["AncestorFilesRequest"]),
-        DependencyKey::new_with_params("Owners", vec!["OwnersRequest"]),
+        DependencyKey::new("HydratedSources").provided_params(vec!["HydrateSourcesRequest"]),
+        DependencyKey::new("AncestorFiles").provided_params(vec!["AncestorFilesRequest"]),
+        DependencyKey::new("Owners").provided_params(vec!["OwnersRequest"]),
       ],
     ),
     Rule(
@@ -562,25 +628,19 @@ fn full_scale_target() {
       "download_external_tool",
       vec![
         DependencyKey::new("ExternalToolRequest"),
-        DependencyKey::new_with_params("Digest", vec!["DownloadFile"]),
-        DependencyKey::new_with_params("ExtractedDigest", vec!["MaybeExtractable"]),
+        DependencyKey::new("Digest").provided_params(vec!["DownloadFile"]),
+        DependencyKey::new("ExtractedDigest").provided_params(vec!["MaybeExtractable"]),
       ],
     ),
     Rule(
       "GlobalOptions",
       "construct_scope_",
-      vec![DependencyKey::new_with_params(
-        "ScopedOptions",
-        vec!["Scope"]
-      )],
+      vec![DependencyKey::new("ScopedOptions").provided_params(vec!["Scope"])],
     ),
     Rule(
       "PexEnvironment",
       "find_pex_python",
-      vec![DependencyKey::new_with_params(
-        "BinaryPaths",
-        vec!["BinaryPathRequest"]
-      )],
+      vec![DependencyKey::new("BinaryPaths").provided_params(vec!["BinaryPathRequest"])],
     ),
     Rule(
       "ProcessResult",
@@ -595,11 +655,9 @@ fn full_scale_target() {
       "hydrate_sources",
       vec![
         DependencyKey::new("HydrateSourcesRequest"),
-        DependencyKey::new_with_params("WrappedTarget", vec!["Address"]),
-        DependencyKey::new_with_params(
-          "GeneratedSources",
-          vec!["GeneratePythonFromProtobufRequest"],
-        ),
+        DependencyKey::new("WrappedTarget").provided_params(vec!["Address"]),
+        DependencyKey::new("GeneratedSources")
+          .provided_params(vec!["GeneratePythonFromProtobufRequest"],),
       ],
     ),
     Rule(
@@ -612,7 +670,7 @@ fn full_scale_target() {
       "determine_source_files",
       vec![
         DependencyKey::new("SourceFilesRequest"),
-        DependencyKey::new_with_params("HydratedSources", vec!["HydrateSourcesRequest"]),
+        DependencyKey::new("HydratedSources").provided_params(vec!["HydrateSourcesRequest"]),
       ],
     ),
     Rule(
@@ -620,7 +678,7 @@ fn full_scale_target() {
       "resolve_unexpanded_targets",
       vec![
         DependencyKey::new("Addresses"),
-        DependencyKey::new_with_params("WrappedTarget", vec!["Address"]),
+        DependencyKey::new("WrappedTarget").provided_params(vec!["Address"]),
       ],
     ),
     Rule(
@@ -628,7 +686,7 @@ fn full_scale_target() {
       "maybe_extract",
       vec![
         DependencyKey::new("MaybeExtractable"),
-        DependencyKey::new_with_params("ProcessResult", vec!["Process"]),
+        DependencyKey::new("ProcessResult").provided_params(vec!["Process"]),
       ],
     ),
     Rule(
@@ -636,10 +694,10 @@ fn full_scale_target() {
       "addresses_with_origins_from_address_specs",
       vec![
         DependencyKey::new("AddressSpecs"),
-        DependencyKey::new_with_params("Address", vec!["AddressInput"]),
-        DependencyKey::new_with_params("TargetAdaptor", vec!["Address"]),
-        DependencyKey::new_with_params("UnexpandedTargets", vec!["Addresses"]),
-        DependencyKey::new_with_params("AddressFamily", vec!["Dir"]),
+        DependencyKey::new("Address").provided_params(vec!["AddressInput"]),
+        DependencyKey::new("TargetAdaptor").provided_params(vec!["Address"]),
+        DependencyKey::new("UnexpandedTargets").provided_params(vec!["Addresses"]),
+        DependencyKey::new("AddressFamily").provided_params(vec!["Dir"]),
       ],
     ),
     Rule(
@@ -647,7 +705,7 @@ fn full_scale_target() {
       "find_build_file",
       vec![
         DependencyKey::new("Address"),
-        DependencyKey::new_with_params("AddressFamily", vec!["Dir"]),
+        DependencyKey::new("AddressFamily").provided_params(vec!["Dir"]),
       ],
     ),
     Rule(
@@ -665,7 +723,7 @@ fn full_scale_target() {
       "find_binary",
       vec![
         DependencyKey::new("BinaryPathRequest"),
-        DependencyKey::new_with_params("FallibleProcessResult", vec!["Process"]),
+        DependencyKey::new("FallibleProcessResult").provided_params(vec!["Process"]),
       ],
     ),
     Rule(
@@ -673,7 +731,7 @@ fn full_scale_target() {
       "get_optional_source_roots",
       vec![
         DependencyKey::new("SourceRootsRequest"),
-        DependencyKey::new_with_params("OptionalSourceRoot", vec!["SourceRootRequest"]),
+        DependencyKey::new("OptionalSourceRoot").provided_params(vec!["SourceRootRequest"]),
       ],
     ),
     Rule(
@@ -681,7 +739,7 @@ fn full_scale_target() {
       "addresses_with_origins_from_filesystem_specs",
       vec![
         DependencyKey::new("FilesystemSpecs"),
-        DependencyKey::new_with_params("Owners", vec!["OwnersRequest"]),
+        DependencyKey::new("Owners").provided_params(vec!["OwnersRequest"]),
       ],
     ),
     Rule(
@@ -709,15 +767,15 @@ fn full_scale_target() {
       "transitive_targets",
       vec![
         DependencyKey::new("Targets"),
-        DependencyKey::new_with_params("Targets", vec!["DependenciesRequest"]),
+        DependencyKey::new("Targets").provided_params(vec!["DependenciesRequest"]),
       ],
     ),
     Rule(
       "FirstPartyModuleToAddressMapping",
       "map_first_party_modules_to_addresses",
       vec![
-        DependencyKey::new_with_params("Targets", vec!["AddressSpecs"]),
-        DependencyKey::new_with_params("StrippedSourceFiles", vec!["SourceFilesRequest"]),
+        DependencyKey::new("Targets").provided_params(vec!["AddressSpecs"]),
+        DependencyKey::new("StrippedSourceFiles").provided_params(vec!["SourceFilesRequest"]),
       ],
     ),
     Rule(
@@ -735,12 +793,12 @@ fn full_scale_target() {
       "generate_python_from_protobuf",
       vec![
         DependencyKey::new("GeneratePythonFromProtobufRequest"),
-        DependencyKey::new_with_params("DownloadedExternalTool", vec!["ExternalToolRequest"]),
-        DependencyKey::new_with_params("ProcessResult", vec!["Process"]),
-        DependencyKey::new_with_params("TransitiveTargets", vec!["Addresses"]),
-        DependencyKey::new_with_params("StrippedSourceFiles", vec!["SourceFilesRequest"]),
-        DependencyKey::new_with_params("Digest", vec!["MergeDigests"]),
-        DependencyKey::new_with_params("SourceRoot", vec!["SourceRootRequest"]),
+        DependencyKey::new("DownloadedExternalTool").provided_params(vec!["ExternalToolRequest"]),
+        DependencyKey::new("ProcessResult").provided_params(vec!["Process"]),
+        DependencyKey::new("TransitiveTargets").provided_params(vec!["Addresses"]),
+        DependencyKey::new("StrippedSourceFiles").provided_params(vec!["SourceFilesRequest"]),
+        DependencyKey::new("Digest").provided_params(vec!["MergeDigests"]),
+        DependencyKey::new("SourceRoot").provided_params(vec!["SourceRootRequest"]),
       ],
     ),
     Rule(
@@ -748,7 +806,7 @@ fn full_scale_target() {
       "get_source_root",
       vec![
         DependencyKey::new("SourceRootRequest"),
-        DependencyKey::new_with_params("OptionalSourceRoot", vec!["SourceRootRequest"]),
+        DependencyKey::new("OptionalSourceRoot").provided_params(vec!["SourceRootRequest"]),
       ],
     ),
     Rule(
@@ -761,7 +819,7 @@ fn full_scale_target() {
       "find_target_adaptor",
       vec![
         DependencyKey::new("Address"),
-        DependencyKey::new_with_params("AddressFamily", vec!["Dir"]),
+        DependencyKey::new("AddressFamily").provided_params(vec!["Dir"]),
       ],
     ),
     Rule(
@@ -779,7 +837,7 @@ fn full_scale_target() {
       "get_source_roots",
       vec![
         DependencyKey::new("SourceRootsRequest"),
-        DependencyKey::new_with_params("OptionalSourceRootsResult", vec!["SourceRootsRequest"]),
+        DependencyKey::new("OptionalSourceRootsResult").provided_params(vec!["SourceRootsRequest"]),
       ],
     ),
     Rule(
@@ -787,7 +845,7 @@ fn full_scale_target() {
       "get_optional_source_root",
       vec![
         DependencyKey::new("SourceRootRequest"),
-        DependencyKey::new_with_params("OptionalSourceRoot", vec!["SourceRootRequest"]),
+        DependencyKey::new("OptionalSourceRoot").provided_params(vec!["SourceRootRequest"]),
       ],
     ),
     Rule(
@@ -795,8 +853,8 @@ fn full_scale_target() {
       "strip_source_roots",
       vec![
         DependencyKey::new("SourceFiles"),
-        DependencyKey::new_with_params("SourceRootsResult", vec!["SourceRootsRequest"]),
-        DependencyKey::new_with_params("Digest", vec!["DigestSubset"]),
+        DependencyKey::new("SourceRootsResult").provided_params(vec!["SourceRootsRequest"]),
+        DependencyKey::new("Digest").provided_params(vec!["DigestSubset"]),
       ],
     ),
     Rule(
@@ -804,7 +862,7 @@ fn full_scale_target() {
       "generate_subtargets",
       vec![
         DependencyKey::new("Address"),
-        DependencyKey::new_with_params("WrappedTarget", vec!["Address"]),
+        DependencyKey::new("WrappedTarget").provided_params(vec!["Address"]),
       ],
     ),
     Rule(
@@ -818,12 +876,13 @@ fn full_scale_target() {
       vec![
         DependencyKey::new("DependenciesRequest"),
         DependencyKey::new("RegisteredTargetTypes"),
-        DependencyKey::new_with_params("Address", vec!["AddressInput"]),
-        DependencyKey::new_with_params("WrappedTarget", vec!["Address"]),
-        DependencyKey::new_with_params("InferredDependencies", vec!["InferPythonDependencies"]),
-        DependencyKey::new_with_params("InferredDependencies", vec!["InferInitDependencies"]),
-        DependencyKey::new_with_params("InferredDependencies", vec!["InferConftestDependencies"]),
-        DependencyKey::new_with_params("Subtargets", vec!["Address"]),
+        DependencyKey::new("Address").provided_params(vec!["AddressInput"]),
+        DependencyKey::new("WrappedTarget").provided_params(vec!["Address"]),
+        DependencyKey::new("InferredDependencies").provided_params(vec!["InferPythonDependencies"]),
+        DependencyKey::new("InferredDependencies").provided_params(vec!["InferInitDependencies"]),
+        DependencyKey::new("InferredDependencies")
+          .provided_params(vec!["InferConftestDependencies"]),
+        DependencyKey::new("Subtargets").provided_params(vec!["Address"]),
       ],
     ),
     Rule(
@@ -832,8 +891,8 @@ fn full_scale_target() {
       vec![
         DependencyKey::new("Address"),
         DependencyKey::new("RegisteredTargetTypes"),
-        DependencyKey::new_with_params("WrappedTarget", vec!["Address"]),
-        DependencyKey::new_with_params("TargetAdaptor", vec!["Address"]),
+        DependencyKey::new("WrappedTarget").provided_params(vec!["Address"]),
+        DependencyKey::new("TargetAdaptor").provided_params(vec!["Address"]),
       ],
     ),
     Rule(