Repository: crossplane/crossplane

Issue:
Proposal: Operation Functions
<!--
Thank you for helping to improve Crossplane!

Please be sure to search for open issues before raising a new one. We use issues
for bug reports and feature requests. Please find us at https://slack.crossplane.io
for questions, support, and discussion.
-->

### What problem are you facing?
<!--
Please tell us a little about your use case - it's okay if it's hypothetical!
Leading with this context helps frame the feature request so we can ensure we
implement it sensibly.
--->

During the design of Composition Functions ([design doc](https://github.com/crossplane/crossplane/blob/579702198282bbbf6a98761c919875769dee61a4/design/design-doc-composition-functions.md)) we observed that the Function pipeline model could be a good way to model "day two operations". Things like upgrading and autoscaling resources.

We decided that the Composition logic probably wasn't the best place to encode day two operations. Mostly because we wanted to keep it focused on one responsibility - composing many (API) resources from one. I do think though that the Functions model, broadly, makes sense for day two operations. This is because day two operations is a very open ended problem space. Imagine for example code that can handle upgrading a database instance, when there might be 10+ different implementations of a database instance (RDS, CloudSQL, etc etc). I don't think we - the Crossplane maintainers - can build this logic for folks. Instead I think we could give folks a place to hook in such logic, and tools to make building the logic as simple as possible.

### How could Crossplane help solve your problem?
<!--
Let us know how you think Crossplane could help with your use case. 
-->

This is super vague, but I could imagine introducing a new variant of the Function package. Something like this:

```yaml
apiVersion: pkg.crossplane.io/v1beta1
kind: Function
metadata:
  name: function-upgrade-rds-instance
spec:
  type: Operation # As opposed to "Composition"
  package: xpkg.upbound.io/crossplane-contrib/upgrade-rds-instance:v0.1.0
```

I imagine an "Operation Function" would be built, deployed, and run almost identically to a Composition Function. The `type` field would signify that it spoke a different gRPC API than a Composition Function.

The question then is what calls a pipeline of Operation Functions? Maybe you have something that looks very similar to a Composition:

```yaml
apiVersion: daytwo.crossplane.io/v1alpha1
kind: Operations
metadata:
  name: upgrade-my-database
spec:
  triggers:
  - type: Cron
    crontab: 5 4 * * *
  pipeline:
  - step: backup
    functionRef:
      name: function-backup-rds-instance
  - step: upgrade
    functionRef:
      name: function-upgrade-rds-instance
```

You could then associate "operations" with an XR (or MR?) by reference or label, similar to how you associate a Composition.

Discussion:
@blakeromano: High level thoughts on this....

Composition Functions now that are apart of compositions allow you to update any resource like the MR/XR etc;... I see Crossplane Functions almost as an extension of a normal Kubernetes Operator in a certain way. You are examining current state and desired state and updating various things based on that. My thought would be that for example let's say I want to on a DB XR do a backup every hour. I can update status to have a `lastBackupTime` param and during reconciliation check that and if I am due for a snapshot, I can start a DB Snapshot with an ID, track the status of that snapshot in the status of the XR, and update `lastBackupTime` in the status of the XR when done.

This is the typical pattern that Kubernetes Operators follow and I am not super convinced that there is a strong need to have functions outside of the Composition. I think with maybe better documentation or standards (with SDKs and XRDs) that having data within the `status` of an XR that this is totally possible in Crossplane Functions now and really am not convinced that a new type of function is needed.@negz: @blakeromano I think there's two issues (at least) with that.

First, composition functions aren't always going to be an ideal 'trigger' for day two things. Imagine you want to make sure all RDS instances are backed up. Do you update every Composition that produces an RDS instance to do this? What if you were using third party Compositions (e.g. from https://marketplace.upbound.io)? I think it would it be more convenient to be able to configure a policy like "for every RDS instance, whatever XR (or lack thereof) created it, whenever it changes, run this function to make sure it's backed up".

Second, it overloads _composition_ functions to do things that are not composition. I think of composition as the process of composing an XR of other resources (e.g. MRs). I'm not sure day two operations on those resources will always/often be related to the act of composing. Apart from just being a philosophical/concept thing this could have practical issues. Consider composition not working because some nice-to-have day two thing is broken, or the potential increase in complexity of a `RunFunctionRequest` and `RunFunctionResponse` schema that begins to attempt to have all the inputs needed to handle both composition and whatever day two things we come up with.@negz: A manual trigger would also be interesting. So so far we have the following possible triggers:

* Cron schedule
* Manual
* XR/MR watch (created, updated, deleted)

I wonder if we should treat day two operations similar to a Kubernetes `Job`.

So imagine creating this manifest just ran the pipeline of functions one time:

```yaml
apiVersion: daytwo.crossplane.io/v1alpha1
kind: Operation
metadata:
  name: upgrade-my-database
spec:
  pipeline:
  - step: backup
    functionRef:
      name: function-backup-rds-instance
  - step: verify
    functionRef:
      name: function-verify-rds-instance-backup
```

Then you could have `CronOperation`, `WatchOperation` etc abstractions on top to 'trigger' the operation (pipeline) by creating an `Operation`.@bobh66: @negz - I agree with the `Job` comparison.  This feels like a declarative workflow specification similar to Argo Workflows.  What does the pipeline take as input?  It seems like there would need to be some sort of GVK/name/label resource-selection capability - "backup THIS specific database instance" or "backup all of the database instances with label foo=bar" that can be passed into the pipeline so it has a context to work with.  Ideally this would leverage the "extra resources" functionality without necessarily requiring the function(s) in the pipeline to explicitly request the things it needs, since they need to be specified anyway.

I like the idea of a `WatchOperation` that can do things like pre-checks on a Kubernetes cluster before an upgrade is allow to proceed, for example.  I might want to have a higher-level composite that has a watch on the version, and the update of the version triggers a `WatchOperation` to do some work before the update is propagated to the `Cluster` object in the composite (presumably one or more Composition Functions would be involved to handle that complexity).@github-actions: Crossplane does not currently have enough maintainers to address every issue and pull request. This issue has been automatically marked as `stale` because it has had no activity in the last 90 days. It will be closed in 14 days if no further activity occurs. Leaving a comment **starting with** `/fresh` will mark this issue as not stale.@jbw976: /fresh@ttreptow: > What does the pipeline take as input? It seems like there would need to be some sort of GVK/name/label resource-selection capability - "backup THIS specific database instance" or "backup all of the database instances with label foo=bar" that can be passed into the pipeline so it has a context to work with. Ideally this would leverage the "extra resources" functionality without necessarily requiring the function(s) in the pipeline to explicitly request the things it needs, since they need to be specified anyway.

I mentioned this in a comment in the PR, but if you think of an Operation as being conceptually similar to a Composition where it just defines the pipeline instead of running it, then you could instantiate a specific "run" of the Operation with the Operation equivalent of an XR. For inputs you could have the equivalent of an XRD that defines the configuration schema of a run. 

So the Operation + (XRD equivalent) combo would create a Kubernetes CRD (XR equivalent) that you could instantiate to run the Operation.@negz: > For inputs you could have the equivalent of an XRD that defines the configuration schema of a run.

I'm on the fence about how much an abstraction like this matters for operations.

With XRs and Compositions we designed around a separation of concern. One team designs the API (XRD) and how it works (Composition), and another team consumes it via a claim. Typically it's a platform team designing the API and one of their customers (e.g. an "app dev" team) consuming it.

How often do we think that separation of concerns will be desired for operations? How often will one team be designing an operation intended to be run by another team? My hunch is it'll be less common.

I'm not _against_ breaking the Operation as proposed out into an abstraction API and an implementation ala compositions if there's clear benefits. It does come at a cost for users though - e.g. designing and maintaining the API, more cognitive complexity in general, etc.@ttreptow: The separation of concerns here depends a bit on the structure of the organization. In some cases, it might be a dev team that writes an operation for executing a failover on a stateful app which is then handed to an ops team with a runbook. Or a database SME writing a backup operation which is then consumed as a sort of black box by other teams.

Speaking of which, I think it would be useful to be able to include Operations (in some form) in Configuration packages alongside Compositions.

I appreciate the desire to keep things simple. I'm likewise not opposed to keeping CronOperation/Operation as a CronJob/Job analogue if we have reasonable workarounds for various use cases. Something like "create an Operation with a Composition" could potentially work as a way to create a repeatable operation execution. 

It would also be useful to enable functions to suspend operations (or at least have a per step timeout that could be set to infinity) as well as to retrigger the operation. That would possibly enable functions to implement watch or other trigger functionality without requiring separate resources.@negz: > The separation of concerns here depends a bit on the structure of the organization.

Yeah, I definitely agree wanting a separation of concerns is likely for some folks. I just wonder whether it'll be enough folks to justify the complexity. Ideally there'd be some way to add it in future. That way we could get a concrete sense of the demand. If there's no abstraction/separation and folks want it, they'll ask. It's always easier to add new functionality than take it away.@github-actions: Crossplane does not currently have enough maintainers to address every issue and pull request. This issue has been automatically marked as `stale` because it has had no activity in the last 90 days. It will be closed in 14 days if no further activity occurs. Leaving a comment **starting with** `/fresh` will mark this issue as not stale.@bobh66: /fresh@github-actions: Crossplane does not currently have enough maintainers to address every issue and pull request. This issue has been automatically marked as `stale` because it has had no activity in the last 90 days. It will be closed in 14 days if no further activity occurs. Leaving a comment **starting with** `/fresh` will mark this issue as not stale.@negz: I've finally opened a design PR for this: https://github.com/crossplane/crossplane/pull/6557.

Solution patch:
diff --git a/Earthfile b/Earthfile
index ffd582a72..ff97f24a6 100644
--- a/Earthfile
+++ b/Earthfile
@@ -136,7 +136,7 @@ go-modules-tidy:
   FROM +go-modules
   CACHE --id go-build --sharing shared /root/.cache/go-build
   COPY generate.go .
-  COPY --dir apis/ cmd/ internal/ test/ .
+  COPY --dir apis/ proto/ cmd/ internal/ test/ .
   RUN go mod tidy
   RUN go mod verify
   SAVE ARTIFACT go.mod AS LOCAL go.mod
@@ -149,7 +149,7 @@ go-generate:
   COPY +kubectl-setup/kubectl /usr/local/bin/kubectl
   COPY generate.go buf.yaml buf.gen.yaml .
   COPY --dir cluster/crd-patches cluster/crd-patches
-  COPY --dir hack/ apis/ internal/ .
+  COPY --dir hack/ apis/ proto/ internal/ .
   RUN go generate -tags 'generate' .
   # TODO(negz): Can this move into generate.go? Ideally it would live there with
   # the code that actually generates the CRDs, but it depends on kubectl.
@@ -159,6 +159,7 @@ go-generate:
     --output=yaml > /tmp/patched.yaml \
     && mv /tmp/patched.yaml cluster/crds/pkg.crossplane.io_deploymentruntimeconfigs.yaml
   SAVE ARTIFACT apis/ AS LOCAL apis
+  SAVE ARTIFACT proto/ AS LOCAL proto
   SAVE ARTIFACT internal/ AS LOCAL internal
   SAVE ARTIFACT cluster/crds AS LOCAL cluster/crds
   SAVE ARTIFACT cluster/meta AS LOCAL cluster/meta
@@ -180,7 +181,7 @@ go-build:
     SET ext = ".exe"
   END
   CACHE --id go-build --sharing shared /root/.cache/go-build
-  COPY --dir apis/ cmd/ internal/ .
+  COPY --dir apis/ proto/ cmd/ internal/ .
   RUN go build -o crossplane${ext} ./cmd/crossplane
   RUN sha256sum crossplane${ext} | head -c 64 > crossplane${ext}.sha256
   RUN go build -o crank${ext} ./cmd/crank
@@ -212,7 +213,7 @@ go-build-e2e:
   ARG CGO_ENABLED=0
   FROM +go-modules
   CACHE --id go-build --sharing shared /root/.cache/go-build
-  COPY --dir apis/ internal/ test/ .
+  COPY --dir apis/ proto/ internal/ test/ .
   RUN go test -c -o e2e ./test/e2e
   SAVE ARTIFACT e2e
 
@@ -220,7 +221,7 @@ go-build-e2e:
 go-test:
   FROM +go-modules
   CACHE --id go-build --sharing shared /root/.cache/go-build
-  COPY --dir apis/ cmd/ internal/ .
+  COPY --dir apis/ proto/ cmd/ internal/ .
   RUN go test -covermode=count -coverprofile=coverage.txt ./...
   SAVE ARTIFACT coverage.txt AS LOCAL _output/tests/coverage.txt
 
@@ -233,9 +234,10 @@ go-lint:
   CACHE --id go-build --sharing shared /root/.cache/go-build
   RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_LINT_VERSION}
   COPY .golangci.yml .
-  COPY --dir apis/ cmd/ internal/ test/ .
+  COPY --dir apis/ proto/ cmd/ internal/ test/ .
   RUN golangci-lint run --fix
   SAVE ARTIFACT apis AS LOCAL apis
+  SAVE ARTIFACT proto AS LOCAL proto
   SAVE ARTIFACT cmd AS LOCAL cmd
   SAVE ARTIFACT internal AS LOCAL internal
   SAVE ARTIFACT test AS LOCAL test
@@ -420,7 +422,7 @@ ci-codeql:
   END
   COPY --dir +ci-codeql-setup/codeql /codeql
   CACHE --id go-build --sharing shared /root/.cache/go-build
-  COPY --dir apis/ cmd/ internal/ .
+  COPY --dir apis/ proto/ cmd/ internal/ .
   RUN /codeql/codeql database create /codeqldb --language=go
   RUN /codeql/codeql database analyze /codeqldb --threads=0 --format=sarif-latest --output=go.sarif --sarif-add-baseline-file-info
   SAVE ARTIFACT go.sarif AS LOCAL _output/codeql/go.sarif
diff --git a/apis/apiextensions/v1/composition_common.go b/apis/apiextensions/v1/composition_common.go
index 5a147f899..ea21594e8 100644
--- a/apis/apiextensions/v1/composition_common.go
+++ b/apis/apiextensions/v1/composition_common.go
@@ -35,8 +35,8 @@ import (
 type CompositionMode string
 
 const (
-	// CompositionModePipeline indicates that a Composition specifies a pipeline
-	// of Composition Functions, each of which is responsible for producing
+	// CompositionModePipeline indicates that a Composition specifies a
+	// pipeline of functions, each of which is responsible for producing
 	// composed resources that Crossplane should create or update.
 	CompositionModePipeline CompositionMode = "Pipeline"
 )
@@ -55,38 +55,38 @@ func TypeReferenceTo(gvk schema.GroupVersionKind) TypeReference {
 	return TypeReference{APIVersion: gvk.GroupVersion().String(), Kind: gvk.Kind}
 }
 
-// A PipelineStep in a Composition Function pipeline.
+// A PipelineStep in a function pipeline.
 type PipelineStep struct {
 	// Step name. Must be unique within its Pipeline.
 	Step string `json:"step"`
 
-	// FunctionRef is a reference to the Composition Function this step should
+	// FunctionRef is a reference to the function this step should
 	// execute.
 	FunctionRef FunctionReference `json:"functionRef"`
 
 	// Input is an optional, arbitrary Kubernetes resource (i.e. a resource
-	// with an apiVersion and kind) that will be passed to the Composition
-	// Function as the 'input' of its RunFunctionRequest.
+	// with an apiVersion and kind) that will be passed to the function as
+	// the 'input' of its RunFunctionRequest.
 	// +optional
 	// +kubebuilder:pruning:PreserveUnknownFields
 	// +kubebuilder:validation:EmbeddedResource
 	Input *runtime.RawExtension `json:"input,omitempty"`
 
-	// Credentials are optional credentials that the Composition Function needs.
+	// Credentials are optional credentials that the function needs.
 	// +optional
 	// +listType=map
 	// +listMapKey=name
 	Credentials []FunctionCredentials `json:"credentials,omitempty"`
 }
 
-// A FunctionReference references a Composition Function that may be used in a
+// A FunctionReference references a function that may be used in a
 // Composition pipeline.
 type FunctionReference struct {
 	// Name of the referenced Function.
 	Name string `json:"name"`
 }
 
-// FunctionCredentials are optional credentials that a Composition Function
+// FunctionCredentials are optional credentials that a function
 // needs to run.
 //
 // +kubebuilder:validation:XValidation:rule="self.source == 'Secret' && has(self.secretRef)",message="the Secret source requires a secretRef"
@@ -104,7 +104,7 @@ type FunctionCredentials struct {
 	SecretRef *xpv1.SecretReference `json:"secretRef,omitempty"`
 }
 
-// A FunctionCredentialsSource is a source from which Composition Function
+// A FunctionCredentialsSource is a source from which function
 // credentials may be acquired.
 type FunctionCredentialsSource string
 
diff --git a/apis/apiextensions/v1/composition_revision_types.go b/apis/apiextensions/v1/composition_revision_types.go
index 8b7d12106..1e663cb81 100644
--- a/apis/apiextensions/v1/composition_revision_types.go
+++ b/apis/apiextensions/v1/composition_revision_types.go
@@ -44,17 +44,16 @@ type CompositionRevisionSpec struct {
 	// Mode controls what type or "mode" of Composition will be used.
 	//
 	// "Pipeline" indicates that a Composition specifies a pipeline of
-	// Composition Functions, each of which is responsible for producing
-	// composed resources that Crossplane should create or update.
+	// functions, each of which is responsible for producing composed
+	// resources that Crossplane should create or update.
 	//
 	// +optional
 	// +kubebuilder:validation:Enum=Pipeline
 	// +kubebuilder:default=Pipeline
 	Mode CompositionMode `json:"mode,omitempty"`
 
-	// Pipeline is a list of composition function steps that will be used when a
-	// composite resource referring to this composition is created. One of
-	// resources and pipeline must be specified - you cannot specify both.
+	// Pipeline is a list of function steps that will be used when a
+	// composite resource referring to this composition is created.
 	//
 	// The Pipeline is only used by the "Pipeline" mode of Composition. It is
 	// ignored by other modes.
@@ -108,6 +107,16 @@ type CompositionRevision struct {
 	Status CompositionRevisionStatus `json:"status,omitempty"`
 }
 
+// GetCondition of this CompositionRevision.
+func (in *CompositionRevision) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
+	return in.Status.GetCondition(ct)
+}
+
+// SetConditions of this CompositionRevision.
+func (in *CompositionRevision) SetConditions(c ...xpv1.Condition) {
+	in.Status.SetConditions(c...)
+}
+
 // +kubebuilder:object:root=true
 
 // CompositionRevisionList contains a list of CompositionRevisions.
diff --git a/apis/apiextensions/v1/composition_types.go b/apis/apiextensions/v1/composition_types.go
index 100177d40..520ecfd12 100644
--- a/apis/apiextensions/v1/composition_types.go
+++ b/apis/apiextensions/v1/composition_types.go
@@ -32,8 +32,8 @@ type CompositionSpec struct {
 	// Mode controls what type or "mode" of Composition will be used.
 	//
 	// "Pipeline" indicates that a Composition specifies a pipeline of
-	// Composition Functions, each of which is responsible for producing
-	// composed resources that Crossplane should create or update.
+	// functions, each of which is responsible for producing composed
+	// resources that Crossplane should create or update.
 	//
 	// +optional
 	// +kubebuilder:validation:Enum=Pipeline
diff --git a/apis/apiextensions/v1/conditions.go b/apis/apiextensions/v1/conditions.go
index 027e1db78..eb98b86ce 100644
--- a/apis/apiextensions/v1/conditions.go
+++ b/apis/apiextensions/v1/conditions.go
@@ -32,6 +32,10 @@ const (
 	// A TypeOffered XRD has created the CRD for its composite resource claim
 	// and started a controller to reconcile instances of said claim.
 	TypeOffered xpv1.ConditionType = "Offered"
+
+	// A TypeValidPipeline CompositionRevision has a valid function
+	// pipeline.
+	TypeValidPipeline xpv1.ConditionType = "ValidPipeline"
 )
 
 // Reasons a resource is or is not established or offered.
@@ -41,6 +45,9 @@ const (
 
 	ReasonTerminatingComposite xpv1.ConditionReason = "TerminatingCompositeResource"
 	ReasonTerminatingClaim     xpv1.ConditionReason = "TerminatingCompositeResourceClaim"
+
+	ReasonValidPipeline       xpv1.ConditionReason = "ValidPipeline"
+	ReasonMissingCapabilities xpv1.ConditionReason = "MissingCapabilities"
 )
 
 // WatchingComposite indicates that Crossplane has defined and is watching for a
@@ -86,3 +93,26 @@ func TerminatingClaim() xpv1.Condition {
 		Reason:             ReasonTerminatingClaim,
 	}
 }
+
+// ValidPipeline indicates that all functions in the CompositionRevision's
+// pipeline are valid.
+func ValidPipeline() xpv1.Condition {
+	return xpv1.Condition{
+		Type:               TypeValidPipeline,
+		Status:             corev1.ConditionTrue,
+		LastTransitionTime: metav1.Now(),
+		Reason:             ReasonValidPipeline,
+	}
+}
+
+// MissingCapabilities indicates that one or more functions in the CompositionRevision's
+// pipeline are missing required capabilities.
+func MissingCapabilities(message string) xpv1.Condition {
+	return xpv1.Condition{
+		Type:               TypeValidPipeline,
+		Status:             corev1.ConditionFalse,
+		LastTransitionTime: metav1.Now(),
+		Reason:             ReasonMissingCapabilities,
+		Message:            message,
+	}
+}
diff --git a/apis/apis.go b/apis/apis.go
index 270570185..ab5c1828c 100644
--- a/apis/apis.go
+++ b/apis/apis.go
@@ -21,6 +21,7 @@ import (
 	"k8s.io/apimachinery/pkg/runtime"
 
 	"github.com/crossplane/crossplane/apis/apiextensions"
+	"github.com/crossplane/crossplane/apis/ops"
 	"github.com/crossplane/crossplane/apis/pkg"
 	"github.com/crossplane/crossplane/apis/protection"
 )
@@ -31,6 +32,7 @@ func init() {
 		apiextensions.AddToScheme,
 		pkg.AddToScheme,
 		protection.AddToScheme,
+		ops.AddToScheme,
 	)
 }
 
diff --git a/apis/ops/ops.go b/apis/ops/ops.go
new file mode 100644
index 000000000..1b1b02c6a
--- /dev/null
+++ b/apis/ops/ops.go
@@ -0,0 +1,39 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package ops contains Kubernetes API groups for day two operation types of Crossplane.
+package ops
+
+import (
+	"k8s.io/apimachinery/pkg/runtime"
+
+	"github.com/crossplane/crossplane/apis/ops/v1alpha1"
+)
+
+func init() {
+	// Register the types with the Scheme so the components can map objects to GroupVersionKinds and back
+	AddToSchemes = append(AddToSchemes,
+		v1alpha1.AddToScheme,
+	)
+}
+
+// AddToSchemes may be used to add all resources defined in the project to a Scheme.
+var AddToSchemes runtime.SchemeBuilder
+
+// AddToScheme adds all Resources to the Scheme.
+func AddToScheme(s *runtime.Scheme) error {
+	return AddToSchemes.AddToScheme(s)
+}
diff --git a/apis/ops/v1alpha1/conditions.go b/apis/ops/v1alpha1/conditions.go
new file mode 100644
index 000000000..232e7bff0
--- /dev/null
+++ b/apis/ops/v1alpha1/conditions.go
@@ -0,0 +1,93 @@
+/*
+Copyright 2025 The Crossplane Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+    http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1alpha1
+
+import (
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+)
+
+// Condition types.
+const (
+	// A TypeSucceeded condition indicates whether an operation has Succeeded.
+	TypeSucceeded xpv1.ConditionType = "Succeeded"
+
+	// A TypeValidPipeline Operation has a valid function pipeline.
+	TypeValidPipeline xpv1.ConditionType = "ValidPipeline"
+)
+
+// Reasons a package is or is not installed.
+const (
+	ReasonPipelineRunning xpv1.ConditionReason = "PipelineRunning"
+	ReasonPipelineSuccess xpv1.ConditionReason = "PipelineSuccess"
+	ReasonPipelineError   xpv1.ConditionReason = "PipelineError"
+
+	ReasonValidPipeline       xpv1.ConditionReason = "ValidPipeline"
+	ReasonMissingCapabilities xpv1.ConditionReason = "MissingCapabilities"
+)
+
+// Running indicates that an operation is running.
+func Running() xpv1.Condition {
+	return xpv1.Condition{
+		Type:               TypeSucceeded,
+		Status:             corev1.ConditionUnknown,
+		LastTransitionTime: metav1.Now(),
+		Reason:             ReasonPipelineRunning,
+	}
+}
+
+// Complete indicates that an operation is complete.
+func Complete() xpv1.Condition {
+	return xpv1.Condition{
+		Type:               TypeSucceeded,
+		Status:             corev1.ConditionTrue,
+		LastTransitionTime: metav1.Now(),
+		Reason:             ReasonPipelineSuccess,
+	}
+}
+
+// Failed indicates that an operation has failed.
+func Failed(message string) xpv1.Condition {
+	return xpv1.Condition{
+		Type:               TypeSucceeded,
+		Status:             corev1.ConditionFalse,
+		LastTransitionTime: metav1.Now(),
+		Reason:             ReasonPipelineError,
+		Message:            message,
+	}
+}
+
+// ValidPipeline indicates that an operation has a valid function pipeline.
+func ValidPipeline() xpv1.Condition {
+	return xpv1.Condition{
+		Type:               TypeValidPipeline,
+		Status:             corev1.ConditionTrue,
+		LastTransitionTime: metav1.Now(),
+		Reason:             ReasonValidPipeline,
+	}
+}
+
+// MissingCapabilities indicates that an operation's functions are missing
+// required capabilities.
+func MissingCapabilities(message string) xpv1.Condition {
+	return xpv1.Condition{
+		Type:               TypeValidPipeline,
+		Status:             corev1.ConditionFalse,
+		LastTransitionTime: metav1.Now(),
+		Reason:             ReasonMissingCapabilities,
+		Message:            message,
+	}
+}
diff --git a/apis/ops/v1alpha1/doc.go b/apis/ops/v1alpha1/doc.go
new file mode 100644
index 000000000..cf9323c73
--- /dev/null
+++ b/apis/ops/v1alpha1/doc.go
@@ -0,0 +1,18 @@
+/*
+Copyright 2025 The Crossplane Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+    http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package v1alpha1 contains API types for running day two operations.
+// +kubebuilder:object:generate=true
+// +groupName=ops.crossplane.io
+// +versionName=v1alpha1
+package v1alpha1
diff --git a/apis/ops/v1alpha1/operation_types.go b/apis/ops/v1alpha1/operation_types.go
new file mode 100644
index 000000000..7c6009028
--- /dev/null
+++ b/apis/ops/v1alpha1/operation_types.go
@@ -0,0 +1,217 @@
+/*
+Copyright 2025 The Crossplane Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+    http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1alpha1
+
+import (
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/utils/ptr"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+)
+
+// A OperationMode determines what mode an operation uses.
+type OperationMode string
+
+const (
+	// OperationModePipeline indicates that an Operation specifies a
+	// pipeline of functions, each of which is responsible for implementing
+	// its logic.
+	OperationModePipeline OperationMode = "Pipeline"
+)
+
+// OperationSpec specifies desired state of an operation.
+type OperationSpec struct {
+	// Mode controls what type or "mode" of operation will be used.
+	//
+	// "Pipeline" indicates that an Operation specifies a pipeline of
+	// functions, each of which is responsible for implementing its logic.
+	//
+	// +kubebuilder:validation:Enum=Pipeline
+	// +kubebuilder:default=Pipeline
+	Mode OperationMode `json:"mode"`
+
+	// Pipeline is a list of operation function steps that will be used when
+	// this operation runs.
+	Pipeline []PipelineStep `json:"pipeline"`
+
+	// RetryLimit configures how many times the operation may fail. When the
+	// failure limit is exceeded, the operation will not be retried.
+	// +optional
+	// +kubebuilder:default:5
+	RetryLimit *int64 `json:"retryLimit,omitempty"`
+}
+
+// A PipelineStep in an operation function pipeline.
+type PipelineStep struct {
+	// Step name. Must be unique within its Pipeline.
+	Step string `json:"step"`
+
+	// FunctionRef is a reference to the function this step should
+	// execute.
+	FunctionRef FunctionReference `json:"functionRef"`
+
+	// Input is an optional, arbitrary Kubernetes resource (i.e. a resource
+	// with an apiVersion and kind) that will be passed to the unction as
+	// the 'input' of its RunFunctionRequest.
+	// +optional
+	// +kubebuilder:pruning:PreserveUnknownFields
+	// +kubebuilder:validation:EmbeddedResource
+	Input *runtime.RawExtension `json:"input,omitempty"`
+
+	// Credentials are optional credentials that the operation function needs.
+	// +optional
+	Credentials []FunctionCredentials `json:"credentials,omitempty"`
+}
+
+// A FunctionReference references an operation function that may be used in an
+// operation pipeline.
+type FunctionReference struct {
+	// Name of the referenced function.
+	Name string `json:"name"`
+}
+
+// FunctionCredentials are optional credentials that a function
+// needs to run.
+type FunctionCredentials struct {
+	// Name of this set of credentials.
+	Name string `json:"name"`
+
+	// Source of the function credentials.
+	// +kubebuilder:validation:Enum=None;Secret
+	Source FunctionCredentialsSource `json:"source"`
+
+	// A SecretRef is a reference to a secret containing credentials that should
+	// be supplied to the function.
+	// +optional
+	SecretRef *xpv1.SecretReference `json:"secretRef,omitempty"`
+}
+
+// A FunctionCredentialsSource is a source from which function
+// credentials may be acquired.
+type FunctionCredentialsSource string
+
+const (
+	// FunctionCredentialsSourceNone indicates that a function does not require
+	// credentials.
+	FunctionCredentialsSourceNone FunctionCredentialsSource = "None"
+
+	// FunctionCredentialsSourceSecret indicates that a function should acquire
+	// credentials from a secret.
+	FunctionCredentialsSourceSecret FunctionCredentialsSource = "Secret"
+)
+
+// OperationStatus represents the observed state of an operation.
+type OperationStatus struct {
+	xpv1.ConditionedStatus `json:",inline"`
+
+	// Number of operation failures.
+	Failures int64 `json:"failures,omitempty"`
+
+	// Pipeline represents the output of the pipeline steps that this operation
+	// ran.
+	Pipeline []PipelineStepStatus `json:"pipeline,omitempty"`
+
+	// AppliedResourceRefs references all resources the Operation applied.
+	AppliedResourceRefs []AppliedResourceRef `json:"appliedResourceRefs,omitempty"`
+}
+
+// PipelineStepStatus represents the status of an individual pipeline step.
+type PipelineStepStatus struct {
+	// Step name. Unique within its Pipeline.
+	Step string `json:"step"`
+
+	// Output of this step.
+	// +kubebuilder:pruning:PreserveUnknownFields
+	Output *runtime.RawExtension `json:"output,omitempty"`
+}
+
+// An AppliedResourceRef is a reference to a resource an Operation applied.
+type AppliedResourceRef struct {
+	// APIVersion of the applied resource.
+	APIVersion string `json:"apiVersion"`
+
+	// Kind of the applied resource.
+	Kind string `json:"kind"`
+
+	// Namespace of the applied resource.
+	// +optional
+	Namespace *string `json:"namespace,omitempty"`
+
+	// Name of the applied resource.
+	Name string `json:"name"`
+}
+
+// Equals returns true if this AppliedResourceRef is equal to the other.
+func (r *AppliedResourceRef) Equals(other AppliedResourceRef) bool {
+	if r.APIVersion != other.APIVersion {
+		return false
+	}
+	if r.Kind != other.Kind {
+		return false
+	}
+	if r.Name != other.Name {
+		return false
+	}
+	return ptr.Deref(r.Namespace, "") == ptr.Deref(other.Namespace, "")
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:storageversion
+// +genclient
+
+// An Operation defines a pipeline of functions that together constitute a day
+// two operation.
+//
+// +kubebuilder:subresource:status
+// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp"
+// +kubebuilder:printcolumn:name="SUCCEEDED",type="string",JSONPath=".status.conditions[?(@.type=='Succeeded')].status"
+// +kubebuilder:resource:scope=Cluster,categories=crossplane,shortName=ops
+type Operation struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   OperationSpec   `json:"spec,omitempty"`
+	Status OperationStatus `json:"status,omitempty"`
+}
+
+// SetConditions delegates to Status.SetConditions.
+// Implements Conditioned.SetConditions.
+func (o *Operation) SetConditions(cs ...xpv1.Condition) {
+	o.Status.SetConditions(cs...)
+}
+
+// GetCondition delegates to Status.GetCondition.
+// Implements Conditioned.GetCondition.
+func (o *Operation) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
+	return o.Status.GetCondition(ct)
+}
+
+// IsComplete returns if this operation has finished running.
+func (o *Operation) IsComplete() bool {
+	c := o.GetCondition(TypeSucceeded)
+	// Normally, checking observedGeneration == generation is required, but Succeeded=True/False are terminal conditions.
+	return c.Status == corev1.ConditionTrue || c.Status == corev1.ConditionFalse
+}
+
+// +kubebuilder:object:root=true
+
+// OperationList contains a list of Operations.
+type OperationList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+
+	Items []Operation `json:"items"`
+}
diff --git a/apis/ops/v1alpha1/register.go b/apis/ops/v1alpha1/register.go
new file mode 100644
index 000000000..3667170b5
--- /dev/null
+++ b/apis/ops/v1alpha1/register.go
@@ -0,0 +1,50 @@
+/*
+Copyright 2025 The Crossplane Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+    http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1alpha1
+
+import (
+	"reflect"
+
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/controller-runtime/pkg/scheme"
+)
+
+// Package type metadata.
+const (
+	Group   = "ops.crossplane.io"
+	Version = "v1alpha1"
+)
+
+var (
+	// SchemeGroupVersion is group version used to register these objects.
+	SchemeGroupVersion = schema.GroupVersion{Group: Group, Version: Version}
+
+	// SchemeBuilder is used to add go types to the GroupVersionKind scheme.
+	SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion}
+
+	// AddToScheme adds all registered types to scheme.
+	AddToScheme = SchemeBuilder.AddToScheme
+)
+
+// Operation type metadata.
+var (
+	OperationKind             = reflect.TypeOf(Operation{}).Name()
+	OperationGroupKind        = schema.GroupKind{Group: Group, Kind: OperationKind}.String()
+	OperationKindAPIVersion   = OperationKind + "." + SchemeGroupVersion.String()
+	OperationGroupVersionKind = SchemeGroupVersion.WithKind(OperationKind)
+)
+
+func init() {
+	SchemeBuilder.Register(&Operation{}, &OperationList{})
+}
diff --git a/apis/ops/v1alpha1/zz_generated.deepcopy.go b/apis/ops/v1alpha1/zz_generated.deepcopy.go
new file mode 100644
index 000000000..f509aaa4e
--- /dev/null
+++ b/apis/ops/v1alpha1/zz_generated.deepcopy.go
@@ -0,0 +1,245 @@
+//go:build !ignore_autogenerated
+
+/*
+Copyright 2021 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	"github.com/crossplane/crossplane-runtime/apis/common/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AppliedResourceRef) DeepCopyInto(out *AppliedResourceRef) {
+	*out = *in
+	if in.Namespace != nil {
+		in, out := &in.Namespace, &out.Namespace
+		*out = new(string)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppliedResourceRef.
+func (in *AppliedResourceRef) DeepCopy() *AppliedResourceRef {
+	if in == nil {
+		return nil
+	}
+	out := new(AppliedResourceRef)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FunctionCredentials) DeepCopyInto(out *FunctionCredentials) {
+	*out = *in
+	if in.SecretRef != nil {
+		in, out := &in.SecretRef, &out.SecretRef
+		*out = new(v1.SecretReference)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionCredentials.
+func (in *FunctionCredentials) DeepCopy() *FunctionCredentials {
+	if in == nil {
+		return nil
+	}
+	out := new(FunctionCredentials)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FunctionReference) DeepCopyInto(out *FunctionReference) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionReference.
+func (in *FunctionReference) DeepCopy() *FunctionReference {
+	if in == nil {
+		return nil
+	}
+	out := new(FunctionReference)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Operation) DeepCopyInto(out *Operation) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Operation.
+func (in *Operation) DeepCopy() *Operation {
+	if in == nil {
+		return nil
+	}
+	out := new(Operation)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Operation) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OperationList) DeepCopyInto(out *OperationList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]Operation, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OperationList.
+func (in *OperationList) DeepCopy() *OperationList {
+	if in == nil {
+		return nil
+	}
+	out := new(OperationList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *OperationList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OperationSpec) DeepCopyInto(out *OperationSpec) {
+	*out = *in
+	if in.Pipeline != nil {
+		in, out := &in.Pipeline, &out.Pipeline
+		*out = make([]PipelineStep, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+	if in.RetryLimit != nil {
+		in, out := &in.RetryLimit, &out.RetryLimit
+		*out = new(int64)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OperationSpec.
+func (in *OperationSpec) DeepCopy() *OperationSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(OperationSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OperationStatus) DeepCopyInto(out *OperationStatus) {
+	*out = *in
+	in.ConditionedStatus.DeepCopyInto(&out.ConditionedStatus)
+	if in.Pipeline != nil {
+		in, out := &in.Pipeline, &out.Pipeline
+		*out = make([]PipelineStepStatus, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+	if in.AppliedResourceRefs != nil {
+		in, out := &in.AppliedResourceRefs, &out.AppliedResourceRefs
+		*out = make([]AppliedResourceRef, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OperationStatus.
+func (in *OperationStatus) DeepCopy() *OperationStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(OperationStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PipelineStep) DeepCopyInto(out *PipelineStep) {
+	*out = *in
+	out.FunctionRef = in.FunctionRef
+	if in.Input != nil {
+		in, out := &in.Input, &out.Input
+		*out = new(runtime.RawExtension)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.Credentials != nil {
+		in, out := &in.Credentials, &out.Credentials
+		*out = make([]FunctionCredentials, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PipelineStep.
+func (in *PipelineStep) DeepCopy() *PipelineStep {
+	if in == nil {
+		return nil
+	}
+	out := new(PipelineStep)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PipelineStepStatus) DeepCopyInto(out *PipelineStepStatus) {
+	*out = *in
+	if in.Output != nil {
+		in, out := &in.Output, &out.Output
+		*out = new(runtime.RawExtension)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PipelineStepStatus.
+func (in *PipelineStepStatus) DeepCopy() *PipelineStepStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(PipelineStepStatus)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/apis/pkg/meta/v1/capabilities.go b/apis/pkg/meta/v1/capabilities.go
new file mode 100644
index 000000000..7a328b7f9
--- /dev/null
+++ b/apis/pkg/meta/v1/capabilities.go
@@ -0,0 +1,28 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1
+
+// Known function capabilities. This shouldn't be treated as an exhaustive list.
+// A package's capabilities array may contain arbitrary entries that aren't
+// meaningful to Crossplane but might be meaningful to some other consumer.
+const (
+	// A function that can be used in a composition.
+	FunctionCapabilityComposition = "composition"
+
+	// A function that can be used in an operation.
+	FunctionCapabilityOperation = "operation"
+)
diff --git a/apis/pkg/meta/v1/interfaces.go b/apis/pkg/meta/v1/interfaces.go
index 0dfe03116..224f181e5 100644
--- a/apis/pkg/meta/v1/interfaces.go
+++ b/apis/pkg/meta/v1/interfaces.go
@@ -35,6 +35,7 @@ type Pkg interface {
 
 	GetCrossplaneConstraints() *CrossplaneConstraints
 	GetDependencies() []Dependency
+	GetCapabilities() []string
 }
 
 // GetCrossplaneConstraints gets the Configuration package's Crossplane version
@@ -48,6 +49,11 @@ func (c *Configuration) GetDependencies() []Dependency {
 	return c.Spec.DependsOn
 }
 
+// GetCapabilities gets the Configuration package's capabilities.
+func (c *Configuration) GetCapabilities() []string {
+	return c.Spec.Capabilities
+}
+
 // GetCrossplaneConstraints gets the Provider package's Crossplane version
 // constraints.
 func (p *Provider) GetCrossplaneConstraints() *CrossplaneConstraints {
@@ -59,6 +65,11 @@ func (p *Provider) GetDependencies() []Dependency {
 	return p.Spec.DependsOn
 }
 
+// GetCapabilities gets the Provider package's capabilities.
+func (p *Provider) GetCapabilities() []string {
+	return p.Spec.Capabilities
+}
+
 // GetCrossplaneConstraints gets the Function package's Crossplane version constraints.
 func (f *Function) GetCrossplaneConstraints() *CrossplaneConstraints {
 	return f.Spec.Crossplane
@@ -68,3 +79,16 @@ func (f *Function) GetCrossplaneConstraints() *CrossplaneConstraints {
 func (f *Function) GetDependencies() []Dependency {
 	return f.Spec.DependsOn
 }
+
+// GetCapabilities gets the Function package's capabilities.
+func (f *Function) GetCapabilities() []string {
+	// If a function doesn't include any capabilities we assume it's a
+	// composition function. Composition functions predate the concept of
+	// function capabilities, so there are many composition functions that
+	// don't explicitly specify any capabilities.
+	if f.Spec.Capabilities == nil {
+		return []string{FunctionCapabilityComposition}
+	}
+
+	return f.Spec.Capabilities
+}
diff --git a/apis/pkg/meta/v1/meta.go b/apis/pkg/meta/v1/meta.go
index 3620d5c8d..624925681 100644
--- a/apis/pkg/meta/v1/meta.go
+++ b/apis/pkg/meta/v1/meta.go
@@ -23,6 +23,11 @@ type MetaSpec struct {
 
 	// Dependencies on other packages.
 	DependsOn []Dependency `json:"dependsOn,omitempty"`
+
+	// Capabilities of this package. Capabilities are opaque strings that
+	// may be meaningful to package consumers.
+	// +optional
+	Capabilities []string `json:"capabilities,omitempty"`
 }
 
 // CrossplaneConstraints specifies a packages compatibility with Crossplane versions.
diff --git a/apis/pkg/meta/v1/zz_generated.deepcopy.go b/apis/pkg/meta/v1/zz_generated.deepcopy.go
index 211548c0e..920f3dfed 100644
--- a/apis/pkg/meta/v1/zz_generated.deepcopy.go
+++ b/apis/pkg/meta/v1/zz_generated.deepcopy.go
@@ -183,6 +183,11 @@ func (in *MetaSpec) DeepCopyInto(out *MetaSpec) {
 			(*in)[i].DeepCopyInto(&(*out)[i])
 		}
 	}
+	if in.Capabilities != nil {
+		in, out := &in.Capabilities, &out.Capabilities
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaSpec.
diff --git a/apis/pkg/meta/v1alpha1/zz_generated.conversion.go b/apis/pkg/meta/v1alpha1/zz_generated.conversion.go
index 7a24410cd..8abdbe12b 100755
--- a/apis/pkg/meta/v1alpha1/zz_generated.conversion.go
+++ b/apis/pkg/meta/v1alpha1/zz_generated.conversion.go
@@ -84,6 +84,12 @@ func (c *GeneratedFromHubConverter) v1MetaSpecToV1alpha1MetaSpec(source v1.MetaS
 			v1alpha1MetaSpec.DependsOn[i] = c.v1DependencyToV1alpha1Dependency(source.DependsOn[i])
 		}
 	}
+	if source.Capabilities != nil {
+		v1alpha1MetaSpec.Capabilities = make([]string, len(source.Capabilities))
+		for j := 0; j < len(source.Capabilities); j++ {
+			v1alpha1MetaSpec.Capabilities[j] = source.Capabilities[j]
+		}
+	}
 	return v1alpha1MetaSpec
 }
 func (c *GeneratedFromHubConverter) v1ProviderSpecToV1alpha1ProviderSpec(source v1.ProviderSpec) ProviderSpec {
@@ -180,6 +186,12 @@ func (c *GeneratedToHubConverter) v1alpha1MetaSpecToV1MetaSpec(source MetaSpec)
 			v1MetaSpec.DependsOn[i] = c.v1alpha1DependencyToV1Dependency(source.DependsOn[i])
 		}
 	}
+	if source.Capabilities != nil {
+		v1MetaSpec.Capabilities = make([]string, len(source.Capabilities))
+		for j := 0; j < len(source.Capabilities); j++ {
+			v1MetaSpec.Capabilities[j] = source.Capabilities[j]
+		}
+	}
 	return v1MetaSpec
 }
 func (c *GeneratedToHubConverter) v1alpha1ProviderSpecToV1ProviderSpec(source ProviderSpec) v1.ProviderSpec {
diff --git a/apis/pkg/meta/v1alpha1/zz_generated.deepcopy.go b/apis/pkg/meta/v1alpha1/zz_generated.deepcopy.go
index 9250d7104..1f9d2bc95 100644
--- a/apis/pkg/meta/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/pkg/meta/v1alpha1/zz_generated.deepcopy.go
@@ -171,6 +171,11 @@ func (in *MetaSpec) DeepCopyInto(out *MetaSpec) {
 			(*in)[i].DeepCopyInto(&(*out)[i])
 		}
 	}
+	if in.Capabilities != nil {
+		in, out := &in.Capabilities, &out.Capabilities
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaSpec.
diff --git a/apis/pkg/meta/v1alpha1/zz_generated.meta.go b/apis/pkg/meta/v1alpha1/zz_generated.meta.go
index e0cb314fd..396f28e8e 100644
--- a/apis/pkg/meta/v1alpha1/zz_generated.meta.go
+++ b/apis/pkg/meta/v1alpha1/zz_generated.meta.go
@@ -25,6 +25,11 @@ type MetaSpec struct {
 
 	// Dependencies on other packages.
 	DependsOn []Dependency `json:"dependsOn,omitempty"`
+
+	// Capabilities of this package. Capabilities are opaque strings that
+	// may be meaningful to package consumers.
+	// +optional
+	Capabilities []string `json:"capabilities,omitempty"`
 }
 
 // CrossplaneConstraints specifies a packages compatibility with Crossplane versions.
diff --git a/apis/pkg/meta/v1beta1/zz_generated.conversion.go b/apis/pkg/meta/v1beta1/zz_generated.conversion.go
index 46d77e7f4..0f1d6fb71 100644
--- a/apis/pkg/meta/v1beta1/zz_generated.conversion.go
+++ b/apis/pkg/meta/v1beta1/zz_generated.conversion.go
@@ -73,6 +73,12 @@ func (c *GeneratedFromHubConverter) v1MetaSpecToV1beta1MetaSpec(source v1.MetaSp
 			v1beta1MetaSpec.DependsOn[i] = c.v1DependencyToV1beta1Dependency(source.DependsOn[i])
 		}
 	}
+	if source.Capabilities != nil {
+		v1beta1MetaSpec.Capabilities = make([]string, len(source.Capabilities))
+		for j := 0; j < len(source.Capabilities); j++ {
+			v1beta1MetaSpec.Capabilities[j] = source.Capabilities[j]
+		}
+	}
 	return v1beta1MetaSpec
 }
 func (c *GeneratedFromHubConverter) v1TypeMetaToV1TypeMeta(source v11.TypeMeta) v11.TypeMeta {
@@ -153,5 +159,11 @@ func (c *GeneratedToHubConverter) v1beta1MetaSpecToV1MetaSpec(source MetaSpec) v
 			v1MetaSpec.DependsOn[i] = c.v1beta1DependencyToV1Dependency(source.DependsOn[i])
 		}
 	}
+	if source.Capabilities != nil {
+		v1MetaSpec.Capabilities = make([]string, len(source.Capabilities))
+		for j := 0; j < len(source.Capabilities); j++ {
+			v1MetaSpec.Capabilities[j] = source.Capabilities[j]
+		}
+	}
 	return v1MetaSpec
 }
diff --git a/apis/pkg/meta/v1beta1/zz_generated.deepcopy.go b/apis/pkg/meta/v1beta1/zz_generated.deepcopy.go
index b62e2a11c..620445c00 100644
--- a/apis/pkg/meta/v1beta1/zz_generated.deepcopy.go
+++ b/apis/pkg/meta/v1beta1/zz_generated.deepcopy.go
@@ -171,6 +171,11 @@ func (in *MetaSpec) DeepCopyInto(out *MetaSpec) {
 			(*in)[i].DeepCopyInto(&(*out)[i])
 		}
 	}
+	if in.Capabilities != nil {
+		in, out := &in.Capabilities, &out.Capabilities
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaSpec.
diff --git a/apis/pkg/meta/v1beta1/zz_generated.meta.go b/apis/pkg/meta/v1beta1/zz_generated.meta.go
index 68c6c9b87..593c871b3 100644
--- a/apis/pkg/meta/v1beta1/zz_generated.meta.go
+++ b/apis/pkg/meta/v1beta1/zz_generated.meta.go
@@ -25,6 +25,11 @@ type MetaSpec struct {
 
 	// Dependencies on other packages.
 	DependsOn []Dependency `json:"dependsOn,omitempty"`
+
+	// Capabilities of this package. Capabilities are opaque strings that
+	// may be meaningful to package consumers.
+	// +optional
+	Capabilities []string `json:"capabilities,omitempty"`
 }
 
 // CrossplaneConstraints specifies a packages compatibility with Crossplane versions.
diff --git a/apis/pkg/v1/interfaces.go b/apis/pkg/v1/interfaces.go
index 6bc7f7c3b..9be8f6080 100644
--- a/apis/pkg/v1/interfaces.go
+++ b/apis/pkg/v1/interfaces.go
@@ -562,6 +562,9 @@ type PackageRevision interface { //nolint:interfacebloat // TODO(negz): Could we
 
 	GetResolvedSource() string
 	SetResolvedSource(s string)
+
+	GetCapabilities() []string
+	SetCapabilities(caps []string)
 }
 
 // GetCondition of this ProviderRevision.
@@ -756,6 +759,16 @@ func (p *ProviderRevision) SetResolvedSource(s string) {
 	p.Status.ResolvedPackage = s
 }
 
+// GetCapabilities of this ProviderRevision.
+func (p *ProviderRevision) GetCapabilities() []string {
+	return p.Status.Capabilities
+}
+
+// SetCapabilities of this ProviderRevision.
+func (p *ProviderRevision) SetCapabilities(caps []string) {
+	p.Status.Capabilities = caps
+}
+
 // GetCondition of this ConfigurationRevision.
 func (p *ConfigurationRevision) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
 	return p.Status.GetCondition(ct)
@@ -898,6 +911,16 @@ func (p *ConfigurationRevision) SetResolvedSource(s string) {
 	p.Status.ResolvedPackage = s
 }
 
+// GetCapabilities of this ConfigurationRevision.
+func (p *ConfigurationRevision) GetCapabilities() []string {
+	return p.Status.Capabilities
+}
+
+// SetCapabilities of this ConfigurationRevision.
+func (p *ConfigurationRevision) SetCapabilities(caps []string) {
+	p.Status.Capabilities = caps
+}
+
 // PackageRevisionList is the interface satisfied by package revision list
 // types.
 // +k8s:deepcopy-gen=false
@@ -1306,6 +1329,16 @@ func (r *FunctionRevision) SetResolvedSource(s string) {
 	r.Status.ResolvedPackage = s
 }
 
+// GetCapabilities of this FunctionRevision.
+func (r *FunctionRevision) GetCapabilities() []string {
+	return r.Status.Capabilities
+}
+
+// SetCapabilities of this FunctionRevision.
+func (r *FunctionRevision) SetCapabilities(caps []string) {
+	r.Status.Capabilities = caps
+}
+
 // GetRevisions of this ConfigurationRevisionList.
 func (p *FunctionRevisionList) GetRevisions() []PackageRevision {
 	prs := make([]PackageRevision, len(p.Items))
diff --git a/apis/pkg/v1/revision_types.go b/apis/pkg/v1/revision_types.go
index 4bd7db228..55bae23ee 100644
--- a/apis/pkg/v1/revision_types.go
+++ b/apis/pkg/v1/revision_types.go
@@ -102,6 +102,10 @@ type PackageRevisionStatus struct {
 	// different from spec.image if the package path was rewritten using an
 	// image config.
 	ResolvedPackage string `json:"resolvedImage,omitempty"`
+
+	// Capabilities of this package. Capabilities are opaque strings that
+	// may be meaningful to package consumers.
+	Capabilities []string `json:"capabilities,omitempty"`
 }
 
 // A ControllerReference references the controller (e.g. Deployment), if any,
diff --git a/apis/pkg/v1/zz_generated.deepcopy.go b/apis/pkg/v1/zz_generated.deepcopy.go
index b14dfbd00..377d56f5e 100644
--- a/apis/pkg/v1/zz_generated.deepcopy.go
+++ b/apis/pkg/v1/zz_generated.deepcopy.go
@@ -500,6 +500,11 @@ func (in *PackageRevisionStatus) DeepCopyInto(out *PackageRevisionStatus) {
 		*out = make([]ImageConfigRef, len(*in))
 		copy(*out, *in)
 	}
+	if in.Capabilities != nil {
+		in, out := &in.Capabilities, &out.Capabilities
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageRevisionStatus.
diff --git a/apis/pkg/v1beta1/zz_generated.deepcopy.go b/apis/pkg/v1beta1/zz_generated.deepcopy.go
index a18618477..feebe2a4c 100644
--- a/apis/pkg/v1beta1/zz_generated.deepcopy.go
+++ b/apis/pkg/v1beta1/zz_generated.deepcopy.go
@@ -937,6 +937,11 @@ func (in *PackageRevisionStatus) DeepCopyInto(out *PackageRevisionStatus) {
 		*out = make([]ImageConfigRef, len(*in))
 		copy(*out, *in)
 	}
+	if in.Capabilities != nil {
+		in, out := &in.Capabilities, &out.Capabilities
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageRevisionStatus.
diff --git a/apis/pkg/v1beta1/zz_generated.revision_types.go b/apis/pkg/v1beta1/zz_generated.revision_types.go
index bbd5e4422..e55e76b35 100644
--- a/apis/pkg/v1beta1/zz_generated.revision_types.go
+++ b/apis/pkg/v1beta1/zz_generated.revision_types.go
@@ -104,6 +104,10 @@ type PackageRevisionStatus struct {
 	// different from spec.image if the package path was rewritten using an
 	// image config.
 	ResolvedPackage string `json:"resolvedImage,omitempty"`
+
+	// Capabilities of this package. Capabilities are opaque strings that
+	// may be meaningful to package consumers.
+	Capabilities []string `json:"capabilities,omitempty"`
 }
 
 // A ControllerReference references the controller (e.g. Deployment), if any,
diff --git a/buf.yaml b/buf.yaml
index cdc8530f1..f62416671 100644
--- a/buf.yaml
+++ b/buf.yaml
@@ -10,7 +10,7 @@ lint:
     - PACKAGE_NO_IMPORT_CYCLE
 breaking:
   use:
-    - FILE
+    - WIRE
   except:
     - EXTENSION_NO_DELETE
     - FIELD_SAME_DEFAULT
diff --git a/cluster/charts/crossplane/templates/clusterrole.yaml b/cluster/charts/crossplane/templates/clusterrole.yaml
index 48a9372a8..bc0b3285b 100644
--- a/cluster/charts/crossplane/templates/clusterrole.yaml
+++ b/cluster/charts/crossplane/templates/clusterrole.yaml
@@ -59,9 +59,9 @@ rules:
   - "*"
 - apiGroups:
   - apiextensions.crossplane.io
+  - ops.crossplane.io
   - pkg.crossplane.io
   - protection.crossplane.io
-  - secrets.crossplane.io
   resources:
   - "*"
   verbs:
diff --git a/cluster/crds/apiextensions.crossplane.io_compositionrevisions.yaml b/cluster/crds/apiextensions.crossplane.io_compositionrevisions.yaml
index 1cf42a2dc..f1e40db83 100644
--- a/cluster/crds/apiextensions.crossplane.io_compositionrevisions.yaml
+++ b/cluster/crds/apiextensions.crossplane.io_compositionrevisions.yaml
@@ -87,28 +87,27 @@ spec:
                   Mode controls what type or "mode" of Composition will be used.
 
                   "Pipeline" indicates that a Composition specifies a pipeline of
-                  Composition Functions, each of which is responsible for producing
-                  composed resources that Crossplane should create or update.
+                  functions, each of which is responsible for producing composed
+                  resources that Crossplane should create or update.
                 enum:
                 - Pipeline
                 type: string
               pipeline:
                 description: |-
-                  Pipeline is a list of composition function steps that will be used when a
-                  composite resource referring to this composition is created. One of
-                  resources and pipeline must be specified - you cannot specify both.
+                  Pipeline is a list of function steps that will be used when a
+                  composite resource referring to this composition is created.
 
                   The Pipeline is only used by the "Pipeline" mode of Composition. It is
                   ignored by other modes.
                 items:
-                  description: A PipelineStep in a Composition Function pipeline.
+                  description: A PipelineStep in a function pipeline.
                   properties:
                     credentials:
-                      description: Credentials are optional credentials that the Composition
-                        Function needs.
+                      description: Credentials are optional credentials that the function
+                        needs.
                       items:
                         description: |-
-                          FunctionCredentials are optional credentials that a Composition Function
+                          FunctionCredentials are optional credentials that a function
                           needs to run.
                         properties:
                           name:
@@ -148,7 +147,7 @@ spec:
                       x-kubernetes-list-type: map
                     functionRef:
                       description: |-
-                        FunctionRef is a reference to the Composition Function this step should
+                        FunctionRef is a reference to the function this step should
                         execute.
                       properties:
                         name:
@@ -160,8 +159,8 @@ spec:
                     input:
                       description: |-
                         Input is an optional, arbitrary Kubernetes resource (i.e. a resource
-                        with an apiVersion and kind) that will be passed to the Composition
-                        Function as the 'input' of its RunFunctionRequest.
+                        with an apiVersion and kind) that will be passed to the function as
+                        the 'input' of its RunFunctionRequest.
                       type: object
                       x-kubernetes-embedded-resource: true
                       x-kubernetes-preserve-unknown-fields: true
diff --git a/cluster/crds/apiextensions.crossplane.io_compositions.yaml b/cluster/crds/apiextensions.crossplane.io_compositions.yaml
index 842fefd7f..3e296ded5 100644
--- a/cluster/crds/apiextensions.crossplane.io_compositions.yaml
+++ b/cluster/crds/apiextensions.crossplane.io_compositions.yaml
@@ -82,8 +82,8 @@ spec:
                   Mode controls what type or "mode" of Composition will be used.
 
                   "Pipeline" indicates that a Composition specifies a pipeline of
-                  Composition Functions, each of which is responsible for producing
-                  composed resources that Crossplane should create or update.
+                  functions, each of which is responsible for producing composed
+                  resources that Crossplane should create or update.
                 enum:
                 - Pipeline
                 type: string
@@ -96,14 +96,14 @@ spec:
                   The Pipeline is only used by the "Pipeline" mode of Composition. It is
                   ignored by other modes.
                 items:
-                  description: A PipelineStep in a Composition Function pipeline.
+                  description: A PipelineStep in a function pipeline.
                   properties:
                     credentials:
-                      description: Credentials are optional credentials that the Composition
-                        Function needs.
+                      description: Credentials are optional credentials that the function
+                        needs.
                       items:
                         description: |-
-                          FunctionCredentials are optional credentials that a Composition Function
+                          FunctionCredentials are optional credentials that a function
                           needs to run.
                         properties:
                           name:
@@ -143,7 +143,7 @@ spec:
                       x-kubernetes-list-type: map
                     functionRef:
                       description: |-
-                        FunctionRef is a reference to the Composition Function this step should
+                        FunctionRef is a reference to the function this step should
                         execute.
                       properties:
                         name:
@@ -155,8 +155,8 @@ spec:
                     input:
                       description: |-
                         Input is an optional, arbitrary Kubernetes resource (i.e. a resource
-                        with an apiVersion and kind) that will be passed to the Composition
-                        Function as the 'input' of its RunFunctionRequest.
+                        with an apiVersion and kind) that will be passed to the function as
+                        the 'input' of its RunFunctionRequest.
                       type: object
                       x-kubernetes-embedded-resource: true
                       x-kubernetes-preserve-unknown-fields: true
diff --git a/cluster/crds/ops.crossplane.io_operations.yaml b/cluster/crds/ops.crossplane.io_operations.yaml
new file mode 100644
index 000000000..59b6be47f
--- /dev/null
+++ b/cluster/crds/ops.crossplane.io_operations.yaml
@@ -0,0 +1,248 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.5
+  name: operations.ops.crossplane.io
+spec:
+  group: ops.crossplane.io
+  names:
+    categories:
+    - crossplane
+    kind: Operation
+    listKind: OperationList
+    plural: operations
+    shortNames:
+    - ops
+    singular: operation
+  scope: Cluster
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .metadata.creationTimestamp
+      name: AGE
+      type: date
+    - jsonPath: .status.conditions[?(@.type=='Succeeded')].status
+      name: SUCCEEDED
+      type: string
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: |-
+          An Operation defines a pipeline of functions that together constitute a day
+          two operation.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: OperationSpec specifies desired state of an operation.
+            properties:
+              mode:
+                default: Pipeline
+                description: |-
+                  Mode controls what type or "mode" of operation will be used.
+
+                  "Pipeline" indicates that an Operation specifies a pipeline of
+                  functions, each of which is responsible for implementing its logic.
+                enum:
+                - Pipeline
+                type: string
+              pipeline:
+                description: |-
+                  Pipeline is a list of operation function steps that will be used when
+                  this operation runs.
+                items:
+                  description: A PipelineStep in an operation function pipeline.
+                  properties:
+                    credentials:
+                      description: Credentials are optional credentials that the operation
+                        function needs.
+                      items:
+                        description: |-
+                          FunctionCredentials are optional credentials that a function
+                          needs to run.
+                        properties:
+                          name:
+                            description: Name of this set of credentials.
+                            type: string
+                          secretRef:
+                            description: |-
+                              A SecretRef is a reference to a secret containing credentials that should
+                              be supplied to the function.
+                            properties:
+                              name:
+                                description: Name of the secret.
+                                type: string
+                              namespace:
+                                description: Namespace of the secret.
+                                type: string
+                            required:
+                            - name
+                            - namespace
+                            type: object
+                          source:
+                            description: Source of the function credentials.
+                            enum:
+                            - None
+                            - Secret
+                            type: string
+                        required:
+                        - name
+                        - source
+                        type: object
+                      type: array
+                    functionRef:
+                      description: |-
+                        FunctionRef is a reference to the function this step should
+                        execute.
+                      properties:
+                        name:
+                          description: Name of the referenced function.
+                          type: string
+                      required:
+                      - name
+                      type: object
+                    input:
+                      description: |-
+                        Input is an optional, arbitrary Kubernetes resource (i.e. a resource
+                        with an apiVersion and kind) that will be passed to the unction as
+                        the 'input' of its RunFunctionRequest.
+                      type: object
+                      x-kubernetes-embedded-resource: true
+                      x-kubernetes-preserve-unknown-fields: true
+                    step:
+                      description: Step name. Must be unique within its Pipeline.
+                      type: string
+                  required:
+                  - functionRef
+                  - step
+                  type: object
+                type: array
+              retryLimit:
+                description: |-
+                  RetryLimit configures how many times the operation may fail. When the
+                  failure limit is exceeded, the operation will not be retried.
+                format: int64
+                type: integer
+            required:
+            - mode
+            - pipeline
+            type: object
+          status:
+            description: OperationStatus represents the observed state of an operation.
+            properties:
+              appliedResourceRefs:
+                description: AppliedResourceRefs references all resources the Operation
+                  applied.
+                items:
+                  description: An AppliedResourceRef is a reference to a resource
+                    an Operation applied.
+                  properties:
+                    apiVersion:
+                      description: APIVersion of the applied resource.
+                      type: string
+                    kind:
+                      description: Kind of the applied resource.
+                      type: string
+                    name:
+                      description: Name of the applied resource.
+                      type: string
+                    namespace:
+                      description: Namespace of the applied resource.
+                      type: string
+                  required:
+                  - apiVersion
+                  - kind
+                  - name
+                  type: object
+                type: array
+              conditions:
+                description: Conditions of the resource.
+                items:
+                  description: A Condition that may apply to a resource.
+                  properties:
+                    lastTransitionTime:
+                      description: |-
+                        LastTransitionTime is the last time this condition transitioned from one
+                        status to another.
+                      format: date-time
+                      type: string
+                    message:
+                      description: |-
+                        A Message containing details about this condition's last transition from
+                        one status to another, if any.
+                      type: string
+                    observedGeneration:
+                      description: |-
+                        ObservedGeneration represents the .metadata.generation that the condition was set based upon.
+                        For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+                        with respect to the current state of the instance.
+                      format: int64
+                      type: integer
+                    reason:
+                      description: A Reason for this condition's last transition from
+                        one status to another.
+                      type: string
+                    status:
+                      description: Status of this condition; is it currently True,
+                        False, or Unknown?
+                      type: string
+                    type:
+                      description: |-
+                        Type of this condition. At most one of each condition type may apply to
+                        a resource at any point in time.
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+                x-kubernetes-list-map-keys:
+                - type
+                x-kubernetes-list-type: map
+              failures:
+                description: Number of operation failures.
+                format: int64
+                type: integer
+              pipeline:
+                description: |-
+                  Pipeline represents the output of the pipeline steps that this operation
+                  ran.
+                items:
+                  description: PipelineStepStatus represents the status of an individual
+                    pipeline step.
+                  properties:
+                    output:
+                      description: Output of this step.
+                      type: object
+                      x-kubernetes-preserve-unknown-fields: true
+                    step:
+                      description: Step name. Unique within its Pipeline.
+                      type: string
+                  required:
+                  - step
+                  type: object
+                type: array
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
diff --git a/cluster/crds/pkg.crossplane.io_configurationrevisions.yaml b/cluster/crds/pkg.crossplane.io_configurationrevisions.yaml
index b353a573e..8db26e00d 100644
--- a/cluster/crds/pkg.crossplane.io_configurationrevisions.yaml
+++ b/cluster/crds/pkg.crossplane.io_configurationrevisions.yaml
@@ -170,6 +170,13 @@ spec:
                   - reason
                   type: object
                 type: array
+              capabilities:
+                description: |-
+                  Capabilities of this package. Capabilities are opaque strings that
+                  may be meaningful to package consumers.
+                items:
+                  type: string
+                type: array
               conditions:
                 description: Conditions of the resource.
                 items:
diff --git a/cluster/crds/pkg.crossplane.io_functionrevisions.yaml b/cluster/crds/pkg.crossplane.io_functionrevisions.yaml
index 04ad03f80..42028d257 100644
--- a/cluster/crds/pkg.crossplane.io_functionrevisions.yaml
+++ b/cluster/crds/pkg.crossplane.io_functionrevisions.yaml
@@ -204,6 +204,13 @@ spec:
                   - reason
                   type: object
                 type: array
+              capabilities:
+                description: |-
+                  Capabilities of this package. Capabilities are opaque strings that
+                  may be meaningful to package consumers.
+                items:
+                  type: string
+                type: array
               conditions:
                 description: Conditions of the resource.
                 items:
@@ -500,6 +507,13 @@ spec:
                   - reason
                   type: object
                 type: array
+              capabilities:
+                description: |-
+                  Capabilities of this package. Capabilities are opaque strings that
+                  may be meaningful to package consumers.
+                items:
+                  type: string
+                type: array
               conditions:
                 description: Conditions of the resource.
                 items:
diff --git a/cluster/crds/pkg.crossplane.io_providerrevisions.yaml b/cluster/crds/pkg.crossplane.io_providerrevisions.yaml
index de7dd9732..500a80665 100644
--- a/cluster/crds/pkg.crossplane.io_providerrevisions.yaml
+++ b/cluster/crds/pkg.crossplane.io_providerrevisions.yaml
@@ -204,6 +204,13 @@ spec:
                   - reason
                   type: object
                 type: array
+              capabilities:
+                description: |-
+                  Capabilities of this package. Capabilities are opaque strings that
+                  may be meaningful to package consumers.
+                items:
+                  type: string
+                type: array
               conditions:
                 description: Conditions of the resource.
                 items:
diff --git a/cluster/meta/meta.pkg.crossplane.io_configurations.yaml b/cluster/meta/meta.pkg.crossplane.io_configurations.yaml
index 6465d7594..9dec57f83 100644
--- a/cluster/meta/meta.pkg.crossplane.io_configurations.yaml
+++ b/cluster/meta/meta.pkg.crossplane.io_configurations.yaml
@@ -40,6 +40,13 @@ spec:
           spec:
             description: ConfigurationSpec specifies the configuration of a Configuration.
             properties:
+              capabilities:
+                description: |-
+                  Capabilities of this package. Capabilities are opaque strings that
+                  may be meaningful to package consumers.
+                items:
+                  type: string
+                type: array
               crossplane:
                 description: Semantic version constraints of Crossplane that package
                   is compatible with.
@@ -132,6 +139,13 @@ spec:
           spec:
             description: ConfigurationSpec specifies the configuration of a Configuration.
             properties:
+              capabilities:
+                description: |-
+                  Capabilities of this package. Capabilities are opaque strings that
+                  may be meaningful to package consumers.
+                items:
+                  type: string
+                type: array
               crossplane:
                 description: Semantic version constraints of Crossplane that package
                   is compatible with.
diff --git a/cluster/meta/meta.pkg.crossplane.io_functions.yaml b/cluster/meta/meta.pkg.crossplane.io_functions.yaml
index 7e32e9702..79df94bbe 100644
--- a/cluster/meta/meta.pkg.crossplane.io_functions.yaml
+++ b/cluster/meta/meta.pkg.crossplane.io_functions.yaml
@@ -39,6 +39,13 @@ spec:
           spec:
             description: FunctionSpec specifies the configuration of a Function.
             properties:
+              capabilities:
+                description: |-
+                  Capabilities of this package. Capabilities are opaque strings that
+                  may be meaningful to package consumers.
+                items:
+                  type: string
+                type: array
               crossplane:
                 description: Semantic version constraints of Crossplane that package
                   is compatible with.
@@ -130,6 +137,13 @@ spec:
           spec:
             description: FunctionSpec specifies the configuration of a Function.
             properties:
+              capabilities:
+                description: |-
+                  Capabilities of this package. Capabilities are opaque strings that
+                  may be meaningful to package consumers.
+                items:
+                  type: string
+                type: array
               crossplane:
                 description: Semantic version constraints of Crossplane that package
                   is compatible with.
diff --git a/cluster/meta/meta.pkg.crossplane.io_providers.yaml b/cluster/meta/meta.pkg.crossplane.io_providers.yaml
index 83f24e956..d469563c4 100644
--- a/cluster/meta/meta.pkg.crossplane.io_providers.yaml
+++ b/cluster/meta/meta.pkg.crossplane.io_providers.yaml
@@ -39,6 +39,13 @@ spec:
           spec:
             description: ProviderSpec specifies the configuration of a Provider.
             properties:
+              capabilities:
+                description: |-
+                  Capabilities of this package. Capabilities are opaque strings that
+                  may be meaningful to package consumers.
+                items:
+                  type: string
+                type: array
               crossplane:
                 description: Semantic version constraints of Crossplane that package
                   is compatible with.
@@ -130,6 +137,13 @@ spec:
           spec:
             description: ProviderSpec specifies the configuration of a Provider.
             properties:
+              capabilities:
+                description: |-
+                  Capabilities of this package. Capabilities are opaque strings that
+                  may be meaningful to package consumers.
+                items:
+                  type: string
+                type: array
               crossplane:
                 description: Semantic version constraints of Crossplane that package
                   is compatible with.
diff --git a/cmd/crank/render/cmd.go b/cmd/crank/render/cmd.go
index ded182f41..5b96a72ae 100644
--- a/cmd/crank/render/cmd.go
+++ b/cmd/crank/render/cmd.go
@@ -45,14 +45,14 @@ type Cmd struct {
 	Functions         string `arg:"" help:"A YAML file or directory of YAML files specifying the Composition Functions to use to render the XR." predictor:"yaml_file_or_directory" type:"path"`
 
 	// Flags. Keep them in alphabetical order.
-	ContextFiles           map[string]string `help:"Comma-separated context key-value pairs to pass to the Function pipeline. Values must be files containing JSON."                           mapsep:""          predictor:"file"`
+	ContextFiles           map[string]string `help:"Comma-separated context key-value pairs to pass to the Function pipeline. Values must be files containing JSON."                           mapsep:""                                                                                                     predictor:"file"`
 	ContextValues          map[string]string `help:"Comma-separated context key-value pairs to pass to the Function pipeline. Values must be JSON. Keys take precedence over --context-files." mapsep:""`
 	IncludeFunctionResults bool              `help:"Include informational and warning messages from Functions in the rendered output as resources of kind: Result."                            short:"r"`
 	IncludeFullXR          bool              `help:"Include a direct copy of the input XR's spec and metadata fields in the rendered output."                                                  short:"x"`
-	ObservedResources      string            `help:"A YAML file or directory of YAML files specifying the observed state of composed resources."                                               placeholder:"PATH" predictor:"yaml_file_or_directory" short:"o"   type:"path"`
-	ExtraResources         string            `help:"A YAML file or directory of YAML files specifying extra resources to pass to the Function pipeline."                                       placeholder:"PATH" predictor:"yaml_file_or_directory" short:"e"   type:"path"`
+	ObservedResources      string            `help:"A YAML file or directory of YAML files specifying the observed state of composed resources."                                               placeholder:"PATH"                                                                                            predictor:"yaml_file_or_directory" short:"o"                          type:"path"`
+	RequiredResources      string            `aliases:"extra-resources"                                                                                                                        help:"A YAML file or directory of YAML files specifying required resources to pass to the Function pipeline." placeholder:"PATH"                 predictor:"yaml_file_or_directory" short:"e"   type:"path"`
 	IncludeContext         bool              `help:"Include the context in the rendered output as a resource of kind: Context."                                                                short:"c"`
-	FunctionCredentials    string            `help:"A YAML file or directory of YAML files specifying credentials to use for Functions to render the XR."                                      placeholder:"PATH" predictor:"yaml_file_or_directory" type:"path"`
+	FunctionCredentials    string            `help:"A YAML file or directory of YAML files specifying credentials to use for Functions to render the XR."                                      placeholder:"PATH"                                                                                            predictor:"yaml_file_or_directory" type:"path"`
 
 	Timeout time.Duration `default:"1m"                                                                                                     help:"How long to run before timing out."`
 	XRD     string        `help:"A YAML file specifying the CompositeResourceDefinition (XRD) that defines the XR's schema and properties." optional:""                               placeholder:"PATH" type:"existingfile"`
@@ -113,9 +113,9 @@ Examples:
   crossplane render xr.yaml composition.yaml functions.yaml \
     --context-values=apiextensions.crossplane.io/environment='{"key": "value"}'
 
-  # Pass extra resources Functions in the pipeline can request.
+  # Pass required resources Functions in the pipeline can request.
   crossplane render xr.yaml composition.yaml functions.yaml \
-	--extra-resources=extra-resources.yaml
+	--required-resources=required-resources.yaml
 
   # Pass credentials to Functions in the pipeline that need them.
   crossplane render xr.yaml composition.yaml functions.yaml \
@@ -211,10 +211,10 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit
 	}
 
 	ers := []unstructured.Unstructured{}
-	if c.ExtraResources != "" {
-		ers, err = LoadExtraResources(c.fs, c.ExtraResources)
+	if c.RequiredResources != "" {
+		ers, err = LoadRequiredResources(c.fs, c.RequiredResources)
 		if err != nil {
-			return errors.Wrapf(err, "cannot load extra resources from %q", c.ExtraResources)
+			return errors.Wrapf(err, "cannot load required resources from %q", c.RequiredResources)
 		}
 	}
 
@@ -242,7 +242,7 @@ func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit
 		Functions:           fns,
 		FunctionCredentials: fcreds,
 		ObservedResources:   ors,
-		ExtraResources:      ers,
+		RequiredResources:   ers,
 		Context:             fctx,
 	})
 	if err != nil {
diff --git a/cmd/crank/render/load.go b/cmd/crank/render/load.go
index 430131d6e..d65574738 100644
--- a/cmd/crank/render/load.go
+++ b/cmd/crank/render/load.go
@@ -132,8 +132,8 @@ func LoadCredentials(fs afero.Fs, file string) ([]corev1.Secret, error) {
 	return secrets, nil
 }
 
-// LoadExtraResources from a stream of YAML manifests.
-func LoadExtraResources(fs afero.Fs, file string) ([]unstructured.Unstructured, error) {
+// LoadRequiredResources from a stream of YAML manifests.
+func LoadRequiredResources(fs afero.Fs, file string) ([]unstructured.Unstructured, error) {
 	stream, err := LoadYAMLStream(fs, file)
 	if err != nil {
 		return nil, errors.Wrap(err, "cannot load YAML stream from file")
diff --git a/cmd/crank/render/render.go b/cmd/crank/render/render.go
index 4fcb8afc6..46b2cf302 100644
--- a/cmd/crank/render/render.go
+++ b/cmd/crank/render/render.go
@@ -40,11 +40,11 @@ import (
 	"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composed"
 	ucomposite "github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composite"
 
-	fnv1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1"
 	apiextensionsv1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
 	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
 	"github.com/crossplane/crossplane/internal/controller/apiextensions/composite"
 	"github.com/crossplane/crossplane/internal/xfn"
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
 )
 
 // Wait for the server to be ready before sending RPCs. Notably this gives
@@ -72,7 +72,7 @@ type Inputs struct {
 	Functions           []pkgv1.Function
 	FunctionCredentials []corev1.Secret
 	ObservedResources   []composed.Unstructured
-	ExtraResources      []unstructured.Unstructured
+	RequiredResources   []unstructured.Unstructured
 	Context             map[string][]byte
 
 	// TODO(negz): Allow supplying observed XR and composed resource connection
@@ -196,7 +196,7 @@ func Render(ctx context.Context, log logging.Logger, in Inputs) (Outputs, error)
 		}
 	}()
 
-	runner := composite.NewFetchingFunctionRunner(runtimes, &FilteringFetcher{extra: in.ExtraResources})
+	runner := xfn.NewFetchingFunctionRunner(runtimes, &FilteringFetcher{extra: in.RequiredResources})
 
 	observed := composite.ComposedResourceStates{}
 
@@ -337,7 +337,7 @@ func Render(ctx context.Context, log logging.Logger, in Inputs) (Outputs, error)
 		}
 
 		cd := composed.New()
-		if err := composite.FromStruct(cd, dr.GetResource()); err != nil {
+		if err := xfn.FromStruct(cd, dr.GetResource()); err != nil {
 			return Outputs{}, errors.Wrapf(err, "cannot unmarshal desired composed resource %q", name)
 		}
 
@@ -363,7 +363,7 @@ func Render(ctx context.Context, log logging.Logger, in Inputs) (Outputs, error)
 	})
 
 	xr := ucomposite.New()
-	if err := composite.FromStruct(xr, d.GetComposite().GetResource()); err != nil {
+	if err := xfn.FromStruct(xr, d.GetComposite().GetResource()); err != nil {
 		return Outputs{}, errors.Wrap(err, "cannot render desired composite resource")
 	}
 
@@ -428,8 +428,8 @@ func SetComposedResourceMetadata(cd resource.Object, xr resource.LegacyComposite
 	return errors.Wrapf(meta.AddControllerReference(cd, or), "cannot set composite resource %q as controller ref of composed resource", xr.GetName())
 }
 
-// FilteringFetcher is a composite.ExtraResourcesFetcher that "fetches" any
-// supplied resource that matches a resource selector.
+// FilteringFetcher is a RequiredResourcesFetcher that "fetches" any supplied
+// resource that matches a resource selector.
 type FilteringFetcher struct {
 	extra []unstructured.Unstructured
 }
@@ -453,7 +453,7 @@ func (f *FilteringFetcher) Fetch(_ context.Context, rs *fnv1.ResourceSelector) (
 		}
 
 		if rs.GetMatchName() == er.GetName() {
-			o, err := composite.AsStruct(&er)
+			o, err := xfn.AsStruct(&er)
 			if err != nil {
 				return nil, errors.Wrapf(err, "cannot marshal extra resource %q", er.GetName())
 			}
@@ -465,7 +465,7 @@ func (f *FilteringFetcher) Fetch(_ context.Context, rs *fnv1.ResourceSelector) (
 
 		if rs.GetMatchLabels() != nil {
 			if labels.SelectorFromSet(rs.GetMatchLabels().GetLabels()).Matches(labels.Set(er.GetLabels())) {
-				o, err := composite.AsStruct(&er)
+				o, err := xfn.AsStruct(&er)
 				if err != nil {
 					return nil, errors.Wrapf(err, "cannot marshal extra resource %q", er.GetName())
 				}
diff --git a/cmd/crossplane/core/core.go b/cmd/crossplane/core/core.go
index 314f5a4d5..2a22a4a4a 100644
--- a/cmd/crossplane/core/core.go
+++ b/cmd/crossplane/core/core.go
@@ -52,6 +52,8 @@ import (
 	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
 	"github.com/crossplane/crossplane/internal/controller/apiextensions"
 	apiextensionscontroller "github.com/crossplane/crossplane/internal/controller/apiextensions/controller"
+	"github.com/crossplane/crossplane/internal/controller/ops"
+	opscontroller "github.com/crossplane/crossplane/internal/controller/ops/controller"
 	"github.com/crossplane/crossplane/internal/controller/pkg"
 	pkgcontroller "github.com/crossplane/crossplane/internal/controller/pkg/controller"
 	"github.com/crossplane/crossplane/internal/controller/protection"
@@ -119,6 +121,7 @@ type startCommand struct {
 	EnableDependencyVersionDowngrades bool `group:"Alpha Features:" help:"Enable support for upgrading and downgrading dependency versions when a dependent package is updated."`
 	EnableSignatureVerification       bool `group:"Alpha Features:" help:"Enable support for package signature verification via ImageConfig API."`
 	EnableFunctionResponseCache       bool `group:"Alpha Features:" help:"Enable support for caching composition function responses."`
+	EnableOperations                  bool `group:"Alpha Features:" help:"Enable support for Operations."`
 
 	XfnCacheDir    string        `default:"/cache/xfn" env:"XFN_CACHE_DIR"     group:"Alpha Features:" help:"Directory used for caching function responses. Requires --enable-function-response-cache."`
 	XfnCacheMaxTTL time.Duration `default:"24h"        env:"XFN_CACHE_MAX_TTL" group:"Alpha Features:" help:"Maximum TTL for cached function responses. Set to 0 to disable. Requires --enable-function-response-cache."`
@@ -309,6 +312,11 @@ func (c *startCommand) Run(s *runtime.Scheme, log logging.Logger) error { //noli
 		log.Info("Alpha feature enabled", "flag", features.EnableAlphaSignatureVerification)
 	}
 
+	if c.EnableOperations {
+		o.Features.Enable(features.EnableAlphaOperations)
+		log.Info("Alpha feature enabled", "flag", features.EnableAlphaOperations)
+	}
+
 	// Claim and XR controllers are started and stopped dynamically by the
 	// ControllerEngine below. When realtime compositions are enabled, they also
 	// start and stop their watches (e.g. of composed resources) dynamically. To
@@ -404,6 +412,9 @@ func (c *startCommand) Run(s *runtime.Scheme, log logging.Logger) error { //noli
 		return errors.Wrap(err, "cannot start garbage collector for custom resource informers")
 	}
 
+	// Automatically fetch required resources.
+	runner = xfn.NewFetchingFunctionRunner(runner, xfn.NewExistingRequiredResourcesFetcher(cached))
+
 	ao := apiextensionscontroller.Options{
 		Options:          o,
 		ControllerEngine: ce,
@@ -414,6 +425,16 @@ func (c *startCommand) Run(s *runtime.Scheme, log logging.Logger) error { //noli
 		return errors.Wrap(err, "cannot setup API extension controllers")
 	}
 
+	if o.Features.Enabled(features.EnableAlphaOperations) {
+		oo := opscontroller.Options{
+			Options:        o,
+			FunctionRunner: runner,
+		}
+		if err := ops.Setup(mgr, oo); err != nil {
+			return errors.Wrap(err, "cannot setup ops controllers")
+		}
+	}
+
 	var pr pkgcontroller.ActiveRuntime
 	switch rt := pkgcontroller.PackageRuntime(c.PackageRuntime); rt {
 	case pkgcontroller.PackageRuntimeUnspecified:
diff --git a/contributing/specifications/functions.md b/contributing/specifications/functions.md
index 50bf81e8f..cc52ad6ad 100644
--- a/contributing/specifications/functions.md
+++ b/contributing/specifications/functions.md
@@ -15,7 +15,7 @@ A Function MUST implement a gRPC `FunctionRunnerService` server. A Function
 SHOULD implement the latest available version of this service - e.g. `v1beta1`.
 The authoritative definition of this service can be found at the following URL.
 
-https://github.com/crossplane/crossplane/tree/main/apis/apiextensions/fn/proto
+https://github.com/crossplane/crossplane/tree/main/proto/fn
 
 A Function MUST copy the tag field from a RunFunctionRequest's RequestMeta
 message to the ResponseMeta tag field of the corresponding RunFunctionResponse.
@@ -110,4 +110,4 @@ example https://github.com/crossplane/function-sdk-go.
 
 [design document]: ../../design/design-doc-composition-functions.md
 [RFC 2119]: https://www.ietf.org/rfc/rfc2119.txt
-[xpkg Specification]: xpkg.md
\ No newline at end of file
+[xpkg Specification]: xpkg.md
diff --git a/generate.go b/generate.go
index 4ab55c5d9..79e246515 100644
--- a/generate.go
+++ b/generate.go
@@ -48,6 +48,7 @@ limitations under the License.
 //go:generate go run -tags generate sigs.k8s.io/controller-tools/cmd/controller-gen object:headerFile=./hack/boilerplate.go.txt paths=./apis/pkg/v1beta1;./apis/pkg/v1 crd:crdVersions=v1,generateEmbeddedObjectMeta=true output:artifacts:config=./cluster/crds
 //go:generate go run -tags generate sigs.k8s.io/controller-tools/cmd/controller-gen object:headerFile=./hack/boilerplate.go.txt paths=./apis/apiextensions/v1alpha1;./apis/apiextensions/v1beta1;./apis/apiextensions/v1;./apis/apiextensions/v2alpha1 crd:crdVersions=v1 output:artifacts:config=./cluster/crds
 //go:generate go run -tags generate sigs.k8s.io/controller-tools/cmd/controller-gen object:headerFile=./hack/boilerplate.go.txt paths=./apis/protection/v1beta1 crd:crdVersions=v1 output:artifacts:config=./cluster/crds
+//go:generate go run -tags generate sigs.k8s.io/controller-tools/cmd/controller-gen object:headerFile=./hack/boilerplate.go.txt paths=./apis/ops/v1alpha1 crd:crdVersions=v1 output:artifacts:config=./cluster/crds
 
 // We generate the meta.pkg.crossplane.io types separately as the generated CRDs
 // are never installed, only used for API documentation.
@@ -64,7 +65,7 @@ limitations under the License.
 
 // Replicate identical gRPC APIs
 
-//go:generate ./hack/duplicate_proto_type.sh apis/apiextensions/fn/proto/v1/run_function.proto apis/apiextensions/fn/proto/v1beta1
+//go:generate ./hack/duplicate_proto_type.sh proto/fn/v1/run_function.proto proto/fn/v1beta1
 
 // Generate gRPC types and stubs.
 //
diff --git a/internal/controller/apiextensions/apiextensions.go b/internal/controller/apiextensions/apiextensions.go
index f687dafd2..e5bdf68ce 100644
--- a/internal/controller/apiextensions/apiextensions.go
+++ b/internal/controller/apiextensions/apiextensions.go
@@ -24,6 +24,7 @@ import (
 	"github.com/crossplane/crossplane/internal/controller/apiextensions/controller"
 	"github.com/crossplane/crossplane/internal/controller/apiextensions/definition"
 	"github.com/crossplane/crossplane/internal/controller/apiextensions/offered"
+	"github.com/crossplane/crossplane/internal/controller/apiextensions/revision"
 )
 
 // Setup API extensions controllers.
@@ -32,6 +33,10 @@ func Setup(mgr ctrl.Manager, o controller.Options) error {
 		return err
 	}
 
+	if err := revision.Setup(mgr, o); err != nil {
+		return err
+	}
+
 	if err := definition.Setup(mgr, o); err != nil {
 		return err
 	}
diff --git a/internal/controller/apiextensions/composite/composition_functions.go b/internal/controller/apiextensions/composite/composition_functions.go
index 334160932..d2abd8825 100644
--- a/internal/controller/apiextensions/composite/composition_functions.go
+++ b/internal/controller/apiextensions/composite/composition_functions.go
@@ -24,16 +24,12 @@ import (
 	"strings"
 	"time"
 
-	"google.golang.org/protobuf/encoding/protojson"
 	"google.golang.org/protobuf/proto"
 	"google.golang.org/protobuf/types/known/structpb"
 	corev1 "k8s.io/api/core/v1"
 	kerrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	kunstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
-	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
-	"k8s.io/apimachinery/pkg/util/json"
 	"k8s.io/apimachinery/pkg/util/validation"
 	"k8s.io/utils/ptr"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -44,14 +40,14 @@ import (
 	"github.com/crossplane/crossplane-runtime/pkg/meta"
 	"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"
 	"github.com/crossplane/crossplane-runtime/pkg/resource"
-	"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured"
 	"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composed"
 	"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composite"
 
-	fnv1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1"
 	v1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
 	"github.com/crossplane/crossplane/internal/names"
 	"github.com/crossplane/crossplane/internal/xcrd"
+	"github.com/crossplane/crossplane/internal/xfn"
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
 )
 
 // Error strings.
@@ -66,11 +62,6 @@ const (
 	errUnmarshalDesiredXRStatus = "cannot unmarshal desired composite resource status from RunFunctionResponse"
 	errXRAsStruct               = "cannot encode composite resource to protocol buffer Struct well-known type"
 	errStructFromUnstructured   = "cannot create Struct"
-	errGetExtraResourceByName   = "cannot get extra resource by name"
-	errNilResourceSelector      = "resource selector should not be nil"
-	errExtraResourceAsStruct    = "cannot encode extra resource to protocol buffer Struct well-known type"
-	errUnknownResourceSelector  = "cannot get extra resource by name: unknown resource selector type"
-	errListExtraResources       = "cannot list extra resources"
 	errGetComposed              = "cannot get composed resource"
 	errMarshalJSON              = "cannot marshal to JSON"
 
@@ -122,7 +113,6 @@ type xr struct {
 	ConnectionDetailsFetcher
 	ComposedResourceObserver
 	ComposedResourceGarbageCollector
-	ExtraResourcesFetcher
 	ManagedFieldsUpgrader
 }
 
@@ -165,19 +155,6 @@ func (fn ComposedResourceObserverFn) ObserveComposedResources(ctx context.Contex
 	return fn(ctx, xr)
 }
 
-// A ExtraResourcesFetcher gets extra resources matching a selector.
-type ExtraResourcesFetcher interface {
-	Fetch(ctx context.Context, rs *fnv1.ResourceSelector) (*fnv1.Resources, error)
-}
-
-// An ExtraResourcesFetcherFn gets extra resources matching the selector.
-type ExtraResourcesFetcherFn func(ctx context.Context, rs *fnv1.ResourceSelector) (*fnv1.Resources, error)
-
-// Fetch gets extra resources matching the selector.
-func (fn ExtraResourcesFetcherFn) Fetch(ctx context.Context, rs *fnv1.ResourceSelector) (*fnv1.Resources, error) {
-	return fn(ctx, rs)
-}
-
 // A ComposedResourceGarbageCollector deletes observed composed resources that
 // are no longer desired.
 type ComposedResourceGarbageCollector interface {
@@ -426,7 +403,7 @@ func (c *FunctionComposer) Compose(ctx context.Context, xr *composite.Unstructur
 
 	for name, dr := range d.GetResources() {
 		cd := composed.New()
-		if err := FromStruct(cd, dr.GetResource()); err != nil {
+		if err := xfn.FromStruct(cd, dr.GetResource()); err != nil {
 			return CompositionResult{}, errors.Wrapf(err, errFmtUnmarshalDesiredCD, name)
 		}
 
@@ -592,7 +569,7 @@ func (c *FunctionComposer) Compose(ctx context.Context, xr *composite.Unstructur
 	n := xr.GetName()
 
 	u := xr.GetUID()
-	if err := FromStruct(xr, d.GetComposite().GetResource()); err != nil {
+	if err := xfn.FromStruct(xr, d.GetComposite().GetResource()); err != nil {
 		return CompositionResult{}, errors.Wrap(err, errUnmarshalDesiredXRStatus)
 	}
 
@@ -762,7 +739,7 @@ func (g *ExistingComposedResourceObserver) ObserveComposedResources(ctx context.
 // AsState builds state for a RunFunctionRequest from the XR and composed
 // resources.
 func AsState(xr resource.Composite, xc managed.ConnectionDetails, rs ComposedResourceStates) (*fnv1.State, error) {
-	r, err := AsStruct(xr)
+	r, err := xfn.AsStruct(xr)
 	if err != nil {
 		return nil, errors.Wrap(err, errXRAsStruct)
 	}
@@ -771,7 +748,7 @@ func AsState(xr resource.Composite, xc managed.ConnectionDetails, rs ComposedRes
 
 	ocds := make(map[string]*fnv1.Resource)
 	for name, or := range rs {
-		r, err := AsStruct(or.Resource)
+		r, err := xfn.AsStruct(or.Resource)
 		if err != nil {
 			return nil, errors.Wrapf(err, errFmtCDAsStruct, name)
 		}
@@ -782,55 +759,6 @@ func AsState(xr resource.Composite, xc managed.ConnectionDetails, rs ComposedRes
 	return &fnv1.State{Composite: oxr, Resources: ocds}, nil
 }
 
-// AsStruct converts the supplied object to a protocol buffer Struct well-known
-// type.
-func AsStruct(o runtime.Object) (*structpb.Struct, error) {
-	// If the supplied object is *Unstructured we don't need to round-trip.
-	if u, ok := o.(*kunstructured.Unstructured); ok {
-		s, err := structpb.NewStruct(u.Object)
-		return s, errors.Wrap(err, errStructFromUnstructured)
-	}
-
-	// If the supplied object wraps *Unstructured we don't need to round-trip.
-	if w, ok := o.(unstructured.Wrapper); ok {
-		s, err := structpb.NewStruct(w.GetUnstructured().Object)
-		return s, errors.Wrap(err, errStructFromUnstructured)
-	}
-
-	// Fall back to a JSON round-trip.
-	b, err := json.Marshal(o)
-	if err != nil {
-		return nil, errors.Wrap(err, errMarshalJSON)
-	}
-
-	s := &structpb.Struct{}
-
-	return s, errors.Wrap(s.UnmarshalJSON(b), errUnmarshalJSON)
-}
-
-// FromStruct populates the supplied object with content loaded from the Struct.
-func FromStruct(o client.Object, s *structpb.Struct) error {
-	// If the supplied object is *Unstructured we don't need to round-trip.
-	if u, ok := o.(*kunstructured.Unstructured); ok {
-		u.Object = s.AsMap()
-		return nil
-	}
-
-	// If the supplied object wraps *Unstructured we don't need to round-trip.
-	if w, ok := o.(unstructured.Wrapper); ok {
-		w.GetUnstructured().Object = s.AsMap()
-		return nil
-	}
-
-	// Fall back to a JSON round-trip.
-	b, err := protojson.Marshal(s)
-	if err != nil {
-		return errors.Wrap(err, errMarshalProtoStruct)
-	}
-
-	return errors.Wrap(json.Unmarshal(b, o), errUnmarshalJSON)
-}
-
 // An DeletingComposedResourceGarbageCollector deletes undesired composed resources from
 // the API server.
 type DeletingComposedResourceGarbageCollector struct {
diff --git a/internal/controller/apiextensions/composite/reconciler.go b/internal/controller/apiextensions/composite/reconciler.go
index f4dac306d..d9db7f78b 100644
--- a/internal/controller/apiextensions/composite/reconciler.go
+++ b/internal/controller/apiextensions/composite/reconciler.go
@@ -568,11 +568,12 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
 
 	rev, err := r.revision.Fetch(ctx, xr)
 	if err != nil {
+		log.Debug(errFetchComp, "error", err)
+
 		if kerrors.IsConflict(err) {
 			return reconcile.Result{Requeue: true}, nil
 		}
 
-		log.Debug(errFetchComp, "error", err)
 		err = errors.Wrap(err, errFetchComp)
 		r.record.Event(xr, event.Warning(reasonCompose, err))
 		status.MarkConditions(xpv1.ReconcileError(err))
@@ -584,6 +585,22 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
 		r.record.Event(xr, event.Normal(reasonResolve, fmt.Sprintf("Selected composition revision: %s", rev.Name)))
 	}
 
+	// Check if the CompositionRevision has a valid pipeline before proceeding.
+	// Only proceed if the pipeline is explicitly marked as valid.
+	if c := rev.GetCondition(v1.TypeValidPipeline); c.Status != corev1.ConditionTrue {
+		msg := "pipeline status unknown"
+		if c.Message != "" {
+			msg = c.Message
+		}
+
+		err := errors.Errorf("selected CompositionRevision %s does not have a valid function pipeline: %s", rev.GetName(), msg)
+
+		r.record.Event(xr, event.Warning(reasonCompose, err))
+		status.MarkConditions(xpv1.ReconcileError(err))
+
+		return reconcile.Result{Requeue: true}, errors.Wrap(r.client.Status().Update(ctx, xr), errUpdateStatus)
+	}
+
 	if err := r.composite.Configure(ctx, xr, rev); err != nil {
 		log.Debug(errConfigure, "error", err)
 
diff --git a/internal/controller/apiextensions/definition/reconciler.go b/internal/controller/apiextensions/definition/reconciler.go
index eb2fc1d82..254be2c9d 100644
--- a/internal/controller/apiextensions/definition/reconciler.go
+++ b/internal/controller/apiextensions/definition/reconciler.go
@@ -509,9 +509,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
 		return reconcile.Result{Requeue: false}, errors.Wrap(r.client.Status().Update(ctx, d), errUpdateStatus)
 	}
 
-	runner := composite.NewFetchingFunctionRunner(r.options.FunctionRunner, composite.NewExistingExtraResourcesFetcher(r.engine.GetCached()))
 	fetcher := composite.NewSecretConnectionDetailsFetcher(r.engine.GetCached())
-	fc := composite.NewFunctionComposer(r.engine.GetCached(), r.engine.GetUncached(), runner,
+	fc := composite.NewFunctionComposer(r.engine.GetCached(), r.engine.GetUncached(), r.options.FunctionRunner,
 		composite.WithComposedResourceObserver(composite.NewExistingComposedResourceObserver(r.engine.GetCached(), r.engine.GetUncached(), fetcher)),
 		composite.WithCompositeConnectionDetailsFetcher(fetcher),
 	)
diff --git a/internal/controller/apiextensions/revision/reconciler.go b/internal/controller/apiextensions/revision/reconciler.go
new file mode 100644
index 000000000..b94221b77
--- /dev/null
+++ b/internal/controller/apiextensions/revision/reconciler.go
@@ -0,0 +1,173 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package revision implements the CompositionRevision controller.
+package revision
+
+import (
+	"context"
+	"strings"
+	"time"
+
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/manager"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+	"github.com/crossplane/crossplane-runtime/pkg/conditions"
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+	"github.com/crossplane/crossplane-runtime/pkg/event"
+	"github.com/crossplane/crossplane-runtime/pkg/logging"
+	"github.com/crossplane/crossplane-runtime/pkg/meta"
+	"github.com/crossplane/crossplane-runtime/pkg/ratelimiter"
+	"github.com/crossplane/crossplane-runtime/pkg/resource"
+
+	v1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
+	pkgmetav1 "github.com/crossplane/crossplane/apis/pkg/meta/v1"
+	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
+	"github.com/crossplane/crossplane/internal/controller/apiextensions/controller"
+	"github.com/crossplane/crossplane/internal/xfn"
+)
+
+const (
+	timeout = 2 * time.Minute
+)
+
+// Event reasons.
+const (
+	reasonCheckCapabilities event.Reason = "CheckCapabilities"
+)
+
+// Setup adds a controller that reconciles CompositionRevisions by validating
+// that all functions in the pipeline have the required composition capability.
+func Setup(mgr ctrl.Manager, o controller.Options) error {
+	name := "revision/" + strings.ToLower(v1.CompositionRevisionGroupKind)
+
+	r := NewReconciler(mgr,
+		WithLogger(o.Logger.WithValues("controller", name)),
+		WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))),
+		WithCapabilityChecker(xfn.NewRevisionCapabilityChecker(mgr.GetClient())))
+
+	return ctrl.NewControllerManagedBy(mgr).
+		Named(name).
+		For(&v1.CompositionRevision{}).
+		Watches(&pkgv1.FunctionRevision{}, EnqueueCompositionRevisionsForFunctionRevision(mgr.GetClient(), o.Logger)).
+		WithOptions(o.ForControllerRuntime()).
+		Complete(ratelimiter.NewReconciler(name, errors.WithSilentRequeueOnConflict(r), o.GlobalRateLimiter))
+}
+
+// ReconcilerOption is used to configure the Reconciler.
+type ReconcilerOption func(*Reconciler)
+
+// WithLogger specifies how the Reconciler should log messages.
+func WithLogger(log logging.Logger) ReconcilerOption {
+	return func(r *Reconciler) {
+		r.log = log
+	}
+}
+
+// WithRecorder specifies how the Reconciler should record Kubernetes events.
+func WithRecorder(er event.Recorder) ReconcilerOption {
+	return func(r *Reconciler) {
+		r.record = er
+	}
+}
+
+// WithCapabilityChecker specifies the CapabilityChecker the Reconciler should use.
+func WithCapabilityChecker(cc xfn.CapabilityChecker) ReconcilerOption {
+	return func(r *Reconciler) {
+		r.functions = cc
+	}
+}
+
+// NewReconciler returns a Reconciler of CompositionRevisions.
+func NewReconciler(mgr manager.Manager, opts ...ReconcilerOption) *Reconciler {
+	r := &Reconciler{
+		client:     mgr.GetClient(),
+		log:        logging.NewNopLogger(),
+		record:     event.NewNopRecorder(),
+		conditions: conditions.ObservedGenerationPropagationManager{},
+		functions:  xfn.NewRevisionCapabilityChecker(mgr.GetClient()),
+	}
+
+	for _, f := range opts {
+		f(r)
+	}
+
+	return r
+}
+
+// A Reconciler reconciles CompositionRevisions by validating that all functions
+// in the pipeline have the required composition capability.
+type Reconciler struct {
+	client client.Client
+
+	log        logging.Logger
+	record     event.Recorder
+	conditions conditions.Manager
+	functions  xfn.CapabilityChecker
+}
+
+// Reconcile a CompositionRevision.
+func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
+	log := r.log.WithValues("request", req)
+	log.Debug("Reconciling")
+
+	ctx, cancel := context.WithTimeout(ctx, timeout)
+	defer cancel()
+
+	rev := &v1.CompositionRevision{}
+	if err := r.client.Get(ctx, req.NamespacedName, rev); err != nil {
+		log.Debug("Cannot get CompositionRevision", "error", err)
+		return reconcile.Result{}, errors.Wrap(resource.IgnoreNotFound(err), "cannot get CompositionRevision")
+	}
+
+	status := r.conditions.For(rev)
+
+	if meta.WasDeleted(rev) {
+		return reconcile.Result{}, nil
+	}
+
+	log = log.WithValues(
+		"uid", rev.GetUID(),
+		"version", rev.GetResourceVersion(),
+		"name", rev.GetName(),
+		"revision", rev.Spec.Revision,
+	)
+
+	// Extract function names from the pipeline
+	names := make([]string, 0, len(rev.Spec.Pipeline))
+	for _, fn := range rev.Spec.Pipeline {
+		names = append(names, fn.FunctionRef.Name)
+	}
+
+	// Check that all functions have the composition capability
+	if err := r.functions.CheckCapabilities(ctx, []string{pkgmetav1.FunctionCapabilityComposition}, names...); err != nil {
+		log.Debug("Function capability check failed", "error", err)
+		r.record.Event(rev, event.Warning(reasonCheckCapabilities, err))
+		status.MarkConditions(xpv1.ReconcileSuccess(), v1.MissingCapabilities(err.Error()))
+
+		// Update status but don't return the error - capability failures are informational
+		return reconcile.Result{}, errors.Wrap(r.client.Status().Update(ctx, rev), "cannot update CompositionRevision status")
+	}
+
+	log.Debug("All functions have required composition capability")
+	r.record.Event(rev, event.Normal(reasonCheckCapabilities, "All functions have required composition capability"))
+	status.MarkConditions(xpv1.ReconcileSuccess(), v1.ValidPipeline())
+
+	return reconcile.Result{}, errors.Wrap(r.client.Status().Update(ctx, rev), "cannot update CompositionRevision status")
+}
diff --git a/internal/controller/apiextensions/revision/watch.go b/internal/controller/apiextensions/revision/watch.go
new file mode 100644
index 000000000..fde9cf4d9
--- /dev/null
+++ b/internal/controller/apiextensions/revision/watch.go
@@ -0,0 +1,76 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package revision
+
+import (
+	"context"
+
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/handler"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	"github.com/crossplane/crossplane-runtime/pkg/logging"
+
+	v1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
+	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
+)
+
+// EnqueueCompositionRevisionsForFunctionRevision enqueues a reconcile for all CompositionRevisions
+// that reference a Function when a FunctionRevision changes.
+func EnqueueCompositionRevisionsForFunctionRevision(kube client.Reader, log logging.Logger) handler.EventHandler {
+	return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
+		fr, ok := o.(*pkgv1.FunctionRevision)
+		if !ok {
+			return nil
+		}
+
+		// Get the function name from the revision's parent package label
+		name := fr.GetLabels()[pkgv1.LabelParentPackage]
+		if name == "" {
+			log.Debug("FunctionRevision has no parent package label", "revision", fr.GetName())
+			return nil
+		}
+
+		// List all CompositionRevisions to find those that reference this function
+		revs := &v1.CompositionRevisionList{}
+		if err := kube.List(ctx, revs); err != nil {
+			log.Debug("Cannot list CompositionRevisions while attempting to enqueue from FunctionRevision", "error", err)
+			return nil
+		}
+
+		var matches []reconcile.Request
+		for _, rev := range revs.Items {
+			for _, fn := range rev.Spec.Pipeline {
+				if fn.FunctionRef.Name != name {
+					continue
+				}
+
+				log.Debug("Enqueuing CompositionRevision for FunctionRevision change",
+					"composition-revision", rev.GetName(),
+					"function-revision", fr.GetName(),
+					"function", name)
+				matches = append(matches, reconcile.Request{NamespacedName: types.NamespacedName{Name: rev.GetName()}})
+
+				// Functions are unique within the pipeline.
+				break
+			}
+		}
+
+		return matches
+	})
+}
diff --git a/internal/controller/ops/controller/options.go b/internal/controller/ops/controller/options.go
new file mode 100644
index 000000000..bea8d4ab9
--- /dev/null
+++ b/internal/controller/ops/controller/options.go
@@ -0,0 +1,32 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package controller contains options specific to ops controllers.
+package controller
+
+import (
+	"github.com/crossplane/crossplane-runtime/pkg/controller"
+
+	"github.com/crossplane/crossplane/internal/xfn"
+)
+
+// Options specific to apiextensions controllers.
+type Options struct {
+	controller.Options
+
+	// FunctionRunner used to run Composition Functions.
+	FunctionRunner xfn.FunctionRunner
+}
diff --git a/internal/controller/ops/operation/constructor.go b/internal/controller/ops/operation/constructor.go
new file mode 100644
index 000000000..385f322ce
--- /dev/null
+++ b/internal/controller/ops/operation/constructor.go
@@ -0,0 +1,104 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package operation implements day two operations.
+package operation
+
+import (
+	"strings"
+
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/builder"
+	"sigs.k8s.io/controller-runtime/pkg/manager"
+	"sigs.k8s.io/controller-runtime/pkg/predicate"
+
+	"github.com/crossplane/crossplane-runtime/pkg/conditions"
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+	"github.com/crossplane/crossplane-runtime/pkg/event"
+	"github.com/crossplane/crossplane-runtime/pkg/logging"
+	"github.com/crossplane/crossplane-runtime/pkg/ratelimiter"
+
+	"github.com/crossplane/crossplane/apis/ops/v1alpha1"
+	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
+	opscontroller "github.com/crossplane/crossplane/internal/controller/ops/controller"
+	"github.com/crossplane/crossplane/internal/xfn"
+)
+
+// Setup adds a controller that reconciles Usages by
+// defining a composite resource and starting a controller to reconcile it.
+func Setup(mgr ctrl.Manager, o opscontroller.Options) error {
+	name := "ops/" + strings.ToLower(v1alpha1.OperationGroupKind)
+
+	r := NewReconciler(mgr,
+		WithLogger(o.Logger.WithValues("controller", name)),
+		WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))),
+		WithFunctionRunner(o.FunctionRunner))
+
+	return ctrl.NewControllerManagedBy(mgr).
+		Named(name).
+		For(&v1alpha1.Operation{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
+		Watches(&pkgv1.FunctionRevision{}, EnqueueOperationsForFunctionRevision(mgr.GetClient(), o.Logger)).
+		WithOptions(o.ForControllerRuntime()).
+		Complete(ratelimiter.NewReconciler(name, errors.WithSilentRequeueOnConflict(r), o.GlobalRateLimiter))
+}
+
+// ReconcilerOption is used to configure the Reconciler.
+type ReconcilerOption func(*Reconciler)
+
+// WithLogger specifies how the Reconciler should log messages.
+func WithLogger(log logging.Logger) ReconcilerOption {
+	return func(r *Reconciler) {
+		r.log = log
+	}
+}
+
+// WithRecorder specifies how the Reconciler should record Kubernetes events.
+func WithRecorder(er event.Recorder) ReconcilerOption {
+	return func(r *Reconciler) {
+		r.record = er
+	}
+}
+
+// WithFunctionRunner specifies how the Reconciler should run functions.
+func WithFunctionRunner(fr xfn.FunctionRunner) ReconcilerOption {
+	return func(r *Reconciler) {
+		r.pipeline = fr
+	}
+}
+
+// WithCapabilityChecker specifies how the Reconciler should check function capabilities.
+func WithCapabilityChecker(cc xfn.CapabilityChecker) ReconcilerOption {
+	return func(r *Reconciler) {
+		r.functions = cc
+	}
+}
+
+// NewReconciler returns a Reconciler of Usages.
+func NewReconciler(mgr manager.Manager, opts ...ReconcilerOption) *Reconciler {
+	r := &Reconciler{
+		client:     mgr.GetClient(),
+		log:        logging.NewNopLogger(),
+		record:     event.NewNopRecorder(),
+		conditions: conditions.ObservedGenerationPropagationManager{},
+		functions:  xfn.NewRevisionCapabilityChecker(mgr.GetClient()),
+	}
+
+	for _, f := range opts {
+		f(r)
+	}
+
+	return r
+}
diff --git a/internal/controller/ops/operation/reconciler.go b/internal/controller/ops/operation/reconciler.go
new file mode 100644
index 000000000..0b8e22d7d
--- /dev/null
+++ b/internal/controller/ops/operation/reconciler.go
@@ -0,0 +1,385 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package operation implements day two operations.
+package operation
+
+import (
+	"context"
+	"fmt"
+	"slices"
+	"time"
+
+	"google.golang.org/protobuf/encoding/protojson"
+	"google.golang.org/protobuf/types/known/structpb"
+	corev1 "k8s.io/api/core/v1"
+	kunstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/utils/ptr"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+	"github.com/crossplane/crossplane-runtime/pkg/conditions"
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+	"github.com/crossplane/crossplane-runtime/pkg/event"
+	"github.com/crossplane/crossplane-runtime/pkg/logging"
+	"github.com/crossplane/crossplane-runtime/pkg/meta"
+	"github.com/crossplane/crossplane-runtime/pkg/resource"
+
+	"github.com/crossplane/crossplane/apis/ops/v1alpha1"
+	pkgmetav1 "github.com/crossplane/crossplane/apis/pkg/meta/v1"
+	"github.com/crossplane/crossplane/internal/xfn"
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
+)
+
+const timeout = 2 * time.Minute
+
+// DefaultRetryLimit before an Operation is marked failed.
+const DefaultRetryLimit = 5
+
+// Event reasons.
+const (
+	reasonRunPipelineStep    = "RunPipelineStep"
+	reasonMaxFailures        = "MaxFailures"
+	reasonFunctionInvocation = "FunctionInvocation"
+	reasonInvalidOutput      = "InvalidOutput"
+	reasonInvalidResource    = "InvalidResource"
+	reasonInvalidPipeline    = "InvalidPipeline"
+)
+
+// FieldOwnerPrefix is used to form the server-side apply field owner
+// that owns the fields this controller mutates on desired resources.
+const FieldOwnerPrefix = "ops.crossplane.io/operation/"
+
+// A Reconciler reconciles Operations.
+type Reconciler struct {
+	client client.Client
+
+	log        logging.Logger
+	record     event.Recorder
+	conditions conditions.Manager
+
+	pipeline  xfn.FunctionRunner
+	functions xfn.CapabilityChecker
+}
+
+// Reconcile an Operation by running its function pipeline.
+func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { //nolint:gocognit // Reconcilers are typically complex.
+	log := r.log.WithValues("request", req)
+
+	ctx, cancel := context.WithTimeout(ctx, timeout)
+	defer cancel()
+
+	op := &v1alpha1.Operation{}
+	if err := r.client.Get(ctx, req.NamespacedName, op); err != nil {
+		// In case object is not found, most likely the object was deleted and
+		// then disappeared while the event was in the processing queue. We
+		// don't need to take any action in that case.
+		log.Debug("cannot get Operation", "error", err)
+		return reconcile.Result{}, errors.Wrap(resource.IgnoreNotFound(err), "cannot get Operation")
+	}
+
+	status := r.conditions.For(op)
+
+	log = log.WithValues(
+		"uid", op.GetUID(),
+		"version", op.GetResourceVersion(),
+		"name", op.GetName(),
+		"namespace", op.GetNamespace(),
+	)
+
+	// Don't reconcile the operation if it's being deleted.
+	if meta.WasDeleted(op) {
+		return reconcile.Result{Requeue: false}, nil
+	}
+
+	// We only want to run this Operation to completion once.
+	if op.IsComplete() {
+		log.Debug("Operation is already complete. Nothing to do.")
+		return reconcile.Result{Requeue: false}, nil
+	}
+
+	// Don't run if we're at the configured failure limit.
+	limit := ptr.Deref(op.Spec.RetryLimit, DefaultRetryLimit)
+	if op.Status.Failures >= limit {
+		log.Debug("Operation failure limit reached. Not running again.", "limit", limit)
+		status.MarkConditions(xpv1.ReconcileSuccess(), v1alpha1.Failed(fmt.Sprintf("failure limit of %d reached", limit)))
+
+		return reconcile.Result{}, errors.Wrap(r.client.Status().Update(ctx, op), "cannot update Operation status")
+	}
+
+	// Updating this status condition ensures we're reconciling the latest
+	// version of the Operation. The update would be rejected if we were
+	// reconciling a stale version. This is important because it helps us
+	// make sure the Operation really isn't complete. That's why we do it
+	// every time, instead of only if the Operation isn't already running.
+	status.MarkConditions(v1alpha1.Running())
+	if err := r.client.Status().Update(ctx, op); err != nil {
+		return reconcile.Result{}, errors.Wrap(err, "cannot update Operation status")
+	}
+
+	// Check that all functions in the pipeline have the operation capability
+	// before running any function.
+	names := make([]string, 0, len(op.Spec.Pipeline))
+	for _, fn := range op.Spec.Pipeline {
+		names = append(names, fn.FunctionRef.Name)
+	}
+
+	// This'll usually be a case that needs human intervention to fix, but
+	// it could be that a newly created function hasn't written its
+	// capabilities to status yet, so we do retry.
+	if err := r.functions.CheckCapabilities(ctx, []string{pkgmetav1.FunctionCapabilityOperation}, names...); err != nil {
+		op.Status.Failures++
+
+		log.Debug("Function capability check failed", "error", err, "failures", op.Status.Failures)
+		err = errors.Wrap(err, "function capability check failed")
+		r.record.Event(op, event.Warning(reasonInvalidPipeline, err))
+		status.MarkConditions(xpv1.ReconcileError(err), v1alpha1.MissingCapabilities(err.Error()))
+		_ = r.client.Status().Update(ctx, op)
+
+		return reconcile.Result{}, err
+	}
+
+	// All functions have the required operation capability
+	status.MarkConditions(v1alpha1.ValidPipeline())
+
+	// The function pipeline starts with empty desired state.
+	d := &fnv1.State{}
+
+	// The function context starts empty.
+	fctx := &structpb.Struct{Fields: map[string]*structpb.Value{}}
+
+	// Run any operation functions in the pipeline. Each function may mutate
+	// the desired state returned by the last, and each function may produce
+	// results that will be emitted as events.
+	for _, fn := range op.Spec.Pipeline {
+		log = log.WithValues("step", fn.Step)
+
+		req := &fnv1.RunFunctionRequest{Desired: d, Context: fctx}
+
+		if fn.Input != nil {
+			in := &structpb.Struct{}
+			if err := in.UnmarshalJSON(fn.Input.Raw); err != nil {
+				log.Debug("Cannot unmarshal input for operation pipeline step", "error", err)
+
+				// An unmarshalable input requires human intervention to fix, so
+				// we immediately fail this operation without retrying.
+				status.MarkConditions(xpv1.ReconcileSuccess(), v1alpha1.Failed(fmt.Sprintf("cannot unmarshal input for operation pipeline step %q", fn.Step)))
+				_ = r.client.Status().Update(ctx, op)
+
+				return reconcile.Result{}, errors.Wrapf(err, "cannot unmarshal input for operation pipeline step %q", fn.Step)
+			}
+
+			req.Input = in
+		}
+
+		req.Credentials = map[string]*fnv1.Credentials{}
+		for _, cs := range fn.Credentials {
+			// For now we only support loading credentials from secrets.
+			if cs.Source != v1alpha1.FunctionCredentialsSourceSecret || cs.SecretRef == nil {
+				continue
+			}
+
+			s := &corev1.Secret{}
+			if err := r.client.Get(ctx, client.ObjectKey{Namespace: cs.SecretRef.Namespace, Name: cs.SecretRef.Name}, s); err != nil {
+				op.Status.Failures++
+
+				log.Debug("Cannot get Operation pipeline step credential", "error", err, "failures", op.Status.Failures, "credential", cs.Name)
+				err = errors.Wrapf(err, "cannot get operation pipeline step %q credential %q from Secret", fn.Step, cs.Name)
+				r.record.Event(op, event.Warning(reasonFunctionInvocation, err))
+				status.MarkConditions(xpv1.ReconcileError(err))
+				_ = r.client.Status().Update(ctx, op)
+
+				return reconcile.Result{}, err
+			}
+
+			req.Credentials[cs.Name] = &fnv1.Credentials{
+				Source: &fnv1.Credentials_CredentialData{
+					CredentialData: &fnv1.CredentialData{
+						Data: s.Data,
+					},
+				},
+			}
+		}
+
+		req.Meta = &fnv1.RequestMeta{Tag: xfn.Tag(req)}
+
+		rsp, err := r.pipeline.RunFunction(ctx, fn.FunctionRef.Name, req)
+		if err != nil {
+			op.Status.Failures++
+
+			log.Debug("Cannot run operation pipeline step", "error", err, "failures", op.Status.Failures)
+			err = errors.Wrapf(err, "failed to invoke pipeline step %q", fn.Step)
+			r.record.Event(op, event.Warning(reasonFunctionInvocation, err))
+			status.MarkConditions(xpv1.ReconcileError(err))
+			_ = r.client.Status().Update(ctx, op)
+
+			return reconcile.Result{}, err
+		}
+
+		// Pass the desired state returned by this Function to the next one.
+		d = rsp.GetDesired()
+
+		// Pass the Function context returned by this Function to the next one.
+		// We intentionally discard/ignore this after the last Function runs.
+		fctx = rsp.GetContext()
+
+		// Results of fatal severity stop the Operation. Other results are
+		// emitted as events.
+		for _, rs := range rsp.GetResults() {
+			switch rs.GetSeverity() {
+			case fnv1.Severity_SEVERITY_FATAL:
+				op.Status.Failures++
+
+				log.Debug("Pipeline step returned a fatal result", "error", rs.GetMessage(), "failures", op.Status.Failures)
+				err = errors.New(rs.GetMessage())
+				r.record.Event(op, event.Warning(reasonFunctionInvocation, err))
+				status.MarkConditions(xpv1.ReconcileError(err))
+				_ = r.client.Status().Update(ctx, op)
+
+				return reconcile.Result{}, err
+			case fnv1.Severity_SEVERITY_WARNING:
+				r.record.Event(op, event.Warning(reasonRunPipelineStep, errors.Errorf("Pipeline step %q: %s", fn.Step, rs.GetMessage())))
+			case fnv1.Severity_SEVERITY_NORMAL:
+				r.record.Event(op, event.Normal(reasonRunPipelineStep, fmt.Sprintf("Pipeline step %q: %s", fn.Step, rs.GetMessage())))
+			case fnv1.Severity_SEVERITY_UNSPECIFIED:
+				// We could hit this case if a Function was built against a newer
+				// protobuf than this build of Crossplane, and the new protobuf
+				// introduced a severity that we don't know about.
+				r.record.Event(op, event.Warning(reasonRunPipelineStep, errors.Errorf("Pipeline step %q returned a result of unknown severity (assuming warning): %s", fn.Step, rs.GetMessage())))
+			}
+		}
+
+		if o := rsp.GetOutput(); o != nil {
+			j, err := protojson.Marshal(o)
+			if err != nil {
+				op.Status.Failures++
+
+				log.Debug("Cannot marshal pipeline step output to JSON", "error", err, "failures", op.Status.Failures)
+				err = errors.Wrapf(err, "cannot marshal pipeline step %q output to JSON", fn.Step)
+				r.record.Event(op, event.Warning(reasonInvalidOutput, err))
+				status.MarkConditions(xpv1.ReconcileError(err))
+				_ = r.client.Status().Update(ctx, op)
+
+				return reconcile.Result{}, err
+			}
+
+			op.Status.Pipeline = AddPipelineStepOutput(op.Status.Pipeline, fn.Step, &runtime.RawExtension{Raw: j})
+		}
+	}
+
+	// Now that all functions have run, we want to apply any desired
+	// resources the pipeline produced.
+	for name, dr := range d.GetResources() {
+		u := &kunstructured.Unstructured{}
+		if err := xfn.FromStruct(u, dr.GetResource()); err != nil {
+			op.Status.Failures++
+
+			log.Debug("Cannot load desired resource from protobuf struct", "error", err, "failures", op.Status.Failures, "resource-name", name)
+			err = errors.Wrapf(err, "cannot load desired resource %q from protobuf struct", name)
+			r.record.Event(op, event.Warning(reasonInvalidResource, err))
+			status.MarkConditions(xpv1.ReconcileError(err))
+			_ = r.client.Status().Update(ctx, op)
+
+			return reconcile.Result{}, err
+		}
+
+		// TODO(negz): Do we really want to force ownership? We'll
+		// always be operating on a resource some other controller owns.
+		// TODO(negz): Do we ever want to be an owner reference of these
+		// resources?
+		if err := r.client.Patch(ctx, u, client.Apply, client.ForceOwnership, client.FieldOwner(FieldOwnerPrefix+op.GetUID())); err != nil {
+			op.Status.Failures++
+			log.Debug("Cannot apply desired resource", "error", err, "failures", op.Status.Failures, "resource-name", name)
+
+			err = errors.Wrap(err, "cannot load desired resource")
+			r.record.Event(op, event.Warning(reasonInvalidResource, err))
+			status.MarkConditions(xpv1.ReconcileError(err))
+			_ = r.client.Status().Update(ctx, op)
+
+			return reconcile.Result{}, err
+		}
+
+		// TODO(negz): A pipeline could overflow this if it returned
+		// hundreds of desired resources. We could switch to a plain
+		// count, but it's pretty useful to know what resources an
+		// Operation applied...
+		op.Status.AppliedResourceRefs = AddResourceRef(op.Status.AppliedResourceRefs, u)
+	}
+
+	status.MarkConditions(xpv1.ReconcileSuccess(), v1alpha1.Complete())
+
+	return reconcile.Result{}, errors.Wrap(r.client.Status().Update(ctx, op), "cannot update Operation status")
+}
+
+// AddResourceRef adds a reference to the supplied resource to supplied
+// references. It only adds resources that aren't already referenced, and keeps
+// the references sorted.
+func AddResourceRef(refs []v1alpha1.AppliedResourceRef, u *kunstructured.Unstructured) []v1alpha1.AppliedResourceRef {
+	ref := v1alpha1.AppliedResourceRef{
+		APIVersion: u.GetAPIVersion(),
+		Kind:       u.GetKind(),
+		Name:       u.GetName(),
+	}
+	if u.GetNamespace() != "" {
+		ref.Namespace = ptr.To(u.GetNamespace())
+	}
+
+	// Don't add the new ref if it's already there.
+	for _, existing := range refs {
+		if existing.Equals(ref) {
+			return refs
+		}
+	}
+
+	refs = append(refs, ref)
+
+	slices.SortStableFunc(refs, func(a, b v1alpha1.AppliedResourceRef) int {
+		sa := a.APIVersion + a.Kind + ptr.Deref(a.Namespace, "") + a.Name
+		sb := b.APIVersion + b.Kind + ptr.Deref(b.Namespace, "") + b.Name
+
+		if sa == sb {
+			return 0
+		}
+
+		if sa > sb {
+			return 1
+		}
+
+		return -1
+	})
+
+	return refs
+}
+
+// AddPipelineStepOutput updates the output for a pipeline step in the
+// supplied pipeline status slice. If the step already exists, its output is
+// updated in place. If it doesn't exist, it's appended to the slice. The input
+// slice is assumed to be sorted by step name.
+func AddPipelineStepOutput(pipeline []v1alpha1.PipelineStepStatus, step string, output *runtime.RawExtension) []v1alpha1.PipelineStepStatus {
+	for i, ps := range pipeline {
+		if ps.Step == step {
+			pipeline[i].Output = output
+			return pipeline
+		}
+	}
+
+	return append(pipeline, v1alpha1.PipelineStepStatus{
+		Step:   step,
+		Output: output,
+	})
+}
diff --git a/internal/controller/ops/operation/watch.go b/internal/controller/ops/operation/watch.go
new file mode 100644
index 000000000..ff40b285e
--- /dev/null
+++ b/internal/controller/ops/operation/watch.go
@@ -0,0 +1,79 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package operation
+
+import (
+	"context"
+
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/handler"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	"github.com/crossplane/crossplane-runtime/pkg/logging"
+
+	"github.com/crossplane/crossplane/apis/ops/v1alpha1"
+	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
+)
+
+// EnqueueOperationsForFunctionRevision enqueues a reconcile for all Operations
+// that reference a Function when a FunctionRevision changes.
+func EnqueueOperationsForFunctionRevision(kube client.Reader, log logging.Logger) handler.EventHandler {
+	return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request {
+		fr, ok := o.(*pkgv1.FunctionRevision)
+		if !ok {
+			return nil
+		}
+
+		// Get the function name from the revision's parent package label
+		name := fr.GetLabels()[pkgv1.LabelParentPackage]
+		if name == "" {
+			log.Debug("FunctionRevision has no parent package label", "revision", fr.GetName())
+			return nil
+		}
+
+		// List all Operations to find those that reference this function
+		ops := &v1alpha1.OperationList{}
+		if err := kube.List(ctx, ops); err != nil {
+			log.Debug("Cannot list Operations while attempting to enqueue from FunctionRevision", "error", err)
+			return nil
+		}
+
+		var matches []reconcile.Request
+		for _, op := range ops.Items {
+			for _, fn := range op.Spec.Pipeline {
+				if fn.FunctionRef.Name != name {
+					continue
+				}
+
+				log.Debug("Enqueuing Operation for FunctionRevision change",
+					"operation", op.GetName(),
+					"function-revision", fr.GetName(),
+					"function", name)
+				matches = append(matches, reconcile.Request{NamespacedName: types.NamespacedName{
+					Namespace: op.GetNamespace(),
+					Name:      op.GetName(),
+				}})
+
+				// Functions are unique within the pipeline.
+				break
+			}
+		}
+
+		return matches
+	})
+}
diff --git a/internal/controller/ops/ops.go b/internal/controller/ops/ops.go
new file mode 100644
index 000000000..4ecad2b85
--- /dev/null
+++ b/internal/controller/ops/ops.go
@@ -0,0 +1,30 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package ops implements the Crossplane Operation controllers.
+package ops
+
+import (
+	ctrl "sigs.k8s.io/controller-runtime"
+
+	"github.com/crossplane/crossplane/internal/controller/ops/controller"
+	"github.com/crossplane/crossplane/internal/controller/ops/operation"
+)
+
+// Setup API extensions controllers.
+func Setup(mgr ctrl.Manager, o controller.Options) error {
+	return operation.Setup(mgr, o)
+}
diff --git a/internal/controller/pkg/revision/reconciler.go b/internal/controller/pkg/revision/reconciler.go
index 17f4e1576..bd3e9958d 100644
--- a/internal/controller/pkg/revision/reconciler.go
+++ b/internal/controller/pkg/revision/reconciler.go
@@ -833,6 +833,9 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
 		return reconcile.Result{}, err
 	}
 
+	// Copy the capabilities from the metadata to the revision.
+	pr.SetCapabilities(pkgMeta.GetCapabilities())
+
 	// Check Crossplane constraints if they exist.
 	if pr.GetIgnoreCrossplaneConstraints() == nil || !*pr.GetIgnoreCrossplaneConstraints() {
 		if err := xpkg.PackageCrossplaneCompatible(r.versioner)(pkgMeta); err != nil {
diff --git a/internal/features/features.go b/internal/features/features.go
index a130f6761..017ea1999 100644
--- a/internal/features/features.go
+++ b/internal/features/features.go
@@ -38,6 +38,10 @@ const (
 	// EnableAlphaFunctionResponseCache enables alpha support for caching
 	// composition function responses.
 	EnableAlphaFunctionResponseCache feature.Flag = "EnableAlphaFunctionResponseCache"
+
+	// EnableAlphaOperations enables alpha support for Operations, including
+	// CronOperations and WatchOperations.
+	EnableAlphaOperations feature.Flag = "EnableAlphaOperations"
 )
 
 // Beta Feature Flags.
diff --git a/internal/xfn/cached/cached_runner.go b/internal/xfn/cached/cached_runner.go
index 6157b05fa..622cd1f29 100644
--- a/internal/xfn/cached/cached_runner.go
+++ b/internal/xfn/cached/cached_runner.go
@@ -29,8 +29,8 @@ import (
 	"github.com/crossplane/crossplane-runtime/pkg/errors"
 	"github.com/crossplane/crossplane-runtime/pkg/logging"
 
-	fnv1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1"
 	"github.com/crossplane/crossplane/internal/xfn/cached/proto/v1alpha1"
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
 )
 
 // A CacheMissReason indicates what caused a cache miss.
diff --git a/internal/xfn/cached/proto/v1alpha1/cached_response.pb.go b/internal/xfn/cached/proto/v1alpha1/cached_response.pb.go
index c2e4cf8e0..f828bea1d 100644
--- a/internal/xfn/cached/proto/v1alpha1/cached_response.pb.go
+++ b/internal/xfn/cached/proto/v1alpha1/cached_response.pb.go
@@ -22,7 +22,7 @@
 package v1alpha1
 
 import (
-	v1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1"
+	v1 "github.com/crossplane/crossplane/proto/fn/v1"
 	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
 	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
 	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
@@ -98,7 +98,7 @@ var File_internal_xfn_cached_proto_v1alpha1_cached_response_proto protoreflect.F
 
 const file_internal_xfn_cached_proto_v1alpha1_cached_response_proto_rawDesc = "" +
 	"\n" +
-	"8internal/xfn/cached/proto/v1alpha1/cached_response.proto\x12\"internal.xfn.cached.proto.v1alpha1\x1a1apis/apiextensions/fn/proto/v1/run_function.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x9f\x01\n" +
+	"8internal/xfn/cached/proto/v1alpha1/cached_response.proto\x12\"internal.xfn.cached.proto.v1alpha1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1eproto/fn/v1/run_function.proto\"\x9f\x01\n" +
 	"\x19CachedRunFunctionResponse\x126\n" +
 	"\bdeadline\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\bdeadline\x12J\n" +
 	"\bresponse\x18\x02 \x01(\v2..apiextensions.fn.proto.v1.RunFunctionResponseR\bresponseBEZCgithub.com/crossplane/crossplane/internal/xfn/cached/proto/v1alpha1b\x06proto3"
diff --git a/internal/xfn/cached/proto/v1alpha1/cached_response.proto b/internal/xfn/cached/proto/v1alpha1/cached_response.proto
index e19b88f71..2e3ec6abe 100644
--- a/internal/xfn/cached/proto/v1alpha1/cached_response.proto
+++ b/internal/xfn/cached/proto/v1alpha1/cached_response.proto
@@ -18,8 +18,8 @@ syntax = "proto3";
 
 package internal.xfn.cached.proto.v1alpha1;
 
-import "apis/apiextensions/fn/proto/v1/run_function.proto";
 import "google/protobuf/timestamp.proto";
+import "proto/fn/v1/run_function.proto";
 
 option go_package = "github.com/crossplane/crossplane/internal/xfn/cached/proto/v1alpha1";
 
diff --git a/internal/xfn/capabilities.go b/internal/xfn/capabilities.go
new file mode 100644
index 000000000..74596bfc5
--- /dev/null
+++ b/internal/xfn/capabilities.go
@@ -0,0 +1,97 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
+*/
+
+package xfn
+
+import (
+	"context"
+	"strings"
+
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+
+	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
+)
+
+// A CapabilityChecker checks whether the named functions have all the required
+// capabilities. It returns an error if any function is missing any capability.
+type CapabilityChecker interface {
+	CheckCapabilities(ctx context.Context, caps []string, names ...string) error
+}
+
+// A CapabilityCheckerFn is a function that can check capabilities.
+type CapabilityCheckerFn func(ctx context.Context, caps []string, names ...string) error
+
+// CheckCapabilities checks the capabilities of the named functions.
+func (fn CapabilityCheckerFn) CheckCapabilities(ctx context.Context, caps []string, names ...string) error {
+	return fn(ctx, caps, names...)
+}
+
+// A RevisionCapabilityChecker is a CapabilityChecker that uses FunctionRevisions
+// to check capabilities.
+type RevisionCapabilityChecker struct {
+	client client.Reader
+}
+
+// NewRevisionCapabilityChecker returns a new RevisionCapabilityChecker.
+func NewRevisionCapabilityChecker(c client.Reader) *RevisionCapabilityChecker {
+	return &RevisionCapabilityChecker{client: c}
+}
+
+// CheckCapabilities returns nil if all the named functions have all the
+// required capabilities.
+func (c *RevisionCapabilityChecker) CheckCapabilities(ctx context.Context, caps []string, names ...string) error {
+	l := &pkgv1.FunctionRevisionList{}
+	if err := c.client.List(ctx, l); err != nil {
+		return errors.Wrap(err, "cannot list FunctionRevisions")
+	}
+
+	check := map[string]bool{}
+	for _, name := range names {
+		check[name] = true
+	}
+
+	for _, rev := range l.Items {
+		// We only want to check the active revision.
+		if rev.Spec.DesiredState != pkgv1.PackageRevisionActive {
+			continue
+		}
+
+		pkgName := rev.GetLabels()[pkgv1.LabelParentPackage]
+		// We only want to check revisions of the named packages.
+		if !check[pkgName] {
+			continue
+		}
+
+		has := map[string]bool{}
+		for _, cap := range rev.GetCapabilities() {
+			has[cap] = true
+		}
+
+		missing := make([]string, 0)
+		for _, cap := range caps {
+			if !has[cap] {
+				missing = append(missing, cap)
+			}
+		}
+
+		if len(missing) > 0 {
+			return errors.Errorf("function %q (active revision %q) is missing required capabilities: %s", pkgName, rev.GetName(), strings.Join(missing, ", "))
+		}
+	}
+
+	return nil
+}
diff --git a/internal/xfn/function_runner.go b/internal/xfn/function_runner.go
index 801084d2d..ade4a0e37 100644
--- a/internal/xfn/function_runner.go
+++ b/internal/xfn/function_runner.go
@@ -22,7 +22,6 @@ import (
 	"sync"
 	"time"
 
-	"github.com/pkg/errors"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/credentials"
@@ -31,11 +30,12 @@ import (
 	"google.golang.org/protobuf/proto"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
 	"github.com/crossplane/crossplane-runtime/pkg/logging"
 
-	fnv1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1"
-	fnv1beta1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1beta1"
 	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
+	fnv1beta1 "github.com/crossplane/crossplane/proto/fn/v1beta1"
 )
 
 // Error strings.
@@ -79,6 +79,14 @@ type FunctionRunner interface {
 	RunFunction(ctx context.Context, name string, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error)
 }
 
+// A FunctionRunnerFn is a function that can run a Composition Function.
+type FunctionRunnerFn func(ctx context.Context, name string, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error)
+
+// RunFunction runs the named Composition Function with the supplied request.
+func (fn FunctionRunnerFn) RunFunction(ctx context.Context, name string, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) {
+	return fn(ctx, name, req)
+}
+
 // A PackagedFunctionRunner runs a Function by making a gRPC call to a Function
 // package's runtime. It creates a gRPC client connection for each Function. The
 // Function's endpoint is determined by reading the status.endpoint of the
diff --git a/internal/xfn/function_runner_metrics.go b/internal/xfn/function_runner_metrics.go
index 1bcd41ca7..c16ca9a35 100644
--- a/internal/xfn/function_runner_metrics.go
+++ b/internal/xfn/function_runner_metrics.go
@@ -23,7 +23,7 @@ import (
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/status"
 
-	fnv1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1"
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
 )
 
 // PrometheusMetrics are requests, errors, and duration (RED) metrics for composition
diff --git a/internal/controller/apiextensions/composite/extra_resources.go b/internal/xfn/required_resources.go
similarity index 64%
rename from internal/controller/apiextensions/composite/extra_resources.go
rename to internal/xfn/required_resources.go
index b56c58406..5e61c0d1a 100644
--- a/internal/controller/apiextensions/composite/extra_resources.go
+++ b/internal/xfn/required_resources.go
@@ -13,7 +13,7 @@ CONDITIONS OF ANY KIND, either express or implied. See the License for the
 specific language governing permissions and limitations under the License.
 */
 
-package composite
+package xfn
 
 import (
 	"context"
@@ -26,28 +26,41 @@ import (
 
 	"github.com/crossplane/crossplane-runtime/pkg/errors"
 
-	fnv1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1"
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
 )
 
 // MaxRequirementsIterations is the maximum number of times a Function should be
-// called, limiting the number of times it can request for extra resources,
+// called, limiting the number of times it can request for required resources,
 // capped for safety.
 const MaxRequirementsIterations = 5
 
-// A FetchingFunctionRunner wraps an underlying FunctionRunner, adding support
-// for fetching any extra resources requested by the function it runs.
+// An RequiredResourcesFetcher gets required resources matching a selector.
+type RequiredResourcesFetcher interface {
+	Fetch(ctx context.Context, rs *fnv1.ResourceSelector) (*fnv1.Resources, error)
+}
+
+// An RequiredResourcesFetcherFn gets required resources matching the selector.
+type RequiredResourcesFetcherFn func(ctx context.Context, rs *fnv1.ResourceSelector) (*fnv1.Resources, error)
+
+// Fetch gets required resources matching the selector.
+func (fn RequiredResourcesFetcherFn) Fetch(ctx context.Context, rs *fnv1.ResourceSelector) (*fnv1.Resources, error) {
+	return fn(ctx, rs)
+}
+
+// A FetchingFunctionRunner wraps an underlyin FunctionRunner, adding support
+// for fetching any required resources requested by the function it runs.
 type FetchingFunctionRunner struct {
 	wrapped   FunctionRunner
-	resources ExtraResourcesFetcher
+	resources RequiredResourcesFetcher
 }
 
 // NewFetchingFunctionRunner returns a FunctionRunner that supports fetching
-// extra resources.
-func NewFetchingFunctionRunner(r FunctionRunner, f ExtraResourcesFetcher) *FetchingFunctionRunner {
+// required resources.
+func NewFetchingFunctionRunner(r FunctionRunner, f RequiredResourcesFetcher) *FetchingFunctionRunner {
 	return &FetchingFunctionRunner{wrapped: r, resources: f}
 }
 
-// RunFunction runs a function, repeatedly fetching any extra resources it asks
+// RunFunction runs a function, repeatedly fetching any required resources it asks
 // for. The function may be run up to MaxRequirementsIterations times.
 func (c *FetchingFunctionRunner) RunFunction(ctx context.Context, name string, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) {
 	// Used to store the requirements returned at the previous iteration.
@@ -76,18 +89,18 @@ func (c *FetchingFunctionRunner) RunFunction(ctx context.Context, name string, r
 		// Store the requirements for the next iteration.
 		requirements = newRequirements
 
-		// Cleanup the extra resources from the previous iteration to store the new ones
-		req.ExtraResources = make(map[string]*fnv1.Resources)
+		// Clean up the required resources from the previous iteration to store the new ones
+		req.RequiredResources = make(map[string]*fnv1.Resources)
 
 		// Fetch the requested resources and add them to the desired state.
-		for name, selector := range newRequirements.GetExtraResources() {
+		for name, selector := range newRequirements.GetResources() {
 			resources, err := c.resources.Fetch(ctx, selector)
 			if err != nil {
 				return nil, errors.Wrapf(err, "fetching resources for %s", name)
 			}
 
 			// Resources would be nil in case of not found resources.
-			req.ExtraResources[name] = resources
+			req.RequiredResources[name] = resources
 		}
 
 		// Pass down the updated context across iterations.
@@ -97,21 +110,21 @@ func (c *FetchingFunctionRunner) RunFunction(ctx context.Context, name string, r
 	return nil, errors.Errorf("requirements didn't stabilize after the maximum number of iterations (%d)", MaxRequirementsIterations)
 }
 
-// ExistingExtraResourcesFetcher fetches extra resources requested by
+// ExistingRequiredResourcesFetcher fetches required resources requested by
 // functions using the provided client.Reader.
-type ExistingExtraResourcesFetcher struct {
+type ExistingRequiredResourcesFetcher struct {
 	client client.Reader
 }
 
-// NewExistingExtraResourcesFetcher returns a new ExistingExtraResourcesFetcher.
-func NewExistingExtraResourcesFetcher(c client.Reader) *ExistingExtraResourcesFetcher {
-	return &ExistingExtraResourcesFetcher{client: c}
+// NewExistingRequiredResourcesFetcher returns a new ExistingRequiredResourcesFetcher.
+func NewExistingRequiredResourcesFetcher(c client.Reader) *ExistingRequiredResourcesFetcher {
+	return &ExistingRequiredResourcesFetcher{client: c}
 }
 
 // Fetch fetches resources requested by functions using the provided client.Reader.
-func (e *ExistingExtraResourcesFetcher) Fetch(ctx context.Context, rs *fnv1.ResourceSelector) (*fnv1.Resources, error) {
+func (e *ExistingRequiredResourcesFetcher) Fetch(ctx context.Context, rs *fnv1.ResourceSelector) (*fnv1.Resources, error) {
 	if rs == nil {
-		return nil, errors.New(errNilResourceSelector)
+		return nil, errors.New("you must specify a resource selector")
 	}
 
 	switch match := rs.GetMatch().(type) {
@@ -130,12 +143,12 @@ func (e *ExistingExtraResourcesFetcher) Fetch(ctx context.Context, rs *fnv1.Reso
 		}
 
 		if err != nil {
-			return nil, errors.Wrap(err, errGetExtraResourceByName)
+			return nil, errors.Wrap(err, "cannot get required resource by name")
 		}
 
 		o, err := AsStruct(r)
 		if err != nil {
-			return nil, errors.Wrap(err, errExtraResourceAsStruct)
+			return nil, errors.Wrap(err, "cannot encode required resource to protobuf Struct")
 		}
 
 		return &fnv1.Resources{Items: []*fnv1.Resource{{Resource: o}}}, nil
@@ -146,14 +159,14 @@ func (e *ExistingExtraResourcesFetcher) Fetch(ctx context.Context, rs *fnv1.Reso
 		list.SetKind(rs.GetKind())
 		// If namespace is empty client.InNamespace will have no effect.
 		if err := e.client.List(ctx, list, client.MatchingLabels(match.MatchLabels.GetLabels()), client.InNamespace(rs.GetNamespace())); err != nil {
-			return nil, errors.Wrap(err, errListExtraResources)
+			return nil, errors.Wrap(err, "cannot list required resources")
 		}
 
 		resources := make([]*fnv1.Resource, len(list.Items))
 		for i, r := range list.Items {
 			o, err := AsStruct(&r)
 			if err != nil {
-				return nil, errors.Wrap(err, errExtraResourceAsStruct)
+				return nil, errors.Wrap(err, "cannot encode required resource to protobuf Struct")
 			}
 
 			resources[i] = &fnv1.Resource{Resource: o}
@@ -162,5 +175,5 @@ func (e *ExistingExtraResourcesFetcher) Fetch(ctx context.Context, rs *fnv1.Reso
 		return &fnv1.Resources{Items: resources}, nil
 	}
 
-	return nil, errors.New(errUnknownResourceSelector)
+	return nil, errors.Errorf("unsupported required resource selector type %T", rs.GetMatch())
 }
diff --git a/internal/xfn/utils.go b/internal/xfn/utils.go
new file mode 100644
index 000000000..855ba1589
--- /dev/null
+++ b/internal/xfn/utils.go
@@ -0,0 +1,99 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
+*/
+
+package xfn
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+
+	"google.golang.org/protobuf/encoding/protojson"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/structpb"
+	kunstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+	"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured"
+
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
+)
+
+// Tag uniquely identifies a request. Two identical requests created by the
+// same Crossplane binary will produce identical tags. Different builds of
+// Crossplane may produce different tags for the same inputs. See the docs for
+// the Deterministic protobuf MarshalOption for more details.
+func Tag(req *fnv1.RunFunctionRequest) string {
+	m := proto.MarshalOptions{Deterministic: true}
+
+	b, err := m.Marshal(req)
+	if err != nil {
+		return ""
+	}
+
+	h := sha256.Sum256(b)
+
+	return hex.EncodeToString(h[:])
+}
+
+// AsStruct converts the supplied object to a protocol buffer Struct well-known
+// type.
+func AsStruct(o runtime.Object) (*structpb.Struct, error) {
+	// If the supplied object is *Unstructured we don't need to round-trip.
+	if u, ok := o.(*kunstructured.Unstructured); ok {
+		s, err := structpb.NewStruct(u.Object)
+		return s, errors.Wrap(err, "cannot create protobuf Struct")
+	}
+
+	// If the supplied object wraps *Unstructured we don't need to round-trip.
+	if w, ok := o.(unstructured.Wrapper); ok {
+		s, err := structpb.NewStruct(w.GetUnstructured().Object)
+		return s, errors.Wrap(err, "cannot create protobuf Struct")
+	}
+
+	// Fall back to a JSON round-trip.
+	b, err := json.Marshal(o)
+	if err != nil {
+		return nil, errors.Wrap(err, "cannot marshal object to JSON")
+	}
+
+	s := &structpb.Struct{}
+
+	return s, errors.Wrap(s.UnmarshalJSON(b), "cannot unmarshal object from JSON")
+}
+
+// FromStruct populates the supplied object with content loaded from the Struct.
+func FromStruct(o runtime.Object, s *structpb.Struct) error {
+	// If the supplied object is *Unstructured we don't need to round-trip.
+	if u, ok := o.(*kunstructured.Unstructured); ok {
+		u.Object = s.AsMap()
+		return nil
+	}
+
+	// If the supplied object wraps *Unstructured we don't need to round-trip.
+	if w, ok := o.(unstructured.Wrapper); ok {
+		w.GetUnstructured().Object = s.AsMap()
+		return nil
+	}
+
+	// Fall back to a JSON round-trip.
+	b, err := protojson.Marshal(s)
+	if err != nil {
+		return errors.Wrap(err, "cannot marshal protobuf Struct to JSON")
+	}
+
+	return errors.Wrap(json.Unmarshal(b, o), "cannot unmarshal JSON to object")
+}
diff --git a/internal/xpkg/lint.go b/internal/xpkg/lint.go
index d8ab9fb03..07f58e75a 100644
--- a/internal/xpkg/lint.go
+++ b/internal/xpkg/lint.go
@@ -28,6 +28,7 @@ import (
 
 	v1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
 	"github.com/crossplane/crossplane/apis/apiextensions/v2alpha1"
+	"github.com/crossplane/crossplane/apis/ops/v1alpha1"
 	pkgmetav1 "github.com/crossplane/crossplane/apis/pkg/meta/v1"
 	"github.com/crossplane/crossplane/internal/version"
 )
@@ -43,6 +44,7 @@ const (
 	errNotMutatingWebhookConfiguration   = "object is not a MutatingWebhookConfiguration"
 	errNotValidatingWebhookConfiguration = "object is not an ValidatingWebhookConfiguration"
 	errNotComposition                    = "object is not a Composition"
+	errNotOperation                      = "object is not an Operation"
 	errBadConstraints                    = "package version constraints are poorly formatted"
 	errFmtCrossplaneIncompatible         = "package is not compatible with Crossplane version (%s)"
 )
@@ -50,7 +52,9 @@ const (
 // NewProviderLinter is a convenience function for creating a package linter for
 // providers.
 func NewProviderLinter() parser.Linter {
-	return parser.NewPackageLinter(parser.PackageLinterFns(OneMeta), parser.ObjectLinterFns(IsProvider, PackageValidSemver),
+	return parser.NewPackageLinter(
+		parser.PackageLinterFns(OneMeta),
+		parser.ObjectLinterFns(IsProvider, PackageValidSemver),
 		parser.ObjectLinterFns(parser.Or(
 			IsCRD,
 			IsValidatingWebhookConfiguration,
@@ -61,13 +65,19 @@ func NewProviderLinter() parser.Linter {
 // NewConfigurationLinter is a convenience function for creating a package linter for
 // configurations.
 func NewConfigurationLinter() parser.Linter {
-	return parser.NewPackageLinter(parser.PackageLinterFns(OneMeta), parser.ObjectLinterFns(IsConfiguration, PackageValidSemver), parser.ObjectLinterFns(parser.Or(IsXRD, IsComposition)))
+	return parser.NewPackageLinter(
+		parser.PackageLinterFns(OneMeta),
+		parser.ObjectLinterFns(IsConfiguration, PackageValidSemver),
+		parser.ObjectLinterFns(parser.Or(IsXRD, IsComposition, IsOperation)))
 }
 
 // NewFunctionLinter is a convenience function for creating a package linter for
 // functions.
 func NewFunctionLinter() parser.Linter {
-	return parser.NewPackageLinter(parser.PackageLinterFns(OneMeta), parser.ObjectLinterFns(IsFunction, PackageValidSemver), parser.ObjectLinterFns())
+	return parser.NewPackageLinter(
+		parser.PackageLinterFns(OneMeta),
+		parser.ObjectLinterFns(IsFunction, PackageValidSemver),
+		parser.ObjectLinterFns())
 }
 
 // OneMeta checks that there is only one meta object in the package.
@@ -199,3 +209,12 @@ func IsComposition(o runtime.Object) error {
 
 	return nil
 }
+
+// IsOperation checks that an object is an Operation.
+func IsOperation(o runtime.Object) error {
+	if _, ok := o.(*v1alpha1.Operation); !ok {
+		return errors.New(errNotOperation)
+	}
+
+	return nil
+}
diff --git a/apis/apiextensions/fn/proto/v1/run_function.pb.go b/proto/fn/v1/run_function.pb.go
similarity index 70%
rename from apis/apiextensions/fn/proto/v1/run_function.pb.go
rename to proto/fn/v1/run_function.pb.go
index 162eca0f3..7b69a648d 100644
--- a/apis/apiextensions/fn/proto/v1/run_function.pb.go
+++ b/proto/fn/v1/run_function.pb.go
@@ -17,9 +17,14 @@
 // versions:
 // 	protoc-gen-go v1.36.6
 // 	protoc        (unknown)
-// source: apis/apiextensions/fn/proto/v1/run_function.proto
+// source: proto/fn/v1/run_function.proto
 
-//buf:lint:ignore PACKAGE_DIRECTORY_MATCH // Too late to fix this now.
+// This package defines the RPC for both composition and operation functions.
+// Only composition functions are part of the 'apiextensions' API group. In
+// retrospect this package should've been crossplane.proto.fn.v1, but it's too
+// late to change it now.
+
+//buf:lint:ignore PACKAGE_DIRECTORY_MATCH
 
 package v1
 
@@ -40,14 +45,14 @@ const (
 	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
 )
 
-// Ready indicates whether a composed resource should be considered ready.
+// Ready indicates whether a resource should be considered ready.
 type Ready int32
 
 const (
 	Ready_READY_UNSPECIFIED Ready = 0
-	// True means the composed resource has been observed to be ready.
+	// True means the resource has been observed to be ready.
 	Ready_READY_TRUE Ready = 1
-	// False means the composed resource has not been observed to be ready.
+	// False means the resource has not been observed to be ready.
 	Ready_READY_FALSE Ready = 2
 )
 
@@ -76,11 +81,11 @@ func (x Ready) String() string {
 }
 
 func (Ready) Descriptor() protoreflect.EnumDescriptor {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_enumTypes[0].Descriptor()
+	return file_proto_fn_v1_run_function_proto_enumTypes[0].Descriptor()
 }
 
 func (Ready) Type() protoreflect.EnumType {
-	return &file_apis_apiextensions_fn_proto_v1_run_function_proto_enumTypes[0]
+	return &file_proto_fn_v1_run_function_proto_enumTypes[0]
 }
 
 func (x Ready) Number() protoreflect.EnumNumber {
@@ -89,24 +94,24 @@ func (x Ready) Number() protoreflect.EnumNumber {
 
 // Deprecated: Use Ready.Descriptor instead.
 func (Ready) EnumDescriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{0}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{0}
 }
 
-// Severity of Function results.
+// Severity of function results.
 type Severity int32
 
 const (
 	Severity_SEVERITY_UNSPECIFIED Severity = 0
-	// Fatal results are fatal; subsequent Composition Functions may run, but
-	// the Composition Function pipeline run will be considered a failure and
-	// the first fatal result will be returned as an error.
+	// Fatal results are fatal; subsequent functions may run, but the function
+	// pipeline run will be considered a failure and the first fatal result will
+	// be returned as an error.
 	Severity_SEVERITY_FATAL Severity = 1
-	// Warning results are non-fatal; the entire Composition will run to
-	// completion but warning events and debug logs associated with the
-	// composite resource will be emitted.
+	// Warning results are non-fatal; the entire pipeline will run to completion
+	// but warning events and debug logs associated with the XR or Operation will
+	// be emitted.
 	Severity_SEVERITY_WARNING Severity = 2
-	// Normal results are emitted as normal events and debug logs associated
-	// with the composite resource.
+	// Normal results are emitted as normal events and debug logs associated with
+	// the XR or operation.
 	Severity_SEVERITY_NORMAL Severity = 3
 )
 
@@ -137,11 +142,11 @@ func (x Severity) String() string {
 }
 
 func (Severity) Descriptor() protoreflect.EnumDescriptor {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_enumTypes[1].Descriptor()
+	return file_proto_fn_v1_run_function_proto_enumTypes[1].Descriptor()
 }
 
 func (Severity) Type() protoreflect.EnumType {
-	return &file_apis_apiextensions_fn_proto_v1_run_function_proto_enumTypes[1]
+	return &file_proto_fn_v1_run_function_proto_enumTypes[1]
 }
 
 func (x Severity) Number() protoreflect.EnumNumber {
@@ -150,20 +155,20 @@ func (x Severity) Number() protoreflect.EnumNumber {
 
 // Deprecated: Use Severity.Descriptor instead.
 func (Severity) EnumDescriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{1}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{1}
 }
 
-// Target of Function results and conditions.
+// Target of function results and conditions.
 type Target int32
 
 const (
-	// If the target is unspecified, the result targets the composite resource.
+	// If the target is unspecified, the result targets the XR.
 	Target_TARGET_UNSPECIFIED Target = 0
-	// Target the composite resource. Results that target the composite resource
-	// should include detailed, advanced information.
+	// Target the XR. Results that target the XR should include detailed, advanced
+	// information.
 	Target_TARGET_COMPOSITE Target = 1
-	// Target the composite and the claim. Results that target the composite and
-	// the claim should include only end-user friendly information.
+	// Target the XR and the claim. Results that target the XR and the claim
+	// should include only end-user friendly information.
 	Target_TARGET_COMPOSITE_AND_CLAIM Target = 2
 )
 
@@ -192,11 +197,11 @@ func (x Target) String() string {
 }
 
 func (Target) Descriptor() protoreflect.EnumDescriptor {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_enumTypes[2].Descriptor()
+	return file_proto_fn_v1_run_function_proto_enumTypes[2].Descriptor()
 }
 
 func (Target) Type() protoreflect.EnumType {
-	return &file_apis_apiextensions_fn_proto_v1_run_function_proto_enumTypes[2]
+	return &file_proto_fn_v1_run_function_proto_enumTypes[2]
 }
 
 func (x Target) Number() protoreflect.EnumNumber {
@@ -205,7 +210,7 @@ func (x Target) Number() protoreflect.EnumNumber {
 
 // Deprecated: Use Target.Descriptor instead.
 func (Target) EnumDescriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{2}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{2}
 }
 
 type Status int32
@@ -244,11 +249,11 @@ func (x Status) String() string {
 }
 
 func (Status) Descriptor() protoreflect.EnumDescriptor {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_enumTypes[3].Descriptor()
+	return file_proto_fn_v1_run_function_proto_enumTypes[3].Descriptor()
 }
 
 func (Status) Type() protoreflect.EnumType {
-	return &file_apis_apiextensions_fn_proto_v1_run_function_proto_enumTypes[3]
+	return &file_proto_fn_v1_run_function_proto_enumTypes[3]
 }
 
 func (x Status) Number() protoreflect.EnumNumber {
@@ -257,45 +262,45 @@ func (x Status) Number() protoreflect.EnumNumber {
 
 // Deprecated: Use Status.Descriptor instead.
 func (Status) EnumDescriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{3}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{3}
 }
 
-// A RunFunctionRequest requests that the Composition Function be run.
+// A RunFunctionRequest requests that the function be run.
 type RunFunctionRequest struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
 	// Metadata pertaining to this request.
 	Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
-	// The observed state prior to invocation of a Function pipeline. State passed
-	// to each Function is fresh as of the time the pipeline was invoked, not as
-	// of the time each Function was invoked.
+	// The observed state prior to invocation of a function pipeline. State passed
+	// to each function is fresh as of the time the pipeline was invoked, not as
+	// of the time each function was invoked.
 	Observed *State `protobuf:"bytes,2,opt,name=observed,proto3" json:"observed,omitempty"`
-	// Desired state according to a Function pipeline. The state passed to a
-	// particular Function may have been accumulated by previous Functions in the
+	// Desired state according to a function pipeline. The state passed to a
+	// particular function may have been accumulated by previous functions in the
 	// pipeline.
 	//
 	// Note that the desired state must be a partial object with only the fields
-	// that this function (and its predecessors in the pipeline) wants to have
-	// set in the object. Copying a non-partial observed state to desired is most
+	// that this function (and its predecessors in the pipeline) wants to have set
+	// in the object. Copying a non-partial observed state to desired is most
 	// likely not what you want to do. Leaving out fields that had been returned
 	// as desired before will result in them being deleted from the objects in the
 	// cluster.
 	Desired *State `protobuf:"bytes,3,opt,name=desired,proto3" json:"desired,omitempty"`
-	// Optional input specific to this Function invocation. A JSON representation
-	// of the 'input' block of the relevant entry in a Composition's pipeline.
+	// Optional input specific to this function invocation. A JSON representation
+	// of the 'input' block of the relevant entry in a function pipeline.
 	Input *structpb.Struct `protobuf:"bytes,4,opt,name=input,proto3,oneof" json:"input,omitempty"`
 	// Optional context. Crossplane may pass arbitrary contextual information to a
-	// Function. A Function may also return context in its RunFunctionResponse,
-	// and that context will be passed to subsequent Functions. Crossplane
-	// discards all context returned by the last Function in the pipeline.
+	// function. A function may also return context in its RunFunctionResponse,
+	// and that context will be passed to subsequent functions. Crossplane
+	// discards all context returned by the last function in the pipeline.
 	Context *structpb.Struct `protobuf:"bytes,5,opt,name=context,proto3,oneof" json:"context,omitempty"`
-	// Optional extra resources that the Function required.
-	// Note that extra resources is a map to Resources, plural.
-	// The map key corresponds to the key in a RunFunctionResponse's
-	// extra_resources field. If a Function requested extra resources that
-	// did not exist, Crossplane sets the map key to an empty Resources message to
-	// indicate that it attempted to satisfy the request.
-	ExtraResources map[string]*Resources `protobuf:"bytes,6,rep,name=extra_resources,json=extraResources,proto3" json:"extra_resources,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
-	// Optional credentials that this Function may use to communicate with an
+	// Optional resources that the function specified in its requirements. Note
+	// that resources is a map to Resources, plural. The map key corresponds to
+	// the key in a RunFunctionResponse's requirements.resources field. If a
+	// function requested required resources that did not exist, Crossplane sets
+	// the map key to an empty Resources message to indicate that it attempted to
+	// satisfy the request.
+	RequiredResources map[string]*Resources `protobuf:"bytes,6,rep,name=required_resources,json=requiredResources,proto3" json:"required_resources,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
+	// Optional credentials that this function may use to communicate with an
 	// external system.
 	Credentials   map[string]*Credentials `protobuf:"bytes,7,rep,name=credentials,proto3" json:"credentials,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
 	unknownFields protoimpl.UnknownFields
@@ -304,7 +309,7 @@ type RunFunctionRequest struct {
 
 func (x *RunFunctionRequest) Reset() {
 	*x = RunFunctionRequest{}
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[0]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[0]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -316,7 +321,7 @@ func (x *RunFunctionRequest) String() string {
 func (*RunFunctionRequest) ProtoMessage() {}
 
 func (x *RunFunctionRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[0]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[0]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -329,7 +334,7 @@ func (x *RunFunctionRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RunFunctionRequest.ProtoReflect.Descriptor instead.
 func (*RunFunctionRequest) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{0}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{0}
 }
 
 func (x *RunFunctionRequest) GetMeta() *RequestMeta {
@@ -367,9 +372,9 @@ func (x *RunFunctionRequest) GetContext() *structpb.Struct {
 	return nil
 }
 
-func (x *RunFunctionRequest) GetExtraResources() map[string]*Resources {
+func (x *RunFunctionRequest) GetRequiredResources() map[string]*Resources {
 	if x != nil {
-		return x.ExtraResources
+		return x.RequiredResources
 	}
 	return nil
 }
@@ -381,7 +386,7 @@ func (x *RunFunctionRequest) GetCredentials() map[string]*Credentials {
 	return nil
 }
 
-// Credentials that a Function may use to communicate with an external system.
+// Credentials that a function may use to communicate with an external system.
 type Credentials struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
 	// Source of the credentials.
@@ -396,7 +401,7 @@ type Credentials struct {
 
 func (x *Credentials) Reset() {
 	*x = Credentials{}
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[1]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[1]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -408,7 +413,7 @@ func (x *Credentials) String() string {
 func (*Credentials) ProtoMessage() {}
 
 func (x *Credentials) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[1]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[1]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -421,7 +426,7 @@ func (x *Credentials) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Credentials.ProtoReflect.Descriptor instead.
 func (*Credentials) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{1}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{1}
 }
 
 func (x *Credentials) GetSource() isCredentials_Source {
@@ -461,7 +466,7 @@ type CredentialData struct {
 
 func (x *CredentialData) Reset() {
 	*x = CredentialData{}
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[2]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[2]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -473,7 +478,7 @@ func (x *CredentialData) String() string {
 func (*CredentialData) ProtoMessage() {}
 
 func (x *CredentialData) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[2]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[2]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -486,7 +491,7 @@ func (x *CredentialData) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use CredentialData.ProtoReflect.Descriptor instead.
 func (*CredentialData) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{2}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{2}
 }
 
 func (x *CredentialData) GetData() map[string][]byte {
@@ -506,7 +511,7 @@ type Resources struct {
 
 func (x *Resources) Reset() {
 	*x = Resources{}
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[3]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[3]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -518,7 +523,7 @@ func (x *Resources) String() string {
 func (*Resources) ProtoMessage() {}
 
 func (x *Resources) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[3]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[3]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -531,7 +536,7 @@ func (x *Resources) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Resources.ProtoReflect.Descriptor instead.
 func (*Resources) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{3}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{3}
 }
 
 func (x *Resources) GetItems() []*Resource {
@@ -541,40 +546,46 @@ func (x *Resources) GetItems() []*Resource {
 	return nil
 }
 
-// A RunFunctionResponse contains the result of a Composition Function run.
+// A RunFunctionResponse contains the result of a function run.
 type RunFunctionResponse struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
 	// Metadata pertaining to this response.
 	Meta *ResponseMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
-	// Desired state according to a Function pipeline. Functions may add desired
+	// Desired state according to a function pipeline. functions may add desired
 	// state, and may mutate or delete any part of the desired state they are
-	// concerned with. A Function must pass through any part of the desired state
+	// concerned with. A function must pass through any part of the desired state
 	// that it is not concerned with.
 	//
 	// Note that the desired state must be a partial object with only the fields
-	// that this function (and its predecessors in the pipeline) wants to have
-	// set in the object. Copying a non-partial observed state to desired is most
+	// that this function (and its predecessors in the pipeline) wants to have set
+	// in the object. Copying a non-partial observed state to desired is most
 	// likely not what you want to do. Leaving out fields that had been returned
 	// as desired before will result in them being deleted from the objects in the
 	// cluster.
 	Desired *State `protobuf:"bytes,2,opt,name=desired,proto3" json:"desired,omitempty"`
-	// Results of the Function run. Results are used for observability purposes.
+	// Results of the function run. Results are used for observability purposes.
 	Results []*Result `protobuf:"bytes,3,rep,name=results,proto3" json:"results,omitempty"`
-	// Optional context to be passed to the next Function in the pipeline as part
+	// Optional context to be passed to the next function in the pipeline as part
 	// of the RunFunctionRequest. Dropped on the last function in the pipeline.
 	Context *structpb.Struct `protobuf:"bytes,4,opt,name=context,proto3,oneof" json:"context,omitempty"`
-	// Requirements that must be satisfied for this Function to run successfully.
+	// Requirements that must be satisfied for this function to run successfully.
 	Requirements *Requirements `protobuf:"bytes,5,opt,name=requirements,proto3" json:"requirements,omitempty"`
-	// Status conditions to be applied to the composite resource. Conditions may also
-	// optionally be applied to the composite resource's associated claim.
-	Conditions    []*Condition `protobuf:"bytes,6,rep,name=conditions,proto3" json:"conditions,omitempty"`
+	// Status conditions to be applied to the XR. Conditions may also optionally
+	// be applied to the XR's associated claim.
+	//
+	// Conditions are only used for composition. They're ignored by Operations.
+	Conditions []*Condition `protobuf:"bytes,6,rep,name=conditions,proto3" json:"conditions,omitempty"`
+	// Optional output specific to this function invocation.
+	//
+	// Only Operations use function output. XRs will discard any function output.
+	Output        *structpb.Struct `protobuf:"bytes,7,opt,name=output,proto3,oneof" json:"output,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
 }
 
 func (x *RunFunctionResponse) Reset() {
 	*x = RunFunctionResponse{}
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[4]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[4]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -586,7 +597,7 @@ func (x *RunFunctionResponse) String() string {
 func (*RunFunctionResponse) ProtoMessage() {}
 
 func (x *RunFunctionResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[4]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[4]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -599,7 +610,7 @@ func (x *RunFunctionResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RunFunctionResponse.ProtoReflect.Descriptor instead.
 func (*RunFunctionResponse) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{4}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{4}
 }
 
 func (x *RunFunctionResponse) GetMeta() *ResponseMeta {
@@ -644,6 +655,13 @@ func (x *RunFunctionResponse) GetConditions() []*Condition {
 	return nil
 }
 
+func (x *RunFunctionResponse) GetOutput() *structpb.Struct {
+	if x != nil {
+		return x.Output
+	}
+	return nil
+}
+
 // RequestMeta contains metadata pertaining to a RunFunctionRequest.
 type RequestMeta struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
@@ -656,7 +674,7 @@ type RequestMeta struct {
 
 func (x *RequestMeta) Reset() {
 	*x = RequestMeta{}
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[5]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[5]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -668,7 +686,7 @@ func (x *RequestMeta) String() string {
 func (*RequestMeta) ProtoMessage() {}
 
 func (x *RequestMeta) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[5]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[5]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -681,7 +699,7 @@ func (x *RequestMeta) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RequestMeta.ProtoReflect.Descriptor instead.
 func (*RequestMeta) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{5}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{5}
 }
 
 func (x *RequestMeta) GetTag() string {
@@ -691,19 +709,19 @@ func (x *RequestMeta) GetTag() string {
 	return ""
 }
 
-// Requirements that must be satisfied for a Function to run successfully.
+// Requirements that must be satisfied for a function to run successfully.
 type Requirements struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
-	// Extra resources that this Function requires.
-	// The map key uniquely identifies the group of resources.
-	ExtraResources map[string]*ResourceSelector `protobuf:"bytes,1,rep,name=extra_resources,json=extraResources,proto3" json:"extra_resources,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
-	unknownFields  protoimpl.UnknownFields
-	sizeCache      protoimpl.SizeCache
+	// Resources that this function requires. The map key uniquely identifies the
+	// group of resources.
+	Resources     map[string]*ResourceSelector `protobuf:"bytes,1,rep,name=resources,proto3" json:"resources,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
 }
 
 func (x *Requirements) Reset() {
 	*x = Requirements{}
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[6]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[6]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -715,7 +733,7 @@ func (x *Requirements) String() string {
 func (*Requirements) ProtoMessage() {}
 
 func (x *Requirements) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[6]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[6]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -728,12 +746,12 @@ func (x *Requirements) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Requirements.ProtoReflect.Descriptor instead.
 func (*Requirements) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{6}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{6}
 }
 
-func (x *Requirements) GetExtraResources() map[string]*ResourceSelector {
+func (x *Requirements) GetResources() map[string]*ResourceSelector {
 	if x != nil {
-		return x.ExtraResources
+		return x.Resources
 	}
 	return nil
 }
@@ -752,9 +770,9 @@ type ResourceSelector struct {
 	//	*ResourceSelector_MatchName
 	//	*ResourceSelector_MatchLabels
 	Match isResourceSelector_Match `protobuf_oneof:"match"`
-	// Match resources in this namespace.
-	// Omit namespace to match cluster scoped resources, or to match namespaced
-	// resources by labels across all namespaces.
+	// Match resources in this namespace. Omit namespace to match cluster scoped
+	// resources, or to match namespaced resources by labels across all
+	// namespaces.
 	Namespace     *string `protobuf:"bytes,5,opt,name=namespace,proto3,oneof" json:"namespace,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
@@ -762,7 +780,7 @@ type ResourceSelector struct {
 
 func (x *ResourceSelector) Reset() {
 	*x = ResourceSelector{}
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[7]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[7]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -774,7 +792,7 @@ func (x *ResourceSelector) String() string {
 func (*ResourceSelector) ProtoMessage() {}
 
 func (x *ResourceSelector) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[7]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[7]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -787,7 +805,7 @@ func (x *ResourceSelector) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ResourceSelector.ProtoReflect.Descriptor instead.
 func (*ResourceSelector) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{7}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{7}
 }
 
 func (x *ResourceSelector) GetApiVersion() string {
@@ -864,7 +882,7 @@ type MatchLabels struct {
 
 func (x *MatchLabels) Reset() {
 	*x = MatchLabels{}
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[8]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[8]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -876,7 +894,7 @@ func (x *MatchLabels) String() string {
 func (*MatchLabels) ProtoMessage() {}
 
 func (x *MatchLabels) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[8]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[8]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -889,7 +907,7 @@ func (x *MatchLabels) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use MatchLabels.ProtoReflect.Descriptor instead.
 func (*MatchLabels) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{8}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{8}
 }
 
 func (x *MatchLabels) GetLabels() map[string]string {
@@ -915,7 +933,7 @@ type ResponseMeta struct {
 
 func (x *ResponseMeta) Reset() {
 	*x = ResponseMeta{}
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[9]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[9]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -927,7 +945,7 @@ func (x *ResponseMeta) String() string {
 func (*ResponseMeta) ProtoMessage() {}
 
 func (x *ResponseMeta) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[9]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[9]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -940,7 +958,7 @@ func (x *ResponseMeta) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ResponseMeta.ProtoReflect.Descriptor instead.
 func (*ResponseMeta) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{9}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{9}
 }
 
 func (x *ResponseMeta) GetTag() string {
@@ -957,12 +975,14 @@ func (x *ResponseMeta) GetTtl() *durationpb.Duration {
 	return nil
 }
 
-// State of the composite resource (XR) and any composed resources.
+// State of the XR (XR) and any resources.
 type State struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
-	// The state of the composite resource (XR).
+	// The state of the XR (XR).
 	Composite *Resource `protobuf:"bytes,1,opt,name=composite,proto3" json:"composite,omitempty"`
-	// The state of any composed resources.
+	// The state of any other resources. In composition functions these are the
+	// composed resources. In operation functions they're arbitrary resources that
+	// the operation wants to create or update.
 	Resources     map[string]*Resource `protobuf:"bytes,2,rep,name=resources,proto3" json:"resources,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
@@ -970,7 +990,7 @@ type State struct {
 
 func (x *State) Reset() {
 	*x = State{}
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[10]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[10]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -982,7 +1002,7 @@ func (x *State) String() string {
 func (*State) ProtoMessage() {}
 
 func (x *State) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[10]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[10]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -995,7 +1015,7 @@ func (x *State) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use State.ProtoReflect.Descriptor instead.
 func (*State) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{10}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{10}
 }
 
 func (x *State) GetComposite() *Resource {
@@ -1012,49 +1032,53 @@ func (x *State) GetResources() map[string]*Resource {
 	return nil
 }
 
-// A Resource represents the state of a composite or composed resource.
+// A Resource represents the state of a Kubernetes resource.
 type Resource struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
 	// The JSON representation of the resource.
 	//
-	//   - Crossplane will set this field in a RunFunctionRequest to the entire
-	//     observed state of a resource - including its metadata, spec, and status.
+	// * Crossplane will set this field in a RunFunctionRequest to the entire
+	// observed state of a resource - including its metadata, spec, and status.
 	//
-	//   - A Function should set this field in a RunFunctionRequest to communicate
-	//     the desired state of a composite or composed resource.
+	// * A function should set this field in a RunFunctionRequest to communicate
+	// the desired state of the resource.
 	//
-	//   - A Function may only specify the desired status of a composite resource -
-	//     not its metadata or spec. A Function should not return desired metadata
-	//     or spec for a composite resource. This will be ignored.
+	// * A function may only specify the desired status of a XR - not its metadata
+	// or spec. A function should not return desired metadata or spec for a XR.
+	// This will be ignored.
 	//
-	//   - A Function may not specify the desired status of a composed resource -
-	//     only its metadata and spec. A Function should not return desired status
-	//     for a composed resource. This will be ignored.
+	// * A function may not specify the desired status of any other resource -
+	// e.g. composed resources. It may only specify their metadata and spec.
+	// Status will be ignored.
 	Resource *structpb.Struct `protobuf:"bytes,1,opt,name=resource,proto3" json:"resource,omitempty"`
 	// The resource's connection details.
 	//
-	//   - Crossplane will set this field in a RunFunctionRequest to communicate the
-	//     the observed connection details of a composite or composed resource.
+	// * Crossplane will set this field in a RunFunctionRequest to communicate the
+	// the observed connection details of a composite or composed resource.
 	//
-	//   - A Function should set this field in a RunFunctionResponse to indicate the
-	//     desired connection details of the composite resource.
+	// * A function should set this field in a RunFunctionResponse to indicate the
+	// desired connection details of the XR.
 	//
-	//   - A Function should not set this field in a RunFunctionResponse to indicate
-	//     the desired connection details of a composed resource. This will be
-	//     ignored.
+	// * A function should not set this field in a RunFunctionResponse to indicate
+	// the desired connection details of a composed resource. This will be
+	// ignored.
+	//
+	// Connection details are only used for composition. They're ignored by
+	// Operations.
 	ConnectionDetails map[string][]byte `protobuf:"bytes,2,rep,name=connection_details,json=connectionDetails,proto3" json:"connection_details,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
 	// Ready indicates whether the resource should be considered ready.
 	//
 	// * Crossplane will never set this field in a RunFunctionRequest.
 	//
-	//   - A Function should set this field to READY_TRUE in a RunFunctionResponse
-	//     to indicate that a desired composed resource is ready.
+	// * A function should set this field to READY_TRUE in a RunFunctionResponse
+	// to indicate that a desired resource is ready.
+	//
+	// * A function should set this field to READY_TRUE in a RunFunctionResponse
+	// to indicate that a desired XR is ready. This overwrites the standard
+	// readiness detection that determines the ready state of the composite by the
+	// ready state of the the composed resources.
 	//
-	//   - A Function should set this field to READY_TRUE in a RunFunctionResponse
-	//     to indicate that a desired composite resource is ready.
-	//     This overwrites the standard readiness detection that determines the
-	//     ready state of the composite by the ready state of the the
-	//     composed resources.
+	// Ready is only used for composition. It's ignored by Operations.
 	Ready         Ready `protobuf:"varint,3,opt,name=ready,proto3,enum=apiextensions.fn.proto.v1.Ready" json:"ready,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
@@ -1062,7 +1086,7 @@ type Resource struct {
 
 func (x *Resource) Reset() {
 	*x = Resource{}
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[11]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[11]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1074,7 +1098,7 @@ func (x *Resource) String() string {
 func (*Resource) ProtoMessage() {}
 
 func (x *Resource) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[11]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[11]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1087,7 +1111,7 @@ func (x *Resource) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Resource.ProtoReflect.Descriptor instead.
 func (*Resource) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{11}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{11}
 }
 
 func (x *Resource) GetResource() *structpb.Struct {
@@ -1111,7 +1135,7 @@ func (x *Resource) GetReady() Ready {
 	return Ready_READY_UNSPECIFIED
 }
 
-// A Result of running a Function.
+// A Result of running a function.
 type Result struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
 	// Severity of this result.
@@ -1129,7 +1153,7 @@ type Result struct {
 
 func (x *Result) Reset() {
 	*x = Result{}
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[12]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[12]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1141,7 +1165,7 @@ func (x *Result) String() string {
 func (*Result) ProtoMessage() {}
 
 func (x *Result) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[12]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[12]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1154,7 +1178,7 @@ func (x *Result) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Result.ProtoReflect.Descriptor instead.
 func (*Result) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{12}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{12}
 }
 
 func (x *Result) GetSeverity() Severity {
@@ -1185,9 +1209,9 @@ func (x *Result) GetTarget() Target {
 	return Target_TARGET_UNSPECIFIED
 }
 
-// Status condition to be applied to the composite resource. Condition may also
-// optionally be applied to the composite resource's associated claim. For
-// detailed information on proper usage of status conditions, please see
+// Status condition to be applied to the XR. Condition may also optionally be
+// applied to the XR's associated claim. For detailed information on proper
+// usage of status conditions, please see
 // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties.
 type Condition struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
@@ -1212,7 +1236,7 @@ type Condition struct {
 
 func (x *Condition) Reset() {
 	*x = Condition{}
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[13]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[13]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1224,7 +1248,7 @@ func (x *Condition) String() string {
 func (*Condition) ProtoMessage() {}
 
 func (x *Condition) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[13]
+	mi := &file_proto_fn_v1_run_function_proto_msgTypes[13]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1237,7 +1261,7 @@ func (x *Condition) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Condition.ProtoReflect.Descriptor instead.
 func (*Condition) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP(), []int{13}
+	return file_proto_fn_v1_run_function_proto_rawDescGZIP(), []int{13}
 }
 
 func (x *Condition) GetType() string {
@@ -1275,20 +1299,20 @@ func (x *Condition) GetTarget() Target {
 	return Target_TARGET_UNSPECIFIED
 }
 
-var File_apis_apiextensions_fn_proto_v1_run_function_proto protoreflect.FileDescriptor
+var File_proto_fn_v1_run_function_proto protoreflect.FileDescriptor
 
-const file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDesc = "" +
+const file_proto_fn_v1_run_function_proto_rawDesc = "" +
 	"\n" +
-	"1apis/apiextensions/fn/proto/v1/run_function.proto\x12\x19apiextensions.fn.proto.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\"\xeb\x05\n" +
+	"\x1eproto/fn/v1/run_function.proto\x12\x19apiextensions.fn.proto.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\"\xf7\x05\n" +
 	"\x12RunFunctionRequest\x12:\n" +
 	"\x04meta\x18\x01 \x01(\v2&.apiextensions.fn.proto.v1.RequestMetaR\x04meta\x12<\n" +
 	"\bobserved\x18\x02 \x01(\v2 .apiextensions.fn.proto.v1.StateR\bobserved\x12:\n" +
 	"\adesired\x18\x03 \x01(\v2 .apiextensions.fn.proto.v1.StateR\adesired\x122\n" +
 	"\x05input\x18\x04 \x01(\v2\x17.google.protobuf.StructH\x00R\x05input\x88\x01\x01\x126\n" +
-	"\acontext\x18\x05 \x01(\v2\x17.google.protobuf.StructH\x01R\acontext\x88\x01\x01\x12j\n" +
-	"\x0fextra_resources\x18\x06 \x03(\v2A.apiextensions.fn.proto.v1.RunFunctionRequest.ExtraResourcesEntryR\x0eextraResources\x12`\n" +
-	"\vcredentials\x18\a \x03(\v2>.apiextensions.fn.proto.v1.RunFunctionRequest.CredentialsEntryR\vcredentials\x1ag\n" +
-	"\x13ExtraResourcesEntry\x12\x10\n" +
+	"\acontext\x18\x05 \x01(\v2\x17.google.protobuf.StructH\x01R\acontext\x88\x01\x01\x12s\n" +
+	"\x12required_resources\x18\x06 \x03(\v2D.apiextensions.fn.proto.v1.RunFunctionRequest.RequiredResourcesEntryR\x11requiredResources\x12`\n" +
+	"\vcredentials\x18\a \x03(\v2>.apiextensions.fn.proto.v1.RunFunctionRequest.CredentialsEntryR\vcredentials\x1aj\n" +
+	"\x16RequiredResourcesEntry\x12\x10\n" +
 	"\x03key\x18\x01 \x01(\tR\x03key\x12:\n" +
 	"\x05value\x18\x02 \x01(\v2$.apiextensions.fn.proto.v1.ResourcesR\x05value:\x028\x01\x1af\n" +
 	"\x10CredentialsEntry\x12\x10\n" +
@@ -1306,7 +1330,7 @@ const file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDesc = "" +
 	"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
 	"\x05value\x18\x02 \x01(\fR\x05value:\x028\x01\"F\n" +
 	"\tResources\x129\n" +
-	"\x05items\x18\x01 \x03(\v2#.apiextensions.fn.proto.v1.ResourceR\x05items\"\xa2\x03\n" +
+	"\x05items\x18\x01 \x03(\v2#.apiextensions.fn.proto.v1.ResourceR\x05items\"\xe3\x03\n" +
 	"\x13RunFunctionResponse\x12;\n" +
 	"\x04meta\x18\x01 \x01(\v2'.apiextensions.fn.proto.v1.ResponseMetaR\x04meta\x12:\n" +
 	"\adesired\x18\x02 \x01(\v2 .apiextensions.fn.proto.v1.StateR\adesired\x12;\n" +
@@ -1315,14 +1339,16 @@ const file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDesc = "" +
 	"\frequirements\x18\x05 \x01(\v2'.apiextensions.fn.proto.v1.RequirementsR\frequirements\x12D\n" +
 	"\n" +
 	"conditions\x18\x06 \x03(\v2$.apiextensions.fn.proto.v1.ConditionR\n" +
-	"conditionsB\n" +
+	"conditions\x124\n" +
+	"\x06output\x18\a \x01(\v2\x17.google.protobuf.StructH\x01R\x06output\x88\x01\x01B\n" +
 	"\n" +
-	"\b_context\"\x1f\n" +
+	"\b_contextB\t\n" +
+	"\a_output\"\x1f\n" +
 	"\vRequestMeta\x12\x10\n" +
-	"\x03tag\x18\x01 \x01(\tR\x03tag\"\xe4\x01\n" +
-	"\fRequirements\x12d\n" +
-	"\x0fextra_resources\x18\x01 \x03(\v2;.apiextensions.fn.proto.v1.Requirements.ExtraResourcesEntryR\x0eextraResources\x1an\n" +
-	"\x13ExtraResourcesEntry\x12\x10\n" +
+	"\x03tag\x18\x01 \x01(\tR\x03tag\"\xcf\x01\n" +
+	"\fRequirements\x12T\n" +
+	"\tresources\x18\x01 \x03(\v26.apiextensions.fn.proto.v1.Requirements.ResourcesEntryR\tresources\x1ai\n" +
+	"\x0eResourcesEntry\x12\x10\n" +
 	"\x03key\x18\x01 \x01(\tR\x03key\x12A\n" +
 	"\x05value\x18\x02 \x01(\v2+.apiextensions.fn.proto.v1.ResourceSelectorR\x05value:\x028\x01\"\xef\x01\n" +
 	"\x10ResourceSelector\x12\x1f\n" +
@@ -1394,23 +1420,23 @@ const file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDesc = "" +
 	"\x15STATUS_CONDITION_TRUE\x10\x02\x12\x1a\n" +
 	"\x16STATUS_CONDITION_FALSE\x10\x032\x87\x01\n" +
 	"\x15FunctionRunnerService\x12n\n" +
-	"\vRunFunction\x12-.apiextensions.fn.proto.v1.RunFunctionRequest\x1a..apiextensions.fn.proto.v1.RunFunctionResponse\"\x00BAZ?github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1b\x06proto3"
+	"\vRunFunction\x12-.apiextensions.fn.proto.v1.RunFunctionRequest\x1a..apiextensions.fn.proto.v1.RunFunctionResponse\"\x00B.Z,github.com/crossplane/crossplane/proto/fn/v1b\x06proto3"
 
 var (
-	file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescOnce sync.Once
-	file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescData []byte
+	file_proto_fn_v1_run_function_proto_rawDescOnce sync.Once
+	file_proto_fn_v1_run_function_proto_rawDescData []byte
 )
 
-func file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescGZIP() []byte {
-	file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescOnce.Do(func() {
-		file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDesc), len(file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDesc)))
+func file_proto_fn_v1_run_function_proto_rawDescGZIP() []byte {
+	file_proto_fn_v1_run_function_proto_rawDescOnce.Do(func() {
+		file_proto_fn_v1_run_function_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_fn_v1_run_function_proto_rawDesc), len(file_proto_fn_v1_run_function_proto_rawDesc)))
 	})
-	return file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDescData
+	return file_proto_fn_v1_run_function_proto_rawDescData
 }
 
-var file_apis_apiextensions_fn_proto_v1_run_function_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
-var file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes = make([]protoimpl.MessageInfo, 21)
-var file_apis_apiextensions_fn_proto_v1_run_function_proto_goTypes = []any{
+var file_proto_fn_v1_run_function_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
+var file_proto_fn_v1_run_function_proto_msgTypes = make([]protoimpl.MessageInfo, 21)
+var file_proto_fn_v1_run_function_proto_goTypes = []any{
 	(Ready)(0),                  // 0: apiextensions.fn.proto.v1.Ready
 	(Severity)(0),               // 1: apiextensions.fn.proto.v1.Severity
 	(Target)(0),                 // 2: apiextensions.fn.proto.v1.Target
@@ -1429,23 +1455,23 @@ var file_apis_apiextensions_fn_proto_v1_run_function_proto_goTypes = []any{
 	(*Resource)(nil),            // 15: apiextensions.fn.proto.v1.Resource
 	(*Result)(nil),              // 16: apiextensions.fn.proto.v1.Result
 	(*Condition)(nil),           // 17: apiextensions.fn.proto.v1.Condition
-	nil,                         // 18: apiextensions.fn.proto.v1.RunFunctionRequest.ExtraResourcesEntry
+	nil,                         // 18: apiextensions.fn.proto.v1.RunFunctionRequest.RequiredResourcesEntry
 	nil,                         // 19: apiextensions.fn.proto.v1.RunFunctionRequest.CredentialsEntry
 	nil,                         // 20: apiextensions.fn.proto.v1.CredentialData.DataEntry
-	nil,                         // 21: apiextensions.fn.proto.v1.Requirements.ExtraResourcesEntry
+	nil,                         // 21: apiextensions.fn.proto.v1.Requirements.ResourcesEntry
 	nil,                         // 22: apiextensions.fn.proto.v1.MatchLabels.LabelsEntry
 	nil,                         // 23: apiextensions.fn.proto.v1.State.ResourcesEntry
 	nil,                         // 24: apiextensions.fn.proto.v1.Resource.ConnectionDetailsEntry
 	(*structpb.Struct)(nil),     // 25: google.protobuf.Struct
 	(*durationpb.Duration)(nil), // 26: google.protobuf.Duration
 }
-var file_apis_apiextensions_fn_proto_v1_run_function_proto_depIdxs = []int32{
+var file_proto_fn_v1_run_function_proto_depIdxs = []int32{
 	9,  // 0: apiextensions.fn.proto.v1.RunFunctionRequest.meta:type_name -> apiextensions.fn.proto.v1.RequestMeta
 	14, // 1: apiextensions.fn.proto.v1.RunFunctionRequest.observed:type_name -> apiextensions.fn.proto.v1.State
 	14, // 2: apiextensions.fn.proto.v1.RunFunctionRequest.desired:type_name -> apiextensions.fn.proto.v1.State
 	25, // 3: apiextensions.fn.proto.v1.RunFunctionRequest.input:type_name -> google.protobuf.Struct
 	25, // 4: apiextensions.fn.proto.v1.RunFunctionRequest.context:type_name -> google.protobuf.Struct
-	18, // 5: apiextensions.fn.proto.v1.RunFunctionRequest.extra_resources:type_name -> apiextensions.fn.proto.v1.RunFunctionRequest.ExtraResourcesEntry
+	18, // 5: apiextensions.fn.proto.v1.RunFunctionRequest.required_resources:type_name -> apiextensions.fn.proto.v1.RunFunctionRequest.RequiredResourcesEntry
 	19, // 6: apiextensions.fn.proto.v1.RunFunctionRequest.credentials:type_name -> apiextensions.fn.proto.v1.RunFunctionRequest.CredentialsEntry
 	6,  // 7: apiextensions.fn.proto.v1.Credentials.credential_data:type_name -> apiextensions.fn.proto.v1.CredentialData
 	20, // 8: apiextensions.fn.proto.v1.CredentialData.data:type_name -> apiextensions.fn.proto.v1.CredentialData.DataEntry
@@ -1456,65 +1482,66 @@ var file_apis_apiextensions_fn_proto_v1_run_function_proto_depIdxs = []int32{
 	25, // 13: apiextensions.fn.proto.v1.RunFunctionResponse.context:type_name -> google.protobuf.Struct
 	10, // 14: apiextensions.fn.proto.v1.RunFunctionResponse.requirements:type_name -> apiextensions.fn.proto.v1.Requirements
 	17, // 15: apiextensions.fn.proto.v1.RunFunctionResponse.conditions:type_name -> apiextensions.fn.proto.v1.Condition
-	21, // 16: apiextensions.fn.proto.v1.Requirements.extra_resources:type_name -> apiextensions.fn.proto.v1.Requirements.ExtraResourcesEntry
-	12, // 17: apiextensions.fn.proto.v1.ResourceSelector.match_labels:type_name -> apiextensions.fn.proto.v1.MatchLabels
-	22, // 18: apiextensions.fn.proto.v1.MatchLabels.labels:type_name -> apiextensions.fn.proto.v1.MatchLabels.LabelsEntry
-	26, // 19: apiextensions.fn.proto.v1.ResponseMeta.ttl:type_name -> google.protobuf.Duration
-	15, // 20: apiextensions.fn.proto.v1.State.composite:type_name -> apiextensions.fn.proto.v1.Resource
-	23, // 21: apiextensions.fn.proto.v1.State.resources:type_name -> apiextensions.fn.proto.v1.State.ResourcesEntry
-	25, // 22: apiextensions.fn.proto.v1.Resource.resource:type_name -> google.protobuf.Struct
-	24, // 23: apiextensions.fn.proto.v1.Resource.connection_details:type_name -> apiextensions.fn.proto.v1.Resource.ConnectionDetailsEntry
-	0,  // 24: apiextensions.fn.proto.v1.Resource.ready:type_name -> apiextensions.fn.proto.v1.Ready
-	1,  // 25: apiextensions.fn.proto.v1.Result.severity:type_name -> apiextensions.fn.proto.v1.Severity
-	2,  // 26: apiextensions.fn.proto.v1.Result.target:type_name -> apiextensions.fn.proto.v1.Target
-	3,  // 27: apiextensions.fn.proto.v1.Condition.status:type_name -> apiextensions.fn.proto.v1.Status
-	2,  // 28: apiextensions.fn.proto.v1.Condition.target:type_name -> apiextensions.fn.proto.v1.Target
-	7,  // 29: apiextensions.fn.proto.v1.RunFunctionRequest.ExtraResourcesEntry.value:type_name -> apiextensions.fn.proto.v1.Resources
-	5,  // 30: apiextensions.fn.proto.v1.RunFunctionRequest.CredentialsEntry.value:type_name -> apiextensions.fn.proto.v1.Credentials
-	11, // 31: apiextensions.fn.proto.v1.Requirements.ExtraResourcesEntry.value:type_name -> apiextensions.fn.proto.v1.ResourceSelector
-	15, // 32: apiextensions.fn.proto.v1.State.ResourcesEntry.value:type_name -> apiextensions.fn.proto.v1.Resource
-	4,  // 33: apiextensions.fn.proto.v1.FunctionRunnerService.RunFunction:input_type -> apiextensions.fn.proto.v1.RunFunctionRequest
-	8,  // 34: apiextensions.fn.proto.v1.FunctionRunnerService.RunFunction:output_type -> apiextensions.fn.proto.v1.RunFunctionResponse
-	34, // [34:35] is the sub-list for method output_type
-	33, // [33:34] is the sub-list for method input_type
-	33, // [33:33] is the sub-list for extension type_name
-	33, // [33:33] is the sub-list for extension extendee
-	0,  // [0:33] is the sub-list for field type_name
-}
-
-func init() { file_apis_apiextensions_fn_proto_v1_run_function_proto_init() }
-func file_apis_apiextensions_fn_proto_v1_run_function_proto_init() {
-	if File_apis_apiextensions_fn_proto_v1_run_function_proto != nil {
+	25, // 16: apiextensions.fn.proto.v1.RunFunctionResponse.output:type_name -> google.protobuf.Struct
+	21, // 17: apiextensions.fn.proto.v1.Requirements.resources:type_name -> apiextensions.fn.proto.v1.Requirements.ResourcesEntry
+	12, // 18: apiextensions.fn.proto.v1.ResourceSelector.match_labels:type_name -> apiextensions.fn.proto.v1.MatchLabels
+	22, // 19: apiextensions.fn.proto.v1.MatchLabels.labels:type_name -> apiextensions.fn.proto.v1.MatchLabels.LabelsEntry
+	26, // 20: apiextensions.fn.proto.v1.ResponseMeta.ttl:type_name -> google.protobuf.Duration
+	15, // 21: apiextensions.fn.proto.v1.State.composite:type_name -> apiextensions.fn.proto.v1.Resource
+	23, // 22: apiextensions.fn.proto.v1.State.resources:type_name -> apiextensions.fn.proto.v1.State.ResourcesEntry
+	25, // 23: apiextensions.fn.proto.v1.Resource.resource:type_name -> google.protobuf.Struct
+	24, // 24: apiextensions.fn.proto.v1.Resource.connection_details:type_name -> apiextensions.fn.proto.v1.Resource.ConnectionDetailsEntry
+	0,  // 25: apiextensions.fn.proto.v1.Resource.ready:type_name -> apiextensions.fn.proto.v1.Ready
+	1,  // 26: apiextensions.fn.proto.v1.Result.severity:type_name -> apiextensions.fn.proto.v1.Severity
+	2,  // 27: apiextensions.fn.proto.v1.Result.target:type_name -> apiextensions.fn.proto.v1.Target
+	3,  // 28: apiextensions.fn.proto.v1.Condition.status:type_name -> apiextensions.fn.proto.v1.Status
+	2,  // 29: apiextensions.fn.proto.v1.Condition.target:type_name -> apiextensions.fn.proto.v1.Target
+	7,  // 30: apiextensions.fn.proto.v1.RunFunctionRequest.RequiredResourcesEntry.value:type_name -> apiextensions.fn.proto.v1.Resources
+	5,  // 31: apiextensions.fn.proto.v1.RunFunctionRequest.CredentialsEntry.value:type_name -> apiextensions.fn.proto.v1.Credentials
+	11, // 32: apiextensions.fn.proto.v1.Requirements.ResourcesEntry.value:type_name -> apiextensions.fn.proto.v1.ResourceSelector
+	15, // 33: apiextensions.fn.proto.v1.State.ResourcesEntry.value:type_name -> apiextensions.fn.proto.v1.Resource
+	4,  // 34: apiextensions.fn.proto.v1.FunctionRunnerService.RunFunction:input_type -> apiextensions.fn.proto.v1.RunFunctionRequest
+	8,  // 35: apiextensions.fn.proto.v1.FunctionRunnerService.RunFunction:output_type -> apiextensions.fn.proto.v1.RunFunctionResponse
+	35, // [35:36] is the sub-list for method output_type
+	34, // [34:35] is the sub-list for method input_type
+	34, // [34:34] is the sub-list for extension type_name
+	34, // [34:34] is the sub-list for extension extendee
+	0,  // [0:34] is the sub-list for field type_name
+}
+
+func init() { file_proto_fn_v1_run_function_proto_init() }
+func file_proto_fn_v1_run_function_proto_init() {
+	if File_proto_fn_v1_run_function_proto != nil {
 		return
 	}
-	file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[0].OneofWrappers = []any{}
-	file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[1].OneofWrappers = []any{
+	file_proto_fn_v1_run_function_proto_msgTypes[0].OneofWrappers = []any{}
+	file_proto_fn_v1_run_function_proto_msgTypes[1].OneofWrappers = []any{
 		(*Credentials_CredentialData)(nil),
 	}
-	file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[4].OneofWrappers = []any{}
-	file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[7].OneofWrappers = []any{
+	file_proto_fn_v1_run_function_proto_msgTypes[4].OneofWrappers = []any{}
+	file_proto_fn_v1_run_function_proto_msgTypes[7].OneofWrappers = []any{
 		(*ResourceSelector_MatchName)(nil),
 		(*ResourceSelector_MatchLabels)(nil),
 	}
-	file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[9].OneofWrappers = []any{}
-	file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[12].OneofWrappers = []any{}
-	file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes[13].OneofWrappers = []any{}
+	file_proto_fn_v1_run_function_proto_msgTypes[9].OneofWrappers = []any{}
+	file_proto_fn_v1_run_function_proto_msgTypes[12].OneofWrappers = []any{}
+	file_proto_fn_v1_run_function_proto_msgTypes[13].OneofWrappers = []any{}
 	type x struct{}
 	out := protoimpl.TypeBuilder{
 		File: protoimpl.DescBuilder{
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
-			RawDescriptor: unsafe.Slice(unsafe.StringData(file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDesc), len(file_apis_apiextensions_fn_proto_v1_run_function_proto_rawDesc)),
+			RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_fn_v1_run_function_proto_rawDesc), len(file_proto_fn_v1_run_function_proto_rawDesc)),
 			NumEnums:      4,
 			NumMessages:   21,
 			NumExtensions: 0,
 			NumServices:   1,
 		},
-		GoTypes:           file_apis_apiextensions_fn_proto_v1_run_function_proto_goTypes,
-		DependencyIndexes: file_apis_apiextensions_fn_proto_v1_run_function_proto_depIdxs,
-		EnumInfos:         file_apis_apiextensions_fn_proto_v1_run_function_proto_enumTypes,
-		MessageInfos:      file_apis_apiextensions_fn_proto_v1_run_function_proto_msgTypes,
+		GoTypes:           file_proto_fn_v1_run_function_proto_goTypes,
+		DependencyIndexes: file_proto_fn_v1_run_function_proto_depIdxs,
+		EnumInfos:         file_proto_fn_v1_run_function_proto_enumTypes,
+		MessageInfos:      file_proto_fn_v1_run_function_proto_msgTypes,
 	}.Build()
-	File_apis_apiextensions_fn_proto_v1_run_function_proto = out.File
-	file_apis_apiextensions_fn_proto_v1_run_function_proto_goTypes = nil
-	file_apis_apiextensions_fn_proto_v1_run_function_proto_depIdxs = nil
+	File_proto_fn_v1_run_function_proto = out.File
+	file_proto_fn_v1_run_function_proto_goTypes = nil
+	file_proto_fn_v1_run_function_proto_depIdxs = nil
 }
diff --git a/apis/apiextensions/fn/proto/v1/run_function.proto b/proto/fn/v1/run_function.proto
similarity index 52%
rename from apis/apiextensions/fn/proto/v1/run_function.proto
rename to proto/fn/v1/run_function.proto
index ecee70373..b67c08577 100644
--- a/apis/apiextensions/fn/proto/v1/run_function.proto
+++ b/proto/fn/v1/run_function.proto
@@ -16,66 +16,71 @@
 
 syntax = "proto3";
 
-//buf:lint:ignore PACKAGE_DIRECTORY_MATCH // Too late to fix this now.
+// This package defines the RPC for both composition and operation functions.
+// Only composition functions are part of the 'apiextensions' API group. In
+// retrospect this package should've been crossplane.proto.fn.v1, but it's too
+// late to change it now.
+
+//buf:lint:ignore PACKAGE_DIRECTORY_MATCH
 package apiextensions.fn.proto.v1;
 
 import "google/protobuf/duration.proto";
 import "google/protobuf/struct.proto";
 
-option go_package = "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1";
+option go_package = "github.com/crossplane/crossplane/proto/fn/v1";
 
-// A FunctionRunnerService is a Composition Function.
+// A FunctionRunnerService is a function.
 service FunctionRunnerService {
-  // RunFunction runs the Composition Function.
+  // RunFunction runs the function.
   rpc RunFunction(RunFunctionRequest) returns (RunFunctionResponse) {}
 }
 
-// A RunFunctionRequest requests that the Composition Function be run.
+// A RunFunctionRequest requests that the function be run.
 message RunFunctionRequest {
   // Metadata pertaining to this request.
   RequestMeta meta = 1;
 
-  // The observed state prior to invocation of a Function pipeline. State passed
-  // to each Function is fresh as of the time the pipeline was invoked, not as
-  // of the time each Function was invoked.
+  // The observed state prior to invocation of a function pipeline. State passed
+  // to each function is fresh as of the time the pipeline was invoked, not as
+  // of the time each function was invoked.
   State observed = 2;
 
-  // Desired state according to a Function pipeline. The state passed to a
-  // particular Function may have been accumulated by previous Functions in the
+  // Desired state according to a function pipeline. The state passed to a
+  // particular function may have been accumulated by previous functions in the
   // pipeline.
   //
   // Note that the desired state must be a partial object with only the fields
-  // that this function (and its predecessors in the pipeline) wants to have
-  // set in the object. Copying a non-partial observed state to desired is most
+  // that this function (and its predecessors in the pipeline) wants to have set
+  // in the object. Copying a non-partial observed state to desired is most
   // likely not what you want to do. Leaving out fields that had been returned
   // as desired before will result in them being deleted from the objects in the
   // cluster.
   State desired = 3;
 
-  // Optional input specific to this Function invocation. A JSON representation
-  // of the 'input' block of the relevant entry in a Composition's pipeline.
+  // Optional input specific to this function invocation. A JSON representation
+  // of the 'input' block of the relevant entry in a function pipeline.
   optional google.protobuf.Struct input = 4;
 
   // Optional context. Crossplane may pass arbitrary contextual information to a
-  // Function. A Function may also return context in its RunFunctionResponse,
-  // and that context will be passed to subsequent Functions. Crossplane
-  // discards all context returned by the last Function in the pipeline.
+  // function. A function may also return context in its RunFunctionResponse,
+  // and that context will be passed to subsequent functions. Crossplane
+  // discards all context returned by the last function in the pipeline.
   optional google.protobuf.Struct context = 5;
 
-  // Optional extra resources that the Function required.
-  // Note that extra resources is a map to Resources, plural.
-  // The map key corresponds to the key in a RunFunctionResponse's
-  // extra_resources field. If a Function requested extra resources that
-  // did not exist, Crossplane sets the map key to an empty Resources message to
-  // indicate that it attempted to satisfy the request.
-  map<string, Resources> extra_resources = 6;
+  // Optional resources that the function specified in its requirements. Note
+  // that resources is a map to Resources, plural. The map key corresponds to
+  // the key in a RunFunctionResponse's requirements.resources field. If a
+  // function requested required resources that did not exist, Crossplane sets
+  // the map key to an empty Resources message to indicate that it attempted to
+  // satisfy the request.
+  map<string, Resources> required_resources = 6;
 
-  // Optional credentials that this Function may use to communicate with an
+  // Optional credentials that this function may use to communicate with an
   // external system.
   map<string, Credentials> credentials = 7;
 }
 
-// Credentials that a Function may use to communicate with an external system.
+// Credentials that a function may use to communicate with an external system.
 message Credentials {
   // Source of the credentials.
   oneof source {
@@ -94,38 +99,44 @@ message Resources {
   repeated Resource items = 1;
 }
 
-// A RunFunctionResponse contains the result of a Composition Function run.
+// A RunFunctionResponse contains the result of a function run.
 message RunFunctionResponse {
   // Metadata pertaining to this response.
   ResponseMeta meta = 1;
 
-  // Desired state according to a Function pipeline. Functions may add desired
+  // Desired state according to a function pipeline. functions may add desired
   // state, and may mutate or delete any part of the desired state they are
-  // concerned with. A Function must pass through any part of the desired state
+  // concerned with. A function must pass through any part of the desired state
   // that it is not concerned with.
   //
-  //
   // Note that the desired state must be a partial object with only the fields
-  // that this function (and its predecessors in the pipeline) wants to have
-  // set in the object. Copying a non-partial observed state to desired is most
+  // that this function (and its predecessors in the pipeline) wants to have set
+  // in the object. Copying a non-partial observed state to desired is most
   // likely not what you want to do. Leaving out fields that had been returned
   // as desired before will result in them being deleted from the objects in the
   // cluster.
   State desired = 2;
 
-  // Results of the Function run. Results are used for observability purposes.
+  // Results of the function run. Results are used for observability purposes.
   repeated Result results = 3;
 
-  // Optional context to be passed to the next Function in the pipeline as part
+  // Optional context to be passed to the next function in the pipeline as part
   // of the RunFunctionRequest. Dropped on the last function in the pipeline.
   optional google.protobuf.Struct context = 4;
 
-  // Requirements that must be satisfied for this Function to run successfully.
+  // Requirements that must be satisfied for this function to run successfully.
   Requirements requirements = 5;
 
-  // Status conditions to be applied to the composite resource. Conditions may also
-  // optionally be applied to the composite resource's associated claim.
+  // Status conditions to be applied to the XR. Conditions may also optionally
+  // be applied to the XR's associated claim.
+  //
+  // Conditions are only used for composition. They're ignored by Operations.
   repeated Condition conditions = 6;
+
+  // Optional output specific to this function invocation.
+  //
+  // Only Operations use function output. XRs will discard any function output.
+  optional google.protobuf.Struct output = 7;
 }
 
 // RequestMeta contains metadata pertaining to a RunFunctionRequest.
@@ -135,11 +146,11 @@ message RequestMeta {
   string tag = 1;
 }
 
-// Requirements that must be satisfied for a Function to run successfully.
+// Requirements that must be satisfied for a function to run successfully.
 message Requirements {
-  // Extra resources that this Function requires.
-  // The map key uniquely identifies the group of resources.
-  map<string, ResourceSelector> extra_resources = 1;
+  // Resources that this function requires. The map key uniquely identifies the
+  // group of resources.
+  map<string, ResourceSelector> resources = 1;
 }
 
 // ResourceSelector selects a group of resources, either by name or by label.
@@ -159,9 +170,9 @@ message ResourceSelector {
     MatchLabels match_labels = 4;
   }
 
-  // Match resources in this namespace.
-  // Omit namespace to match cluster scoped resources, or to match namespaced
-  // resources by labels across all namespaces.
+  // Match resources in this namespace. Omit namespace to match cluster scoped
+  // resources, or to match namespaced resources by labels across all
+  // namespaces.
   optional string namespace = 5;
 }
 
@@ -182,74 +193,80 @@ message ResponseMeta {
   optional google.protobuf.Duration ttl = 2;
 }
 
-// State of the composite resource (XR) and any composed resources.
+// State of the XR (XR) and any resources.
 message State {
-  // The state of the composite resource (XR).
+  // The state of the XR (XR).
   Resource composite = 1;
 
-  // The state of any composed resources.
+  // The state of any other resources. In composition functions these are the
+  // composed resources. In operation functions they're arbitrary resources that
+  // the operation wants to create or update.
   map<string, Resource> resources = 2;
 }
 
-// A Resource represents the state of a composite or composed resource.
+// A Resource represents the state of a Kubernetes resource.
 message Resource {
   // The JSON representation of the resource.
   //
   // * Crossplane will set this field in a RunFunctionRequest to the entire
-  //   observed state of a resource - including its metadata, spec, and status.
+  // observed state of a resource - including its metadata, spec, and status.
   //
-  // * A Function should set this field in a RunFunctionRequest to communicate
-  //   the desired state of a composite or composed resource.
+  // * A function should set this field in a RunFunctionRequest to communicate
+  // the desired state of the resource.
   //
-  // * A Function may only specify the desired status of a composite resource -
-  //   not its metadata or spec. A Function should not return desired metadata
-  //   or spec for a composite resource. This will be ignored.
+  // * A function may only specify the desired status of a XR - not its metadata
+  // or spec. A function should not return desired metadata or spec for a XR.
+  // This will be ignored.
   //
-  // * A Function may not specify the desired status of a composed resource -
-  //   only its metadata and spec. A Function should not return desired status
-  //   for a composed resource. This will be ignored.
+  // * A function may not specify the desired status of any other resource -
+  // e.g. composed resources. It may only specify their metadata and spec.
+  // Status will be ignored.
   google.protobuf.Struct resource = 1;
 
   // The resource's connection details.
   //
   // * Crossplane will set this field in a RunFunctionRequest to communicate the
-  //   the observed connection details of a composite or composed resource.
+  // the observed connection details of a composite or composed resource.
+  //
+  // * A function should set this field in a RunFunctionResponse to indicate the
+  // desired connection details of the XR.
   //
-  // * A Function should set this field in a RunFunctionResponse to indicate the
-  //   desired connection details of the composite resource.
+  // * A function should not set this field in a RunFunctionResponse to indicate
+  // the desired connection details of a composed resource. This will be
+  // ignored.
   //
-  // * A Function should not set this field in a RunFunctionResponse to indicate
-  //   the desired connection details of a composed resource. This will be
-  //   ignored.
+  // Connection details are only used for composition. They're ignored by
+  // Operations.
   map<string, bytes> connection_details = 2;
 
   // Ready indicates whether the resource should be considered ready.
   //
   // * Crossplane will never set this field in a RunFunctionRequest.
   //
-  // * A Function should set this field to READY_TRUE in a RunFunctionResponse
-  //   to indicate that a desired composed resource is ready.
+  // * A function should set this field to READY_TRUE in a RunFunctionResponse
+  // to indicate that a desired resource is ready.
+  //
+  // * A function should set this field to READY_TRUE in a RunFunctionResponse
+  // to indicate that a desired XR is ready. This overwrites the standard
+  // readiness detection that determines the ready state of the composite by the
+  // ready state of the the composed resources.
   //
-  // * A Function should set this field to READY_TRUE in a RunFunctionResponse
-  //   to indicate that a desired composite resource is ready.
-  //   This overwrites the standard readiness detection that determines the
-  //   ready state of the composite by the ready state of the the
-  //   composed resources.
+  // Ready is only used for composition. It's ignored by Operations.
   Ready ready = 3;
 }
 
-// Ready indicates whether a composed resource should be considered ready.
+// Ready indicates whether a resource should be considered ready.
 enum Ready {
   READY_UNSPECIFIED = 0;
 
-  // True means the composed resource has been observed to be ready.
+  // True means the resource has been observed to be ready.
   READY_TRUE = 1;
 
-  // False means the composed resource has not been observed to be ready.
+  // False means the resource has not been observed to be ready.
   READY_FALSE = 2;
 }
 
-// A Result of running a Function.
+// A Result of running a function.
 message Result {
   // Severity of this result.
   Severity severity = 1;
@@ -265,42 +282,42 @@ message Result {
   optional Target target = 4;
 }
 
-// Severity of Function results.
+// Severity of function results.
 enum Severity {
   SEVERITY_UNSPECIFIED = 0;
 
-  // Fatal results are fatal; subsequent Composition Functions may run, but
-  // the Composition Function pipeline run will be considered a failure and
-  // the first fatal result will be returned as an error.
+  // Fatal results are fatal; subsequent functions may run, but the function
+  // pipeline run will be considered a failure and the first fatal result will
+  // be returned as an error.
   SEVERITY_FATAL = 1;
 
-  // Warning results are non-fatal; the entire Composition will run to
-  // completion but warning events and debug logs associated with the
-  // composite resource will be emitted.
+  // Warning results are non-fatal; the entire pipeline will run to completion
+  // but warning events and debug logs associated with the XR or Operation will
+  // be emitted.
   SEVERITY_WARNING = 2;
 
-  // Normal results are emitted as normal events and debug logs associated
-  // with the composite resource.
+  // Normal results are emitted as normal events and debug logs associated with
+  // the XR or operation.
   SEVERITY_NORMAL = 3;
 }
 
-// Target of Function results and conditions.
+// Target of function results and conditions.
 enum Target {
-  // If the target is unspecified, the result targets the composite resource.
+  // If the target is unspecified, the result targets the XR.
   TARGET_UNSPECIFIED = 0;
 
-  // Target the composite resource. Results that target the composite resource
-  // should include detailed, advanced information.
+  // Target the XR. Results that target the XR should include detailed, advanced
+  // information.
   TARGET_COMPOSITE = 1;
 
-  // Target the composite and the claim. Results that target the composite and
-  // the claim should include only end-user friendly information.
+  // Target the XR and the claim. Results that target the XR and the claim
+  // should include only end-user friendly information.
   TARGET_COMPOSITE_AND_CLAIM = 2;
 }
 
-// Status condition to be applied to the composite resource. Condition may also
-// optionally be applied to the composite resource's associated claim. For
-// detailed information on proper usage of status conditions, please see
+// Status condition to be applied to the XR. Condition may also optionally be
+// applied to the XR's associated claim. For detailed information on proper
+// usage of status conditions, please see
 // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties.
 message Condition {
   // Type of condition in PascalCase.
diff --git a/apis/apiextensions/fn/proto/v1/run_function_grpc.pb.go b/proto/fn/v1/run_function_grpc.pb.go
similarity index 91%
rename from apis/apiextensions/fn/proto/v1/run_function_grpc.pb.go
rename to proto/fn/v1/run_function_grpc.pb.go
index 15abac6ab..55d664a14 100644
--- a/apis/apiextensions/fn/proto/v1/run_function_grpc.pb.go
+++ b/proto/fn/v1/run_function_grpc.pb.go
@@ -17,9 +17,14 @@
 // versions:
 // - protoc-gen-go-grpc v1.3.0
 // - protoc             (unknown)
-// source: apis/apiextensions/fn/proto/v1/run_function.proto
+// source: proto/fn/v1/run_function.proto
 
-//buf:lint:ignore PACKAGE_DIRECTORY_MATCH // Too late to fix this now.
+// This package defines the RPC for both composition and operation functions.
+// Only composition functions are part of the 'apiextensions' API group. In
+// retrospect this package should've been crossplane.proto.fn.v1, but it's too
+// late to change it now.
+
+//buf:lint:ignore PACKAGE_DIRECTORY_MATCH
 
 package v1
 
@@ -43,7 +48,7 @@ const (
 //
 // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
 type FunctionRunnerServiceClient interface {
-	// RunFunction runs the Composition Function.
+	// RunFunction runs the function.
 	RunFunction(ctx context.Context, in *RunFunctionRequest, opts ...grpc.CallOption) (*RunFunctionResponse, error)
 }
 
@@ -68,7 +73,7 @@ func (c *functionRunnerServiceClient) RunFunction(ctx context.Context, in *RunFu
 // All implementations must embed UnimplementedFunctionRunnerServiceServer
 // for forward compatibility
 type FunctionRunnerServiceServer interface {
-	// RunFunction runs the Composition Function.
+	// RunFunction runs the function.
 	RunFunction(context.Context, *RunFunctionRequest) (*RunFunctionResponse, error)
 	mustEmbedUnimplementedFunctionRunnerServiceServer()
 }
@@ -124,5 +129,5 @@ var FunctionRunnerService_ServiceDesc = grpc.ServiceDesc{
 		},
 	},
 	Streams:  []grpc.StreamDesc{},
-	Metadata: "apis/apiextensions/fn/proto/v1/run_function.proto",
+	Metadata: "proto/fn/v1/run_function.proto",
 }
diff --git a/apis/apiextensions/fn/proto/v1beta1/zz_generated_run_function.pb.go b/proto/fn/v1beta1/zz_generated_run_function.pb.go
similarity index 68%
rename from apis/apiextensions/fn/proto/v1beta1/zz_generated_run_function.pb.go
rename to proto/fn/v1beta1/zz_generated_run_function.pb.go
index de79ca241..564c6ba69 100644
--- a/apis/apiextensions/fn/proto/v1beta1/zz_generated_run_function.pb.go
+++ b/proto/fn/v1beta1/zz_generated_run_function.pb.go
@@ -13,15 +13,20 @@
 //See the License for the specific language governing permissions and
 //limitations under the License.
 
-// Generated from apis/apiextensions/fn/proto/v1/run_function.proto by ./hack/duplicate_proto_type.sh. DO NOT EDIT.
+// Generated from proto/fn/v1/run_function.proto by ./hack/duplicate_proto_type.sh. DO NOT EDIT.
 
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.36.6
 // 	protoc        (unknown)
-// source: apis/apiextensions/fn/proto/v1beta1/zz_generated_run_function.proto
+// source: proto/fn/v1beta1/zz_generated_run_function.proto
 
-//buf:lint:ignore PACKAGE_DIRECTORY_MATCH // Too late to fix this now.
+// This package defines the RPC for both composition and operation functions.
+// Only composition functions are part of the 'apiextensions' API group. In
+// retrospect this package should've been crossplane.proto.fn.v1, but it's too
+// late to change it now.
+
+//buf:lint:ignore PACKAGE_DIRECTORY_MATCH
 
 package v1beta1
 
@@ -42,14 +47,14 @@ const (
 	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
 )
 
-// Ready indicates whether a composed resource should be considered ready.
+// Ready indicates whether a resource should be considered ready.
 type Ready int32
 
 const (
 	Ready_READY_UNSPECIFIED Ready = 0
-	// True means the composed resource has been observed to be ready.
+	// True means the resource has been observed to be ready.
 	Ready_READY_TRUE Ready = 1
-	// False means the composed resource has not been observed to be ready.
+	// False means the resource has not been observed to be ready.
 	Ready_READY_FALSE Ready = 2
 )
 
@@ -78,11 +83,11 @@ func (x Ready) String() string {
 }
 
 func (Ready) Descriptor() protoreflect.EnumDescriptor {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_enumTypes[0].Descriptor()
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_enumTypes[0].Descriptor()
 }
 
 func (Ready) Type() protoreflect.EnumType {
-	return &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_enumTypes[0]
+	return &file_proto_fn_v1beta1_zz_generated_run_function_proto_enumTypes[0]
 }
 
 func (x Ready) Number() protoreflect.EnumNumber {
@@ -91,24 +96,24 @@ func (x Ready) Number() protoreflect.EnumNumber {
 
 // Deprecated: Use Ready.Descriptor instead.
 func (Ready) EnumDescriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{0}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{0}
 }
 
-// Severity of Function results.
+// Severity of function results.
 type Severity int32
 
 const (
 	Severity_SEVERITY_UNSPECIFIED Severity = 0
-	// Fatal results are fatal; subsequent Composition Functions may run, but
-	// the Composition Function pipeline run will be considered a failure and
-	// the first fatal result will be returned as an error.
+	// Fatal results are fatal; subsequent functions may run, but the function
+	// pipeline run will be considered a failure and the first fatal result will
+	// be returned as an error.
 	Severity_SEVERITY_FATAL Severity = 1
-	// Warning results are non-fatal; the entire Composition will run to
-	// completion but warning events and debug logs associated with the
-	// composite resource will be emitted.
+	// Warning results are non-fatal; the entire pipeline will run to completion
+	// but warning events and debug logs associated with the XR or Operation will
+	// be emitted.
 	Severity_SEVERITY_WARNING Severity = 2
-	// Normal results are emitted as normal events and debug logs associated
-	// with the composite resource.
+	// Normal results are emitted as normal events and debug logs associated with
+	// the XR or operation.
 	Severity_SEVERITY_NORMAL Severity = 3
 )
 
@@ -139,11 +144,11 @@ func (x Severity) String() string {
 }
 
 func (Severity) Descriptor() protoreflect.EnumDescriptor {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_enumTypes[1].Descriptor()
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_enumTypes[1].Descriptor()
 }
 
 func (Severity) Type() protoreflect.EnumType {
-	return &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_enumTypes[1]
+	return &file_proto_fn_v1beta1_zz_generated_run_function_proto_enumTypes[1]
 }
 
 func (x Severity) Number() protoreflect.EnumNumber {
@@ -152,20 +157,20 @@ func (x Severity) Number() protoreflect.EnumNumber {
 
 // Deprecated: Use Severity.Descriptor instead.
 func (Severity) EnumDescriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{1}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{1}
 }
 
-// Target of Function results and conditions.
+// Target of function results and conditions.
 type Target int32
 
 const (
-	// If the target is unspecified, the result targets the composite resource.
+	// If the target is unspecified, the result targets the XR.
 	Target_TARGET_UNSPECIFIED Target = 0
-	// Target the composite resource. Results that target the composite resource
-	// should include detailed, advanced information.
+	// Target the XR. Results that target the XR should include detailed, advanced
+	// information.
 	Target_TARGET_COMPOSITE Target = 1
-	// Target the composite and the claim. Results that target the composite and
-	// the claim should include only end-user friendly information.
+	// Target the XR and the claim. Results that target the XR and the claim
+	// should include only end-user friendly information.
 	Target_TARGET_COMPOSITE_AND_CLAIM Target = 2
 )
 
@@ -194,11 +199,11 @@ func (x Target) String() string {
 }
 
 func (Target) Descriptor() protoreflect.EnumDescriptor {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_enumTypes[2].Descriptor()
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_enumTypes[2].Descriptor()
 }
 
 func (Target) Type() protoreflect.EnumType {
-	return &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_enumTypes[2]
+	return &file_proto_fn_v1beta1_zz_generated_run_function_proto_enumTypes[2]
 }
 
 func (x Target) Number() protoreflect.EnumNumber {
@@ -207,7 +212,7 @@ func (x Target) Number() protoreflect.EnumNumber {
 
 // Deprecated: Use Target.Descriptor instead.
 func (Target) EnumDescriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{2}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{2}
 }
 
 type Status int32
@@ -246,11 +251,11 @@ func (x Status) String() string {
 }
 
 func (Status) Descriptor() protoreflect.EnumDescriptor {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_enumTypes[3].Descriptor()
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_enumTypes[3].Descriptor()
 }
 
 func (Status) Type() protoreflect.EnumType {
-	return &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_enumTypes[3]
+	return &file_proto_fn_v1beta1_zz_generated_run_function_proto_enumTypes[3]
 }
 
 func (x Status) Number() protoreflect.EnumNumber {
@@ -259,45 +264,45 @@ func (x Status) Number() protoreflect.EnumNumber {
 
 // Deprecated: Use Status.Descriptor instead.
 func (Status) EnumDescriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{3}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{3}
 }
 
-// A RunFunctionRequest requests that the Composition Function be run.
+// A RunFunctionRequest requests that the function be run.
 type RunFunctionRequest struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
 	// Metadata pertaining to this request.
 	Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
-	// The observed state prior to invocation of a Function pipeline. State passed
-	// to each Function is fresh as of the time the pipeline was invoked, not as
-	// of the time each Function was invoked.
+	// The observed state prior to invocation of a function pipeline. State passed
+	// to each function is fresh as of the time the pipeline was invoked, not as
+	// of the time each function was invoked.
 	Observed *State `protobuf:"bytes,2,opt,name=observed,proto3" json:"observed,omitempty"`
-	// Desired state according to a Function pipeline. The state passed to a
-	// particular Function may have been accumulated by previous Functions in the
+	// Desired state according to a function pipeline. The state passed to a
+	// particular function may have been accumulated by previous functions in the
 	// pipeline.
 	//
 	// Note that the desired state must be a partial object with only the fields
-	// that this function (and its predecessors in the pipeline) wants to have
-	// set in the object. Copying a non-partial observed state to desired is most
+	// that this function (and its predecessors in the pipeline) wants to have set
+	// in the object. Copying a non-partial observed state to desired is most
 	// likely not what you want to do. Leaving out fields that had been returned
 	// as desired before will result in them being deleted from the objects in the
 	// cluster.
 	Desired *State `protobuf:"bytes,3,opt,name=desired,proto3" json:"desired,omitempty"`
-	// Optional input specific to this Function invocation. A JSON representation
-	// of the 'input' block of the relevant entry in a Composition's pipeline.
+	// Optional input specific to this function invocation. A JSON representation
+	// of the 'input' block of the relevant entry in a function pipeline.
 	Input *structpb.Struct `protobuf:"bytes,4,opt,name=input,proto3,oneof" json:"input,omitempty"`
 	// Optional context. Crossplane may pass arbitrary contextual information to a
-	// Function. A Function may also return context in its RunFunctionResponse,
-	// and that context will be passed to subsequent Functions. Crossplane
-	// discards all context returned by the last Function in the pipeline.
+	// function. A function may also return context in its RunFunctionResponse,
+	// and that context will be passed to subsequent functions. Crossplane
+	// discards all context returned by the last function in the pipeline.
 	Context *structpb.Struct `protobuf:"bytes,5,opt,name=context,proto3,oneof" json:"context,omitempty"`
-	// Optional extra resources that the Function required.
-	// Note that extra resources is a map to Resources, plural.
-	// The map key corresponds to the key in a RunFunctionResponse's
-	// extra_resources field. If a Function requested extra resources that
-	// did not exist, Crossplane sets the map key to an empty Resources message to
-	// indicate that it attempted to satisfy the request.
-	ExtraResources map[string]*Resources `protobuf:"bytes,6,rep,name=extra_resources,json=extraResources,proto3" json:"extra_resources,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
-	// Optional credentials that this Function may use to communicate with an
+	// Optional resources that the function specified in its requirements. Note
+	// that resources is a map to Resources, plural. The map key corresponds to
+	// the key in a RunFunctionResponse's requirements.resources field. If a
+	// function requested required resources that did not exist, Crossplane sets
+	// the map key to an empty Resources message to indicate that it attempted to
+	// satisfy the request.
+	RequiredResources map[string]*Resources `protobuf:"bytes,6,rep,name=required_resources,json=requiredResources,proto3" json:"required_resources,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
+	// Optional credentials that this function may use to communicate with an
 	// external system.
 	Credentials   map[string]*Credentials `protobuf:"bytes,7,rep,name=credentials,proto3" json:"credentials,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
 	unknownFields protoimpl.UnknownFields
@@ -306,7 +311,7 @@ type RunFunctionRequest struct {
 
 func (x *RunFunctionRequest) Reset() {
 	*x = RunFunctionRequest{}
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[0]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[0]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -318,7 +323,7 @@ func (x *RunFunctionRequest) String() string {
 func (*RunFunctionRequest) ProtoMessage() {}
 
 func (x *RunFunctionRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[0]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[0]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -331,7 +336,7 @@ func (x *RunFunctionRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RunFunctionRequest.ProtoReflect.Descriptor instead.
 func (*RunFunctionRequest) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{0}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{0}
 }
 
 func (x *RunFunctionRequest) GetMeta() *RequestMeta {
@@ -369,9 +374,9 @@ func (x *RunFunctionRequest) GetContext() *structpb.Struct {
 	return nil
 }
 
-func (x *RunFunctionRequest) GetExtraResources() map[string]*Resources {
+func (x *RunFunctionRequest) GetRequiredResources() map[string]*Resources {
 	if x != nil {
-		return x.ExtraResources
+		return x.RequiredResources
 	}
 	return nil
 }
@@ -383,7 +388,7 @@ func (x *RunFunctionRequest) GetCredentials() map[string]*Credentials {
 	return nil
 }
 
-// Credentials that a Function may use to communicate with an external system.
+// Credentials that a function may use to communicate with an external system.
 type Credentials struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
 	// Source of the credentials.
@@ -398,7 +403,7 @@ type Credentials struct {
 
 func (x *Credentials) Reset() {
 	*x = Credentials{}
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[1]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[1]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -410,7 +415,7 @@ func (x *Credentials) String() string {
 func (*Credentials) ProtoMessage() {}
 
 func (x *Credentials) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[1]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[1]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -423,7 +428,7 @@ func (x *Credentials) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Credentials.ProtoReflect.Descriptor instead.
 func (*Credentials) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{1}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{1}
 }
 
 func (x *Credentials) GetSource() isCredentials_Source {
@@ -463,7 +468,7 @@ type CredentialData struct {
 
 func (x *CredentialData) Reset() {
 	*x = CredentialData{}
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[2]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[2]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -475,7 +480,7 @@ func (x *CredentialData) String() string {
 func (*CredentialData) ProtoMessage() {}
 
 func (x *CredentialData) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[2]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[2]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -488,7 +493,7 @@ func (x *CredentialData) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use CredentialData.ProtoReflect.Descriptor instead.
 func (*CredentialData) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{2}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{2}
 }
 
 func (x *CredentialData) GetData() map[string][]byte {
@@ -508,7 +513,7 @@ type Resources struct {
 
 func (x *Resources) Reset() {
 	*x = Resources{}
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[3]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[3]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -520,7 +525,7 @@ func (x *Resources) String() string {
 func (*Resources) ProtoMessage() {}
 
 func (x *Resources) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[3]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[3]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -533,7 +538,7 @@ func (x *Resources) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Resources.ProtoReflect.Descriptor instead.
 func (*Resources) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{3}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{3}
 }
 
 func (x *Resources) GetItems() []*Resource {
@@ -543,40 +548,46 @@ func (x *Resources) GetItems() []*Resource {
 	return nil
 }
 
-// A RunFunctionResponse contains the result of a Composition Function run.
+// A RunFunctionResponse contains the result of a function run.
 type RunFunctionResponse struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
 	// Metadata pertaining to this response.
 	Meta *ResponseMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
-	// Desired state according to a Function pipeline. Functions may add desired
+	// Desired state according to a function pipeline. functions may add desired
 	// state, and may mutate or delete any part of the desired state they are
-	// concerned with. A Function must pass through any part of the desired state
+	// concerned with. A function must pass through any part of the desired state
 	// that it is not concerned with.
 	//
 	// Note that the desired state must be a partial object with only the fields
-	// that this function (and its predecessors in the pipeline) wants to have
-	// set in the object. Copying a non-partial observed state to desired is most
+	// that this function (and its predecessors in the pipeline) wants to have set
+	// in the object. Copying a non-partial observed state to desired is most
 	// likely not what you want to do. Leaving out fields that had been returned
 	// as desired before will result in them being deleted from the objects in the
 	// cluster.
 	Desired *State `protobuf:"bytes,2,opt,name=desired,proto3" json:"desired,omitempty"`
-	// Results of the Function run. Results are used for observability purposes.
+	// Results of the function run. Results are used for observability purposes.
 	Results []*Result `protobuf:"bytes,3,rep,name=results,proto3" json:"results,omitempty"`
-	// Optional context to be passed to the next Function in the pipeline as part
+	// Optional context to be passed to the next function in the pipeline as part
 	// of the RunFunctionRequest. Dropped on the last function in the pipeline.
 	Context *structpb.Struct `protobuf:"bytes,4,opt,name=context,proto3,oneof" json:"context,omitempty"`
-	// Requirements that must be satisfied for this Function to run successfully.
+	// Requirements that must be satisfied for this function to run successfully.
 	Requirements *Requirements `protobuf:"bytes,5,opt,name=requirements,proto3" json:"requirements,omitempty"`
-	// Status conditions to be applied to the composite resource. Conditions may also
-	// optionally be applied to the composite resource's associated claim.
-	Conditions    []*Condition `protobuf:"bytes,6,rep,name=conditions,proto3" json:"conditions,omitempty"`
+	// Status conditions to be applied to the XR. Conditions may also optionally
+	// be applied to the XR's associated claim.
+	//
+	// Conditions are only used for composition. They're ignored by Operations.
+	Conditions []*Condition `protobuf:"bytes,6,rep,name=conditions,proto3" json:"conditions,omitempty"`
+	// Optional output specific to this function invocation.
+	//
+	// Only Operations use function output. XRs will discard any function output.
+	Output        *structpb.Struct `protobuf:"bytes,7,opt,name=output,proto3,oneof" json:"output,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
 }
 
 func (x *RunFunctionResponse) Reset() {
 	*x = RunFunctionResponse{}
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[4]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[4]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -588,7 +599,7 @@ func (x *RunFunctionResponse) String() string {
 func (*RunFunctionResponse) ProtoMessage() {}
 
 func (x *RunFunctionResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[4]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[4]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -601,7 +612,7 @@ func (x *RunFunctionResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RunFunctionResponse.ProtoReflect.Descriptor instead.
 func (*RunFunctionResponse) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{4}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{4}
 }
 
 func (x *RunFunctionResponse) GetMeta() *ResponseMeta {
@@ -646,6 +657,13 @@ func (x *RunFunctionResponse) GetConditions() []*Condition {
 	return nil
 }
 
+func (x *RunFunctionResponse) GetOutput() *structpb.Struct {
+	if x != nil {
+		return x.Output
+	}
+	return nil
+}
+
 // RequestMeta contains metadata pertaining to a RunFunctionRequest.
 type RequestMeta struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
@@ -658,7 +676,7 @@ type RequestMeta struct {
 
 func (x *RequestMeta) Reset() {
 	*x = RequestMeta{}
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[5]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[5]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -670,7 +688,7 @@ func (x *RequestMeta) String() string {
 func (*RequestMeta) ProtoMessage() {}
 
 func (x *RequestMeta) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[5]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[5]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -683,7 +701,7 @@ func (x *RequestMeta) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RequestMeta.ProtoReflect.Descriptor instead.
 func (*RequestMeta) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{5}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{5}
 }
 
 func (x *RequestMeta) GetTag() string {
@@ -693,19 +711,19 @@ func (x *RequestMeta) GetTag() string {
 	return ""
 }
 
-// Requirements that must be satisfied for a Function to run successfully.
+// Requirements that must be satisfied for a function to run successfully.
 type Requirements struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
-	// Extra resources that this Function requires.
-	// The map key uniquely identifies the group of resources.
-	ExtraResources map[string]*ResourceSelector `protobuf:"bytes,1,rep,name=extra_resources,json=extraResources,proto3" json:"extra_resources,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
-	unknownFields  protoimpl.UnknownFields
-	sizeCache      protoimpl.SizeCache
+	// Resources that this function requires. The map key uniquely identifies the
+	// group of resources.
+	Resources     map[string]*ResourceSelector `protobuf:"bytes,1,rep,name=resources,proto3" json:"resources,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
 }
 
 func (x *Requirements) Reset() {
 	*x = Requirements{}
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[6]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[6]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -717,7 +735,7 @@ func (x *Requirements) String() string {
 func (*Requirements) ProtoMessage() {}
 
 func (x *Requirements) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[6]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[6]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -730,12 +748,12 @@ func (x *Requirements) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Requirements.ProtoReflect.Descriptor instead.
 func (*Requirements) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{6}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{6}
 }
 
-func (x *Requirements) GetExtraResources() map[string]*ResourceSelector {
+func (x *Requirements) GetResources() map[string]*ResourceSelector {
 	if x != nil {
-		return x.ExtraResources
+		return x.Resources
 	}
 	return nil
 }
@@ -754,9 +772,9 @@ type ResourceSelector struct {
 	//	*ResourceSelector_MatchName
 	//	*ResourceSelector_MatchLabels
 	Match isResourceSelector_Match `protobuf_oneof:"match"`
-	// Match resources in this namespace.
-	// Omit namespace to match cluster scoped resources, or to match namespaced
-	// resources by labels across all namespaces.
+	// Match resources in this namespace. Omit namespace to match cluster scoped
+	// resources, or to match namespaced resources by labels across all
+	// namespaces.
 	Namespace     *string `protobuf:"bytes,5,opt,name=namespace,proto3,oneof" json:"namespace,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
@@ -764,7 +782,7 @@ type ResourceSelector struct {
 
 func (x *ResourceSelector) Reset() {
 	*x = ResourceSelector{}
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[7]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[7]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -776,7 +794,7 @@ func (x *ResourceSelector) String() string {
 func (*ResourceSelector) ProtoMessage() {}
 
 func (x *ResourceSelector) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[7]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[7]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -789,7 +807,7 @@ func (x *ResourceSelector) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ResourceSelector.ProtoReflect.Descriptor instead.
 func (*ResourceSelector) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{7}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{7}
 }
 
 func (x *ResourceSelector) GetApiVersion() string {
@@ -866,7 +884,7 @@ type MatchLabels struct {
 
 func (x *MatchLabels) Reset() {
 	*x = MatchLabels{}
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[8]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[8]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -878,7 +896,7 @@ func (x *MatchLabels) String() string {
 func (*MatchLabels) ProtoMessage() {}
 
 func (x *MatchLabels) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[8]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[8]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -891,7 +909,7 @@ func (x *MatchLabels) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use MatchLabels.ProtoReflect.Descriptor instead.
 func (*MatchLabels) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{8}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{8}
 }
 
 func (x *MatchLabels) GetLabels() map[string]string {
@@ -917,7 +935,7 @@ type ResponseMeta struct {
 
 func (x *ResponseMeta) Reset() {
 	*x = ResponseMeta{}
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[9]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[9]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -929,7 +947,7 @@ func (x *ResponseMeta) String() string {
 func (*ResponseMeta) ProtoMessage() {}
 
 func (x *ResponseMeta) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[9]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[9]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -942,7 +960,7 @@ func (x *ResponseMeta) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ResponseMeta.ProtoReflect.Descriptor instead.
 func (*ResponseMeta) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{9}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{9}
 }
 
 func (x *ResponseMeta) GetTag() string {
@@ -959,12 +977,14 @@ func (x *ResponseMeta) GetTtl() *durationpb.Duration {
 	return nil
 }
 
-// State of the composite resource (XR) and any composed resources.
+// State of the XR (XR) and any resources.
 type State struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
-	// The state of the composite resource (XR).
+	// The state of the XR (XR).
 	Composite *Resource `protobuf:"bytes,1,opt,name=composite,proto3" json:"composite,omitempty"`
-	// The state of any composed resources.
+	// The state of any other resources. In composition functions these are the
+	// composed resources. In operation functions they're arbitrary resources that
+	// the operation wants to create or update.
 	Resources     map[string]*Resource `protobuf:"bytes,2,rep,name=resources,proto3" json:"resources,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
@@ -972,7 +992,7 @@ type State struct {
 
 func (x *State) Reset() {
 	*x = State{}
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[10]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[10]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -984,7 +1004,7 @@ func (x *State) String() string {
 func (*State) ProtoMessage() {}
 
 func (x *State) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[10]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[10]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -997,7 +1017,7 @@ func (x *State) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use State.ProtoReflect.Descriptor instead.
 func (*State) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{10}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{10}
 }
 
 func (x *State) GetComposite() *Resource {
@@ -1014,49 +1034,53 @@ func (x *State) GetResources() map[string]*Resource {
 	return nil
 }
 
-// A Resource represents the state of a composite or composed resource.
+// A Resource represents the state of a Kubernetes resource.
 type Resource struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
 	// The JSON representation of the resource.
 	//
-	//   - Crossplane will set this field in a RunFunctionRequest to the entire
-	//     observed state of a resource - including its metadata, spec, and status.
+	// * Crossplane will set this field in a RunFunctionRequest to the entire
+	// observed state of a resource - including its metadata, spec, and status.
 	//
-	//   - A Function should set this field in a RunFunctionRequest to communicate
-	//     the desired state of a composite or composed resource.
+	// * A function should set this field in a RunFunctionRequest to communicate
+	// the desired state of the resource.
 	//
-	//   - A Function may only specify the desired status of a composite resource -
-	//     not its metadata or spec. A Function should not return desired metadata
-	//     or spec for a composite resource. This will be ignored.
+	// * A function may only specify the desired status of a XR - not its metadata
+	// or spec. A function should not return desired metadata or spec for a XR.
+	// This will be ignored.
 	//
-	//   - A Function may not specify the desired status of a composed resource -
-	//     only its metadata and spec. A Function should not return desired status
-	//     for a composed resource. This will be ignored.
+	// * A function may not specify the desired status of any other resource -
+	// e.g. composed resources. It may only specify their metadata and spec.
+	// Status will be ignored.
 	Resource *structpb.Struct `protobuf:"bytes,1,opt,name=resource,proto3" json:"resource,omitempty"`
 	// The resource's connection details.
 	//
-	//   - Crossplane will set this field in a RunFunctionRequest to communicate the
-	//     the observed connection details of a composite or composed resource.
+	// * Crossplane will set this field in a RunFunctionRequest to communicate the
+	// the observed connection details of a composite or composed resource.
 	//
-	//   - A Function should set this field in a RunFunctionResponse to indicate the
-	//     desired connection details of the composite resource.
+	// * A function should set this field in a RunFunctionResponse to indicate the
+	// desired connection details of the XR.
 	//
-	//   - A Function should not set this field in a RunFunctionResponse to indicate
-	//     the desired connection details of a composed resource. This will be
-	//     ignored.
+	// * A function should not set this field in a RunFunctionResponse to indicate
+	// the desired connection details of a composed resource. This will be
+	// ignored.
+	//
+	// Connection details are only used for composition. They're ignored by
+	// Operations.
 	ConnectionDetails map[string][]byte `protobuf:"bytes,2,rep,name=connection_details,json=connectionDetails,proto3" json:"connection_details,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
 	// Ready indicates whether the resource should be considered ready.
 	//
 	// * Crossplane will never set this field in a RunFunctionRequest.
 	//
-	//   - A Function should set this field to READY_TRUE in a RunFunctionResponse
-	//     to indicate that a desired composed resource is ready.
+	// * A function should set this field to READY_TRUE in a RunFunctionResponse
+	// to indicate that a desired resource is ready.
+	//
+	// * A function should set this field to READY_TRUE in a RunFunctionResponse
+	// to indicate that a desired XR is ready. This overwrites the standard
+	// readiness detection that determines the ready state of the composite by the
+	// ready state of the the composed resources.
 	//
-	//   - A Function should set this field to READY_TRUE in a RunFunctionResponse
-	//     to indicate that a desired composite resource is ready.
-	//     This overwrites the standard readiness detection that determines the
-	//     ready state of the composite by the ready state of the the
-	//     composed resources.
+	// Ready is only used for composition. It's ignored by Operations.
 	Ready         Ready `protobuf:"varint,3,opt,name=ready,proto3,enum=apiextensions.fn.proto.v1beta1.Ready" json:"ready,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
@@ -1064,7 +1088,7 @@ type Resource struct {
 
 func (x *Resource) Reset() {
 	*x = Resource{}
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[11]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[11]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1076,7 +1100,7 @@ func (x *Resource) String() string {
 func (*Resource) ProtoMessage() {}
 
 func (x *Resource) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[11]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[11]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1089,7 +1113,7 @@ func (x *Resource) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Resource.ProtoReflect.Descriptor instead.
 func (*Resource) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{11}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{11}
 }
 
 func (x *Resource) GetResource() *structpb.Struct {
@@ -1113,7 +1137,7 @@ func (x *Resource) GetReady() Ready {
 	return Ready_READY_UNSPECIFIED
 }
 
-// A Result of running a Function.
+// A Result of running a function.
 type Result struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
 	// Severity of this result.
@@ -1131,7 +1155,7 @@ type Result struct {
 
 func (x *Result) Reset() {
 	*x = Result{}
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[12]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[12]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1143,7 +1167,7 @@ func (x *Result) String() string {
 func (*Result) ProtoMessage() {}
 
 func (x *Result) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[12]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[12]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1156,7 +1180,7 @@ func (x *Result) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Result.ProtoReflect.Descriptor instead.
 func (*Result) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{12}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{12}
 }
 
 func (x *Result) GetSeverity() Severity {
@@ -1187,9 +1211,9 @@ func (x *Result) GetTarget() Target {
 	return Target_TARGET_UNSPECIFIED
 }
 
-// Status condition to be applied to the composite resource. Condition may also
-// optionally be applied to the composite resource's associated claim. For
-// detailed information on proper usage of status conditions, please see
+// Status condition to be applied to the XR. Condition may also optionally be
+// applied to the XR's associated claim. For detailed information on proper
+// usage of status conditions, please see
 // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties.
 type Condition struct {
 	state protoimpl.MessageState `protogen:"open.v1"`
@@ -1214,7 +1238,7 @@ type Condition struct {
 
 func (x *Condition) Reset() {
 	*x = Condition{}
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[13]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[13]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -1226,7 +1250,7 @@ func (x *Condition) String() string {
 func (*Condition) ProtoMessage() {}
 
 func (x *Condition) ProtoReflect() protoreflect.Message {
-	mi := &file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[13]
+	mi := &file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[13]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1239,7 +1263,7 @@ func (x *Condition) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Condition.ProtoReflect.Descriptor instead.
 func (*Condition) Descriptor() ([]byte, []int) {
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{13}
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP(), []int{13}
 }
 
 func (x *Condition) GetType() string {
@@ -1277,20 +1301,20 @@ func (x *Condition) GetTarget() Target {
 	return Target_TARGET_UNSPECIFIED
 }
 
-var File_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto protoreflect.FileDescriptor
+var File_proto_fn_v1beta1_zz_generated_run_function_proto protoreflect.FileDescriptor
 
-const file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDesc = "" +
+const file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDesc = "" +
 	"\n" +
-	"Capis/apiextensions/fn/proto/v1beta1/zz_generated_run_function.proto\x12\x1eapiextensions.fn.proto.v1beta1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x8e\x06\n" +
+	"0proto/fn/v1beta1/zz_generated_run_function.proto\x12\x1eapiextensions.fn.proto.v1beta1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x9a\x06\n" +
 	"\x12RunFunctionRequest\x12?\n" +
 	"\x04meta\x18\x01 \x01(\v2+.apiextensions.fn.proto.v1beta1.RequestMetaR\x04meta\x12A\n" +
 	"\bobserved\x18\x02 \x01(\v2%.apiextensions.fn.proto.v1beta1.StateR\bobserved\x12?\n" +
 	"\adesired\x18\x03 \x01(\v2%.apiextensions.fn.proto.v1beta1.StateR\adesired\x122\n" +
 	"\x05input\x18\x04 \x01(\v2\x17.google.protobuf.StructH\x00R\x05input\x88\x01\x01\x126\n" +
-	"\acontext\x18\x05 \x01(\v2\x17.google.protobuf.StructH\x01R\acontext\x88\x01\x01\x12o\n" +
-	"\x0fextra_resources\x18\x06 \x03(\v2F.apiextensions.fn.proto.v1beta1.RunFunctionRequest.ExtraResourcesEntryR\x0eextraResources\x12e\n" +
-	"\vcredentials\x18\a \x03(\v2C.apiextensions.fn.proto.v1beta1.RunFunctionRequest.CredentialsEntryR\vcredentials\x1al\n" +
-	"\x13ExtraResourcesEntry\x12\x10\n" +
+	"\acontext\x18\x05 \x01(\v2\x17.google.protobuf.StructH\x01R\acontext\x88\x01\x01\x12x\n" +
+	"\x12required_resources\x18\x06 \x03(\v2I.apiextensions.fn.proto.v1beta1.RunFunctionRequest.RequiredResourcesEntryR\x11requiredResources\x12e\n" +
+	"\vcredentials\x18\a \x03(\v2C.apiextensions.fn.proto.v1beta1.RunFunctionRequest.CredentialsEntryR\vcredentials\x1ao\n" +
+	"\x16RequiredResourcesEntry\x12\x10\n" +
 	"\x03key\x18\x01 \x01(\tR\x03key\x12?\n" +
 	"\x05value\x18\x02 \x01(\v2).apiextensions.fn.proto.v1beta1.ResourcesR\x05value:\x028\x01\x1ak\n" +
 	"\x10CredentialsEntry\x12\x10\n" +
@@ -1308,7 +1332,7 @@ const file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_r
 	"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
 	"\x05value\x18\x02 \x01(\fR\x05value:\x028\x01\"K\n" +
 	"\tResources\x12>\n" +
-	"\x05items\x18\x01 \x03(\v2(.apiextensions.fn.proto.v1beta1.ResourceR\x05items\"\xbb\x03\n" +
+	"\x05items\x18\x01 \x03(\v2(.apiextensions.fn.proto.v1beta1.ResourceR\x05items\"\xfc\x03\n" +
 	"\x13RunFunctionResponse\x12@\n" +
 	"\x04meta\x18\x01 \x01(\v2,.apiextensions.fn.proto.v1beta1.ResponseMetaR\x04meta\x12?\n" +
 	"\adesired\x18\x02 \x01(\v2%.apiextensions.fn.proto.v1beta1.StateR\adesired\x12@\n" +
@@ -1317,14 +1341,16 @@ const file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_r
 	"\frequirements\x18\x05 \x01(\v2,.apiextensions.fn.proto.v1beta1.RequirementsR\frequirements\x12I\n" +
 	"\n" +
 	"conditions\x18\x06 \x03(\v2).apiextensions.fn.proto.v1beta1.ConditionR\n" +
-	"conditionsB\n" +
+	"conditions\x124\n" +
+	"\x06output\x18\a \x01(\v2\x17.google.protobuf.StructH\x01R\x06output\x88\x01\x01B\n" +
 	"\n" +
-	"\b_context\"\x1f\n" +
+	"\b_contextB\t\n" +
+	"\a_output\"\x1f\n" +
 	"\vRequestMeta\x12\x10\n" +
-	"\x03tag\x18\x01 \x01(\tR\x03tag\"\xee\x01\n" +
-	"\fRequirements\x12i\n" +
-	"\x0fextra_resources\x18\x01 \x03(\v2@.apiextensions.fn.proto.v1beta1.Requirements.ExtraResourcesEntryR\x0eextraResources\x1as\n" +
-	"\x13ExtraResourcesEntry\x12\x10\n" +
+	"\x03tag\x18\x01 \x01(\tR\x03tag\"\xd9\x01\n" +
+	"\fRequirements\x12Y\n" +
+	"\tresources\x18\x01 \x03(\v2;.apiextensions.fn.proto.v1beta1.Requirements.ResourcesEntryR\tresources\x1an\n" +
+	"\x0eResourcesEntry\x12\x10\n" +
 	"\x03key\x18\x01 \x01(\tR\x03key\x12F\n" +
 	"\x05value\x18\x02 \x01(\v20.apiextensions.fn.proto.v1beta1.ResourceSelectorR\x05value:\x028\x01\"\xf4\x01\n" +
 	"\x10ResourceSelector\x12\x1f\n" +
@@ -1396,23 +1422,23 @@ const file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_r
 	"\x15STATUS_CONDITION_TRUE\x10\x02\x12\x1a\n" +
 	"\x16STATUS_CONDITION_FALSE\x10\x032\x91\x01\n" +
 	"\x15FunctionRunnerService\x12x\n" +
-	"\vRunFunction\x122.apiextensions.fn.proto.v1beta1.RunFunctionRequest\x1a3.apiextensions.fn.proto.v1beta1.RunFunctionResponse\"\x00BFZDgithub.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1beta1b\x06proto3"
+	"\vRunFunction\x122.apiextensions.fn.proto.v1beta1.RunFunctionRequest\x1a3.apiextensions.fn.proto.v1beta1.RunFunctionResponse\"\x00B3Z1github.com/crossplane/crossplane/proto/fn/v1beta1b\x06proto3"
 
 var (
-	file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescOnce sync.Once
-	file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescData []byte
+	file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescOnce sync.Once
+	file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescData []byte
 )
 
-func file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescGZIP() []byte {
-	file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescOnce.Do(func() {
-		file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDesc), len(file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDesc)))
+func file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescGZIP() []byte {
+	file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescOnce.Do(func() {
+		file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDesc), len(file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDesc)))
 	})
-	return file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDescData
+	return file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDescData
 }
 
-var file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
-var file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes = make([]protoimpl.MessageInfo, 21)
-var file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_goTypes = []any{
+var file_proto_fn_v1beta1_zz_generated_run_function_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
+var file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes = make([]protoimpl.MessageInfo, 21)
+var file_proto_fn_v1beta1_zz_generated_run_function_proto_goTypes = []any{
 	(Ready)(0),                  // 0: apiextensions.fn.proto.v1beta1.Ready
 	(Severity)(0),               // 1: apiextensions.fn.proto.v1beta1.Severity
 	(Target)(0),                 // 2: apiextensions.fn.proto.v1beta1.Target
@@ -1431,23 +1457,23 @@ var file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_goT
 	(*Resource)(nil),            // 15: apiextensions.fn.proto.v1beta1.Resource
 	(*Result)(nil),              // 16: apiextensions.fn.proto.v1beta1.Result
 	(*Condition)(nil),           // 17: apiextensions.fn.proto.v1beta1.Condition
-	nil,                         // 18: apiextensions.fn.proto.v1beta1.RunFunctionRequest.ExtraResourcesEntry
+	nil,                         // 18: apiextensions.fn.proto.v1beta1.RunFunctionRequest.RequiredResourcesEntry
 	nil,                         // 19: apiextensions.fn.proto.v1beta1.RunFunctionRequest.CredentialsEntry
 	nil,                         // 20: apiextensions.fn.proto.v1beta1.CredentialData.DataEntry
-	nil,                         // 21: apiextensions.fn.proto.v1beta1.Requirements.ExtraResourcesEntry
+	nil,                         // 21: apiextensions.fn.proto.v1beta1.Requirements.ResourcesEntry
 	nil,                         // 22: apiextensions.fn.proto.v1beta1.MatchLabels.LabelsEntry
 	nil,                         // 23: apiextensions.fn.proto.v1beta1.State.ResourcesEntry
 	nil,                         // 24: apiextensions.fn.proto.v1beta1.Resource.ConnectionDetailsEntry
 	(*structpb.Struct)(nil),     // 25: google.protobuf.Struct
 	(*durationpb.Duration)(nil), // 26: google.protobuf.Duration
 }
-var file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_depIdxs = []int32{
+var file_proto_fn_v1beta1_zz_generated_run_function_proto_depIdxs = []int32{
 	9,  // 0: apiextensions.fn.proto.v1beta1.RunFunctionRequest.meta:type_name -> apiextensions.fn.proto.v1beta1.RequestMeta
 	14, // 1: apiextensions.fn.proto.v1beta1.RunFunctionRequest.observed:type_name -> apiextensions.fn.proto.v1beta1.State
 	14, // 2: apiextensions.fn.proto.v1beta1.RunFunctionRequest.desired:type_name -> apiextensions.fn.proto.v1beta1.State
 	25, // 3: apiextensions.fn.proto.v1beta1.RunFunctionRequest.input:type_name -> google.protobuf.Struct
 	25, // 4: apiextensions.fn.proto.v1beta1.RunFunctionRequest.context:type_name -> google.protobuf.Struct
-	18, // 5: apiextensions.fn.proto.v1beta1.RunFunctionRequest.extra_resources:type_name -> apiextensions.fn.proto.v1beta1.RunFunctionRequest.ExtraResourcesEntry
+	18, // 5: apiextensions.fn.proto.v1beta1.RunFunctionRequest.required_resources:type_name -> apiextensions.fn.proto.v1beta1.RunFunctionRequest.RequiredResourcesEntry
 	19, // 6: apiextensions.fn.proto.v1beta1.RunFunctionRequest.credentials:type_name -> apiextensions.fn.proto.v1beta1.RunFunctionRequest.CredentialsEntry
 	6,  // 7: apiextensions.fn.proto.v1beta1.Credentials.credential_data:type_name -> apiextensions.fn.proto.v1beta1.CredentialData
 	20, // 8: apiextensions.fn.proto.v1beta1.CredentialData.data:type_name -> apiextensions.fn.proto.v1beta1.CredentialData.DataEntry
@@ -1458,65 +1484,66 @@ var file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_dep
 	25, // 13: apiextensions.fn.proto.v1beta1.RunFunctionResponse.context:type_name -> google.protobuf.Struct
 	10, // 14: apiextensions.fn.proto.v1beta1.RunFunctionResponse.requirements:type_name -> apiextensions.fn.proto.v1beta1.Requirements
 	17, // 15: apiextensions.fn.proto.v1beta1.RunFunctionResponse.conditions:type_name -> apiextensions.fn.proto.v1beta1.Condition
-	21, // 16: apiextensions.fn.proto.v1beta1.Requirements.extra_resources:type_name -> apiextensions.fn.proto.v1beta1.Requirements.ExtraResourcesEntry
-	12, // 17: apiextensions.fn.proto.v1beta1.ResourceSelector.match_labels:type_name -> apiextensions.fn.proto.v1beta1.MatchLabels
-	22, // 18: apiextensions.fn.proto.v1beta1.MatchLabels.labels:type_name -> apiextensions.fn.proto.v1beta1.MatchLabels.LabelsEntry
-	26, // 19: apiextensions.fn.proto.v1beta1.ResponseMeta.ttl:type_name -> google.protobuf.Duration
-	15, // 20: apiextensions.fn.proto.v1beta1.State.composite:type_name -> apiextensions.fn.proto.v1beta1.Resource
-	23, // 21: apiextensions.fn.proto.v1beta1.State.resources:type_name -> apiextensions.fn.proto.v1beta1.State.ResourcesEntry
-	25, // 22: apiextensions.fn.proto.v1beta1.Resource.resource:type_name -> google.protobuf.Struct
-	24, // 23: apiextensions.fn.proto.v1beta1.Resource.connection_details:type_name -> apiextensions.fn.proto.v1beta1.Resource.ConnectionDetailsEntry
-	0,  // 24: apiextensions.fn.proto.v1beta1.Resource.ready:type_name -> apiextensions.fn.proto.v1beta1.Ready
-	1,  // 25: apiextensions.fn.proto.v1beta1.Result.severity:type_name -> apiextensions.fn.proto.v1beta1.Severity
-	2,  // 26: apiextensions.fn.proto.v1beta1.Result.target:type_name -> apiextensions.fn.proto.v1beta1.Target
-	3,  // 27: apiextensions.fn.proto.v1beta1.Condition.status:type_name -> apiextensions.fn.proto.v1beta1.Status
-	2,  // 28: apiextensions.fn.proto.v1beta1.Condition.target:type_name -> apiextensions.fn.proto.v1beta1.Target
-	7,  // 29: apiextensions.fn.proto.v1beta1.RunFunctionRequest.ExtraResourcesEntry.value:type_name -> apiextensions.fn.proto.v1beta1.Resources
-	5,  // 30: apiextensions.fn.proto.v1beta1.RunFunctionRequest.CredentialsEntry.value:type_name -> apiextensions.fn.proto.v1beta1.Credentials
-	11, // 31: apiextensions.fn.proto.v1beta1.Requirements.ExtraResourcesEntry.value:type_name -> apiextensions.fn.proto.v1beta1.ResourceSelector
-	15, // 32: apiextensions.fn.proto.v1beta1.State.ResourcesEntry.value:type_name -> apiextensions.fn.proto.v1beta1.Resource
-	4,  // 33: apiextensions.fn.proto.v1beta1.FunctionRunnerService.RunFunction:input_type -> apiextensions.fn.proto.v1beta1.RunFunctionRequest
-	8,  // 34: apiextensions.fn.proto.v1beta1.FunctionRunnerService.RunFunction:output_type -> apiextensions.fn.proto.v1beta1.RunFunctionResponse
-	34, // [34:35] is the sub-list for method output_type
-	33, // [33:34] is the sub-list for method input_type
-	33, // [33:33] is the sub-list for extension type_name
-	33, // [33:33] is the sub-list for extension extendee
-	0,  // [0:33] is the sub-list for field type_name
-}
-
-func init() { file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_init() }
-func file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_init() {
-	if File_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto != nil {
+	25, // 16: apiextensions.fn.proto.v1beta1.RunFunctionResponse.output:type_name -> google.protobuf.Struct
+	21, // 17: apiextensions.fn.proto.v1beta1.Requirements.resources:type_name -> apiextensions.fn.proto.v1beta1.Requirements.ResourcesEntry
+	12, // 18: apiextensions.fn.proto.v1beta1.ResourceSelector.match_labels:type_name -> apiextensions.fn.proto.v1beta1.MatchLabels
+	22, // 19: apiextensions.fn.proto.v1beta1.MatchLabels.labels:type_name -> apiextensions.fn.proto.v1beta1.MatchLabels.LabelsEntry
+	26, // 20: apiextensions.fn.proto.v1beta1.ResponseMeta.ttl:type_name -> google.protobuf.Duration
+	15, // 21: apiextensions.fn.proto.v1beta1.State.composite:type_name -> apiextensions.fn.proto.v1beta1.Resource
+	23, // 22: apiextensions.fn.proto.v1beta1.State.resources:type_name -> apiextensions.fn.proto.v1beta1.State.ResourcesEntry
+	25, // 23: apiextensions.fn.proto.v1beta1.Resource.resource:type_name -> google.protobuf.Struct
+	24, // 24: apiextensions.fn.proto.v1beta1.Resource.connection_details:type_name -> apiextensions.fn.proto.v1beta1.Resource.ConnectionDetailsEntry
+	0,  // 25: apiextensions.fn.proto.v1beta1.Resource.ready:type_name -> apiextensions.fn.proto.v1beta1.Ready
+	1,  // 26: apiextensions.fn.proto.v1beta1.Result.severity:type_name -> apiextensions.fn.proto.v1beta1.Severity
+	2,  // 27: apiextensions.fn.proto.v1beta1.Result.target:type_name -> apiextensions.fn.proto.v1beta1.Target
+	3,  // 28: apiextensions.fn.proto.v1beta1.Condition.status:type_name -> apiextensions.fn.proto.v1beta1.Status
+	2,  // 29: apiextensions.fn.proto.v1beta1.Condition.target:type_name -> apiextensions.fn.proto.v1beta1.Target
+	7,  // 30: apiextensions.fn.proto.v1beta1.RunFunctionRequest.RequiredResourcesEntry.value:type_name -> apiextensions.fn.proto.v1beta1.Resources
+	5,  // 31: apiextensions.fn.proto.v1beta1.RunFunctionRequest.CredentialsEntry.value:type_name -> apiextensions.fn.proto.v1beta1.Credentials
+	11, // 32: apiextensions.fn.proto.v1beta1.Requirements.ResourcesEntry.value:type_name -> apiextensions.fn.proto.v1beta1.ResourceSelector
+	15, // 33: apiextensions.fn.proto.v1beta1.State.ResourcesEntry.value:type_name -> apiextensions.fn.proto.v1beta1.Resource
+	4,  // 34: apiextensions.fn.proto.v1beta1.FunctionRunnerService.RunFunction:input_type -> apiextensions.fn.proto.v1beta1.RunFunctionRequest
+	8,  // 35: apiextensions.fn.proto.v1beta1.FunctionRunnerService.RunFunction:output_type -> apiextensions.fn.proto.v1beta1.RunFunctionResponse
+	35, // [35:36] is the sub-list for method output_type
+	34, // [34:35] is the sub-list for method input_type
+	34, // [34:34] is the sub-list for extension type_name
+	34, // [34:34] is the sub-list for extension extendee
+	0,  // [0:34] is the sub-list for field type_name
+}
+
+func init() { file_proto_fn_v1beta1_zz_generated_run_function_proto_init() }
+func file_proto_fn_v1beta1_zz_generated_run_function_proto_init() {
+	if File_proto_fn_v1beta1_zz_generated_run_function_proto != nil {
 		return
 	}
-	file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[0].OneofWrappers = []any{}
-	file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[1].OneofWrappers = []any{
+	file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[0].OneofWrappers = []any{}
+	file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[1].OneofWrappers = []any{
 		(*Credentials_CredentialData)(nil),
 	}
-	file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[4].OneofWrappers = []any{}
-	file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[7].OneofWrappers = []any{
+	file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[4].OneofWrappers = []any{}
+	file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[7].OneofWrappers = []any{
 		(*ResourceSelector_MatchName)(nil),
 		(*ResourceSelector_MatchLabels)(nil),
 	}
-	file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[9].OneofWrappers = []any{}
-	file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[12].OneofWrappers = []any{}
-	file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes[13].OneofWrappers = []any{}
+	file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[9].OneofWrappers = []any{}
+	file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[12].OneofWrappers = []any{}
+	file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes[13].OneofWrappers = []any{}
 	type x struct{}
 	out := protoimpl.TypeBuilder{
 		File: protoimpl.DescBuilder{
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
-			RawDescriptor: unsafe.Slice(unsafe.StringData(file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDesc), len(file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_rawDesc)),
+			RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDesc), len(file_proto_fn_v1beta1_zz_generated_run_function_proto_rawDesc)),
 			NumEnums:      4,
 			NumMessages:   21,
 			NumExtensions: 0,
 			NumServices:   1,
 		},
-		GoTypes:           file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_goTypes,
-		DependencyIndexes: file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_depIdxs,
-		EnumInfos:         file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_enumTypes,
-		MessageInfos:      file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_msgTypes,
+		GoTypes:           file_proto_fn_v1beta1_zz_generated_run_function_proto_goTypes,
+		DependencyIndexes: file_proto_fn_v1beta1_zz_generated_run_function_proto_depIdxs,
+		EnumInfos:         file_proto_fn_v1beta1_zz_generated_run_function_proto_enumTypes,
+		MessageInfos:      file_proto_fn_v1beta1_zz_generated_run_function_proto_msgTypes,
 	}.Build()
-	File_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto = out.File
-	file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_goTypes = nil
-	file_apis_apiextensions_fn_proto_v1beta1_zz_generated_run_function_proto_depIdxs = nil
+	File_proto_fn_v1beta1_zz_generated_run_function_proto = out.File
+	file_proto_fn_v1beta1_zz_generated_run_function_proto_goTypes = nil
+	file_proto_fn_v1beta1_zz_generated_run_function_proto_depIdxs = nil
 }
diff --git a/apis/apiextensions/fn/proto/v1beta1/zz_generated_run_function.proto b/proto/fn/v1beta1/zz_generated_run_function.proto
similarity index 52%
rename from apis/apiextensions/fn/proto/v1beta1/zz_generated_run_function.proto
rename to proto/fn/v1beta1/zz_generated_run_function.proto
index ad9d9c1eb..1332ae5e6 100644
--- a/apis/apiextensions/fn/proto/v1beta1/zz_generated_run_function.proto
+++ b/proto/fn/v1beta1/zz_generated_run_function.proto
@@ -14,70 +14,75 @@
    limitations under the License.
 */
 
-// Generated from apis/apiextensions/fn/proto/v1/run_function.proto by ./hack/duplicate_proto_type.sh. DO NOT EDIT.
+// Generated from proto/fn/v1/run_function.proto by ./hack/duplicate_proto_type.sh. DO NOT EDIT.
 
 syntax = "proto3";
 
-//buf:lint:ignore PACKAGE_DIRECTORY_MATCH // Too late to fix this now.
+// This package defines the RPC for both composition and operation functions.
+// Only composition functions are part of the 'apiextensions' API group. In
+// retrospect this package should've been crossplane.proto.fn.v1, but it's too
+// late to change it now.
+
+//buf:lint:ignore PACKAGE_DIRECTORY_MATCH
 package apiextensions.fn.proto.v1beta1;
 
 import "google/protobuf/duration.proto";
 import "google/protobuf/struct.proto";
 
-option go_package = "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1beta1";
+option go_package = "github.com/crossplane/crossplane/proto/fn/v1beta1";
 
-// A FunctionRunnerService is a Composition Function.
+// A FunctionRunnerService is a function.
 service FunctionRunnerService {
-  // RunFunction runs the Composition Function.
+  // RunFunction runs the function.
   rpc RunFunction(RunFunctionRequest) returns (RunFunctionResponse) {}
 }
 
-// A RunFunctionRequest requests that the Composition Function be run.
+// A RunFunctionRequest requests that the function be run.
 message RunFunctionRequest {
   // Metadata pertaining to this request.
   RequestMeta meta = 1;
 
-  // The observed state prior to invocation of a Function pipeline. State passed
-  // to each Function is fresh as of the time the pipeline was invoked, not as
-  // of the time each Function was invoked.
+  // The observed state prior to invocation of a function pipeline. State passed
+  // to each function is fresh as of the time the pipeline was invoked, not as
+  // of the time each function was invoked.
   State observed = 2;
 
-  // Desired state according to a Function pipeline. The state passed to a
-  // particular Function may have been accumulated by previous Functions in the
+  // Desired state according to a function pipeline. The state passed to a
+  // particular function may have been accumulated by previous functions in the
   // pipeline.
   //
   // Note that the desired state must be a partial object with only the fields
-  // that this function (and its predecessors in the pipeline) wants to have
-  // set in the object. Copying a non-partial observed state to desired is most
+  // that this function (and its predecessors in the pipeline) wants to have set
+  // in the object. Copying a non-partial observed state to desired is most
   // likely not what you want to do. Leaving out fields that had been returned
   // as desired before will result in them being deleted from the objects in the
   // cluster.
   State desired = 3;
 
-  // Optional input specific to this Function invocation. A JSON representation
-  // of the 'input' block of the relevant entry in a Composition's pipeline.
+  // Optional input specific to this function invocation. A JSON representation
+  // of the 'input' block of the relevant entry in a function pipeline.
   optional google.protobuf.Struct input = 4;
 
   // Optional context. Crossplane may pass arbitrary contextual information to a
-  // Function. A Function may also return context in its RunFunctionResponse,
-  // and that context will be passed to subsequent Functions. Crossplane
-  // discards all context returned by the last Function in the pipeline.
+  // function. A function may also return context in its RunFunctionResponse,
+  // and that context will be passed to subsequent functions. Crossplane
+  // discards all context returned by the last function in the pipeline.
   optional google.protobuf.Struct context = 5;
 
-  // Optional extra resources that the Function required.
-  // Note that extra resources is a map to Resources, plural.
-  // The map key corresponds to the key in a RunFunctionResponse's
-  // extra_resources field. If a Function requested extra resources that
-  // did not exist, Crossplane sets the map key to an empty Resources message to
-  // indicate that it attempted to satisfy the request.
-  map<string, Resources> extra_resources = 6;
+  // Optional resources that the function specified in its requirements. Note
+  // that resources is a map to Resources, plural. The map key corresponds to
+  // the key in a RunFunctionResponse's requirements.resources field. If a
+  // function requested required resources that did not exist, Crossplane sets
+  // the map key to an empty Resources message to indicate that it attempted to
+  // satisfy the request.
+  map<string, Resources> required_resources = 6;
 
-  // Optional credentials that this Function may use to communicate with an
+  // Optional credentials that this function may use to communicate with an
   // external system.
   map<string, Credentials> credentials = 7;
 }
 
-// Credentials that a Function may use to communicate with an external system.
+// Credentials that a function may use to communicate with an external system.
 message Credentials {
   // Source of the credentials.
   oneof source {
@@ -96,38 +101,44 @@ message Resources {
   repeated Resource items = 1;
 }
 
-// A RunFunctionResponse contains the result of a Composition Function run.
+// A RunFunctionResponse contains the result of a function run.
 message RunFunctionResponse {
   // Metadata pertaining to this response.
   ResponseMeta meta = 1;
 
-  // Desired state according to a Function pipeline. Functions may add desired
+  // Desired state according to a function pipeline. functions may add desired
   // state, and may mutate or delete any part of the desired state they are
-  // concerned with. A Function must pass through any part of the desired state
+  // concerned with. A function must pass through any part of the desired state
   // that it is not concerned with.
   //
-  //
   // Note that the desired state must be a partial object with only the fields
-  // that this function (and its predecessors in the pipeline) wants to have
-  // set in the object. Copying a non-partial observed state to desired is most
+  // that this function (and its predecessors in the pipeline) wants to have set
+  // in the object. Copying a non-partial observed state to desired is most
   // likely not what you want to do. Leaving out fields that had been returned
   // as desired before will result in them being deleted from the objects in the
   // cluster.
   State desired = 2;
 
-  // Results of the Function run. Results are used for observability purposes.
+  // Results of the function run. Results are used for observability purposes.
   repeated Result results = 3;
 
-  // Optional context to be passed to the next Function in the pipeline as part
+  // Optional context to be passed to the next function in the pipeline as part
   // of the RunFunctionRequest. Dropped on the last function in the pipeline.
   optional google.protobuf.Struct context = 4;
 
-  // Requirements that must be satisfied for this Function to run successfully.
+  // Requirements that must be satisfied for this function to run successfully.
   Requirements requirements = 5;
 
-  // Status conditions to be applied to the composite resource. Conditions may also
-  // optionally be applied to the composite resource's associated claim.
+  // Status conditions to be applied to the XR. Conditions may also optionally
+  // be applied to the XR's associated claim.
+  //
+  // Conditions are only used for composition. They're ignored by Operations.
   repeated Condition conditions = 6;
+
+  // Optional output specific to this function invocation.
+  //
+  // Only Operations use function output. XRs will discard any function output.
+  optional google.protobuf.Struct output = 7;
 }
 
 // RequestMeta contains metadata pertaining to a RunFunctionRequest.
@@ -137,11 +148,11 @@ message RequestMeta {
   string tag = 1;
 }
 
-// Requirements that must be satisfied for a Function to run successfully.
+// Requirements that must be satisfied for a function to run successfully.
 message Requirements {
-  // Extra resources that this Function requires.
-  // The map key uniquely identifies the group of resources.
-  map<string, ResourceSelector> extra_resources = 1;
+  // Resources that this function requires. The map key uniquely identifies the
+  // group of resources.
+  map<string, ResourceSelector> resources = 1;
 }
 
 // ResourceSelector selects a group of resources, either by name or by label.
@@ -161,9 +172,9 @@ message ResourceSelector {
     MatchLabels match_labels = 4;
   }
 
-  // Match resources in this namespace.
-  // Omit namespace to match cluster scoped resources, or to match namespaced
-  // resources by labels across all namespaces.
+  // Match resources in this namespace. Omit namespace to match cluster scoped
+  // resources, or to match namespaced resources by labels across all
+  // namespaces.
   optional string namespace = 5;
 }
 
@@ -184,74 +195,80 @@ message ResponseMeta {
   optional google.protobuf.Duration ttl = 2;
 }
 
-// State of the composite resource (XR) and any composed resources.
+// State of the XR (XR) and any resources.
 message State {
-  // The state of the composite resource (XR).
+  // The state of the XR (XR).
   Resource composite = 1;
 
-  // The state of any composed resources.
+  // The state of any other resources. In composition functions these are the
+  // composed resources. In operation functions they're arbitrary resources that
+  // the operation wants to create or update.
   map<string, Resource> resources = 2;
 }
 
-// A Resource represents the state of a composite or composed resource.
+// A Resource represents the state of a Kubernetes resource.
 message Resource {
   // The JSON representation of the resource.
   //
   // * Crossplane will set this field in a RunFunctionRequest to the entire
-  //   observed state of a resource - including its metadata, spec, and status.
+  // observed state of a resource - including its metadata, spec, and status.
   //
-  // * A Function should set this field in a RunFunctionRequest to communicate
-  //   the desired state of a composite or composed resource.
+  // * A function should set this field in a RunFunctionRequest to communicate
+  // the desired state of the resource.
   //
-  // * A Function may only specify the desired status of a composite resource -
-  //   not its metadata or spec. A Function should not return desired metadata
-  //   or spec for a composite resource. This will be ignored.
+  // * A function may only specify the desired status of a XR - not its metadata
+  // or spec. A function should not return desired metadata or spec for a XR.
+  // This will be ignored.
   //
-  // * A Function may not specify the desired status of a composed resource -
-  //   only its metadata and spec. A Function should not return desired status
-  //   for a composed resource. This will be ignored.
+  // * A function may not specify the desired status of any other resource -
+  // e.g. composed resources. It may only specify their metadata and spec.
+  // Status will be ignored.
   google.protobuf.Struct resource = 1;
 
   // The resource's connection details.
   //
   // * Crossplane will set this field in a RunFunctionRequest to communicate the
-  //   the observed connection details of a composite or composed resource.
+  // the observed connection details of a composite or composed resource.
+  //
+  // * A function should set this field in a RunFunctionResponse to indicate the
+  // desired connection details of the XR.
   //
-  // * A Function should set this field in a RunFunctionResponse to indicate the
-  //   desired connection details of the composite resource.
+  // * A function should not set this field in a RunFunctionResponse to indicate
+  // the desired connection details of a composed resource. This will be
+  // ignored.
   //
-  // * A Function should not set this field in a RunFunctionResponse to indicate
-  //   the desired connection details of a composed resource. This will be
-  //   ignored.
+  // Connection details are only used for composition. They're ignored by
+  // Operations.
   map<string, bytes> connection_details = 2;
 
   // Ready indicates whether the resource should be considered ready.
   //
   // * Crossplane will never set this field in a RunFunctionRequest.
   //
-  // * A Function should set this field to READY_TRUE in a RunFunctionResponse
-  //   to indicate that a desired composed resource is ready.
+  // * A function should set this field to READY_TRUE in a RunFunctionResponse
+  // to indicate that a desired resource is ready.
+  //
+  // * A function should set this field to READY_TRUE in a RunFunctionResponse
+  // to indicate that a desired XR is ready. This overwrites the standard
+  // readiness detection that determines the ready state of the composite by the
+  // ready state of the the composed resources.
   //
-  // * A Function should set this field to READY_TRUE in a RunFunctionResponse
-  //   to indicate that a desired composite resource is ready.
-  //   This overwrites the standard readiness detection that determines the
-  //   ready state of the composite by the ready state of the the
-  //   composed resources.
+  // Ready is only used for composition. It's ignored by Operations.
   Ready ready = 3;
 }
 
-// Ready indicates whether a composed resource should be considered ready.
+// Ready indicates whether a resource should be considered ready.
 enum Ready {
   READY_UNSPECIFIED = 0;
 
-  // True means the composed resource has been observed to be ready.
+  // True means the resource has been observed to be ready.
   READY_TRUE = 1;
 
-  // False means the composed resource has not been observed to be ready.
+  // False means the resource has not been observed to be ready.
   READY_FALSE = 2;
 }
 
-// A Result of running a Function.
+// A Result of running a function.
 message Result {
   // Severity of this result.
   Severity severity = 1;
@@ -267,42 +284,42 @@ message Result {
   optional Target target = 4;
 }
 
-// Severity of Function results.
+// Severity of function results.
 enum Severity {
   SEVERITY_UNSPECIFIED = 0;
 
-  // Fatal results are fatal; subsequent Composition Functions may run, but
-  // the Composition Function pipeline run will be considered a failure and
-  // the first fatal result will be returned as an error.
+  // Fatal results are fatal; subsequent functions may run, but the function
+  // pipeline run will be considered a failure and the first fatal result will
+  // be returned as an error.
   SEVERITY_FATAL = 1;
 
-  // Warning results are non-fatal; the entire Composition will run to
-  // completion but warning events and debug logs associated with the
-  // composite resource will be emitted.
+  // Warning results are non-fatal; the entire pipeline will run to completion
+  // but warning events and debug logs associated with the XR or Operation will
+  // be emitted.
   SEVERITY_WARNING = 2;
 
-  // Normal results are emitted as normal events and debug logs associated
-  // with the composite resource.
+  // Normal results are emitted as normal events and debug logs associated with
+  // the XR or operation.
   SEVERITY_NORMAL = 3;
 }
 
-// Target of Function results and conditions.
+// Target of function results and conditions.
 enum Target {
-  // If the target is unspecified, the result targets the composite resource.
+  // If the target is unspecified, the result targets the XR.
   TARGET_UNSPECIFIED = 0;
 
-  // Target the composite resource. Results that target the composite resource
-  // should include detailed, advanced information.
+  // Target the XR. Results that target the XR should include detailed, advanced
+  // information.
   TARGET_COMPOSITE = 1;
 
-  // Target the composite and the claim. Results that target the composite and
-  // the claim should include only end-user friendly information.
+  // Target the XR and the claim. Results that target the XR and the claim
+  // should include only end-user friendly information.
   TARGET_COMPOSITE_AND_CLAIM = 2;
 }
 
-// Status condition to be applied to the composite resource. Condition may also
-// optionally be applied to the composite resource's associated claim. For
-// detailed information on proper usage of status conditions, please see
+// Status condition to be applied to the XR. Condition may also optionally be
+// applied to the XR's associated claim. For detailed information on proper
+// usage of status conditions, please see
 // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties.
 message Condition {
   // Type of condition in PascalCase.
diff --git a/apis/apiextensions/fn/proto/v1beta1/zz_generated_run_function_grpc.pb.go b/proto/fn/v1beta1/zz_generated_run_function_grpc.pb.go
similarity index 89%
rename from apis/apiextensions/fn/proto/v1beta1/zz_generated_run_function_grpc.pb.go
rename to proto/fn/v1beta1/zz_generated_run_function_grpc.pb.go
index 130f6f3e7..4b5dd5aa1 100644
--- a/apis/apiextensions/fn/proto/v1beta1/zz_generated_run_function_grpc.pb.go
+++ b/proto/fn/v1beta1/zz_generated_run_function_grpc.pb.go
@@ -13,15 +13,20 @@
 //See the License for the specific language governing permissions and
 //limitations under the License.
 
-// Generated from apis/apiextensions/fn/proto/v1/run_function.proto by ./hack/duplicate_proto_type.sh. DO NOT EDIT.
+// Generated from proto/fn/v1/run_function.proto by ./hack/duplicate_proto_type.sh. DO NOT EDIT.
 
 // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
 // versions:
 // - protoc-gen-go-grpc v1.3.0
 // - protoc             (unknown)
-// source: apis/apiextensions/fn/proto/v1beta1/zz_generated_run_function.proto
+// source: proto/fn/v1beta1/zz_generated_run_function.proto
 
-//buf:lint:ignore PACKAGE_DIRECTORY_MATCH // Too late to fix this now.
+// This package defines the RPC for both composition and operation functions.
+// Only composition functions are part of the 'apiextensions' API group. In
+// retrospect this package should've been crossplane.proto.fn.v1, but it's too
+// late to change it now.
+
+//buf:lint:ignore PACKAGE_DIRECTORY_MATCH
 
 package v1beta1
 
@@ -45,7 +50,7 @@ const (
 //
 // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
 type FunctionRunnerServiceClient interface {
-	// RunFunction runs the Composition Function.
+	// RunFunction runs the function.
 	RunFunction(ctx context.Context, in *RunFunctionRequest, opts ...grpc.CallOption) (*RunFunctionResponse, error)
 }
 
@@ -70,7 +75,7 @@ func (c *functionRunnerServiceClient) RunFunction(ctx context.Context, in *RunFu
 // All implementations must embed UnimplementedFunctionRunnerServiceServer
 // for forward compatibility
 type FunctionRunnerServiceServer interface {
-	// RunFunction runs the Composition Function.
+	// RunFunction runs the function.
 	RunFunction(context.Context, *RunFunctionRequest) (*RunFunctionResponse, error)
 	mustEmbedUnimplementedFunctionRunnerServiceServer()
 }
@@ -126,5 +131,5 @@ var FunctionRunnerService_ServiceDesc = grpc.ServiceDesc{
 		},
 	},
 	Streams:  []grpc.StreamDesc{},
-	Metadata: "apis/apiextensions/fn/proto/v1beta1/zz_generated_run_function.proto",
+	Metadata: "proto/fn/v1beta1/zz_generated_run_function.proto",
 }



Test patch:
diff --git a/cmd/crank/render/load_test.go b/cmd/crank/render/load_test.go
index c164c3ee2..c8989b5b7 100644
--- a/cmd/crank/render/load_test.go
+++ b/cmd/crank/render/load_test.go
@@ -274,7 +274,7 @@ func TestLoadFunctions(t *testing.T) {
 						},
 						Spec: pkgv1.FunctionSpec{
 							PackageSpec: pkgv1.PackageSpec{
-								Package: "xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.2.1",
+								Package: "xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0",
 							},
 						},
 					},
@@ -401,7 +401,7 @@ func TestLoadObservedResources(t *testing.T) {
 	}
 }
 
-func TestLoadExtraResources(t *testing.T) {
+func TestLoadRequiredResources(t *testing.T) {
 	fs := afero.FromIOFS{FS: testdatafs}
 
 	type args struct {
@@ -428,7 +428,7 @@ func TestLoadExtraResources(t *testing.T) {
 					{
 						Object: MustLoadJSON(`{
 							"apiVersion": "example.org/v1alpha1",
-							"kind": "ExtraResourceA",
+							"kind": "RequiredResourceA",
 							"metadata": {
 								"name": "test-extra-a",
 								"annotations": {
@@ -443,7 +443,7 @@ func TestLoadExtraResources(t *testing.T) {
 					{
 						Object: MustLoadJSON(`{
 							"apiVersion": "example.org/v1",
-							"kind": "ExtraResourceB",
+							"kind": "RequiredResourceB",
 							"metadata": {
 								"name": "test-extra-b",
 								"annotations": {
@@ -473,14 +473,14 @@ func TestLoadExtraResources(t *testing.T) {
 	}
 	for name, tc := range cases {
 		t.Run(name, func(t *testing.T) {
-			f, err := LoadExtraResources(tc.args.fs, tc.args.file)
+			f, err := LoadRequiredResources(tc.args.fs, tc.args.file)
 
 			if diff := cmp.Diff(tc.want.out, f, test.EquateConditions()); diff != "" {
-				t.Errorf("LoadExtraResources(..), -want, +got:\n%s", diff)
+				t.Errorf("LoadRequiredResources(..), -want, +got:\n%s", diff)
 			}
 
 			if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" {
-				t.Errorf("LoadExtraResources(..), -want, +got:\n%s", diff)
+				t.Errorf("LoadRequiredResources(..), -want, +got:\n%s", diff)
 			}
 		})
 	}
diff --git a/cmd/crank/render/render_test.go b/cmd/crank/render/render_test.go
index 62a61ded3..23f6bd2b8 100644
--- a/cmd/crank/render/render_test.go
+++ b/cmd/crank/render/render_test.go
@@ -38,15 +38,15 @@ import (
 	"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composed"
 	ucomposite "github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composite"
 
-	fnv1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1"
 	apiextensionsv1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
 	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
-	"github.com/crossplane/crossplane/internal/controller/apiextensions/composite"
+	"github.com/crossplane/crossplane/internal/xfn"
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
 )
 
 var (
-	_ composite.FunctionRunner        = &RuntimeFunctionRunner{}
-	_ composite.ExtraResourcesFetcher = &FilteringFetcher{}
+	_ xfn.FunctionRunner           = &RuntimeFunctionRunner{}
+	_ xfn.RequiredResourcesFetcher = &FilteringFetcher{}
 )
 
 func TestRender(t *testing.T) {
@@ -539,7 +539,7 @@ func TestRender(t *testing.T) {
 				},
 			},
 		},
-		"SuccessWithExtraResources": {
+		"SuccessWithResources": {
 			args: args{
 				ctx: context.Background(),
 				in: Inputs{
@@ -574,7 +574,7 @@ func TestRender(t *testing.T) {
 								case 0:
 									return &fnv1.RunFunctionResponse{
 										Requirements: &fnv1.Requirements{
-											ExtraResources: map[string]*fnv1.ResourceSelector{
+											Resources: map[string]*fnv1.ResourceSelector{
 												"extra-resource-by-name": {
 													ApiVersion: "test.crossplane.io/v1",
 													Kind:       "Foo",
@@ -586,17 +586,17 @@ func TestRender(t *testing.T) {
 										},
 									}, nil
 								case 1:
-									if len(request.GetExtraResources()) == 0 {
+									if len(request.GetRequiredResources()) == 0 {
 										t.Fatalf("expected extra resources to be passed to function on second call")
 									}
-									res := request.GetExtraResources()["extra-resource-by-name"]
+									res := request.GetRequiredResources()["extra-resource-by-name"]
 									if res == nil || len(res.GetItems()) == 0 {
 										t.Fatalf("expected extra resource to be passed to function on second call")
 									}
 									foo := (res.GetItems()[0].GetResource().AsMap()["spec"].(map[string]interface{}))["foo"].(string)
 									return &fnv1.RunFunctionResponse{
 										Requirements: &fnv1.Requirements{
-											ExtraResources: map[string]*fnv1.ResourceSelector{
+											Resources: map[string]*fnv1.ResourceSelector{
 												"extra-resource-by-name": {
 													ApiVersion: "test.crossplane.io/v1",
 													Kind:       "Foo",
@@ -654,7 +654,7 @@ func TestRender(t *testing.T) {
 							}
 						}(),
 					},
-					ExtraResources: []unstructured.Unstructured{
+					RequiredResources: []unstructured.Unstructured{
 						{
 							Object: MustLoadJSON(`{
 								"apiVersion": "test.crossplane.io/v1",
@@ -826,7 +826,7 @@ func (r *MockFunctionRunner) RunFunction(ctx context.Context, req *fnv1.RunFunct
 	return r.RunFunc(ctx, req)
 }
 
-func TestFilterExtraResources(t *testing.T) {
+func TestFilterResources(t *testing.T) {
 	type params struct {
 		ers []unstructured.Unstructured
 	}
@@ -1063,11 +1063,11 @@ func TestFilterExtraResources(t *testing.T) {
 
 			out, err := f.Fetch(tc.args.ctx, tc.args.selector)
 			if diff := cmp.Diff(tc.want.out, out, cmpopts.EquateEmpty(), cmpopts.IgnoreUnexported(fnv1.Resources{}, fnv1.Resource{}, structpb.Struct{}, structpb.Value{})); diff != "" {
-				t.Errorf("%s\nfilterExtraResources(...): -want, +got:\n%s", tc.reason, diff)
+				t.Errorf("%s\nfilterResources(...): -want, +got:\n%s", tc.reason, diff)
 			}
 
 			if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" {
-				t.Errorf("%s\nfilterExtraResources(...): -want error, +got error:\n%s", tc.reason, diff)
+				t.Errorf("%s\nfilterResources(...): -want error, +got error:\n%s", tc.reason, diff)
 			}
 		})
 	}
diff --git a/cmd/crank/render/testdata/extra-resources.yaml b/cmd/crank/render/testdata/extra-resources.yaml
index 27299d771..a5770519b 100644
--- a/cmd/crank/render/testdata/extra-resources.yaml
+++ b/cmd/crank/render/testdata/extra-resources.yaml
@@ -1,6 +1,6 @@
 ---
 apiVersion: example.org/v1alpha1
-kind: ExtraResourceA
+kind: RequiredResourceA
 metadata:
   name: test-extra-a
   annotations:
@@ -9,7 +9,7 @@ spec:
   coolField: "I'm cool!"
 ---
 apiVersion: example.org/v1
-kind: ExtraResourceB
+kind: RequiredResourceB
 metadata:
   name: test-extra-b
   annotations:
@@ -17,4 +17,4 @@ metadata:
   labels:
     some-label: "another-value"
 spec:
-  coolerField: "I'm cooler!"
\ No newline at end of file
+  coolerField: "I'm cooler!"
diff --git a/cmd/crank/render/testdata/functions.yaml b/cmd/crank/render/testdata/functions.yaml
index d27ac4e06..28e444936 100644
--- a/cmd/crank/render/testdata/functions.yaml
+++ b/cmd/crank/render/testdata/functions.yaml
@@ -16,7 +16,7 @@ metadata:
     render.crossplane.io/runtime: Development
     render.crossplane.io/runtime-development-target: localhost:9444
 spec:
-  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.2.1
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
 ---
 apiVersion: pkg.crossplane.io/v1
 kind: Function
diff --git a/internal/controller/apiextensions/composite/composition_functions_test.go b/internal/controller/apiextensions/composite/composition_functions_test.go
index f84ede600..be4128222 100644
--- a/internal/controller/apiextensions/composite/composition_functions_test.go
+++ b/internal/controller/apiextensions/composite/composition_functions_test.go
@@ -47,9 +47,9 @@ import (
 	"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composite"
 	"github.com/crossplane/crossplane-runtime/pkg/test"
 
-	fnv1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1"
 	v1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
 	"github.com/crossplane/crossplane/internal/xcrd"
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
 )
 
 func TestFunctionCompose(t *testing.T) {
diff --git a/internal/controller/apiextensions/composite/reconciler_test.go b/internal/controller/apiextensions/composite/reconciler_test.go
index bcb66b55e..6c552ed7d 100644
--- a/internal/controller/apiextensions/composite/reconciler_test.go
+++ b/internal/controller/apiextensions/composite/reconciler_test.go
@@ -207,6 +207,62 @@ func TestReconcile(t *testing.T) {
 				r: reconcile.Result{Requeue: true},
 			},
 		},
+		"InvalidPipelineError": {
+			reason: "We should return an error if the CompositionRevision does not have a valid pipeline.",
+			args: args{
+				c: &test.MockClient{
+					MockGet: test.NewMockGetFn(nil),
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
+						cr.SetCompositionReference(&corev1.ObjectReference{})
+						cr.SetConditions(xpv1.ReconcileError(errors.New("selected CompositionRevision test-revision does not have a valid function pipeline: pipeline status unknown")))
+					})),
+				},
+				opts: []ReconcilerOption{
+					WithCompositeFinalizer(resource.NewNopFinalizer()),
+					WithCompositionSelector(CompositionSelectorFn(func(_ context.Context, cr resource.Composite) error {
+						cr.SetCompositionReference(&corev1.ObjectReference{})
+						return nil
+					})),
+					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
+						rev := &v1.CompositionRevision{}
+						rev.SetName("test-revision")
+						// No ValidPipeline condition set, so it will be Unknown by default
+						return rev, nil
+					})),
+				},
+			},
+			want: want{
+				r: reconcile.Result{Requeue: true},
+			},
+		},
+		"InvalidPipelineWithMessageError": {
+			reason: "We should return an error with the condition message if the CompositionRevision has an invalid pipeline with a detailed message.",
+			args: args{
+				c: &test.MockClient{
+					MockGet: test.NewMockGetFn(nil),
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
+						cr.SetCompositionReference(&corev1.ObjectReference{})
+						cr.SetConditions(xpv1.ReconcileError(errors.New("selected CompositionRevision test-revision does not have a valid function pipeline: function foo-function does not have the required composition capability")))
+					})),
+				},
+				opts: []ReconcilerOption{
+					WithCompositeFinalizer(resource.NewNopFinalizer()),
+					WithCompositionSelector(CompositionSelectorFn(func(_ context.Context, cr resource.Composite) error {
+						cr.SetCompositionReference(&corev1.ObjectReference{})
+						return nil
+					})),
+					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
+						rev := &v1.CompositionRevision{}
+						rev.SetName("test-revision")
+						rev.SetConditions(v1.MissingCapabilities("function foo-function does not have the required composition capability"))
+						return rev, nil
+					})),
+				},
+			},
+			want: want{
+				r: reconcile.Result{Requeue: true},
+			},
+		},
 		"ConfigureCompositeError": {
 			reason: "We should return any error encountered while configuring the composite resource.",
 			args: args{
@@ -224,7 +280,7 @@ func TestReconcile(t *testing.T) {
 						return nil
 					})),
 					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
-						return &v1.CompositionRevision{}, nil
+						return NewCompositionRevision(), nil
 					})),
 					WithConfigurator(ConfiguratorFn(func(_ context.Context, _ resource.Composite, _ *v1.CompositionRevision) error {
 						return errBoom
@@ -252,7 +308,7 @@ func TestReconcile(t *testing.T) {
 						return nil
 					})),
 					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
-						return &v1.CompositionRevision{}, nil
+						return NewCompositionRevision(), nil
 					})),
 					WithConfigurator(ConfiguratorFn(func(_ context.Context, _ resource.Composite, _ *v1.CompositionRevision) error {
 						return nil
@@ -283,7 +339,7 @@ func TestReconcile(t *testing.T) {
 						return nil
 					})),
 					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
-						return &v1.CompositionRevision{}, nil
+						return NewCompositionRevision(), nil
 					})),
 					WithConfigurator(ConfiguratorFn(func(_ context.Context, _ resource.Composite, _ *v1.CompositionRevision) error {
 						return nil
@@ -317,7 +373,7 @@ func TestReconcile(t *testing.T) {
 						return nil
 					})),
 					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
-						return &v1.CompositionRevision{}, nil
+						return NewCompositionRevision(), nil
 					})),
 					WithConfigurator(ConfiguratorFn(func(_ context.Context, _ resource.Composite, _ *v1.CompositionRevision) error {
 						return nil
@@ -358,7 +414,7 @@ func TestReconcile(t *testing.T) {
 						return nil
 					})),
 					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
-						return &v1.CompositionRevision{}, nil
+						return NewCompositionRevision(), nil
 					})),
 					WithConfigurator(ConfiguratorFn(func(_ context.Context, _ resource.Composite, _ *v1.CompositionRevision) error {
 						return nil
@@ -428,7 +484,7 @@ func TestReconcile(t *testing.T) {
 						return nil
 					})),
 					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
-						return &v1.CompositionRevision{}, nil
+						return NewCompositionRevision(), nil
 					})),
 					WithConfigurator(ConfiguratorFn(func(_ context.Context, _ resource.Composite, _ *v1.CompositionRevision) error {
 						return nil
@@ -514,7 +570,7 @@ func TestReconcile(t *testing.T) {
 						return nil
 					})),
 					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
-						return &v1.CompositionRevision{}, nil
+						return NewCompositionRevision(), nil
 					})),
 					WithConfigurator(ConfiguratorFn(func(_ context.Context, _ resource.Composite, _ *v1.CompositionRevision) error {
 						return nil
@@ -553,7 +609,7 @@ func TestReconcile(t *testing.T) {
 						return nil
 					})),
 					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
-						return &v1.CompositionRevision{}, nil
+						return NewCompositionRevision(), nil
 					})),
 					WithConfigurator(ConfiguratorFn(func(_ context.Context, _ resource.Composite, _ *v1.CompositionRevision) error {
 						return nil
@@ -660,7 +716,7 @@ func TestReconcile(t *testing.T) {
 						return nil
 					})),
 					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
-						return &v1.CompositionRevision{}, nil
+						return NewCompositionRevision(), nil
 					})),
 					WithConfigurator(ConfiguratorFn(func(_ context.Context, _ resource.Composite, _ *v1.CompositionRevision) error {
 						return nil
@@ -842,7 +898,7 @@ func TestReconcile(t *testing.T) {
 						return nil
 					})),
 					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
-						return &v1.CompositionRevision{}, nil
+						return NewCompositionRevision(), nil
 					})),
 					WithConfigurator(ConfiguratorFn(func(_ context.Context, _ resource.Composite, _ *v1.CompositionRevision) error {
 						return nil
@@ -1007,7 +1063,7 @@ func TestReconcile(t *testing.T) {
 						return nil
 					})),
 					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
-						return &v1.CompositionRevision{}, nil
+						return NewCompositionRevision(), nil
 					})),
 					WithConfigurator(ConfiguratorFn(func(_ context.Context, _ resource.Composite, _ *v1.CompositionRevision) error {
 						return nil
@@ -1094,7 +1150,7 @@ func TestReconcile(t *testing.T) {
 						return nil
 					})),
 					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
-						return &v1.CompositionRevision{}, nil
+						return NewCompositionRevision(), nil
 					})),
 					WithConfigurator(ConfiguratorFn(func(_ context.Context, _ resource.Composite, _ *v1.CompositionRevision) error {
 						return nil
@@ -1166,7 +1222,7 @@ func TestReconcile(t *testing.T) {
 						return nil
 					})),
 					WithCompositionRevisionFetcher(CompositionRevisionFetcherFn(func(_ context.Context, _ resource.Composite) (*v1.CompositionRevision, error) {
-						return &v1.CompositionRevision{}, nil
+						return NewCompositionRevision(), nil
 					})),
 					WithConfigurator(ConfiguratorFn(func(_ context.Context, _ resource.Composite, _ *v1.CompositionRevision) error {
 						return nil
@@ -1219,6 +1275,12 @@ func TestReconcile(t *testing.T) {
 	}
 }
 
+func NewCompositionRevision() *v1.CompositionRevision {
+	rev := &v1.CompositionRevision{}
+	rev.SetConditions(v1.ValidPipeline())
+	return rev
+}
+
 type CompositeModifier func(cr *composite.Unstructured)
 
 func NewComposite(m ...CompositeModifier) *composite.Unstructured {
diff --git a/internal/controller/apiextensions/revision/reconciler_test.go b/internal/controller/apiextensions/revision/reconciler_test.go
new file mode 100644
index 000000000..544d71c3a
--- /dev/null
+++ b/internal/controller/apiextensions/revision/reconciler_test.go
@@ -0,0 +1,186 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package revision
+
+import (
+	"context"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	kerrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/manager"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+	"github.com/crossplane/crossplane-runtime/pkg/event"
+	"github.com/crossplane/crossplane-runtime/pkg/logging"
+	"github.com/crossplane/crossplane-runtime/pkg/resource/fake"
+	"github.com/crossplane/crossplane-runtime/pkg/test"
+
+	v1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
+	"github.com/crossplane/crossplane/internal/xfn"
+)
+
+func TestReconcile(t *testing.T) {
+	errBoom := errors.New("boom")
+
+	type params struct {
+		mgr  manager.Manager
+		opts []ReconcilerOption
+	}
+
+	type args struct {
+		ctx context.Context
+		req reconcile.Request
+	}
+
+	type want struct {
+		r   reconcile.Result
+		err error
+	}
+
+	cases := map[string]struct {
+		reason string
+		params params
+		args   args
+		want   want
+	}{
+		"CompositionRevisionNotFound": {
+			reason: "We should not return an error if the CompositionRevision was not found.",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(nil, func(_ client.Object) error {
+							return kerrors.NewNotFound(schema.GroupResource{}, "")
+						}),
+					},
+				},
+			},
+			args: args{
+				ctx: context.Background(),
+				req: reconcile.Request{},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+		"ValidPipeline": {
+			reason: "We should mark the CompositionRevision as having a valid pipeline when all functions have the composition capability.",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
+							rev := obj.(*v1.CompositionRevision)
+							*rev = v1.CompositionRevision{
+								ObjectMeta: metav1.ObjectMeta{
+									Name: "test-revision",
+								},
+								Spec: v1.CompositionRevisionSpec{
+									Pipeline: []v1.PipelineStep{
+										{
+											Step: "test-step",
+											FunctionRef: v1.FunctionReference{
+												Name: "test-function",
+											},
+										},
+									},
+								},
+							}
+							return nil
+						}),
+						MockStatusUpdate: test.NewMockSubResourceUpdateFn(nil),
+					},
+				},
+				opts: []ReconcilerOption{
+					WithLogger(logging.NewNopLogger()),
+					WithRecorder(event.NewNopRecorder()),
+					WithCapabilityChecker(xfn.CapabilityCheckerFn(func(_ context.Context, _ []string, _ ...string) error {
+						return nil
+					})),
+				},
+			},
+			args: args{
+				ctx: context.Background(),
+				req: reconcile.Request{NamespacedName: client.ObjectKey{Name: "test-revision"}},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+		"InvalidPipeline": {
+			reason: "We should mark the CompositionRevision as having an invalid pipeline when functions lack the composition capability.",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
+							rev := obj.(*v1.CompositionRevision)
+							*rev = v1.CompositionRevision{
+								ObjectMeta: metav1.ObjectMeta{
+									Name: "test-revision",
+								},
+								Spec: v1.CompositionRevisionSpec{
+									Pipeline: []v1.PipelineStep{
+										{
+											Step: "test-step",
+											FunctionRef: v1.FunctionReference{
+												Name: "test-function",
+											},
+										},
+									},
+								},
+							}
+							return nil
+						}),
+						MockStatusUpdate: test.NewMockSubResourceUpdateFn(nil),
+					},
+				},
+				opts: []ReconcilerOption{
+					WithLogger(logging.NewNopLogger()),
+					WithRecorder(event.NewNopRecorder()),
+					WithCapabilityChecker(xfn.CapabilityCheckerFn(func(_ context.Context, _ []string, _ ...string) error {
+						return errBoom
+					})),
+				},
+			},
+			args: args{
+				ctx: context.Background(),
+				req: reconcile.Request{NamespacedName: client.ObjectKey{Name: "test-revision"}},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			r := NewReconciler(tc.params.mgr, tc.params.opts...)
+			got, err := r.Reconcile(tc.args.ctx, tc.args.req)
+
+			if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" {
+				t.Errorf("\n%s\nr.Reconcile(...): -want error, +got error:\n%s", tc.reason, diff)
+			}
+			if diff := cmp.Diff(tc.want.r, got, test.EquateErrors()); diff != "" {
+				t.Errorf("\n%s\nr.Reconcile(...): -want, +got:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
diff --git a/internal/controller/ops/operation/reconciler_test.go b/internal/controller/ops/operation/reconciler_test.go
new file mode 100644
index 000000000..4026b4ba5
--- /dev/null
+++ b/internal/controller/ops/operation/reconciler_test.go
@@ -0,0 +1,763 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package operation
+
+import (
+	"context"
+	"encoding/json"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"google.golang.org/protobuf/encoding/protojson"
+	"google.golang.org/protobuf/types/known/structpb"
+	corev1 "k8s.io/api/core/v1"
+	kerrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	kunstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/utils/ptr"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/manager"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	v1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+	"github.com/crossplane/crossplane-runtime/pkg/resource/fake"
+	"github.com/crossplane/crossplane-runtime/pkg/test"
+
+	"github.com/crossplane/crossplane/apis/ops/v1alpha1"
+	"github.com/crossplane/crossplane/internal/xfn"
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
+)
+
+func TestReconcile(t *testing.T) {
+	type params struct {
+		mgr  manager.Manager
+		opts []ReconcilerOption
+	}
+
+	type want struct {
+		r   reconcile.Result
+		err error
+	}
+
+	cases := map[string]struct {
+		reason string
+		params params
+		want   want
+	}{
+		"NotFound": {
+			reason: "We should return early if the Operation was not found.",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(kerrors.NewNotFound(schema.GroupResource{}, "")),
+					},
+				},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+		"GetError": {
+			reason: "We should return an error if we can't get the Operation",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(errors.New("boom")),
+					},
+				},
+			},
+			want: want{
+				r:   reconcile.Result{},
+				err: cmpopts.AnyError,
+			},
+		},
+		"Deleted": {
+			reason: "We should return early if the Operation was deleted.",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
+							op := &v1alpha1.Operation{
+								ObjectMeta: metav1.ObjectMeta{
+									DeletionTimestamp: ptr.To(metav1.Now()),
+								},
+							}
+							op.DeepCopyInto(obj.(*v1alpha1.Operation))
+
+							return nil
+						}),
+					},
+				},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+		"Complete": {
+			reason: "We should return early if the Operation is complete.",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
+							op := &v1alpha1.Operation{}
+							op.SetConditions(v1alpha1.Complete())
+							op.DeepCopyInto(obj.(*v1alpha1.Operation))
+
+							return nil
+						}),
+					},
+				},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+		"RetryLimitReached": {
+			reason: "We should return early if the Operation retry limit was reached.",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
+							op := &v1alpha1.Operation{
+								Spec: v1alpha1.OperationSpec{
+									RetryLimit: ptr.To[int64](3),
+								},
+								Status: v1alpha1.OperationStatus{
+									Failures: 3,
+								},
+							}
+							op.DeepCopyInto(obj.(*v1alpha1.Operation))
+
+							return nil
+						}),
+						MockStatusUpdate: test.NewMockSubResourceUpdateFn(nil),
+					},
+				},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+		"UpdateStatusToRunningError": {
+			reason: "We should return an error if we can't update the Operation's status to indicate it's running.",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
+							op := &v1alpha1.Operation{}
+							op.DeepCopyInto(obj.(*v1alpha1.Operation))
+
+							return nil
+						}),
+						MockStatusUpdate: test.NewMockSubResourceUpdateFn(errors.New("boom")),
+					},
+				},
+			},
+			want: want{
+				r:   reconcile.Result{},
+				err: cmpopts.AnyError,
+			},
+		},
+		"GetCredentialSecretError": {
+			reason: "We should return an error if we can't get function credentials from a Secret",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
+							if _, ok := obj.(*corev1.Secret); ok {
+								return errors.New("boom")
+							}
+
+							op := &v1alpha1.Operation{
+								Spec: v1alpha1.OperationSpec{
+									Pipeline: []v1alpha1.PipelineStep{
+										{
+											Step: "get-creds",
+											FunctionRef: v1alpha1.FunctionReference{
+												Name: "function-cool",
+											},
+											Credentials: []v1alpha1.FunctionCredentials{
+												{
+													Name:   "doesnt-exist",
+													Source: v1alpha1.FunctionCredentialsSourceSecret,
+													SecretRef: &v1.SecretReference{
+														Namespace: "default",
+														Name:      "creds",
+													},
+												},
+											},
+										},
+									},
+								},
+							}
+							op.DeepCopyInto(obj.(*v1alpha1.Operation))
+
+							return nil
+						}),
+						MockStatusUpdate: test.NewMockSubResourceUpdateFn(nil),
+					},
+				},
+				opts: []ReconcilerOption{
+					WithCapabilityChecker(xfn.CapabilityCheckerFn(func(_ context.Context, _ []string, _ ...string) error {
+						return nil
+					})),
+				},
+			},
+			want: want{
+				r:   reconcile.Result{},
+				err: cmpopts.AnyError,
+			},
+		},
+		"RunFunctionError": {
+			reason: "We should return an error if we can't run a function",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
+							if _, ok := obj.(*corev1.Secret); ok {
+								return errors.New("boom")
+							}
+
+							op := &v1alpha1.Operation{
+								Spec: v1alpha1.OperationSpec{
+									Pipeline: []v1alpha1.PipelineStep{
+										{
+											Step: "get-creds",
+											FunctionRef: v1alpha1.FunctionReference{
+												Name: "function-cool",
+											},
+										},
+									},
+								},
+							}
+							op.DeepCopyInto(obj.(*v1alpha1.Operation))
+
+							return nil
+						}),
+						MockStatusUpdate: test.NewMockSubResourceUpdateFn(nil),
+					},
+				},
+				opts: []ReconcilerOption{
+					WithCapabilityChecker(xfn.CapabilityCheckerFn(func(_ context.Context, _ []string, _ ...string) error {
+						return nil
+					})),
+					WithFunctionRunner(xfn.FunctionRunnerFn(func(_ context.Context, _ string, _ *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) {
+						return nil, errors.New("boom")
+					})),
+				},
+			},
+			want: want{
+				r:   reconcile.Result{},
+				err: cmpopts.AnyError,
+			},
+		},
+		"FatalResultError": {
+			reason: "We should return an error if a function returns a fatal result.",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
+							if _, ok := obj.(*corev1.Secret); ok {
+								return errors.New("boom")
+							}
+
+							op := &v1alpha1.Operation{
+								Spec: v1alpha1.OperationSpec{
+									Pipeline: []v1alpha1.PipelineStep{
+										{
+											Step: "get-creds",
+											FunctionRef: v1alpha1.FunctionReference{
+												Name: "function-cool",
+											},
+										},
+									},
+								},
+							}
+							op.DeepCopyInto(obj.(*v1alpha1.Operation))
+
+							return nil
+						}),
+						MockStatusUpdate: test.NewMockSubResourceUpdateFn(nil),
+					},
+				},
+				opts: []ReconcilerOption{
+					WithCapabilityChecker(xfn.CapabilityCheckerFn(func(_ context.Context, _ []string, _ ...string) error {
+						return nil
+					})),
+					WithFunctionRunner(xfn.FunctionRunnerFn(func(_ context.Context, _ string, _ *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) {
+						rsp := &fnv1.RunFunctionResponse{
+							Results: []*fnv1.Result{
+								{
+									Severity: fnv1.Severity_SEVERITY_FATAL,
+									Message:  "bad stuff!",
+								},
+							},
+						}
+						return rsp, nil
+					})),
+				},
+			},
+			want: want{
+				r:   reconcile.Result{},
+				err: cmpopts.AnyError,
+			},
+		},
+		"PatchResourceError": {
+			reason: "We should return an error if we can't patch a desired resource",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
+							if _, ok := obj.(*corev1.Secret); ok {
+								return errors.New("boom")
+							}
+
+							op := &v1alpha1.Operation{
+								Spec: v1alpha1.OperationSpec{
+									Pipeline: []v1alpha1.PipelineStep{
+										{
+											Step: "get-creds",
+											FunctionRef: v1alpha1.FunctionReference{
+												Name: "function-cool",
+											},
+										},
+									},
+								},
+							}
+							op.DeepCopyInto(obj.(*v1alpha1.Operation))
+
+							return nil
+						}),
+						MockStatusUpdate: test.NewMockSubResourceUpdateFn(nil),
+						MockPatch:        test.NewMockPatchFn(errors.New("boom")),
+					},
+				},
+				opts: []ReconcilerOption{
+					WithCapabilityChecker(xfn.CapabilityCheckerFn(func(_ context.Context, _ []string, _ ...string) error {
+						return nil
+					})),
+					WithFunctionRunner(xfn.FunctionRunnerFn(func(_ context.Context, _ string, _ *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) {
+						rsp := &fnv1.RunFunctionResponse{
+							Desired: &fnv1.State{
+								Resources: map[string]*fnv1.Resource{
+									"patch-me": {
+										Resource: MustStructJSON(`{
+											"apiVersion": "example.org/v1",
+											"kind": "Test",
+											"metadata": {
+												"name": "patch-me"
+											},
+											"spec": {
+												"widgets": 42
+											}
+										}`),
+									},
+								},
+							},
+						}
+						return rsp, nil
+					})),
+				},
+			},
+			want: want{
+				r:   reconcile.Result{},
+				err: cmpopts.AnyError,
+			},
+		},
+		"CapabilityCheckError": {
+			reason: "We should increment failures and return an error if a function doesn't have the required operation capability",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
+							op := &v1alpha1.Operation{
+								Spec: v1alpha1.OperationSpec{
+									Pipeline: []v1alpha1.PipelineStep{
+										{
+											Step: "check-caps",
+											FunctionRef: v1alpha1.FunctionReference{
+												Name: "function-missing-caps",
+											},
+										},
+									},
+								},
+							}
+							op.DeepCopyInto(obj.(*v1alpha1.Operation))
+
+							return nil
+						}),
+						MockStatusUpdate: test.NewMockSubResourceUpdateFn(nil),
+					},
+				},
+				opts: []ReconcilerOption{
+					WithCapabilityChecker(xfn.CapabilityCheckerFn(func(_ context.Context, _ []string, _ ...string) error {
+						return errors.New("boom")
+					})),
+				},
+			},
+			want: want{
+				r:   reconcile.Result{},
+				err: cmpopts.AnyError,
+			},
+		},
+		"Success": {
+			reason: "We shouldn't return an error if we successfully run the Operation",
+			params: params{
+				mgr: &fake.Manager{
+					Client: &test.MockClient{
+						MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
+							if _, ok := obj.(*corev1.Secret); ok {
+								return errors.New("boom")
+							}
+
+							op := &v1alpha1.Operation{
+								Spec: v1alpha1.OperationSpec{
+									Pipeline: []v1alpha1.PipelineStep{
+										{
+											Step: "get-creds",
+											FunctionRef: v1alpha1.FunctionReference{
+												Name: "function-cool",
+											},
+										},
+									},
+								},
+							}
+							op.DeepCopyInto(obj.(*v1alpha1.Operation))
+
+							return nil
+						}),
+						MockStatusUpdate: test.NewMockSubResourceUpdateFn(nil),
+						MockPatch: test.NewMockPatchFn(nil, func(obj client.Object) error {
+							want := MustUnstructJSON(`{
+								"apiVersion": "example.org/v1",
+								"kind": "Test",
+								"metadata": {
+									"name": "patch-me"
+								},
+								"spec": {
+									"cool": true
+								}
+							}`)
+							if diff := cmp.Diff(want, obj); diff != "" {
+								t.Errorf("Patch(...): -want object, +got object:\n%s", diff)
+							}
+
+							return nil
+						}),
+					},
+				},
+				opts: []ReconcilerOption{
+					WithCapabilityChecker(xfn.CapabilityCheckerFn(func(_ context.Context, _ []string, _ ...string) error {
+						return nil
+					})),
+					WithFunctionRunner(xfn.FunctionRunnerFn(func(_ context.Context, _ string, _ *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) {
+						rsp := &fnv1.RunFunctionResponse{
+							Desired: &fnv1.State{
+								Resources: map[string]*fnv1.Resource{
+									"patch-me": {
+										Resource: MustStructJSON(`{
+											"apiVersion": "example.org/v1",
+											"kind": "Test",
+											"metadata": {
+												"name": "patch-me"
+											},
+											"spec": {
+												"cool": true
+											}
+										}`),
+									},
+								},
+							},
+						}
+						return rsp, nil
+					})),
+				},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			r := NewReconciler(tc.params.mgr, tc.params.opts...)
+
+			got, err := r.Reconcile(context.Background(), reconcile.Request{})
+			if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" {
+				t.Errorf("\n%s\nr.Reconcile(...): -want error, +got error:\n%s", tc.reason, diff)
+			}
+
+			if diff := cmp.Diff(tc.want.r, got); diff != "" {
+				t.Errorf("\n%s\nr.Reconcile(...): -want result, +got:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
+
+func MustStructJSON(j string) *structpb.Struct {
+	s := &structpb.Struct{}
+	if err := protojson.Unmarshal([]byte(j), s); err != nil {
+		panic(err)
+	}
+	return s
+}
+
+func MustUnstructJSON(j string) *kunstructured.Unstructured {
+	u := &kunstructured.Unstructured{}
+	if err := json.Unmarshal([]byte(j), u); err != nil {
+		panic(err)
+	}
+	return u
+}
+
+func TestAddPipelineStepOutput(t *testing.T) {
+	type args struct {
+		pipeline []v1alpha1.PipelineStepStatus
+		step     string
+		output   *runtime.RawExtension
+	}
+
+	type want struct {
+		pipeline []v1alpha1.PipelineStepStatus
+	}
+
+	output1 := &runtime.RawExtension{Raw: []byte(`{"key": "value1"}`)}
+	output2 := &runtime.RawExtension{Raw: []byte(`{"key": "value2"}`)}
+	outputUpdated := &runtime.RawExtension{Raw: []byte(`{"key": "updated"}`)}
+
+	cases := map[string]struct {
+		reason string
+		args   args
+		want   want
+	}{
+		"AddToEmptyPipeline": {
+			reason: "Should add step to empty pipeline",
+			args: args{
+				pipeline: []v1alpha1.PipelineStepStatus{},
+				step:     "step1",
+				output:   output1,
+			},
+			want: want{
+				pipeline: []v1alpha1.PipelineStepStatus{
+					{Step: "step1", Output: output1},
+				},
+			},
+		},
+		"AddNewStep": {
+			reason: "Should add new step to existing pipeline",
+			args: args{
+				pipeline: []v1alpha1.PipelineStepStatus{
+					{Step: "step1", Output: output1},
+				},
+				step:   "step2",
+				output: output2,
+			},
+			want: want{
+				pipeline: []v1alpha1.PipelineStepStatus{
+					{Step: "step1", Output: output1},
+					{Step: "step2", Output: output2},
+				},
+			},
+		},
+		"UpdateExistingStep": {
+			reason: "Should update existing step output in place",
+			args: args{
+				pipeline: []v1alpha1.PipelineStepStatus{
+					{Step: "step1", Output: output1},
+					{Step: "step2", Output: output2},
+				},
+				step:   "step1",
+				output: outputUpdated,
+			},
+			want: want{
+				pipeline: []v1alpha1.PipelineStepStatus{
+					{Step: "step1", Output: outputUpdated},
+					{Step: "step2", Output: output2},
+				},
+			},
+		},
+		"UpdateMiddleStep": {
+			reason: "Should update step in middle of pipeline",
+			args: args{
+				pipeline: []v1alpha1.PipelineStepStatus{
+					{Step: "step1", Output: output1},
+					{Step: "step2", Output: output2},
+					{Step: "step3", Output: output1},
+				},
+				step:   "step2",
+				output: outputUpdated,
+			},
+			want: want{
+				pipeline: []v1alpha1.PipelineStepStatus{
+					{Step: "step1", Output: output1},
+					{Step: "step2", Output: outputUpdated},
+					{Step: "step3", Output: output1},
+				},
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			got := AddPipelineStepOutput(tc.args.pipeline, tc.args.step, tc.args.output)
+			if diff := cmp.Diff(tc.want.pipeline, got); diff != "" {
+				t.Errorf("\n%s\nAddPipelineStepOutput(...): -want, +got:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
+
+func TestAddResourceRef(t *testing.T) {
+	type args struct {
+		refs []v1alpha1.AppliedResourceRef
+		u    *kunstructured.Unstructured
+	}
+
+	type want struct {
+		refs []v1alpha1.AppliedResourceRef
+	}
+
+	// Test resources
+	clusterResource := &kunstructured.Unstructured{}
+	clusterResource.SetAPIVersion("example.org/v1")
+	clusterResource.SetKind("ClusterResource")
+	clusterResource.SetName("cluster-resource")
+
+	namespacedResource := &kunstructured.Unstructured{}
+	namespacedResource.SetAPIVersion("example.org/v1")
+	namespacedResource.SetKind("NamespacedResource")
+	namespacedResource.SetName("namespaced-resource")
+	namespacedResource.SetNamespace("default")
+
+	anotherNamespacedResource := &kunstructured.Unstructured{}
+	anotherNamespacedResource.SetAPIVersion("example.org/v1")
+	anotherNamespacedResource.SetKind("NamespacedResource")
+	anotherNamespacedResource.SetName("another-resource")
+	anotherNamespacedResource.SetNamespace("other")
+
+	// Expected refs
+	clusterRef := v1alpha1.AppliedResourceRef{
+		APIVersion: "example.org/v1",
+		Kind:       "ClusterResource",
+		Name:       "cluster-resource",
+	}
+
+	namespacedRef := v1alpha1.AppliedResourceRef{
+		APIVersion: "example.org/v1",
+		Kind:       "NamespacedResource",
+		Name:       "namespaced-resource",
+		Namespace:  ptr.To("default"),
+	}
+
+	anotherNamespacedRef := v1alpha1.AppliedResourceRef{
+		APIVersion: "example.org/v1",
+		Kind:       "NamespacedResource",
+		Name:       "another-resource",
+		Namespace:  ptr.To("other"),
+	}
+
+	cases := map[string]struct {
+		reason string
+		args   args
+		want   want
+	}{
+		"AddToEmptySlice": {
+			reason: "Should add resource to empty slice",
+			args: args{
+				refs: []v1alpha1.AppliedResourceRef{},
+				u:    clusterResource,
+			},
+			want: want{
+				refs: []v1alpha1.AppliedResourceRef{clusterRef},
+			},
+		},
+		"AddClusterScopedResource": {
+			reason: "Should add cluster-scoped resource without namespace",
+			args: args{
+				refs: []v1alpha1.AppliedResourceRef{},
+				u:    clusterResource,
+			},
+			want: want{
+				refs: []v1alpha1.AppliedResourceRef{clusterRef},
+			},
+		},
+		"AddNamespacedResource": {
+			reason: "Should add namespaced resource with namespace",
+			args: args{
+				refs: []v1alpha1.AppliedResourceRef{},
+				u:    namespacedResource,
+			},
+			want: want{
+				refs: []v1alpha1.AppliedResourceRef{namespacedRef},
+			},
+		},
+		"AddMultipleResourcesSorted": {
+			reason: "Should add multiple resources and keep them sorted",
+			args: args{
+				refs: []v1alpha1.AppliedResourceRef{namespacedRef},
+				u:    clusterResource,
+			},
+			want: want{
+				refs: []v1alpha1.AppliedResourceRef{clusterRef, namespacedRef},
+			},
+		},
+		"AddDuplicateResource": {
+			reason: "Should not add duplicate resource",
+			args: args{
+				refs: []v1alpha1.AppliedResourceRef{clusterRef},
+				u:    clusterResource,
+			},
+			want: want{
+				refs: []v1alpha1.AppliedResourceRef{clusterRef},
+			},
+		},
+		"AddResourceToSortedSlice": {
+			reason: "Should add resource to sorted slice and maintain order",
+			args: args{
+				refs: []v1alpha1.AppliedResourceRef{clusterRef, namespacedRef},
+				u:    anotherNamespacedResource,
+			},
+			want: want{
+				refs: []v1alpha1.AppliedResourceRef{clusterRef, namespacedRef, anotherNamespacedRef},
+			},
+		},
+		"AddDuplicateNamespacedResource": {
+			reason: "Should not add duplicate namespaced resource",
+			args: args{
+				refs: []v1alpha1.AppliedResourceRef{namespacedRef},
+				u:    namespacedResource,
+			},
+			want: want{
+				refs: []v1alpha1.AppliedResourceRef{namespacedRef},
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			got := AddResourceRef(tc.args.refs, tc.args.u)
+			if diff := cmp.Diff(tc.want.refs, got); diff != "" {
+				t.Errorf("\n%s\nAddResourceRef(...): -want, +got:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
diff --git a/internal/xfn/cached/cached_runner_test.go b/internal/xfn/cached/cached_runner_test.go
index 0035503df..af57b4d4f 100644
--- a/internal/xfn/cached/cached_runner_test.go
+++ b/internal/xfn/cached/cached_runner_test.go
@@ -32,8 +32,8 @@ import (
 
 	"github.com/crossplane/crossplane-runtime/pkg/logging"
 
-	fnv1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1"
 	"github.com/crossplane/crossplane/internal/xfn/cached/proto/v1alpha1"
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
 )
 
 type FunctionRunnerFn func(ctx context.Context, name string, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error)
diff --git a/internal/xfn/capabilities_test.go b/internal/xfn/capabilities_test.go
new file mode 100644
index 000000000..92fae95b4
--- /dev/null
+++ b/internal/xfn/capabilities_test.go
@@ -0,0 +1,328 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
+*/
+
+package xfn
+
+import (
+	"context"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+	"github.com/crossplane/crossplane-runtime/pkg/test"
+
+	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
+)
+
+func TestRevisionCapabilityChecker(t *testing.T) {
+	type args struct {
+		ctx   context.Context
+		caps  []string
+		names []string
+	}
+
+	type want struct {
+		err error
+	}
+
+	cases := map[string]struct {
+		reason string
+		c      client.Reader
+		args   args
+		want   want
+	}{
+		"ListError": {
+			reason: "We should return an error if we can't list FunctionRevisions",
+			c: &test.MockClient{
+				MockList: test.NewMockListFn(errors.New("boom")),
+			},
+			args: args{
+				ctx:   context.Background(),
+				caps:  []string{"cap1"},
+				names: []string{"test-fn"},
+			},
+			want: want{
+				err: cmpopts.AnyError,
+			},
+		},
+		"NoRevisions": {
+			reason: "We should return nil if there are no revisions",
+			c: &test.MockClient{
+				MockList: test.NewMockListFn(nil, func(obj client.ObjectList) error {
+					obj.(*pkgv1.FunctionRevisionList).Items = []pkgv1.FunctionRevision{}
+					return nil
+				}),
+			},
+			args: args{
+				ctx:   context.Background(),
+				caps:  []string{"cap1"},
+				names: []string{"test-fn"},
+			},
+			want: want{
+				err: nil,
+			},
+		},
+		"NoActiveRevisions": {
+			reason: "We should return nil if there are no active revisions",
+			c: &test.MockClient{
+				MockList: test.NewMockListFn(nil, func(obj client.ObjectList) error {
+					obj.(*pkgv1.FunctionRevisionList).Items = []pkgv1.FunctionRevision{
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "test-fn-abc123",
+								Labels: map[string]string{
+									pkgv1.LabelParentPackage: "test-fn",
+								},
+							},
+							Spec: pkgv1.FunctionRevisionSpec{
+								PackageRevisionSpec: pkgv1.PackageRevisionSpec{
+									DesiredState: pkgv1.PackageRevisionInactive,
+								},
+							},
+							Status: pkgv1.FunctionRevisionStatus{
+								PackageRevisionStatus: pkgv1.PackageRevisionStatus{
+									Capabilities: []string{"cap1", "cap2"},
+								},
+							},
+						},
+					}
+					return nil
+				}),
+			},
+			args: args{
+				ctx:   context.Background(),
+				caps:  []string{"cap1"},
+				names: []string{"test-fn"},
+			},
+			want: want{
+				err: nil,
+			},
+		},
+		"NoMatchingPackages": {
+			reason: "We should return nil if there are no matching packages",
+			c: &test.MockClient{
+				MockList: test.NewMockListFn(nil, func(obj client.ObjectList) error {
+					obj.(*pkgv1.FunctionRevisionList).Items = []pkgv1.FunctionRevision{
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "different-fn-abc123",
+								Labels: map[string]string{
+									pkgv1.LabelParentPackage: "different-fn",
+								},
+							},
+							Spec: pkgv1.FunctionRevisionSpec{
+								PackageRevisionSpec: pkgv1.PackageRevisionSpec{
+									DesiredState: pkgv1.PackageRevisionActive,
+								},
+							},
+							Status: pkgv1.FunctionRevisionStatus{
+								PackageRevisionStatus: pkgv1.PackageRevisionStatus{
+									Capabilities: []string{"cap1", "cap2"},
+								},
+							},
+						},
+					}
+					return nil
+				}),
+			},
+			args: args{
+				ctx:   context.Background(),
+				caps:  []string{"cap1"},
+				names: []string{"test-fn"},
+			},
+			want: want{
+				err: nil,
+			},
+		},
+		"HasAllCapabilities": {
+			reason: "We should return nil if the function has all required capabilities",
+			c: &test.MockClient{
+				MockList: test.NewMockListFn(nil, func(obj client.ObjectList) error {
+					obj.(*pkgv1.FunctionRevisionList).Items = []pkgv1.FunctionRevision{
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "test-fn-abc123",
+								Labels: map[string]string{
+									pkgv1.LabelParentPackage: "test-fn",
+								},
+							},
+							Spec: pkgv1.FunctionRevisionSpec{
+								PackageRevisionSpec: pkgv1.PackageRevisionSpec{
+									DesiredState: pkgv1.PackageRevisionActive,
+								},
+							},
+							Status: pkgv1.FunctionRevisionStatus{
+								PackageRevisionStatus: pkgv1.PackageRevisionStatus{
+									Capabilities: []string{"cap1", "cap2", "cap3"},
+								},
+							},
+						},
+					}
+					return nil
+				}),
+			},
+			args: args{
+				ctx:   context.Background(),
+				caps:  []string{"cap1", "cap2"},
+				names: []string{"test-fn"},
+			},
+			want: want{
+				err: nil,
+			},
+		},
+		"MissingCapabilities": {
+			reason: "We should return an error if the function is missing required capabilities",
+			c: &test.MockClient{
+				MockList: test.NewMockListFn(nil, func(obj client.ObjectList) error {
+					obj.(*pkgv1.FunctionRevisionList).Items = []pkgv1.FunctionRevision{
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "test-fn-abc123",
+								Labels: map[string]string{
+									pkgv1.LabelParentPackage: "test-fn",
+								},
+							},
+							Spec: pkgv1.FunctionRevisionSpec{
+								PackageRevisionSpec: pkgv1.PackageRevisionSpec{
+									DesiredState: pkgv1.PackageRevisionActive,
+								},
+							},
+							Status: pkgv1.FunctionRevisionStatus{
+								PackageRevisionStatus: pkgv1.PackageRevisionStatus{
+									Capabilities: []string{"cap1"},
+								},
+							},
+						},
+					}
+					return nil
+				}),
+			},
+			args: args{
+				ctx:   context.Background(),
+				caps:  []string{"cap1", "cap2", "cap3"},
+				names: []string{"test-fn"},
+			},
+			want: want{
+				err: cmpopts.AnyError,
+			},
+		},
+		"MissingParentPackageLabel": {
+			reason: "We should skip revisions without parent package label",
+			c: &test.MockClient{
+				MockList: test.NewMockListFn(nil, func(obj client.ObjectList) error {
+					obj.(*pkgv1.FunctionRevisionList).Items = []pkgv1.FunctionRevision{
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "test-fn-abc123",
+								// No parent package label
+							},
+							Spec: pkgv1.FunctionRevisionSpec{
+								PackageRevisionSpec: pkgv1.PackageRevisionSpec{
+									DesiredState: pkgv1.PackageRevisionActive,
+								},
+							},
+							Status: pkgv1.FunctionRevisionStatus{
+								PackageRevisionStatus: pkgv1.PackageRevisionStatus{
+									Capabilities: []string{"cap1"},
+								},
+							},
+						},
+					}
+					return nil
+				}),
+			},
+			args: args{
+				ctx:   context.Background(),
+				caps:  []string{"cap1"},
+				names: []string{"test-fn"},
+			},
+			want: want{
+				err: nil,
+			},
+		},
+		"MultiplePackages": {
+			reason: "We should check capabilities for multiple packages",
+			c: &test.MockClient{
+				MockList: test.NewMockListFn(nil, func(obj client.ObjectList) error {
+					obj.(*pkgv1.FunctionRevisionList).Items = []pkgv1.FunctionRevision{
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "fn1-abc123",
+								Labels: map[string]string{
+									pkgv1.LabelParentPackage: "fn1",
+								},
+							},
+							Spec: pkgv1.FunctionRevisionSpec{
+								PackageRevisionSpec: pkgv1.PackageRevisionSpec{
+									DesiredState: pkgv1.PackageRevisionActive,
+								},
+							},
+							Status: pkgv1.FunctionRevisionStatus{
+								PackageRevisionStatus: pkgv1.PackageRevisionStatus{
+									Capabilities: []string{"cap1", "cap2"},
+								},
+							},
+						},
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "fn2-def456",
+								Labels: map[string]string{
+									pkgv1.LabelParentPackage: "fn2",
+								},
+							},
+							Spec: pkgv1.FunctionRevisionSpec{
+								PackageRevisionSpec: pkgv1.PackageRevisionSpec{
+									DesiredState: pkgv1.PackageRevisionActive,
+								},
+							},
+							Status: pkgv1.FunctionRevisionStatus{
+								PackageRevisionStatus: pkgv1.PackageRevisionStatus{
+									Capabilities: []string{"cap1", "cap3"},
+								},
+							},
+						},
+					}
+					return nil
+				}),
+			},
+			args: args{
+				ctx:   context.Background(),
+				caps:  []string{"cap1"},
+				names: []string{"fn1", "fn2"},
+			},
+			want: want{
+				err: nil,
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			c := &RevisionCapabilityChecker{
+				client: tc.c,
+			}
+
+			err := c.CheckCapabilities(tc.args.ctx, tc.args.caps, tc.args.names...)
+
+			if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" {
+				t.Errorf("\n%s\nCheckCapabilities(...): -want error, +got error:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
diff --git a/internal/xfn/function_runner_test.go b/internal/xfn/function_runner_test.go
index 01f310a1c..b141e05b1 100644
--- a/internal/xfn/function_runner_test.go
+++ b/internal/xfn/function_runner_test.go
@@ -22,18 +22,18 @@ import (
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
-	"github.com/pkg/errors"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/credentials/insecure"
 	"google.golang.org/protobuf/testing/protocmp"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
 	"github.com/crossplane/crossplane-runtime/pkg/test"
 
-	fnv1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1"
-	fnv1beta1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1beta1"
 	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
+	fnv1beta1 "github.com/crossplane/crossplane/proto/fn/v1beta1"
 )
 
 var _ fnv1.FunctionRunnerServiceClient = &BetaFallBackFunctionRunnerServiceClient{}
diff --git a/internal/controller/apiextensions/composite/extra_resources_test.go b/internal/xfn/required_resources_test.go
similarity index 91%
rename from internal/controller/apiextensions/composite/extra_resources_test.go
rename to internal/xfn/required_resources_test.go
index 760dababe..6c9320b6b 100644
--- a/internal/controller/apiextensions/composite/extra_resources_test.go
+++ b/internal/xfn/required_resources_test.go
@@ -13,7 +13,7 @@ CONDITIONS OF ANY KIND, either express or implied. See the License for the
 specific language governing permissions and limitations under the License.
 */
 
-package composite
+package xfn
 
 import (
 	"context"
@@ -33,12 +33,12 @@ import (
 	"github.com/crossplane/crossplane-runtime/pkg/errors"
 	"github.com/crossplane/crossplane-runtime/pkg/test"
 
-	fnv1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1"
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
 )
 
 var _ FunctionRunner = &FetchingFunctionRunner{}
 
-func TestExistingExtraResourcesFetcherFetch(t *testing.T) {
+func TestExistingRequiredResourcesFetcherFetch(t *testing.T) {
 	errBoom := errors.New("boom")
 
 	type args struct {
@@ -108,24 +108,24 @@ func TestExistingExtraResourcesFetcherFetch(t *testing.T) {
 					MockList: test.NewMockListFn(nil, func(obj client.ObjectList) error {
 						obj.(*kunstructured.UnstructuredList).Items = []kunstructured.Unstructured{
 							{
-								Object: map[string]interface{}{
+								Object: map[string]any{
 									"apiVersion": "test.crossplane.io/v1",
 									"kind":       "Foo",
-									"metadata": map[string]interface{}{
+									"metadata": map[string]any{
 										"name": "cool-resource",
-										"labels": map[string]interface{}{
+										"labels": map[string]any{
 											"cool": "resource",
 										},
 									},
 								},
 							},
 							{
-								Object: map[string]interface{}{
+								Object: map[string]any{
 									"apiVersion": "test.crossplane.io/v1",
 									"kind":       "Foo",
-									"metadata": map[string]interface{}{
+									"metadata": map[string]any{
 										"name": "cooler-resource",
-										"labels": map[string]interface{}{
+										"labels": map[string]any{
 											"cool": "resource",
 										},
 									},
@@ -232,7 +232,7 @@ func TestExistingExtraResourcesFetcherFetch(t *testing.T) {
 	}
 	for name, tc := range cases {
 		t.Run(name, func(t *testing.T) {
-			g := NewExistingExtraResourcesFetcher(tc.args.c)
+			g := NewExistingRequiredResourcesFetcher(tc.args.c)
 
 			res, err := g.Fetch(context.Background(), tc.args.rs)
 			if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" {
@@ -260,7 +260,7 @@ func TestFetchingFunctionRunner(t *testing.T) {
 
 	type params struct {
 		wrapped   FunctionRunner
-		resources ExtraResourcesFetcher
+		resources RequiredResourcesFetcher
 	}
 
 	type args struct {
@@ -350,7 +350,7 @@ func TestFetchingFunctionRunner(t *testing.T) {
 				wrapped: FunctionRunnerFn(func(_ context.Context, _ string, _ *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) {
 					rsp := &fnv1.RunFunctionResponse{
 						Requirements: &fnv1.Requirements{
-							ExtraResources: map[string]*fnv1.ResourceSelector{
+							Resources: map[string]*fnv1.ResourceSelector{
 								"gimme": {
 									ApiVersion: "test.crossplane.io/v1",
 									Kind:       "CoolResource",
@@ -360,7 +360,7 @@ func TestFetchingFunctionRunner(t *testing.T) {
 					}
 					return rsp, nil
 				}),
-				resources: ExtraResourcesFetcherFn(func(_ context.Context, _ *fnv1.ResourceSelector) (*fnv1.Resources, error) {
+				resources: RequiredResourcesFetcherFn(func(_ context.Context, _ *fnv1.ResourceSelector) (*fnv1.Resources, error) {
 					return nil, errors.New("boom")
 				}),
 			},
@@ -377,7 +377,7 @@ func TestFetchingFunctionRunner(t *testing.T) {
 				wrapped: FunctionRunnerFn(func(_ context.Context, _ string, _ *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) {
 					rsp := &fnv1.RunFunctionResponse{
 						Requirements: &fnv1.Requirements{
-							ExtraResources: map[string]*fnv1.ResourceSelector{
+							Resources: map[string]*fnv1.ResourceSelector{
 								"gimme": {
 									ApiVersion: "test.crossplane.io/v1",
 
@@ -389,7 +389,7 @@ func TestFetchingFunctionRunner(t *testing.T) {
 					}
 					return rsp, nil
 				}),
-				resources: ExtraResourcesFetcherFn(func(_ context.Context, _ *fnv1.ResourceSelector) (*fnv1.Resources, error) {
+				resources: RequiredResourcesFetcherFn(func(_ context.Context, _ *fnv1.ResourceSelector) (*fnv1.Resources, error) {
 					return &fnv1.Resources{}, nil
 				}),
 			},
@@ -408,7 +408,7 @@ func TestFetchingFunctionRunner(t *testing.T) {
 					// we're called, in response to our requirements.
 					if called {
 						want := &fnv1.RunFunctionRequest{
-							ExtraResources: map[string]*fnv1.Resources{
+							RequiredResources: map[string]*fnv1.Resources{
 								"gimme": {
 									Items: []*fnv1.Resource{{Resource: coolResource}},
 								},
@@ -425,7 +425,7 @@ func TestFetchingFunctionRunner(t *testing.T) {
 
 					rsp := &fnv1.RunFunctionResponse{
 						Requirements: &fnv1.Requirements{
-							ExtraResources: map[string]*fnv1.ResourceSelector{
+							Resources: map[string]*fnv1.ResourceSelector{
 								"gimme": {
 									ApiVersion: "test.crossplane.io/v1",
 									Kind:       "CoolResource",
@@ -435,7 +435,7 @@ func TestFetchingFunctionRunner(t *testing.T) {
 					}
 					return rsp, nil
 				}),
-				resources: ExtraResourcesFetcherFn(func(_ context.Context, _ *fnv1.ResourceSelector) (*fnv1.Resources, error) {
+				resources: RequiredResourcesFetcherFn(func(_ context.Context, _ *fnv1.ResourceSelector) (*fnv1.Resources, error) {
 					r := &fnv1.Resources{
 						Items: []*fnv1.Resource{{Resource: coolResource}},
 					}
@@ -448,7 +448,7 @@ func TestFetchingFunctionRunner(t *testing.T) {
 			want: want{
 				rsp: &fnv1.RunFunctionResponse{
 					Requirements: &fnv1.Requirements{
-						ExtraResources: map[string]*fnv1.ResourceSelector{
+						Resources: map[string]*fnv1.ResourceSelector{
 							"gimme": {
 								ApiVersion: "test.crossplane.io/v1",
 								Kind:       "CoolResource",
diff --git a/internal/xfn/utils_test.go b/internal/xfn/utils_test.go
new file mode 100644
index 000000000..f7c00d37a
--- /dev/null
+++ b/internal/xfn/utils_test.go
@@ -0,0 +1,226 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
+*/
+
+package xfn
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"google.golang.org/protobuf/testing/protocmp"
+	"google.golang.org/protobuf/types/known/structpb"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	kunstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+
+	"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composite"
+
+	fnv1 "github.com/crossplane/crossplane/proto/fn/v1"
+)
+
+func TestTag(t *testing.T) {
+	cases := map[string]struct {
+		reason string
+		req    *fnv1.RunFunctionRequest
+		want   string
+	}{
+		"NilRequest": {
+			reason: "It should be possible to get a tag for a request.",
+			req: &fnv1.RunFunctionRequest{
+				Observed: &fnv1.State{
+					Composite: &fnv1.Resource{
+						Resource: MustStruct(map[string]any{
+							"apiVersion": "example.org/v1",
+							"kind":       "Test",
+						}),
+					},
+				},
+			},
+			// TODO(negz): This could change if proto wire encoding
+			// changes when we update the library. If that happens
+			// too often I'm fine just deleting this test.
+			want: "60e065117e1992fb17c3c5ef2a50de370eeec23fbd380836d31ad388bbe4e082",
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			got := Tag(tc.req)
+			if diff := cmp.Diff(tc.want, got); diff != "" {
+				t.Errorf("\n%s\nTag(...): -want, +got:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
+
+func TestAsStruct(t *testing.T) {
+	type want struct {
+		s   *structpb.Struct
+		err error
+	}
+
+	cases := map[string]struct {
+		reason string
+		o      runtime.Object
+		want   want
+	}{
+		"Unstructured": {
+			reason: "It should be possible to convert a Kubernetes unstructured.Unstructured to a struct",
+			o: &kunstructured.Unstructured{Object: map[string]any{
+				"apiVersion": "example.org/v1",
+				"kind":       "Test",
+			}},
+			want: want{
+				s: MustStruct(map[string]any{
+					"apiVersion": "example.org/v1",
+					"kind":       "Test",
+				}),
+			},
+		},
+		"Composite": {
+			reason: "It should be possible to convert a Crossplane composite.Unstructured to a struct",
+			o: composite.New(composite.WithGroupVersionKind(schema.GroupVersionKind{
+				Group:   "example.org",
+				Version: "v1",
+				Kind:    "Test",
+			})),
+			want: want{
+				s: MustStruct(map[string]any{
+					"apiVersion": "example.org/v1",
+					"kind":       "Test",
+				}),
+			},
+		},
+		"ConfigMap": {
+			reason: "It should be possible to convert a real, not unstructured, resource to a struct",
+			o: &corev1.ConfigMap{
+				ObjectMeta: metav1.ObjectMeta{Name: "cool-map"},
+			},
+			want: want{
+				s: MustStruct(map[string]any{
+					"metadata": map[string]any{
+						"name":              "cool-map",
+						"creationTimestamp": nil,
+					},
+				}),
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			s, err := AsStruct(tc.o)
+			if diff := cmp.Diff(tc.want.s, s, protocmp.Transform()); diff != "" {
+				t.Errorf("\n%s\nTag(...): -want struct, +got struct:\n%s", tc.reason, diff)
+			}
+
+			if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" {
+				t.Errorf("\n%s\nTag(...): -want error, +got error:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
+
+func TestFromStruct(t *testing.T) {
+	type args struct {
+		o runtime.Object
+		s *structpb.Struct
+	}
+	type want struct {
+		o   runtime.Object
+		err error
+	}
+
+	cases := map[string]struct {
+		reason string
+		args   args
+		want   want
+	}{
+		"Unstructured": {
+			reason: "It should be possible to convert a struct to a Kubernetes unstructured.Unstructured",
+			args: args{
+				o: &kunstructured.Unstructured{},
+				s: MustStruct(map[string]any{
+					"apiVersion": "example.org/v1",
+					"kind":       "Test",
+				}),
+			},
+			want: want{
+				o: &kunstructured.Unstructured{Object: map[string]any{
+					"apiVersion": "example.org/v1",
+					"kind":       "Test",
+				}},
+			},
+		},
+		"Composite": {
+			reason: "It should be possible to convert a struct to a Crossplane composite.Unstructured",
+			args: args{
+				o: composite.New(),
+				s: MustStruct(map[string]any{
+					"apiVersion": "example.org/v1",
+					"kind":       "Test",
+				}),
+			},
+			want: want{
+				o: composite.New(composite.WithGroupVersionKind(schema.GroupVersionKind{
+					Group:   "example.org",
+					Version: "v1",
+					Kind:    "Test",
+				})),
+			},
+		},
+		"ConfigMap": {
+			reason: "It should be possible to convert a struct to a real, not unstructured, resource",
+			args: args{
+				o: &corev1.ConfigMap{},
+				s: MustStruct(map[string]any{
+					"metadata": map[string]any{
+						"name": "cool-map",
+					},
+				}),
+			},
+			want: want{
+				o: &corev1.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{Name: "cool-map"},
+				},
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			err := FromStruct(tc.args.o, tc.args.s)
+			if diff := cmp.Diff(tc.want.o, tc.args.o, protocmp.Transform()); diff != "" {
+				t.Errorf("\n%s\nTag(...): -want object, +got object:\n%s", tc.reason, diff)
+			}
+
+			if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" {
+				t.Errorf("\n%s\nTag(...): -want error, +got error:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
+
+func MustStruct(v map[string]any) *structpb.Struct {
+	s, err := structpb.NewStruct(v)
+	if err != nil {
+		panic(err)
+	}
+
+	return s
+}
diff --git a/internal/xpkg/lint_test.go b/internal/xpkg/lint_test.go
index 333d2e15e..008ec1b40 100644
--- a/internal/xpkg/lint_test.go
+++ b/internal/xpkg/lint_test.go
@@ -33,6 +33,7 @@ import (
 	"github.com/crossplane/crossplane-runtime/pkg/test"
 
 	v1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
+	"github.com/crossplane/crossplane/apis/ops/v1alpha1"
 	pkgmetav1 "github.com/crossplane/crossplane/apis/pkg/meta/v1"
 	pkgmetav1alpha1 "github.com/crossplane/crossplane/apis/pkg/meta/v1alpha1"
 	pkgmetav1beta1 "github.com/crossplane/crossplane/apis/pkg/meta/v1beta1"
@@ -88,6 +89,11 @@ metadata:
 
 	v1CompBytes = []byte(`apiVersion: apiextensions.crossplane.io/v1
 kind: Composition
+metadata:
+  name: test`)
+
+	v1alpha1OpBytes = []byte(`apiVersion: ops.crossplane.io/v1alpha1
+kind: Operation
 metadata:
   name: test`)
 
@@ -111,6 +117,8 @@ metadata:
 	_                = yaml.Unmarshal(v1XRDBytes, v1XRD)
 	v1Comp           = &v1.Composition{}
 	_                = yaml.Unmarshal(v1CompBytes, v1Comp)
+	v1alpha1Op       = &v1alpha1.Operation{}
+	_                = yaml.Unmarshal(v1alpha1OpBytes, v1alpha1Op)
 
 	meta, _ = BuildMetaScheme()
 	obj, _  = BuildObjectScheme()
@@ -483,3 +491,30 @@ func TestIsComposition(t *testing.T) {
 		})
 	}
 }
+
+func TestIsOperation(t *testing.T) {
+	cases := map[string]struct {
+		reason string
+		obj    runtime.Object
+		err    error
+	}{
+		"v1": {
+			reason: "Should not return error if object is an operation.",
+			obj:    v1alpha1Op,
+		},
+		"ErrNotOperation": {
+			reason: "Should return error if object is not an operation.",
+			obj:    v1beta1crd,
+			err:    errors.New(errNotOperation),
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			err := IsOperation(tc.obj)
+			if diff := cmp.Diff(tc.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("\n%s\nIsOperation(...): -want error, +got error:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
diff --git a/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/functions.yaml
index f37255b68..2dbbba638 100644
--- a/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/functions.yaml
@@ -5,10 +5,4 @@ kind: Function
 metadata:
   name: function-dummy-ext-basic
 spec:
-  # NOTE(negz): This is currently manually pushed. See README.md at
-  # https://github.com/crossplane-contrib/function-dummy.
-  # NOTE(negz): This version of the function uses meta.pkg.crossplane.io/v1beta1
-  # and is built with an old SDK that only serves v1beta1 RPCs. This is
-  # intentional. We want to make sure Crossplane is backward compatible with
-  # older v1beta1 functions.
-  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
diff --git a/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/functions.yaml
index f37255b68..2dbbba638 100644
--- a/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/functions.yaml
@@ -5,10 +5,4 @@ kind: Function
 metadata:
   name: function-dummy-ext-basic
 spec:
-  # NOTE(negz): This is currently manually pushed. See README.md at
-  # https://github.com/crossplane-contrib/function-dummy.
-  # NOTE(negz): This version of the function uses meta.pkg.crossplane.io/v1beta1
-  # and is built with an old SDK that only serves v1beta1 RPCs. This is
-  # intentional. We want to make sure Crossplane is backward compatible with
-  # older v1beta1 functions.
-  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
diff --git a/test/e2e/manifests/apiextensions/composition/composition-selection/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/composition-selection/setup/functions.yaml
index 046af037c..7dff67686 100644
--- a/test/e2e/manifests/apiextensions/composition/composition-selection/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/composition-selection/setup/functions.yaml
@@ -5,10 +5,4 @@ kind: Function
 metadata:
   name: function-dummy-ext-selection
 spec:
-  # NOTE(negz): This is currently manually pushed. See README.md at
-  # https://github.com/crossplane-contrib/function-dummy.
-  # NOTE(negz): This version of the function uses meta.pkg.crossplane.io/v1beta1
-  # and is built with an old SDK that only serves v1beta1 RPCs. This is
-  # intentional. We want to make sure Crossplane is backward compatible with
-  # older v1beta1 functions.
-  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
diff --git a/test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/setup/functions.yaml
index 194fdd036..0cd22d563 100644
--- a/test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/setup/functions.yaml
@@ -5,10 +5,4 @@ kind: Function
 metadata:
   name: function-dummy-ext-bind-existing-xr
 spec:
-  # NOTE(negz): This is currently manually pushed. See README.md at
-  # https://github.com/crossplane-contrib/function-dummy.
-  # NOTE(negz): This version of the function uses meta.pkg.crossplane.io/v1beta1
-  # and is built with an old SDK that only serves v1beta1 RPCs. This is
-  # intentional. We want to make sure Crossplane is backward compatible with
-  # older v1beta1 functions.
-  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
diff --git a/test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/setup/functions.yaml
index 4f60710ba..a5df54c7e 100644
--- a/test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/setup/functions.yaml
@@ -6,10 +6,4 @@ metadata:
   name: function-dummy-ext-removals
   name: function-dummy-ext-removals
 spec:
-  # NOTE(negz): This is currently manually pushed. See README.md at
-  # https://github.com/crossplane-contrib/function-dummy.
-  # NOTE(negz): This version of the function uses meta.pkg.crossplane.io/v1beta1
-  # and is built with an old SDK that only serves v1beta1 RPCs. This is
-  # intentional. We want to make sure Crossplane is backward compatible with
-  # older v1beta1 functions.
-  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
diff --git a/test/e2e/manifests/apiextensions/composition/legacy/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/legacy/setup/functions.yaml
index bf237149b..a4a4d156b 100644
--- a/test/e2e/manifests/apiextensions/composition/legacy/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/legacy/setup/functions.yaml
@@ -5,13 +5,11 @@ kind: Function
 metadata:
   name: function-dummy-ext-functions
 spec:
-  # NOTE(negz): This is currently manually pushed. See README.md at
-  # https://github.com/crossplane-contrib/function-dummy.
   # NOTE(negz): This version of the function uses meta.pkg.crossplane.io/v1beta1
   # and is built with an old SDK that only serves v1beta1 RPCs. This is
   # intentional. We want to make sure Crossplane is backward compatible with
   # older v1beta1 functions.
-  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.3.0
 ---
 apiVersion: pkg.crossplane.io/v1
 kind: Function
diff --git a/test/e2e/manifests/apiextensions/composition/namespaced-xr-no-cluster-scoped-resource/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/namespaced-xr-no-cluster-scoped-resource/setup/functions.yaml
index 22deb63d6..578ea9ee6 100644
--- a/test/e2e/manifests/apiextensions/composition/namespaced-xr-no-cluster-scoped-resource/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/namespaced-xr-no-cluster-scoped-resource/setup/functions.yaml
@@ -4,4 +4,4 @@ kind: Function
 metadata:
   name: function-dummy-ext-basic
 spec:
-  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
diff --git a/test/e2e/manifests/apiextensions/composition/realtime-revision-selection/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/realtime-revision-selection/setup/functions.yaml
index d2248f6ba..e92369e75 100644
--- a/test/e2e/manifests/apiextensions/composition/realtime-revision-selection/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/composition/realtime-revision-selection/setup/functions.yaml
@@ -5,10 +5,4 @@ kind: Function
 metadata:
   name: function-dummy-ext-realtime-revision
 spec:
-  # NOTE(negz): This is currently manually pushed. See README.md at
-  # https://github.com/crossplane-contrib/function-dummy.
-  # NOTE(negz): This version of the function uses meta.pkg.crossplane.io/v1beta1
-  # and is built with an old SDK that only serves v1beta1 RPCs. This is
-  # intentional. We want to make sure Crossplane is backward compatible with
-  # older v1beta1 functions.
-  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
diff --git a/test/e2e/manifests/apiextensions/usage/composition/setup/functions.yaml b/test/e2e/manifests/apiextensions/usage/composition/setup/functions.yaml
index 55ef029e6..6126d5570 100644
--- a/test/e2e/manifests/apiextensions/usage/composition/setup/functions.yaml
+++ b/test/e2e/manifests/apiextensions/usage/composition/setup/functions.yaml
@@ -5,13 +5,7 @@ kind: Function
 metadata:
   name: function-dummy-apiextensions-usage
 spec:
-  # NOTE(negz): This is currently manually pushed. See README.md at
-  # https://github.com/crossplane-contrib/function-dummy.
-  # NOTE(negz): This version of the function uses meta.pkg.crossplane.io/v1beta1
-  # and is built with an old SDK that only serves v1beta1 RPCs. This is
-  # intentional. We want to make sure Crossplane is backward compatible with
-  # older v1beta1 functions.
-  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
 ---
 apiVersion: pkg.crossplane.io/v1
 kind: Function
diff --git a/test/e2e/manifests/lifecycle/upgrade/setup/functions.yaml b/test/e2e/manifests/lifecycle/upgrade/setup/functions.yaml
index 3d1c05d3e..4e276cfe2 100644
--- a/test/e2e/manifests/lifecycle/upgrade/setup/functions.yaml
+++ b/test/e2e/manifests/lifecycle/upgrade/setup/functions.yaml
@@ -5,13 +5,7 @@ kind: Function
 metadata:
   name: function-dummy-lifecycle-upgrade
 spec:
-  # NOTE(negz): This is currently manually pushed. See README.md at
-  # https://github.com/crossplane-contrib/function-dummy.
-  # NOTE(negz): This version of the function uses meta.pkg.crossplane.io/v1beta1
-  # and is built with an old SDK that only serves v1beta1 RPCs. This is
-  # intentional. We want to make sure Crossplane is backward compatible with
-  # older v1beta1 functions.
-  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
 ---
 apiVersion: pkg.crossplane.io/v1
 kind: Function
diff --git a/test/e2e/manifests/ops/operations/basic/operation.yaml b/test/e2e/manifests/ops/operations/basic/operation.yaml
new file mode 100644
index 000000000..b8b2579fb
--- /dev/null
+++ b/test/e2e/manifests/ops/operations/basic/operation.yaml
@@ -0,0 +1,37 @@
+apiVersion: ops.crossplane.io/v1alpha1
+kind: Operation
+metadata:
+  name: basic
+spec:
+  mode: Pipeline
+  pipeline:
+  - step: create-a-configmap
+    functionRef:
+      name: function-dummy-ops-basic
+    input:
+      apiVersion: dummy.fn.crossplane.io/v1beta1
+      kind: Response
+      # This is a YAML-serialized RunFunctionResponse. function-dummy will
+      # overlay the desired state on any that was passed into it.
+      response:
+        desired:
+          resources:
+            configmap:
+              resource:
+                apiVersion: v1
+                kind: ConfigMap
+                metadata:
+                  namespace: default
+                  name: cool-map
+                data:
+                  coolData: "I'm cool!"
+        results:
+         - severity: SEVERITY_NORMAL
+           message: "I am doing an operate!"
+    credentials:
+    - name: important-secret
+      source: Secret
+      secretRef:
+        namespace: crossplane-system
+        name: super-secret
+    
diff --git a/test/e2e/manifests/ops/operations/basic/setup/functions.yaml b/test/e2e/manifests/ops/operations/basic/setup/functions.yaml
new file mode 100644
index 000000000..93fb1b0fa
--- /dev/null
+++ b/test/e2e/manifests/ops/operations/basic/setup/functions.yaml
@@ -0,0 +1,8 @@
+---
+# We intentionally use v1beta1 here, to make sure it still works.
+apiVersion: pkg.crossplane.io/v1beta1
+kind: Function
+metadata:
+  name: function-dummy-ops-basic
+spec:
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
diff --git a/test/e2e/manifests/ops/operations/basic/setup/secret.yaml b/test/e2e/manifests/ops/operations/basic/setup/secret.yaml
new file mode 100644
index 000000000..22e00c9ac
--- /dev/null
+++ b/test/e2e/manifests/ops/operations/basic/setup/secret.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  namespace: crossplane-system
+  name: super-secret
+# We don't actually use the data, we just want to make sure we can load the
+# secret.
+data: {}
\ No newline at end of file
diff --git a/test/e2e/manifests/pkg/deployment-runtime-config/setup/functions.yaml b/test/e2e/manifests/pkg/deployment-runtime-config/setup/functions.yaml
index fd066675a..d41dda938 100644
--- a/test/e2e/manifests/pkg/deployment-runtime-config/setup/functions.yaml
+++ b/test/e2e/manifests/pkg/deployment-runtime-config/setup/functions.yaml
@@ -4,9 +4,7 @@ kind: Function
 metadata:
   name: function-dummy-pkg-drc
 spec:
-  # NOTE(negz): This is currently manually pushed. See README.md at
-  # https://github.com/crossplane-contrib/function-dummy.
-  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
   runtimeConfigRef:
     name: function-dummy-rc
 
diff --git a/test/e2e/manifests/pkg/externally-managed-service-account/setup/functions.yaml b/test/e2e/manifests/pkg/externally-managed-service-account/setup/functions.yaml
index 2800c2674..247d70244 100644
--- a/test/e2e/manifests/pkg/externally-managed-service-account/setup/functions.yaml
+++ b/test/e2e/manifests/pkg/externally-managed-service-account/setup/functions.yaml
@@ -4,7 +4,5 @@ kind: Function
 metadata:
   name: function-dummy-pkg-external-sa
 spec:
-  # NOTE(negz): This is currently manually pushed. See README.md at
-  # https://github.com/crossplane-contrib/function-dummy.
-  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
+  package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.0
 
diff --git a/test/e2e/ops_operations_test.go b/test/e2e/ops_operations_test.go
new file mode 100644
index 000000000..0634d77fc
--- /dev/null
+++ b/test/e2e/ops_operations_test.go
@@ -0,0 +1,87 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package e2e
+
+import (
+	"testing"
+	"time"
+
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/e2e-framework/pkg/features"
+	"sigs.k8s.io/e2e-framework/third_party/helm"
+
+	"github.com/crossplane/crossplane/apis/ops/v1alpha1"
+	"github.com/crossplane/crossplane/test/e2e/config"
+	"github.com/crossplane/crossplane/test/e2e/funcs"
+)
+
+// LabelAreaAPIExtensions is applied to all features pertaining to ops (e.g.
+// Operations, CronOperations, etc).
+const LabelAreaOps = "ops"
+
+// Tests that should be part of the test suite for the alpha Operations
+// feature.
+const SuiteOps = "ops"
+
+func init() {
+	environment.AddTestSuite(SuiteOps,
+		config.WithHelmInstallOpts(
+			helm.WithArgs("--set args={--debug,--enable-operations}"),
+		),
+		config.WithLabelsToSelect(features.Labels{
+			config.LabelTestSuite: []string{SuiteOps, config.TestSuiteDefault},
+		}),
+	)
+}
+
+func TestBasicOperation(t *testing.T) {
+	cm := &v1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "cool-map"}}
+
+	manifests := "test/e2e/manifests/ops/operations/basic"
+	environment.Test(t,
+		features.NewWithDescription(t.Name(), "Tests the correct functioning of a basic Operation that creates a ConfigMap.").
+			WithLabel(LabelArea, LabelAreaOps).
+			WithLabel(LabelSize, LabelSizeSmall).
+			WithLabel(config.LabelTestSuite, SuiteOps).
+			WithSetup("CreatePrerequisites", funcs.AllOf(
+				funcs.ApplyResources(FieldManager, manifests, "setup/*.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "setup/*.yaml"),
+			)).
+			Assess("CreateOperation", funcs.AllOf(
+				funcs.ApplyResources(FieldManager, manifests, "operation.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "operation.yaml"),
+			)).
+			Assess("OperationSucceeded", funcs.AllOf(
+				funcs.ResourcesHaveConditionWithin(30*time.Second, manifests, "operation.yaml", v1alpha1.Complete()),
+				funcs.ResourcesHaveFieldValueWithin(30*time.Second, manifests, "operation.yaml", "status.appliedResourceRefs[0].name", "cool-map"),
+				funcs.ResourceHasFieldValueWithin(30*time.Second, cm, "data[coolData]", "I'm cool!"),
+				// TODO(negz): Test function output when we have
+				// a function-dummy release containing this PR.
+				// https://github.com/crossplane-contrib/function-dummy/pull/42
+			)).
+			WithTeardown("DeleteOperation", funcs.AllOf(
+				funcs.DeleteResources(manifests, "operation.yaml"),
+				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "operation.yaml"),
+			)).
+			WithTeardown("DeletePrerequisites", funcs.AllOf(
+				funcs.DeleteResourcesWithPropagationPolicy(manifests, "setup/*.yaml", metav1.DeletePropagationForeground),
+				funcs.ResourcesDeletedWithin(3*time.Minute, manifests, "setup/*.yaml"),
+			)).
+			Feature(),
+	)
+}