diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/SnapshotClient.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/SnapshotClient.java
index 730399481f72b..a96b39357ac1d 100644
--- a/client/rest-high-level/src/main/java/org/elasticsearch/client/SnapshotClient.java
+++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/SnapshotClient.java
@@ -28,6 +28,8 @@
 import org.elasticsearch.action.admin.cluster.snapshots.status.SnapshotsStatusRequest;
 import org.elasticsearch.action.admin.cluster.snapshots.status.SnapshotsStatusResponse;
 import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.client.snapshots.GetSnapshottableFeaturesRequest;
+import org.elasticsearch.client.snapshots.GetSnapshottableFeaturesResponse;
 
 import java.io.IOException;
 
@@ -378,4 +380,47 @@ public Cancellable deleteAsync(DeleteSnapshotRequest deleteSnapshotRequest, Requ
             SnapshotRequestConverters::deleteSnapshot, options,
             AcknowledgedResponse::fromXContent, listener, emptySet());
     }
+
+    /**
+     * Get a list of features which can be included in a snapshot as feature states.
+     * See <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/get-snapshottable-features-api.html"> Get Snapshottable
+     * Features API on elastic.co</a>
+     *
+     * @param getFeaturesRequest the request
+     * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized
+     * @return the response
+     * @throws IOException in case there is a problem sending the request or parsing back the response
+     */
+    public GetSnapshottableFeaturesResponse getFeatures(GetSnapshottableFeaturesRequest getFeaturesRequest, RequestOptions options)
+        throws IOException {
+        return restHighLevelClient.performRequestAndParseEntity(
+            getFeaturesRequest,
+            SnapshotRequestConverters::getSnapshottableFeatures,
+            options,
+            GetSnapshottableFeaturesResponse::parse,
+            emptySet()
+        );
+    }
+
+    /**
+     * Asynchronously get a list of features which can be included in a snapshot as feature states.
+     * See <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/get-snapshottable-features-api.html"> Get Snapshottable
+     * Features API on elastic.co</a>
+     *
+     * @param getFeaturesRequest the request
+     * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized
+     * @param listener the listener to be notified upon request completion
+     * @return cancellable that may be used to cancel the request
+     */
+    public Cancellable getFeaturesAsync(GetSnapshottableFeaturesRequest getFeaturesRequest, RequestOptions options,
+                            ActionListener<GetSnapshottableFeaturesResponse> listener) {
+        return restHighLevelClient.performRequestAsyncAndParseEntity(
+            getFeaturesRequest,
+            SnapshotRequestConverters::getSnapshottableFeatures,
+            options,
+            GetSnapshottableFeaturesResponse::parse,
+            listener,
+            emptySet()
+        );
+    }
 }
diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/SnapshotRequestConverters.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/SnapshotRequestConverters.java
index 31383d0c351bc..21dc404036886 100644
--- a/client/rest-high-level/src/main/java/org/elasticsearch/client/SnapshotRequestConverters.java
+++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/SnapshotRequestConverters.java
@@ -23,6 +23,7 @@
 import org.elasticsearch.action.admin.cluster.snapshots.get.GetSnapshotsRequest;
 import org.elasticsearch.action.admin.cluster.snapshots.restore.RestoreSnapshotRequest;
 import org.elasticsearch.action.admin.cluster.snapshots.status.SnapshotsStatusRequest;
+import org.elasticsearch.client.snapshots.GetSnapshottableFeaturesRequest;
 import org.elasticsearch.common.Strings;
 
 import java.io.IOException;
@@ -190,4 +191,13 @@ static Request deleteSnapshot(DeleteSnapshotRequest deleteSnapshotRequest) {
         request.addParameters(parameters.asMap());
         return request;
     }
+
+    static Request getSnapshottableFeatures(GetSnapshottableFeaturesRequest getSnapshottableFeaturesRequest) {
+        String endpoint = "/_snapshottable_features";
+        Request request = new Request(HttpGet.METHOD_NAME, endpoint);
+        RequestConverters.Params parameters = new RequestConverters.Params();
+        parameters.withMasterTimeout(getSnapshottableFeaturesRequest.masterNodeTimeout());
+        request.addParameters(parameters.asMap());
+        return request;
+    }
 }
diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/snapshots/GetSnapshottableFeaturesRequest.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/snapshots/GetSnapshottableFeaturesRequest.java
new file mode 100644
index 0000000000000..458c3f5720440
--- /dev/null
+++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/snapshots/GetSnapshottableFeaturesRequest.java
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.client.snapshots;
+
+import org.elasticsearch.client.TimedRequest;
+
+/**
+ * A {@link TimedRequest} to get the list of features available to be included in snapshots in the cluster.
+ */
+public class GetSnapshottableFeaturesRequest extends TimedRequest {
+}
diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/snapshots/GetSnapshottableFeaturesResponse.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/snapshots/GetSnapshottableFeaturesResponse.java
new file mode 100644
index 0000000000000..049eba6b051b8
--- /dev/null
+++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/snapshots/GetSnapshottableFeaturesResponse.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.client.snapshots;
+
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.ObjectParser;
+import org.elasticsearch.common.xcontent.XContentParser;
+
+import java.util.List;
+import java.util.Objects;
+
+public class GetSnapshottableFeaturesResponse {
+
+    private final List<SnapshottableFeature> features;
+
+    private static final ParseField FEATURES = new ParseField("features");
+
+    @SuppressWarnings("unchecked")
+    private static final ConstructingObjectParser<GetSnapshottableFeaturesResponse, Void> PARSER = new ConstructingObjectParser<>(
+        "snapshottable_features_response", true, (a, ctx) -> new GetSnapshottableFeaturesResponse((List<SnapshottableFeature>) a[0])
+    );
+
+    static {
+        PARSER.declareObjectArray(ConstructingObjectParser.constructorArg(), SnapshottableFeature::parse, FEATURES);
+    }
+
+    public GetSnapshottableFeaturesResponse(List<SnapshottableFeature> features) {
+        this.features = features;
+    }
+
+    public List<SnapshottableFeature> getFeatures() {
+        return features;
+    }
+
+    public static GetSnapshottableFeaturesResponse parse(XContentParser parser) {
+        return PARSER.apply(parser, null);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if ((o instanceof GetSnapshottableFeaturesResponse) == false) return false;
+        GetSnapshottableFeaturesResponse that = (GetSnapshottableFeaturesResponse) o;
+        return getFeatures().equals(that.getFeatures());
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(getFeatures());
+    }
+
+    public static class SnapshottableFeature {
+
+        private final String featureName;
+        private final String description;
+
+        private static final ParseField FEATURE_NAME = new ParseField("name");
+        private static final ParseField DESCRIPTION = new ParseField("description");
+
+        private static final ConstructingObjectParser<SnapshottableFeature, Void> PARSER =  new ConstructingObjectParser<>(
+            "feature", true, (a, ctx) -> new SnapshottableFeature((String) a[0], (String) a[1])
+        );
+
+        static {
+            PARSER.declareField(ConstructingObjectParser.constructorArg(),
+                (p, c) -> p.text(), FEATURE_NAME, ObjectParser.ValueType.STRING);
+            PARSER.declareField(ConstructingObjectParser.constructorArg(),
+                (p, c) -> p.text(), DESCRIPTION, ObjectParser.ValueType.STRING);
+        }
+
+        public SnapshottableFeature(String featureName, String description) {
+            this.featureName = featureName;
+            this.description = description;
+        }
+
+        public static SnapshottableFeature parse(XContentParser parser, Void ctx) {
+            return PARSER.apply(parser, ctx);
+        }
+
+        public String getFeatureName() {
+            return featureName;
+        }
+
+        public String getDescription() {
+            return description;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if ((o instanceof SnapshottableFeature) == false) return false;
+            SnapshottableFeature feature = (SnapshottableFeature) o;
+            return Objects.equals(getFeatureName(), feature.getFeatureName());
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(getFeatureName());
+        }
+    }
+}
diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/SnapshotIT.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/SnapshotIT.java
index fe093c81c4faf..aa7879b43d181 100644
--- a/client/rest-high-level/src/test/java/org/elasticsearch/client/SnapshotIT.java
+++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/SnapshotIT.java
@@ -28,6 +28,8 @@
 import org.elasticsearch.action.admin.cluster.snapshots.status.SnapshotsStatusRequest;
 import org.elasticsearch.action.admin.cluster.snapshots.status.SnapshotsStatusResponse;
 import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.client.snapshots.GetSnapshottableFeaturesRequest;
+import org.elasticsearch.client.snapshots.GetSnapshottableFeaturesResponse;
 import org.elasticsearch.cluster.metadata.IndexMetadata;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.common.xcontent.XContentType;
@@ -39,12 +41,16 @@
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
+import static org.elasticsearch.snapshots.SnapshotsService.NO_FEATURE_STATES_VALUE;
+import static org.elasticsearch.tasks.TaskResultsService.TASKS_FEATURE_NAME;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.greaterThan;
 import static org.hamcrest.Matchers.hasSize;
 import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
 
 public class SnapshotIT extends ESRestHighLevelClientTestCase {
 
@@ -150,6 +156,14 @@ public void testCreateSnapshot() throws Exception {
         }
         request.partial(randomBoolean());
         request.includeGlobalState(randomBoolean());
+        final List<String> featureStates = randomFrom(
+            List.of(
+                Collections.emptyList(),
+                Collections.singletonList(TASKS_FEATURE_NAME),
+                Collections.singletonList(NO_FEATURE_STATES_VALUE)
+            )
+        );
+        request.featureStates(featureStates);
 
         CreateSnapshotResponse response = createTestSnapshot(request);
         assertEquals(waitForCompletion ? RestStatus.OK : RestStatus.ACCEPTED, response.status());
@@ -262,9 +276,14 @@ public void testRestoreSnapshot() throws IOException {
         assertFalse("index [" + testIndex + "] should have been deleted", indexExists(testIndex));
 
         RestoreSnapshotRequest request = new RestoreSnapshotRequest(testRepository, testSnapshot);
+        request.indices(testIndex);
         request.waitForCompletion(true);
         request.renamePattern(testIndex);
         request.renameReplacement(restoredIndex);
+        if (randomBoolean()) {
+            request.includeGlobalState(true);
+            request.featureStates(Collections.singletonList(NO_FEATURE_STATES_VALUE));
+        }
 
         RestoreSnapshotResponse response = execute(request, highLevelClient().snapshot()::restore,
                 highLevelClient().snapshot()::restoreAsync);
@@ -364,6 +383,18 @@ public void testCloneSnapshot() throws IOException {
         assertTrue(response.isAcknowledged());
     }
 
+    public void testGetFeatures() throws IOException {
+        GetSnapshottableFeaturesRequest request = new GetSnapshottableFeaturesRequest();
+
+        GetSnapshottableFeaturesResponse response = execute(request,
+            highLevelClient().snapshot()::getFeatures, highLevelClient().snapshot()::getFeaturesAsync);
+
+        assertThat(response, notNullValue());
+        assertThat(response.getFeatures(), notNullValue());
+        assertThat(response.getFeatures().size(), greaterThan(1));
+        assertTrue(response.getFeatures().stream().anyMatch(feature -> "tasks".equals(feature.getFeatureName())));
+    }
+
     private static Map<String, Object> randomUserMetadata() {
         if (randomBoolean()) {
             return null;
diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/snapshots/GetSnapshottableFeaturesResponseTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/snapshots/GetSnapshottableFeaturesResponseTests.java
new file mode 100644
index 0000000000000..0b899af725c7b
--- /dev/null
+++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/snapshots/GetSnapshottableFeaturesResponseTests.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.client.snapshots;
+
+import org.elasticsearch.client.AbstractResponseTestCase;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.common.xcontent.XContentType;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.hamcrest.Matchers.everyItem;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.in;
+import static org.hamcrest.Matchers.is;
+
+public class GetSnapshottableFeaturesResponseTests extends AbstractResponseTestCase<
+    org.elasticsearch.action.admin.cluster.snapshots.features.GetSnapshottableFeaturesResponse,
+    GetSnapshottableFeaturesResponse> {
+
+    @Override
+    protected org.elasticsearch.action.admin.cluster.snapshots.features.GetSnapshottableFeaturesResponse createServerTestInstance(
+        XContentType xContentType
+    ) {
+        return new org.elasticsearch.action.admin.cluster.snapshots.features.GetSnapshottableFeaturesResponse(
+            randomList(
+                10,
+                () -> new org.elasticsearch.action.admin.cluster.snapshots.features.GetSnapshottableFeaturesResponse.SnapshottableFeature(
+                    randomAlphaOfLengthBetween(4, 10),
+                    randomAlphaOfLengthBetween(5, 10)
+                )
+            )
+        );
+    }
+
+    @Override
+    protected GetSnapshottableFeaturesResponse doParseToClientInstance(XContentParser parser) throws IOException {
+        return GetSnapshottableFeaturesResponse.parse(parser);
+    }
+
+    @Override
+    protected void assertInstances(
+        org.elasticsearch.action.admin.cluster.snapshots.features.GetSnapshottableFeaturesResponse serverTestInstance,
+        GetSnapshottableFeaturesResponse clientInstance
+    ) {
+        assertNotNull(serverTestInstance.getSnapshottableFeatures());
+        assertNotNull(serverTestInstance.getSnapshottableFeatures());
+
+        assertThat(clientInstance.getFeatures(), hasSize(serverTestInstance.getSnapshottableFeatures().size()));
+
+        Map<String, String> clientFeatures = clientInstance.getFeatures()
+            .stream()
+            .collect(Collectors.toMap(f -> f.getFeatureName(), f -> f.getDescription()));
+        Map<String, String> serverFeatures = serverTestInstance.getSnapshottableFeatures()
+            .stream()
+            .collect(Collectors.toMap(f -> f.getFeatureName(), f -> f.getDescription()));
+
+        assertThat(clientFeatures.entrySet(), everyItem(is(in(serverFeatures.entrySet()))));
+    }
+}
diff --git a/docs/reference/snapshot-restore/apis/create-snapshot-api.asciidoc b/docs/reference/snapshot-restore/apis/create-snapshot-api.asciidoc
index a0a80cefd35d6..bcad8e399211b 100644
--- a/docs/reference/snapshot-restore/apis/create-snapshot-api.asciidoc
+++ b/docs/reference/snapshot-restore/apis/create-snapshot-api.asciidoc
@@ -99,20 +99,35 @@ argument is provided, the snapshot only includes the specified data streams and
 +
 --
 (Optional, Boolean)
-If `true`, the current cluster state is included in the snapshot.
+If `true`, the current global state is included in the snapshot.
 Defaults to `true`.
 
-The cluster state includes:
+The global state includes:
 
 * Persistent cluster settings
 * Index templates
 * Legacy index templates
 * Ingest pipelines
 * {ilm-init} lifecycle policies
+* Data stored in system indices, such as Watches and task records (configurable via `feature_states`)
 --
 +
 IMPORTANT: By default, the entire snapshot will fail if one or more indices included in the snapshot do not have all primary shards available. You can change this behavior by setting <<create-snapshot-api-partial,`partial`>> to `true`.
 
+[[create-snapshot-api-feature-states]]
+`feature_states`::
+(Optional, array of strings)
+A list of feature states to be included in this snapshot. A list of features
+available for inclusion in the snapshot and their descriptions be can be
+retrieved using the <<get-snapshottable-features-api,get snapshottable features API>>.
+Each feature state includes one or more system indices containing data necessary
+for the function of that feature. Providing an empty array will include no feature
+states in the snapshot, regardless of the value of `include_global_state`.
++
+By default, all available feature states will be included in the snapshot if
+`include_global_state` is `true`, or no feature states if `include_global_state`
+is `false`.
+
 include::{es-repo-dir}/rest-api/common-parms.asciidoc[tag=master-timeout]
 
 `metadata`::
@@ -163,6 +178,7 @@ The API returns the following response:
     "version": <version>,
     "indices": [],
     "data_streams": [],
+    "feature_states": [],
     "include_global_state": false,
     "metadata": {
       "taken_by": "user123",
diff --git a/docs/reference/snapshot-restore/apis/get-snapshot-api.asciidoc b/docs/reference/snapshot-restore/apis/get-snapshot-api.asciidoc
index 241283a29d4e4..35a9a0e8d4611 100644
--- a/docs/reference/snapshot-restore/apis/get-snapshot-api.asciidoc
+++ b/docs/reference/snapshot-restore/apis/get-snapshot-api.asciidoc
@@ -122,6 +122,15 @@ List of <<data-streams,data streams>> included in the snapshot.
 (Boolean)
 Indicates whether the current cluster state is included in the snapshot.
 
+[[get-snapshot-api-feature-states]]
+`feature_states`::
+(array)
+List of feature states which were included when the snapshot was taken,
+including the list of system indices included as part of the feature state. The
+`feature_name` field of each can be used in the `feature_states` parameter when
+restoring the snapshot to restore a subset of feature states. Only present if
+the snapshot includes one or more feature states.
+
 `start_time`::
 (string)
 Date timestamp of when the snapshot creation process started.
@@ -218,6 +227,7 @@ The API returns the following response:
           "version": <version>,
           "indices": [],
           "data_streams": [],
+          "feature_states": [],
           "include_global_state": true,
           "state": "SUCCESS",
           "start_time": "2020-07-06T21:55:18.129Z",
diff --git a/docs/reference/snapshot-restore/apis/get-snapshottable-features-api.asciidoc b/docs/reference/snapshot-restore/apis/get-snapshottable-features-api.asciidoc
new file mode 100644
index 0000000000000..6515a03936586
--- /dev/null
+++ b/docs/reference/snapshot-restore/apis/get-snapshottable-features-api.asciidoc
@@ -0,0 +1,56 @@
+[[get-snapshottable-features-api]]
+=== Get Snapshottable Features API
+++++
+<titleabbrev>Get snapshottable features</titleabbrev>
+++++
+
+Gets a list of features which can be included in snapshots using the
+<<create-snapshot-api-feature-states,`feature_states` field>> when creating a
+snapshot.
+
+[source,console]
+-----------------------------------
+GET /_snapshottable_features
+-----------------------------------
+
+[[get-snapshottable-features-api-request]]
+==== {api-request-title}
+
+`GET /_snapshottable_features`
+
+
+[[get-snapshottable-features-api-desc]]
+==== {api-description-title}
+
+You can use the get snapshottable features API to determine which feature states
+to include when <<snapshots-take-snapshot,taking a snapshot>>. By default, all
+feature states are included in a snapshot if that snapshot includes the global
+state, or none if it does not.
+
+A feature state includes one or more system indices necessary for a given
+feature to function. In order to ensure data integrity, all system indices that
+comprise a feature state are snapshotted and restored together.
+
+The features listed by this API are a combination of built-in features and
+features defined by plugins. In order for a feature's state to be listed in this
+API and recognized as a valid feature state by the create snapshot API, the
+plugin which defines that feature must be installed on the master node.
+
+==== {api-examples-title}
+
+[source,console-result]
+----
+{
+    "features": [
+        {
+            "name": "tasks",
+            "description": "Manages task results"
+        },
+        {
+            "name": "kibana",
+            "description": "Manages Kibana configuration and reports"
+        }
+    ]
+}
+----
+// TESTRESPONSE[skip:response differs between default distro and OSS]
diff --git a/docs/reference/snapshot-restore/apis/restore-snapshot-api.asciidoc b/docs/reference/snapshot-restore/apis/restore-snapshot-api.asciidoc
index 0f348148f07ee..af3be7cea1834 100644
--- a/docs/reference/snapshot-restore/apis/restore-snapshot-api.asciidoc
+++ b/docs/reference/snapshot-restore/apis/restore-snapshot-api.asciidoc
@@ -129,21 +129,33 @@ indices.
 +
 --
 (Optional, Boolean)
-If `false`, the cluster state is not restored. Defaults to `false`.
+If `false`, the global state is not restored. Defaults to `false`.
 
-If `true`, the current cluster state is included in the restore operation.
+If `true`, the current global state is included in the restore operation.
 
-The cluster state includes:
+The global state includes:
 
 * Persistent cluster settings
 * Index templates
 * Legacy index templates
 * Ingest pipelines
 * {ilm-init} lifecycle policies
+* For snapshots taken after 7.12.0, data stored in system indices, such as Watches and task records, replacing any existing configuration (configurable via `feature_states`)
 --
 +
 IMPORTANT: By default, the entire restore operation will fail if one or more indices included in the snapshot do not have all primary shards available. You can change this behavior by setting <<restore-snapshot-api-partial,`partial`>> to `true`.
 
+[[restore-snapshot-api-feature-states]]
+`feature_states`::
+(Optional, array of strings)
+A comma-separated list of feature states you wish to restore. Each feature state contains one or more system indices. The list of feature states
+available in a given snapshot are returned by the <<get-snapshot-api-feature-states, Get Snapshot API>>. Note that feature
+states restored this way will completely replace any existing configuration, rather than returning an error if the system index already exists.
+Providing an empty array will restore no feature states, regardless of the value of `include_global_state`.
++
+By default, all available feature states will be restored if `include_global_state` is `true`, and no feature states will be restored if
+`include_global_state` is `false`.
+
 [[restore-snapshot-api-index-settings]]
 `index_settings`::
 (Optional, string)
diff --git a/docs/reference/snapshot-restore/apis/snapshot-restore-apis.asciidoc b/docs/reference/snapshot-restore/apis/snapshot-restore-apis.asciidoc
index cf70d3bcb2eab..2691f56fc786d 100644
--- a/docs/reference/snapshot-restore/apis/snapshot-restore-apis.asciidoc
+++ b/docs/reference/snapshot-restore/apis/snapshot-restore-apis.asciidoc
@@ -36,3 +36,4 @@ include::get-snapshot-api.asciidoc[]
 include::get-snapshot-status-api.asciidoc[]
 include::restore-snapshot-api.asciidoc[]
 include::delete-snapshot-api.asciidoc[]
+include::get-snapshottable-features-api.asciidoc[]
diff --git a/docs/reference/snapshot-restore/restore-snapshot.asciidoc b/docs/reference/snapshot-restore/restore-snapshot.asciidoc
index 907856f53050a..f889fe5053f8b 100644
--- a/docs/reference/snapshot-restore/restore-snapshot.asciidoc
+++ b/docs/reference/snapshot-restore/restore-snapshot.asciidoc
@@ -32,6 +32,9 @@ By default, all data streams and indices in the snapshot are restored, but the c
 supports <<multi-index,multi-target syntax>>. To include the global cluster state, set
 `include_global_state` to `true` in the restore request body.
 
+Because all indices in the snapshot are restored by default, all system indices will be restored
+by default as well.
+
 [WARNING]
 ====
 Each data stream requires a matching
@@ -88,7 +91,7 @@ POST /_snapshot/my_backup/snapshot_1/_restore
 // TEST[continued]
 
 <1> By default, `include_global_state` is `false`, meaning the snapshot's
-cluster state is not restored.
+cluster state and feature states are not restored.
 +
 If `true`, the snapshot's persistent settings, index templates, ingest
 pipelines, and {ilm-init} policies are restored into the current cluster. This
diff --git a/docs/reference/snapshot-restore/take-snapshot.asciidoc b/docs/reference/snapshot-restore/take-snapshot.asciidoc
index ddc2812dbe280..5723ffde7ec9f 100644
--- a/docs/reference/snapshot-restore/take-snapshot.asciidoc
+++ b/docs/reference/snapshot-restore/take-snapshot.asciidoc
@@ -77,8 +77,10 @@ The snapshot process starts immediately for the primary shards that have been st
 relocation or initialization of shards to complete before snapshotting them.
 
 Besides creating a copy of each data stream and index, the snapshot process can also store global cluster metadata, which includes persistent
-cluster settings and templates. The transient settings and registered snapshot repositories are not stored as part of
-the snapshot.
+cluster settings, templates, and data stored in system indices, such as Watches and task records, regardless of whether those system
+indices are named in the `indices` section of the request. The <<create-snapshot-api-feature-states,`feature_states` field>> can be used to
+select a subset of system indices to be included in the snapshot. The transient settings and registered snapshot repositories are not stored
+as part of the snapshot.
 
 While a snapshot of a particular shard is being
 created, this shard cannot be moved to another node, which can interfere with rebalancing and allocation
@@ -101,7 +103,7 @@ the snapshot.
 IMPORTANT: The global cluster state includes the cluster's index
 templates, such as those <<create-a-data-stream-template,matching a data
 stream>>. If your snapshot includes data streams, we recommend storing the
-cluster state as part of the snapshot. This lets you later restored any
+global state as part of the snapshot. This lets you later restored any
 templates required for a data stream.
 
 By default, the entire snapshot will fail if one or more indices participating in the snapshot do not have
@@ -125,4 +127,4 @@ PUT /_snapshot/my_backup/%3Csnapshot-%7Bnow%2Fd%7D%3E
 -----------------------------------
 // TEST[continued]
 
-NOTE: You can also create snapshots that are copies of part of an existing snapshot using the <<clone-snapshot-api,clone snapshot API>>.
\ No newline at end of file
+NOTE: You can also create snapshots that are copies of part of an existing snapshot using the <<clone-snapshot-api,clone snapshot API>>.
diff --git a/modules/kibana/src/main/java/org/elasticsearch/kibana/KibanaPlugin.java b/modules/kibana/src/main/java/org/elasticsearch/kibana/KibanaPlugin.java
index 2afb2b058ec53..5dc532efbf1a1 100644
--- a/modules/kibana/src/main/java/org/elasticsearch/kibana/KibanaPlugin.java
+++ b/modules/kibana/src/main/java/org/elasticsearch/kibana/KibanaPlugin.java
@@ -64,6 +64,16 @@ public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings sett
             .collect(Collectors.toUnmodifiableList());
     }
 
+    @Override
+    public String getFeatureName() {
+        return "kibana";
+    }
+
+    @Override
+    public String getFeatureDescription() {
+        return "Manages Kibana configuration and reports";
+    }
+
     @Override
     public List<RestHandler> getRestHandlers(
         Settings settings,
diff --git a/qa/smoke-test-http/src/test/java/org/elasticsearch/http/SystemIndexRestIT.java b/qa/smoke-test-http/src/test/java/org/elasticsearch/http/SystemIndexRestIT.java
index 639b8e93c423d..84fd235d71b1d 100644
--- a/qa/smoke-test-http/src/test/java/org/elasticsearch/http/SystemIndexRestIT.java
+++ b/qa/smoke-test-http/src/test/java/org/elasticsearch/http/SystemIndexRestIT.java
@@ -130,6 +130,16 @@ public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings sett
             return Collections.singletonList(new SystemIndexDescriptor(SYSTEM_INDEX_NAME, "System indices for tests"));
         }
 
+        @Override
+        public String getFeatureName() {
+            return SystemIndexRestIT.class.getSimpleName();
+        }
+
+        @Override
+        public String getFeatureDescription() {
+            return "test plugin";
+        }
+
         public static class AddDocRestHandler extends BaseRestHandler {
             @Override
             public boolean allowSystemIndexAccessByDefault() {
diff --git a/rest-api-spec/src/main/resources/rest-api-spec/api/snapshot.get_features.json b/rest-api-spec/src/main/resources/rest-api-spec/api/snapshot.get_features.json
new file mode 100644
index 0000000000000..76b340d329dd8
--- /dev/null
+++ b/rest-api-spec/src/main/resources/rest-api-spec/api/snapshot.get_features.json
@@ -0,0 +1,29 @@
+{
+  "snapshot.get_features":{
+    "documentation":{
+      "url":"https://www.elastic.co/guide/en/elasticsearch/reference/master/modules-snapshots.html",
+      "description":"Returns a list of features which can be snapshotted in this cluster."
+    },
+    "stability":"stable",
+    "visibility":"public",
+    "headers":{
+      "accept": [ "application/json"]
+    },
+    "url":{
+      "paths":[
+        {
+          "path":"/_snapshottable_features",
+          "methods":[
+            "GET"
+          ]
+        }
+      ]
+    },
+    "params":{
+      "master_timeout":{
+        "type":"time",
+        "description":"Explicit operation timeout for connection to master node"
+      }
+    }
+  }
+}
diff --git a/rest-api-spec/src/main/resources/rest-api-spec/test/snapshot.features/10_basic.yml b/rest-api-spec/src/main/resources/rest-api-spec/test/snapshot.features/10_basic.yml
new file mode 100644
index 0000000000000..6d0567a72e312
--- /dev/null
+++ b/rest-api-spec/src/main/resources/rest-api-spec/test/snapshot.features/10_basic.yml
@@ -0,0 +1,8 @@
+---
+"Get Features":
+  - skip:
+      features: contains
+      version: " - 7.99.99" # Adjust this after backport
+      reason: "This API was added in 7.12.0"
+  - do: { snapshot.get_features: {}}
+  - contains: {'features': {'name': 'tasks'}}
diff --git a/server/src/internalClusterTest/java/org/elasticsearch/cluster/ClusterInfoServiceIT.java b/server/src/internalClusterTest/java/org/elasticsearch/cluster/ClusterInfoServiceIT.java
index d4979a1f1cbf9..47657f6f336f2 100644
--- a/server/src/internalClusterTest/java/org/elasticsearch/cluster/ClusterInfoServiceIT.java
+++ b/server/src/internalClusterTest/java/org/elasticsearch/cluster/ClusterInfoServiceIT.java
@@ -84,6 +84,16 @@ public List<ActionFilter> getActionFilters() {
         public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings settings) {
             return List.of(new SystemIndexDescriptor(TEST_SYSTEM_INDEX_NAME, "System index for [" + getTestClass().getName() + ']'));
         }
+
+        @Override
+        public String getFeatureName() {
+            return ClusterInfoServiceIT.class.getSimpleName();
+        }
+
+        @Override
+        public String getFeatureDescription() {
+            return "test plugin";
+        }
     }
 
     public static class BlockingActionFilter extends org.elasticsearch.action.support.ActionFilter.Simple {
diff --git a/server/src/internalClusterTest/java/org/elasticsearch/cluster/ClusterStateDiffIT.java b/server/src/internalClusterTest/java/org/elasticsearch/cluster/ClusterStateDiffIT.java
index a3ebff40d4d8f..059d0f7c5ea6c 100644
--- a/server/src/internalClusterTest/java/org/elasticsearch/cluster/ClusterStateDiffIT.java
+++ b/server/src/internalClusterTest/java/org/elasticsearch/cluster/ClusterStateDiffIT.java
@@ -709,8 +709,8 @@ public ClusterState.Custom randomCreate(String name) {
                                 SnapshotsInProgressSerializationTests.randomState(ImmutableOpenMap.of()),
                                 Collections.emptyList(),
                                 Collections.emptyList(),
-                                Math.abs(randomLong()),
-                                randomIntBetween(0, 1000),
+                                Collections.emptyList(),
+                                Math.abs(randomLong()), randomIntBetween(0, 1000),
                                 ImmutableOpenMap.of(),
                                 null,
                                 SnapshotInfoTests.randomUserMetadata(),
diff --git a/server/src/internalClusterTest/java/org/elasticsearch/indices/TestSystemIndexPlugin.java b/server/src/internalClusterTest/java/org/elasticsearch/indices/TestSystemIndexPlugin.java
index d4be0d3a2e432..dfcd4a90ee174 100644
--- a/server/src/internalClusterTest/java/org/elasticsearch/indices/TestSystemIndexPlugin.java
+++ b/server/src/internalClusterTest/java/org/elasticsearch/indices/TestSystemIndexPlugin.java
@@ -23,4 +23,14 @@ public class TestSystemIndexPlugin extends Plugin implements SystemIndexPlugin {
     public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings settings) {
         return List.of(new TestSystemIndexDescriptor());
     }
+
+    @Override
+    public String getFeatureName() {
+        return this.getClass().getSimpleName();
+    }
+
+    @Override
+    public String getFeatureDescription() {
+        return this.getClass().getCanonicalName();
+    }
 }
diff --git a/server/src/internalClusterTest/java/org/elasticsearch/snapshots/SystemIndicesSnapshotIT.java b/server/src/internalClusterTest/java/org/elasticsearch/snapshots/SystemIndicesSnapshotIT.java
new file mode 100644
index 0000000000000..a617b672a57ca
--- /dev/null
+++ b/server/src/internalClusterTest/java/org/elasticsearch/snapshots/SystemIndicesSnapshotIT.java
@@ -0,0 +1,957 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.snapshots;
+
+import org.apache.logging.log4j.LogManager;
+import org.elasticsearch.action.ActionFuture;
+import org.elasticsearch.action.admin.cluster.snapshots.create.CreateSnapshotResponse;
+import org.elasticsearch.action.admin.cluster.snapshots.get.GetSnapshotsResponse;
+import org.elasticsearch.action.admin.cluster.snapshots.restore.RestoreSnapshotResponse;
+import org.elasticsearch.cluster.health.ClusterHealthStatus;
+import org.elasticsearch.common.logging.DeprecationLogger;
+import org.elasticsearch.common.logging.Loggers;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.indices.SystemIndexDescriptor;
+import org.elasticsearch.plugins.Plugin;
+import org.elasticsearch.plugins.SystemIndexPlugin;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.test.ESIntegTestCase;
+import org.elasticsearch.test.MockLogAppender;
+import org.junit.Before;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import static org.elasticsearch.snapshots.SnapshotsService.NO_FEATURE_STATES_VALUE;
+import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.in;
+import static org.hamcrest.Matchers.lessThan;
+import static org.hamcrest.Matchers.not;
+
+@ESIntegTestCase.ClusterScope(scope = ESIntegTestCase.Scope.TEST, numDataNodes = 0)
+public class SystemIndicesSnapshotIT extends AbstractSnapshotIntegTestCase {
+
+    public static final String REPO_NAME = "test-repo";
+
+    private List<String> dataNodes = null;
+
+    @Override
+    protected Collection<Class<? extends Plugin>> nodePlugins() {
+        List<Class<? extends Plugin>> plugins = new ArrayList<>(super.nodePlugins());
+        plugins.add(SystemIndexTestPlugin.class);
+        plugins.add(AnotherSystemIndexTestPlugin.class);
+        plugins.add(AssociatedIndicesTestPlugin.class);
+        return plugins;
+    }
+
+    @Before
+    public void setup() {
+        internalCluster().startMasterOnlyNodes(2);
+        dataNodes = internalCluster().startDataOnlyNodes(2);
+    }
+
+    /**
+     * Test that if a snapshot includes system indices and we restore global state,
+     * with no reference to feature state, the system indices are restored too.
+     */
+    public void testRestoreSystemIndicesAsGlobalState() {
+        createRepository(REPO_NAME, "fs");
+        // put a document in a system index
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        // run a snapshot including global state
+        createFullSnapshot(REPO_NAME, "test-snap");
+
+        // add another document
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "2", "purpose", "post-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        assertThat(getDocCount(SystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(2L));
+
+        // restore snapshot with global state, without closing the system index
+        RestoreSnapshotResponse restoreSnapshotResponse = clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setRestoreGlobalState(true)
+            .get();
+        assertThat(restoreSnapshotResponse.getRestoreInfo().totalShards(), greaterThan(0));
+
+        // verify only the original document is restored
+        assertThat(getDocCount(SystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(1L));
+    }
+
+    /**
+     * If we take a snapshot with includeGlobalState set to false, are system indices included?
+     */
+    public void testSnapshotWithoutGlobalState() {
+        createRepository(REPO_NAME, "fs");
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "system index doc");
+        indexDoc("not-a-system-index", "1", "purpose", "non system index doc");
+
+        // run a snapshot without global state
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(false)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        // check snapshot info for for which
+        clusterAdmin().prepareGetRepositories(REPO_NAME).get();
+        Set<String> snapshottedIndices = clusterAdmin().prepareGetSnapshots(REPO_NAME).get()
+            .getSnapshots(REPO_NAME).stream()
+            .map(SnapshotInfo::indices)
+            .flatMap(Collection::stream)
+            .collect(Collectors.toSet());
+
+        assertThat("not-a-system-index", in(snapshottedIndices));
+        // TODO: without global state the system index shouldn't be snapshotted (8.0 & later only)
+        // assertThat(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, not(in(snapshottedIndices)));
+    }
+
+    /**
+     * Test that we can snapshot feature states by name.
+     */
+    public void testSnapshotByFeature() {
+        createRepository(REPO_NAME, "fs");
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        indexDoc(AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        // snapshot by feature
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setIncludeGlobalState(false)
+            .setWaitForCompletion(true)
+            .setFeatureStates(SystemIndexTestPlugin.class.getSimpleName(), AnotherSystemIndexTestPlugin.class.getSimpleName())
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        // add some other documents
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "2", "purpose", "post-snapshot doc");
+        indexDoc(AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME, "2", "purpose", "post-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        assertThat(getDocCount(SystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(2L));
+        assertThat(getDocCount(AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(2L));
+
+        // restore indices as global state without closing the index
+        RestoreSnapshotResponse restoreSnapshotResponse = clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setRestoreGlobalState(true)
+            .get();
+        assertThat(restoreSnapshotResponse.getRestoreInfo().totalShards(), greaterThan(0));
+
+        // verify only the original document is restored
+        assertThat(getDocCount(SystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(1L));
+        assertThat(getDocCount(SystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(1L));
+    }
+
+    /**
+     * Take a snapshot with global state but don't restore system indexes. By
+     * default, snapshot restorations ignore global state. This means that,
+     * for now, the system index is treated as part of the snapshot and must be
+     * handled explicitly. Otherwise, as in this test, there will be an
+     * exception.
+     */
+    public void testDefaultRestoreOnlyRegularIndices() {
+        createRepository(REPO_NAME, "fs");
+        final String regularIndex = "test-idx";
+
+        indexDoc(regularIndex, "1", "purpose", "create an index that can be restored");
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        refresh(regularIndex, SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        // snapshot including global state
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setIndices(regularIndex)
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(true)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        // Delete the regular index so we can restore it
+        assertAcked(cluster().client().admin().indices().prepareDelete(regularIndex));
+
+        // restore indices by feature, with only the regular index named explicitly
+        SnapshotRestoreException exception = expectThrows(SnapshotRestoreException.class,
+            () -> clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+                .setWaitForCompletion(true)
+                .get());
+
+        assertThat(exception.getMessage(), containsString(
+            "cannot restore index [" +
+                SystemIndexTestPlugin.SYSTEM_INDEX_NAME
+                + "] because an open index with same name already exists"));
+    }
+
+    /**
+     * Take a snapshot with global state but restore features by state.
+     */
+    public void testRestoreByFeature() {
+        createRepository(REPO_NAME, "fs");
+        final String regularIndex = "test-idx";
+
+        indexDoc(regularIndex, "1", "purpose", "create an index that can be restored");
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        indexDoc(AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        refresh(regularIndex, SystemIndexTestPlugin.SYSTEM_INDEX_NAME, AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        // snapshot including global state
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(true)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        // add some other documents
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "2", "purpose", "post-snapshot doc");
+        indexDoc(AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME, "2", "purpose", "post-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        assertThat(getDocCount(SystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(2L));
+        assertThat(getDocCount(AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(2L));
+
+        // Delete the regular index so we can restore it
+        assertAcked(cluster().client().admin().indices().prepareDelete(regularIndex));
+
+        // restore indices by feature, with only the regular index named explicitly
+        RestoreSnapshotResponse restoreSnapshotResponse = clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setIndices(regularIndex)
+            .setFeatureStates("SystemIndexTestPlugin")
+            .get();
+        assertThat(restoreSnapshotResponse.getRestoreInfo().totalShards(), greaterThan(0));
+
+        // verify that the restored system index has only one document
+        assertThat(getDocCount(SystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(1L));
+
+        // but the non-requested feature should still have its new document
+        assertThat(getDocCount(AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(2L));
+    }
+
+    /**
+     * Test that if a feature state has associated indices, they are included in the snapshot
+     * when that feature state is selected.
+     */
+    public void testSnapshotAndRestoreAssociatedIndices() {
+        createRepository(REPO_NAME, "fs");
+        final String regularIndex = "regular-idx";
+
+        // put documents into a regular index as well as the system index and associated index of a feature
+        indexDoc(regularIndex, "1", "purpose", "pre-snapshot doc");
+        indexDoc(AssociatedIndicesTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        indexDoc(AssociatedIndicesTestPlugin.ASSOCIATED_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        refresh(regularIndex, AssociatedIndicesTestPlugin.SYSTEM_INDEX_NAME, AssociatedIndicesTestPlugin.ASSOCIATED_INDEX_NAME);
+
+        // snapshot
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setIndices(regularIndex)
+            .setFeatureStates(AssociatedIndicesTestPlugin.class.getSimpleName())
+            .setWaitForCompletion(true)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        // verify the correctness of the snapshot
+        Set<String> snapshottedIndices = clusterAdmin().prepareGetSnapshots(REPO_NAME).get()
+            .getSnapshots(REPO_NAME).stream()
+            .map(SnapshotInfo::indices)
+            .flatMap(Collection::stream)
+            .collect(Collectors.toSet());
+        assertThat(snapshottedIndices, hasItem(AssociatedIndicesTestPlugin.SYSTEM_INDEX_NAME));
+        assertThat(snapshottedIndices, hasItem(AssociatedIndicesTestPlugin.ASSOCIATED_INDEX_NAME));
+
+        // add some other documents
+        indexDoc(regularIndex, "2", "purpose", "post-snapshot doc");
+        indexDoc(AssociatedIndicesTestPlugin.SYSTEM_INDEX_NAME, "2", "purpose", "post-snapshot doc");
+        refresh(regularIndex, AssociatedIndicesTestPlugin.SYSTEM_INDEX_NAME);
+
+        assertThat(getDocCount(regularIndex), equalTo(2L));
+        assertThat(getDocCount(AssociatedIndicesTestPlugin.SYSTEM_INDEX_NAME), equalTo(2L));
+
+        // And delete the associated index so we can restore it
+        assertAcked(client().admin().indices().prepareDelete(AssociatedIndicesTestPlugin.ASSOCIATED_INDEX_NAME).get());
+
+        // restore the feature state and its associated index
+        RestoreSnapshotResponse restoreSnapshotResponse = clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+            .setIndices(AssociatedIndicesTestPlugin.ASSOCIATED_INDEX_NAME)
+            .setWaitForCompletion(true)
+            .setFeatureStates(AssociatedIndicesTestPlugin.class.getSimpleName())
+            .get();
+        assertThat(restoreSnapshotResponse.getRestoreInfo().totalShards(), greaterThan(0));
+
+        // verify only the original document is restored
+        assertThat(getDocCount(AssociatedIndicesTestPlugin.SYSTEM_INDEX_NAME), equalTo(1L));
+        assertThat(getDocCount(AssociatedIndicesTestPlugin.ASSOCIATED_INDEX_NAME), equalTo(1L));
+    }
+
+    /**
+     * Check that if we request a feature not in the snapshot, we get an error.
+     */
+    public void testRestoreFeatureNotInSnapshot() {
+        createRepository(REPO_NAME, "fs");
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        // snapshot including global state
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(true)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        final String fakeFeatureStateName = "NonExistentTestPlugin";
+        SnapshotRestoreException exception = expectThrows(
+            SnapshotRestoreException.class,
+            () -> clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+                .setWaitForCompletion(true)
+                .setFeatureStates("SystemIndexTestPlugin", fakeFeatureStateName)
+                .get());
+
+        assertThat(exception.getMessage(),
+            containsString("requested feature states [[" + fakeFeatureStateName + "]] are not present in snapshot"));
+    }
+
+    /**
+     * Check that directly requesting a system index in a restore request logs a deprecation warning.
+     * @throws IllegalAccessException if something goes wrong with the mock log appender
+     */
+    public void testRestoringSystemIndexByNameIsDeprecated() throws IllegalAccessException {
+        createRepository(REPO_NAME, "fs");
+        // put a document in system index
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        // snapshot including global state
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(true)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        // Delete the index so we can restore it without requesting the feature state
+        assertAcked(client().admin().indices().prepareDelete(SystemIndexTestPlugin.SYSTEM_INDEX_NAME).get());
+
+        // Set up a mock log appender to watch for the log message we expect
+        MockLogAppender mockLogAppender = new MockLogAppender();
+        Loggers.addAppender(LogManager.getLogger("org.elasticsearch.deprecation.snapshots.RestoreService"), mockLogAppender);
+        mockLogAppender.start();
+        mockLogAppender.addExpectation(new MockLogAppender.SeenEventExpectation(
+            "restore-system-index-from-snapshot",
+            "org.elasticsearch.deprecation.snapshots.RestoreService",
+            DeprecationLogger.DEPRECATION,
+            "Restoring system indices by name is deprecated. Use feature states instead. System indices: [.test-system-idx]"));
+
+        // restore system index by name, rather than feature state
+        RestoreSnapshotResponse restoreSnapshotResponse = clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setIndices(SystemIndexTestPlugin.SYSTEM_INDEX_NAME)
+            .get();
+        assertThat(restoreSnapshotResponse.getRestoreInfo().totalShards(), greaterThan(0));
+
+        // Check that the message was logged and remove log appender
+        mockLogAppender.assertAllExpectationsMatched();
+        mockLogAppender.stop();
+        Loggers.removeAppender(LogManager.getLogger("org.elasticsearch.deprecation.snapshots.RestoreService"), mockLogAppender);
+
+        // verify only the original document is restored
+        assertThat(getDocCount(SystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(1L));
+    }
+
+    /**
+     * Check that if a system index matches a rename pattern in a restore request, it's not renamed
+     */
+    public void testSystemIndicesCannotBeRenamed() {
+        createRepository(REPO_NAME, "fs");
+        final String nonSystemIndex = ".test-non-system-index";
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        indexDoc(nonSystemIndex, "1", "purpose", "pre-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        // snapshot including global state
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(true)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        assertAcked(client().admin().indices().prepareDelete(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, nonSystemIndex).get());
+
+        // Restore using a rename pattern that matches both the regular and the system index
+        clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setRestoreGlobalState(true)
+            .setRenamePattern(".test-(.+)")
+            .setRenameReplacement(".test-restored-$1")
+            .get();
+
+        // The original system index and the renamed normal index should exist
+        assertTrue("System index not renamed", indexExists(SystemIndexTestPlugin.SYSTEM_INDEX_NAME));
+        assertTrue("Non-system index was renamed", indexExists(".test-restored-non-system-index"));
+
+        // The original normal index should still be deleted, and there shouldn't be a renamed version of the system index
+        assertFalse("Renamed system index doesn't exist", indexExists(".test-restored-system-index"));
+        assertFalse("Original non-system index doesn't exist", indexExists(nonSystemIndex));
+    }
+
+    /**
+     * If the list of feature states to restore is left unspecified and we are restoring global state,
+     * all feature states should be restored.
+     */
+    public void testRestoreSystemIndicesAsGlobalStateWithDefaultFeatureStateList() {
+        createRepository(REPO_NAME, "fs");
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        // run a snapshot including global state
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(true)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        // add another document
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "2", "purpose", "post-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        assertThat(getDocCount(SystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(2L));
+
+        // restore indices as global state a null list of feature states
+        RestoreSnapshotResponse restoreSnapshotResponse = clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setRestoreGlobalState(true)
+            .get();
+        assertThat(restoreSnapshotResponse.getRestoreInfo().totalShards(), greaterThan(0));
+
+        // verify that the system index is destroyed
+        assertThat(getDocCount(SystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(1L));
+    }
+
+    /**
+     * If the list of feature states to restore contains only "none" and we are restoring global state,
+     * no feature states should be restored.
+     *
+     * In this test, we explicitly request a regular index to avoid any confusion over the meaning of
+     * "all indices."
+     */
+    public void testRestoreSystemIndicesAsGlobalStateWithEmptyListOfFeatureStates() {
+        createRepository(REPO_NAME, "fs");
+        String regularIndex = "my-index";
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        indexDoc(regularIndex, "1", "purpose", "pre-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, regularIndex);
+
+        // run a snapshot including global state
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(true)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        // add another document
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "2", "purpose", "post-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        assertAcked(client().admin().indices().prepareDelete(regularIndex).get());
+        assertThat(getDocCount(SystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(2L));
+
+        // restore regular index, with global state and an empty list of feature states
+        RestoreSnapshotResponse restoreSnapshotResponse = clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+            .setIndices(regularIndex)
+            .setWaitForCompletion(true)
+            .setRestoreGlobalState(true)
+            .setFeatureStates(new String[]{ randomFrom("none", "NONE") })
+            .get();
+        assertThat(restoreSnapshotResponse.getRestoreInfo().totalShards(), greaterThan(0));
+
+        // verify that the system index still has the updated document, i.e. has not been restored
+        assertThat(getDocCount(SystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(2L));
+    }
+
+    /**
+     * If the list of feature states to restore contains only "none" and we are restoring global state,
+     * no feature states should be restored. However, for backwards compatibility, if no index is
+     * specified, system indices are included in "all indices." In this edge case, we get an error
+     * saying that the system index must be closed, because here it is included in "all indices."
+     */
+    public void testRestoreSystemIndicesAsGlobalStateWithEmptyListOfFeatureStatesNoIndicesSpecified() {
+        createRepository(REPO_NAME, "fs");
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        // run a snapshot including global state
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(true)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        // restore indices as global state without closing the index
+        SnapshotRestoreException exception = expectThrows(
+            SnapshotRestoreException.class,
+            () -> clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+                .setWaitForCompletion(true)
+                .setRestoreGlobalState(true)
+                .setFeatureStates(new String[]{ randomFrom("none", "NONE") })
+                .get());
+
+        assertThat(exception.getMessage(), containsString("cannot restore index [" + SystemIndexTestPlugin.SYSTEM_INDEX_NAME
+            + "] because an open index with same name already exists in the cluster."));
+    }
+
+    /**
+     * When a feature state is restored, all indices that are part of that feature state should be deleted, then the indices in
+     * the snapshot should be restored.
+     *
+     * However, other feature states should be unaffected.
+     */
+    public void testAllSystemIndicesAreRemovedWhenThatFeatureStateIsRestored() {
+        createRepository(REPO_NAME, "fs");
+        // Create a system index we'll snapshot and restore
+        final String systemIndexInSnapshot = SystemIndexTestPlugin.SYSTEM_INDEX_NAME + "-1";
+        indexDoc(systemIndexInSnapshot, "1", "purpose", "pre-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME + "*");
+
+        // And one we'll snapshot but not restore
+        indexDoc(AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+
+        // And a regular index so we can avoid matching all indices on the restore
+        final String regularIndex = "regular-index";
+        indexDoc(regularIndex, "1", "purpose", "pre-snapshot doc");
+
+        // run a snapshot including global state
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(true)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        // Now index another doc and create another index in the same pattern as the first index
+        final String systemIndexNotInSnapshot = SystemIndexTestPlugin.SYSTEM_INDEX_NAME + "-2";
+        indexDoc(systemIndexInSnapshot, "2", "purpose", "post-snapshot doc");
+        indexDoc(systemIndexNotInSnapshot, "1", "purpose", "post-snapshot doc");
+
+        // Add another doc to the second system index, so we can be sure it hasn't been touched
+        indexDoc(AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME, "2", "purpose", "post-snapshot doc");
+        refresh(systemIndexInSnapshot, systemIndexNotInSnapshot, AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        // Delete the regular index so we can restore it
+        assertAcked(cluster().client().admin().indices().prepareDelete(regularIndex));
+
+        // restore the snapshot
+        RestoreSnapshotResponse restoreSnapshotResponse = clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+            .setIndices(regularIndex)
+            .setFeatureStates("SystemIndexTestPlugin")
+            .setWaitForCompletion(true)
+            .setRestoreGlobalState(true)
+            .get();
+        assertThat(restoreSnapshotResponse.getRestoreInfo().totalShards(), greaterThan(0));
+
+        // The index we created after the snapshot should be gone
+        assertFalse(indexExists(systemIndexNotInSnapshot));
+        // And the first index should have a single doc
+        assertThat(getDocCount(systemIndexInSnapshot), equalTo(1L));
+        // And the system index whose state we didn't restore shouldn't have been touched and still have 2 docs
+        assertThat(getDocCount(AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(2L));
+    }
+
+    public void testSystemIndexAliasesAreAlwaysRestored() {
+        createRepository(REPO_NAME, "fs");
+        // Create a system index
+        final String systemIndexName = SystemIndexTestPlugin.SYSTEM_INDEX_NAME + "-1";
+        indexDoc(systemIndexName, "1", "purpose", "pre-snapshot doc");
+
+        // And a regular index
+        // And a regular index so we can avoid matching all indices on the restore
+        final String regularIndex = "regular-index";
+        final String regularAlias = "regular-alias";
+        indexDoc(regularIndex, "1", "purpose", "pre-snapshot doc");
+
+        // And make sure they both have aliases
+        final String systemIndexAlias = SystemIndexTestPlugin.SYSTEM_INDEX_NAME + "-alias";
+        assertAcked(client().admin().indices().prepareAliases()
+            .addAlias(systemIndexName, systemIndexAlias)
+            .addAlias(regularIndex, regularAlias).get());
+
+        // run a snapshot including global state
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(true)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        // And delete both the indices
+        assertAcked(cluster().client().admin().indices().prepareDelete(regularIndex, systemIndexName));
+
+        // Now restore the snapshot with no aliases
+        RestoreSnapshotResponse restoreSnapshotResponse = clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+            .setIndices(regularIndex)
+            .setFeatureStates("SystemIndexTestPlugin")
+            .setWaitForCompletion(true)
+            .setRestoreGlobalState(false)
+            .setIncludeAliases(false)
+            .get();
+        assertThat(restoreSnapshotResponse.getRestoreInfo().totalShards(), greaterThan(0));
+
+        // The regular index should exist
+        assertTrue(indexExists(regularIndex));
+        assertFalse(indexExists(regularAlias));
+        // And the system index, queried by alias, should have a doc
+        assertTrue(indexExists(systemIndexName));
+        assertTrue(indexExists(systemIndexAlias));
+        assertThat(getDocCount(systemIndexAlias), equalTo(1L));
+
+    }
+
+    /**
+     * Tests that the special "none" feature state name cannot be combined with other
+     * feature state names, and an error occurs if it's tried.
+     */
+    public void testNoneFeatureStateMustBeAlone() {
+        createRepository(REPO_NAME, "fs");
+        // put a document in a system index
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        // run a snapshot including global state
+        IllegalArgumentException createEx = expectThrows(
+            IllegalArgumentException.class,
+            () -> clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+                .setWaitForCompletion(true)
+                .setIncludeGlobalState(randomBoolean())
+                .setFeatureStates("SystemIndexTestPlugin", "none", "AnotherSystemIndexTestPlugin")
+                .get()
+        );
+        assertThat(createEx.getMessage(), equalTo("the feature_states value [none] indicates that no feature states should be " +
+            "snapshotted, but other feature states were requested: [SystemIndexTestPlugin, none, AnotherSystemIndexTestPlugin]"));
+
+        // create a successful snapshot with global state/all features
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(true)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        SnapshotRestoreException restoreEx = expectThrows(
+            SnapshotRestoreException.class,
+            () -> clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+                .setWaitForCompletion(true)
+                .setRestoreGlobalState(randomBoolean())
+                .setFeatureStates("SystemIndexTestPlugin", "none")
+                .get()
+        );
+        assertThat(
+            restoreEx.getMessage(),
+            allOf( // the order of the requested feature states is non-deterministic so just check that it includes most of the right stuff
+                containsString(
+                    "the feature_states value [none] indicates that no feature states should be restored, but other feature states were "
+                        + "requested:"
+                ),
+                containsString("SystemIndexTestPlugin")
+            )
+        );
+    }
+
+    /**
+     * Tests that using the special "none" feature state value creates a snapshot with no feature states included
+     */
+    public void testNoneFeatureStateOnCreation() {
+        createRepository(REPO_NAME, "fs");
+        final String regularIndex = "test-idx";
+
+        indexDoc(regularIndex, "1", "purpose", "create an index that can be restored");
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        refresh(regularIndex, SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setIndices(regularIndex)
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(true)
+            .setFeatureStates(randomFrom("none", "NONE"))
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        // Verify that the system index was not included
+        Set<String> snapshottedIndices = clusterAdmin().prepareGetSnapshots(REPO_NAME).get()
+            .getSnapshots(REPO_NAME).stream()
+            .map(SnapshotInfo::indices)
+            .flatMap(Collection::stream)
+            .collect(Collectors.toSet());
+
+        assertThat(snapshottedIndices, allOf(hasItem(regularIndex), not(hasItem(SystemIndexTestPlugin.SYSTEM_INDEX_NAME))));
+    }
+
+    public void testNoneFeatureStateOnRestore() {
+        createRepository(REPO_NAME, "fs");
+        final String regularIndex = "test-idx";
+
+        indexDoc(regularIndex, "1", "purpose", "create an index that can be restored");
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        refresh(regularIndex, SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        // Create a snapshot
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setIndices(regularIndex)
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(true)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        // Index another doc into the system index
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "2", "purpose", "post-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+        assertThat(getDocCount(SystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(2L));
+        // And delete the regular index so we can restore it
+        assertAcked(cluster().client().admin().indices().prepareDelete(regularIndex));
+
+        // Restore the snapshot specifying the regular index and "none" for feature states
+        RestoreSnapshotResponse restoreSnapshotResponse = clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+            .setIndices(regularIndex)
+            .setWaitForCompletion(true)
+            .setRestoreGlobalState(randomBoolean())
+            .setFeatureStates(randomFrom("none", "NONE"))
+            .get();
+        assertThat(restoreSnapshotResponse.getRestoreInfo().totalShards(), greaterThan(0));
+
+        // The regular index should only have one doc
+        assertThat(getDocCount(regularIndex), equalTo(1L));
+        // But the system index shouldn't have been touched
+        assertThat(getDocCount(SystemIndexTestPlugin.SYSTEM_INDEX_NAME), equalTo(2L));
+    }
+
+    /**
+     * This test checks a piece of BWC logic, and so should be removed when we block restoring system indices by name.
+     *
+     * This test checks whether it's possible to change the name of a system index when it's restored by name (rather than by feature state)
+     */
+    public void testCanRenameSystemIndicesIfRestoredByIndexName() {
+        createRepository(REPO_NAME, "fs");
+        indexDoc(SystemIndexTestPlugin.SYSTEM_INDEX_NAME, "1", "purpose", "pre-snapshot doc");
+        refresh(SystemIndexTestPlugin.SYSTEM_INDEX_NAME);
+
+        // snapshot including our system index
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, "test-snap")
+            .setWaitForCompletion(true)
+            .setIncludeGlobalState(false)
+            .get();
+        assertSnapshotSuccess(createSnapshotResponse);
+
+        // Now restore it with a rename
+        clusterAdmin().prepareRestoreSnapshot(REPO_NAME, "test-snap")
+            .setIndices(SystemIndexTestPlugin.SYSTEM_INDEX_NAME)
+            .setWaitForCompletion(true)
+            .setRestoreGlobalState(false)
+            .setFeatureStates(NO_FEATURE_STATES_VALUE)
+            .setRenamePattern(".test-(.+)")
+            .setRenameReplacement("restored-$1")
+            .get();
+
+        assertTrue("The renamed system index should be present", indexExists("restored-system-idx"));
+        assertTrue("The original index should still be present", indexExists(SystemIndexTestPlugin.SYSTEM_INDEX_NAME));
+    }
+
+    /**
+     * Ensures that if we can only capture a partial snapshot of a system index, then the feature state associated with that index is
+     * not included in the snapshot, because it would not be safe to restore that feature state.
+     */
+    public void testPartialSnapshotsOfSystemIndexRemovesFeatureState() throws Exception {
+        final String partialIndexName = SystemIndexTestPlugin.SYSTEM_INDEX_NAME;
+        final String fullIndexName = AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME;
+
+        createRepositoryNoVerify(REPO_NAME, "mock");
+
+        // Creating the index that we'll get a partial snapshot of with a bunch of shards
+        assertAcked(prepareCreate(partialIndexName, 0, indexSettingsNoReplicas(6)));
+        indexDoc(partialIndexName, "1", "purpose", "pre-snapshot doc");
+        // And another one with the default
+        indexDoc(fullIndexName, "1", "purpose", "pre-snapshot doc");
+        ensureGreen();
+
+        // Stop a random data node so we lose a shard from the partial index
+        internalCluster().stopRandomDataNode();
+        assertBusy(() -> assertEquals(ClusterHealthStatus.RED, client().admin().cluster().prepareHealth().get().getStatus()),
+            30, TimeUnit.SECONDS);
+
+        // Get ready to block
+        blockMasterFromFinalizingSnapshotOnIndexFile(REPO_NAME);
+
+        // Start a snapshot and wait for it to hit the block, then kill the master to force a failover
+        final String partialSnapName = "test-partial-snap";
+        CreateSnapshotResponse createSnapshotResponse = clusterAdmin().prepareCreateSnapshot(REPO_NAME, partialSnapName)
+            .setIncludeGlobalState(true)
+            .setWaitForCompletion(false)
+            .setPartial(true)
+            .get();
+        assertThat(createSnapshotResponse.status(), equalTo(RestStatus.ACCEPTED));
+        waitForBlock(internalCluster().getMasterName(), REPO_NAME);
+        internalCluster().stopCurrentMasterNode();
+
+        // Now get the snapshot and do our checks
+        assertBusy(() -> {
+            GetSnapshotsResponse snapshotsStatusResponse = client().admin().cluster()
+                .prepareGetSnapshots(REPO_NAME).setSnapshots(partialSnapName).get();
+            SnapshotInfo snapshotInfo = snapshotsStatusResponse.getSnapshots(REPO_NAME).get(0);
+            assertNotNull(snapshotInfo);
+            assertThat(snapshotInfo.failedShards(), lessThan(snapshotInfo.totalShards()));
+            List<String> statesInSnapshot = snapshotInfo.featureStates().stream()
+                .map(SnapshotFeatureInfo::getPluginName)
+                .collect(Collectors.toList());
+            assertThat(statesInSnapshot, not(hasItem((new SystemIndexTestPlugin()).getFeatureName())));
+            assertThat(statesInSnapshot, hasItem((new AnotherSystemIndexTestPlugin()).getFeatureName()));
+        });
+    }
+
+    public void testParallelIndexDeleteRemovesFeatureState() throws Exception {
+        final String indexToBeDeleted = SystemIndexTestPlugin.SYSTEM_INDEX_NAME;
+        final String fullIndexName = AnotherSystemIndexTestPlugin.SYSTEM_INDEX_NAME;
+        final String nonsystemIndex = "nonsystem-idx";
+
+        // Stop one data node so we only have one data node to start with
+        internalCluster().stopNode(dataNodes.get(1));
+        dataNodes.remove(1);
+
+        createRepositoryNoVerify(REPO_NAME, "mock");
+
+        // Creating the index that we'll get a partial snapshot of with a bunch of shards
+        assertAcked(prepareCreate(indexToBeDeleted, 0, indexSettingsNoReplicas(6)));
+        indexDoc(indexToBeDeleted, "1", "purpose", "pre-snapshot doc");
+        // And another one with the default
+        indexDoc(fullIndexName, "1", "purpose", "pre-snapshot doc");
+
+        // Now start up a new node and create an index that should get allocated to it
+        dataNodes.add(internalCluster().startDataOnlyNode());
+        createIndexWithContent(
+            nonsystemIndex,
+            indexSettingsNoReplicas(2).put("index.routing.allocation.require._name", dataNodes.get(1)).build()
+        );
+        refresh();
+        ensureGreen();
+
+        logger.info("--> Created indices, blocking repo on new data node...");
+        blockDataNode(REPO_NAME, dataNodes.get(1));
+
+        // Start a snapshot - need to do this async because some blocks will block this call
+        logger.info("--> Blocked repo, starting snapshot...");
+        final String partialSnapName = "test-partial-snap";
+        ActionFuture<CreateSnapshotResponse> createSnapshotFuture = clusterAdmin().prepareCreateSnapshot(REPO_NAME, partialSnapName)
+            .setIndices(nonsystemIndex)
+            .setIncludeGlobalState(true)
+            .setWaitForCompletion(true)
+            .setPartial(true)
+            .execute();
+
+        logger.info("--> Started snapshot, waiting for block...");
+        waitForBlock(dataNodes.get(1), REPO_NAME);
+
+        logger.info("--> Repo hit block, deleting the index...");
+        assertAcked(cluster().client().admin().indices().prepareDelete(indexToBeDeleted));
+
+        logger.info("--> Index deleted, unblocking repo...");
+        unblockNode(REPO_NAME, dataNodes.get(1));
+
+        logger.info("--> Repo unblocked, checking that snapshot finished...");
+        CreateSnapshotResponse createSnapshotResponse = createSnapshotFuture.actionGet();
+        logger.info(createSnapshotResponse.toString());
+        assertThat(createSnapshotResponse.status(), equalTo(RestStatus.OK));
+
+        logger.info("--> All operations complete, running assertions");
+        SnapshotInfo snapshotInfo = createSnapshotResponse.getSnapshotInfo();
+        assertNotNull(snapshotInfo);
+        assertThat(snapshotInfo.indices(), not(hasItem(indexToBeDeleted)));
+        List<String> statesInSnapshot = snapshotInfo.featureStates().stream()
+            .map(SnapshotFeatureInfo::getPluginName)
+            .collect(Collectors.toList());
+        assertThat(statesInSnapshot, not(hasItem((new SystemIndexTestPlugin()).getFeatureName())));
+        assertThat(statesInSnapshot, hasItem((new AnotherSystemIndexTestPlugin()).getFeatureName()));
+    }
+
+    private void assertSnapshotSuccess(CreateSnapshotResponse createSnapshotResponse) {
+        assertThat(createSnapshotResponse.getSnapshotInfo().successfulShards(), greaterThan(0));
+        assertThat(createSnapshotResponse.getSnapshotInfo().successfulShards(),
+            equalTo(createSnapshotResponse.getSnapshotInfo().totalShards()));
+    }
+
+    private long getDocCount(String indexName) {
+        return client().admin().indices().prepareStats(indexName).get().getPrimaries().getDocs().getCount();
+    }
+
+    public static class SystemIndexTestPlugin extends Plugin implements SystemIndexPlugin {
+
+        public static final String SYSTEM_INDEX_NAME = ".test-system-idx";
+
+        @Override
+        public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings settings) {
+            return Collections.singletonList(new SystemIndexDescriptor(SYSTEM_INDEX_NAME + "*", "System indices for tests"));
+        }
+
+        @Override
+        public String getFeatureName() {
+            return SystemIndexTestPlugin.class.getSimpleName();
+        }
+
+        @Override
+        public String getFeatureDescription() {
+            return "A simple test plugin";
+        }
+    }
+
+    public static class AnotherSystemIndexTestPlugin extends Plugin implements SystemIndexPlugin {
+
+        public static final String SYSTEM_INDEX_NAME = ".another-test-system-idx";
+
+        @Override
+        public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings settings) {
+            return Collections.singletonList(new SystemIndexDescriptor(SYSTEM_INDEX_NAME, "System indices for tests"));
+        }
+
+        @Override
+        public String getFeatureName() {
+            return AnotherSystemIndexTestPlugin.class.getSimpleName();
+        }
+
+        @Override
+        public String getFeatureDescription() {
+            return "Another simple test plugin";
+        }
+    }
+
+    public static class AssociatedIndicesTestPlugin extends Plugin implements SystemIndexPlugin {
+
+        public static final String SYSTEM_INDEX_NAME = ".third-test-system-idx";
+        public static final String ASSOCIATED_INDEX_NAME = ".associated-idx";
+
+        @Override
+        public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings settings) {
+            return Collections.singletonList(new SystemIndexDescriptor(SYSTEM_INDEX_NAME, "System & associated indices for tests"));
+        }
+
+        @Override
+        public Collection<String> getAssociatedIndexPatterns() {
+            return Collections.singletonList(ASSOCIATED_INDEX_NAME);
+        }
+
+        @Override
+        public String getFeatureName() {
+            return AssociatedIndicesTestPlugin.class.getSimpleName();
+        }
+
+        @Override
+        public String getFeatureDescription() {
+            return "Another simple test plugin";
+        }
+    }
+}
diff --git a/server/src/main/java/org/elasticsearch/action/ActionModule.java b/server/src/main/java/org/elasticsearch/action/ActionModule.java
index cdb56efe022d9..d4ff96be8db45 100644
--- a/server/src/main/java/org/elasticsearch/action/ActionModule.java
+++ b/server/src/main/java/org/elasticsearch/action/ActionModule.java
@@ -58,6 +58,8 @@
 import org.elasticsearch.action.admin.cluster.snapshots.create.TransportCreateSnapshotAction;
 import org.elasticsearch.action.admin.cluster.snapshots.delete.DeleteSnapshotAction;
 import org.elasticsearch.action.admin.cluster.snapshots.delete.TransportDeleteSnapshotAction;
+import org.elasticsearch.action.admin.cluster.snapshots.features.SnapshottableFeaturesAction;
+import org.elasticsearch.action.admin.cluster.snapshots.features.TransportSnapshottableFeaturesAction;
 import org.elasticsearch.action.admin.cluster.snapshots.get.GetSnapshotsAction;
 import org.elasticsearch.action.admin.cluster.snapshots.get.TransportGetSnapshotsAction;
 import org.elasticsearch.action.admin.cluster.snapshots.restore.RestoreSnapshotAction;
@@ -282,6 +284,7 @@
 import org.elasticsearch.rest.action.admin.cluster.RestRemoteClusterInfoAction;
 import org.elasticsearch.rest.action.admin.cluster.RestRestoreSnapshotAction;
 import org.elasticsearch.rest.action.admin.cluster.RestSnapshotsStatusAction;
+import org.elasticsearch.rest.action.admin.cluster.RestSnapshottableFeaturesAction;
 import org.elasticsearch.rest.action.admin.cluster.RestVerifyRepositoryAction;
 import org.elasticsearch.rest.action.admin.cluster.dangling.RestDeleteDanglingIndexAction;
 import org.elasticsearch.rest.action.admin.cluster.dangling.RestImportDanglingIndexAction;
@@ -497,6 +500,7 @@ public <Request extends ActionRequest, Response extends ActionResponse> void reg
         actions.register(CloneSnapshotAction.INSTANCE, TransportCloneSnapshotAction.class);
         actions.register(RestoreSnapshotAction.INSTANCE, TransportRestoreSnapshotAction.class);
         actions.register(SnapshotsStatusAction.INSTANCE, TransportSnapshotsStatusAction.class);
+        actions.register(SnapshottableFeaturesAction.INSTANCE, TransportSnapshottableFeaturesAction.class);
 
         actions.register(IndicesStatsAction.INSTANCE, TransportIndicesStatsAction.class);
         actions.register(IndicesSegmentsAction.INSTANCE, TransportIndicesSegmentsAction.class);
@@ -646,6 +650,7 @@ public void initRestHandlers(Supplier<DiscoveryNodes> nodesInCluster) {
         registerHandler.accept(new RestRestoreSnapshotAction());
         registerHandler.accept(new RestDeleteSnapshotAction());
         registerHandler.accept(new RestSnapshotsStatusAction());
+        registerHandler.accept(new RestSnapshottableFeaturesAction());
         registerHandler.accept(new RestGetIndicesAction());
         registerHandler.accept(new RestIndicesStatsAction());
         registerHandler.accept(new RestIndicesSegmentsAction());
diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequest.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequest.java
index a86ba4f88bf2c..ceb5111528455 100644
--- a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequest.java
+++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequest.java
@@ -22,6 +22,7 @@
 import org.elasticsearch.common.xcontent.ToXContentObject;
 import org.elasticsearch.common.xcontent.XContentBuilder;
 import org.elasticsearch.common.xcontent.XContentFactory;
+import org.elasticsearch.snapshots.SnapshotsService;
 
 import java.io.IOException;
 import java.util.Arrays;
@@ -64,6 +65,8 @@ public class CreateSnapshotRequest extends MasterNodeRequest<CreateSnapshotReque
 
     private IndicesOptions indicesOptions = IndicesOptions.strictExpandHidden();
 
+    private String[] featureStates = EMPTY_ARRAY;
+
     private boolean partial = false;
 
     private boolean includeGlobalState = true;
@@ -95,6 +98,9 @@ public CreateSnapshotRequest(StreamInput in) throws IOException {
         if (in.getVersion().before(SETTINGS_IN_REQUEST_VERSION)) {
             readSettingsFromStream(in);
         }
+        if (in.getVersion().onOrAfter(SnapshotsService.FEATURE_STATES_VERSION)) {
+            featureStates = in.readStringArray();
+        }
         includeGlobalState = in.readBoolean();
         waitForCompletion = in.readBoolean();
         partial = in.readBoolean();
@@ -111,6 +117,9 @@ public void writeTo(StreamOutput out) throws IOException {
         if (out.getVersion().before(SETTINGS_IN_REQUEST_VERSION)) {
             writeSettingsToStream(Settings.EMPTY, out);
         }
+        if (out.getVersion().onOrAfter(SnapshotsService.FEATURE_STATES_VERSION)) {
+            out.writeStringArray(featureStates);
+        }
         out.writeBoolean(includeGlobalState);
         out.writeBoolean(waitForCompletion);
         out.writeBoolean(partial);
@@ -139,6 +148,9 @@ public ActionRequestValidationException validate() {
         if (indicesOptions == null) {
             validationException = addValidationError("indicesOptions is null", validationException);
         }
+        if (featureStates == null) {
+            validationException = addValidationError("featureStates is null", validationException);
+        }
         final int metadataSize = metadataSize(userMetadata);
         if (metadataSize > MAXIMUM_METADATA_BYTES) {
             validationException = addValidationError("metadata must be smaller than 1024 bytes, but was [" + metadataSize + "]",
@@ -337,6 +349,28 @@ public CreateSnapshotRequest userMetadata(Map<String, Object> userMetadata) {
         return this;
     }
 
+    /**
+     * @return Which plugin states should be included in the snapshot
+     */
+    public String[] featureStates() {
+        return featureStates;
+    }
+
+    /**
+     * @param featureStates The plugin states to be included in the snapshot
+     */
+    public CreateSnapshotRequest featureStates(String[] featureStates) {
+        this.featureStates = featureStates;
+        return this;
+    }
+
+    /**
+     * @param featureStates The plugin states to be included in the snapshot
+     */
+    public CreateSnapshotRequest featureStates(List<String> featureStates) {
+        return featureStates(featureStates.toArray(EMPTY_ARRAY));
+    }
+
     /**
      * Parses snapshot definition.
      *
@@ -355,6 +389,12 @@ public CreateSnapshotRequest source(Map<String, Object> source) {
                 } else {
                     throw new IllegalArgumentException("malformed indices section, should be an array of strings");
                 }
+            } else if (name.equals("feature_states")) {
+                if (entry.getValue() instanceof List) {
+                    featureStates((List<String>) entry.getValue());
+                } else {
+                    throw new IllegalArgumentException("malformed feature_states section, should be an array of strings");
+                }
             } else if (name.equals("partial")) {
                 partial(nodeBooleanValue(entry.getValue(), "partial"));
             } else if (name.equals("include_global_state")) {
@@ -380,6 +420,13 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
             builder.value(index);
         }
         builder.endArray();
+        if (featureStates != null) {
+            builder.startArray("feature_states");
+            for (String plugin : featureStates) {
+                builder.value(plugin);
+            }
+            builder.endArray();
+        }
         builder.field("partial", partial);
         builder.field("include_global_state", includeGlobalState);
         if (indicesOptions != null) {
@@ -407,6 +454,7 @@ public boolean equals(Object o) {
             Objects.equals(repository, that.repository) &&
             Arrays.equals(indices, that.indices) &&
             Objects.equals(indicesOptions, that.indicesOptions) &&
+            Arrays.equals(featureStates, that.featureStates) &&
             Objects.equals(masterNodeTimeout, that.masterNodeTimeout) &&
             Objects.equals(userMetadata, that.userMetadata);
     }
@@ -416,6 +464,7 @@ public int hashCode() {
         int result = Objects.hash(snapshot, repository, indicesOptions, partial, includeGlobalState,
             waitForCompletion, userMetadata);
         result = 31 * result + Arrays.hashCode(indices);
+        result = 31 * result + Arrays.hashCode(featureStates);
         return result;
     }
 
@@ -426,6 +475,7 @@ public String toString() {
             ", repository='" + repository + '\'' +
             ", indices=" + (indices == null ? null : Arrays.asList(indices)) +
             ", indicesOptions=" + indicesOptions +
+            ", featureStates=" + Arrays.asList(featureStates) +
             ", partial=" + partial +
             ", includeGlobalState=" + includeGlobalState +
             ", waitForCompletion=" + waitForCompletion +
diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequestBuilder.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequestBuilder.java
index 2fe7033c85ab2..355060834d8f3 100644
--- a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequestBuilder.java
+++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequestBuilder.java
@@ -111,4 +111,15 @@ public CreateSnapshotRequestBuilder setIncludeGlobalState(boolean includeGlobalS
         request.includeGlobalState(includeGlobalState);
         return this;
     }
+
+    /**
+     * Provide a list of features whose state indices should be included in the snapshot
+     *
+     * @param featureStates A list of feature names
+     * @return this builder
+     */
+    public CreateSnapshotRequestBuilder setFeatureStates(String... featureStates) {
+        request.featureStates(featureStates);
+        return this;
+    }
 }
diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/features/GetSnapshottableFeaturesRequest.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/features/GetSnapshottableFeaturesRequest.java
new file mode 100644
index 0000000000000..545f5c7fbdd7a
--- /dev/null
+++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/features/GetSnapshottableFeaturesRequest.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.action.admin.cluster.snapshots.features;
+
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.support.master.MasterNodeRequest;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+
+import java.io.IOException;
+
+public class GetSnapshottableFeaturesRequest extends MasterNodeRequest<GetSnapshottableFeaturesRequest> {
+
+    public GetSnapshottableFeaturesRequest() {
+
+    }
+
+    public GetSnapshottableFeaturesRequest(StreamInput in) throws IOException {
+        super(in);
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        super.writeTo(out);
+    }
+
+    @Override
+    public ActionRequestValidationException validate() {
+        return null;
+    }
+}
diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/features/GetSnapshottableFeaturesResponse.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/features/GetSnapshottableFeaturesResponse.java
new file mode 100644
index 0000000000000..a2048bab29c58
--- /dev/null
+++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/features/GetSnapshottableFeaturesResponse.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.action.admin.cluster.snapshots.features;
+
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.common.xcontent.ToXContentObject;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+public class GetSnapshottableFeaturesResponse extends ActionResponse implements ToXContentObject {
+
+    private final List<SnapshottableFeature> snapshottableFeatures;
+
+    public GetSnapshottableFeaturesResponse(List<SnapshottableFeature> features) {
+        this.snapshottableFeatures = Collections.unmodifiableList(features);
+    }
+
+    public GetSnapshottableFeaturesResponse(StreamInput in) throws IOException {
+        super(in);
+        snapshottableFeatures = in.readList(SnapshottableFeature::new);
+    }
+
+    public List<SnapshottableFeature> getSnapshottableFeatures() {
+        return snapshottableFeatures;
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        out.writeList(snapshottableFeatures);
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        {
+            builder.startArray("features");
+            for (SnapshottableFeature feature : snapshottableFeatures) {
+                builder.value(feature);
+            }
+            builder.endArray();
+        }
+        builder.endObject();
+        return builder;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if ((o instanceof GetSnapshottableFeaturesResponse) == false) return false;
+        GetSnapshottableFeaturesResponse that = (GetSnapshottableFeaturesResponse) o;
+        return snapshottableFeatures.equals(that.snapshottableFeatures);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(snapshottableFeatures);
+    }
+
+    public static class SnapshottableFeature implements Writeable, ToXContentObject {
+
+        private final String featureName;
+        private final String description;
+
+        public SnapshottableFeature(String featureName, String description) {
+            this.featureName = featureName;
+            this.description = description;
+        }
+
+        public SnapshottableFeature(StreamInput in) throws IOException {
+            featureName = in.readString();
+            description = in.readString();
+        }
+
+        public String getFeatureName() {
+            return featureName;
+        }
+
+        public String getDescription() {
+            return description;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            out.writeString(featureName);
+            out.writeString(description);
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            builder.startObject();
+            builder.field("name", featureName);
+            builder.field("description", description);
+            builder.endObject();
+            return builder;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if ((o instanceof SnapshottableFeature) == false) return false;
+            SnapshottableFeature feature = (SnapshottableFeature) o;
+            return Objects.equals(getFeatureName(), feature.getFeatureName());
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(getFeatureName());
+        }
+    }
+}
diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/features/SnapshottableFeaturesAction.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/features/SnapshottableFeaturesAction.java
new file mode 100644
index 0000000000000..38bf7afd6b505
--- /dev/null
+++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/features/SnapshottableFeaturesAction.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.action.admin.cluster.snapshots.features;
+
+import org.elasticsearch.action.ActionType;
+
+public class SnapshottableFeaturesAction extends ActionType<GetSnapshottableFeaturesResponse> {
+
+    public static final SnapshottableFeaturesAction INSTANCE = new SnapshottableFeaturesAction();
+    public static final String NAME = "cluster:admin/snapshot/features/get";
+
+    private SnapshottableFeaturesAction() {
+        super(NAME, GetSnapshottableFeaturesResponse::new);
+    }
+}
diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/features/TransportSnapshottableFeaturesAction.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/features/TransportSnapshottableFeaturesAction.java
new file mode 100644
index 0000000000000..62f32bc460f0e
--- /dev/null
+++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/features/TransportSnapshottableFeaturesAction.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.action.admin.cluster.snapshots.features;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.master.TransportMasterNodeAction;
+import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.cluster.block.ClusterBlockException;
+import org.elasticsearch.cluster.block.ClusterBlockLevel;
+import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.indices.SystemIndices;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.transport.TransportService;
+
+import java.util.stream.Collectors;
+
+public class TransportSnapshottableFeaturesAction extends TransportMasterNodeAction<GetSnapshottableFeaturesRequest,
+    GetSnapshottableFeaturesResponse> {
+
+    private final SystemIndices systemIndices;
+
+    @Inject
+    public TransportSnapshottableFeaturesAction(TransportService transportService, ClusterService clusterService, ThreadPool threadPool,
+                                                ActionFilters actionFilters, IndexNameExpressionResolver indexNameExpressionResolver,
+                                                SystemIndices systemIndices) {
+        super(SnapshottableFeaturesAction.NAME, transportService, clusterService, threadPool, actionFilters,
+            GetSnapshottableFeaturesRequest::new, indexNameExpressionResolver, GetSnapshottableFeaturesResponse::new,
+            ThreadPool.Names.SAME);
+        this.systemIndices = systemIndices;
+    }
+
+    @Override
+    protected void masterOperation(Task task, GetSnapshottableFeaturesRequest request, ClusterState state,
+                                   ActionListener<GetSnapshottableFeaturesResponse> listener) throws Exception {
+        listener.onResponse(new GetSnapshottableFeaturesResponse(systemIndices.getFeatures().entrySet().stream()
+            .map(featureEntry -> new GetSnapshottableFeaturesResponse.SnapshottableFeature(
+                featureEntry.getKey(),
+                featureEntry.getValue().getDescription()))
+            .collect(Collectors.toList())));
+    }
+
+    @Override
+    protected ClusterBlockException checkBlock(GetSnapshottableFeaturesRequest request, ClusterState state) {
+        return state.blocks().globalBlockedException(ClusterBlockLevel.METADATA_READ);
+    }
+}
diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/get/TransportGetSnapshotsAction.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/get/TransportGetSnapshotsAction.java
index fedccc0854450..ea62ee86a9fb1 100644
--- a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/get/TransportGetSnapshotsAction.java
+++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/get/TransportGetSnapshotsAction.java
@@ -279,7 +279,8 @@ private static List<SnapshotInfo> buildSimpleSnapshotInfos(final Set<SnapshotId>
         for (SnapshotId snapshotId : toResolve) {
             final List<String> indices = snapshotsToIndices.getOrDefault(snapshotId, Collections.emptyList());
             CollectionUtil.timSort(indices);
-            snapshotInfos.add(new SnapshotInfo(snapshotId, indices, Collections.emptyList(), repositoryData.getSnapshotState(snapshotId)));
+            snapshotInfos.add(new SnapshotInfo(snapshotId, indices, Collections.emptyList(), Collections.emptyList(),
+                repositoryData.getSnapshotState(snapshotId)));
         }
         CollectionUtil.timSort(snapshotInfos);
         return Collections.unmodifiableList(snapshotInfos);
diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequest.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequest.java
index f2d412d7baab9..0498472dd8d91 100644
--- a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequest.java
+++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequest.java
@@ -28,6 +28,7 @@
 import java.util.Objects;
 
 import static org.elasticsearch.action.ValidateActions.addValidationError;
+import static org.elasticsearch.snapshots.SnapshotsService.FEATURE_STATES_VERSION;
 import static org.elasticsearch.common.settings.Settings.Builder.EMPTY_SETTINGS;
 import static org.elasticsearch.common.settings.Settings.readSettingsFromStream;
 import static org.elasticsearch.common.settings.Settings.writeSettingsToStream;
@@ -42,6 +43,7 @@ public class RestoreSnapshotRequest extends MasterNodeRequest<RestoreSnapshotReq
     private String repository;
     private String[] indices = Strings.EMPTY_ARRAY;
     private IndicesOptions indicesOptions = IndicesOptions.strictExpandOpen();
+    private String[] featureStates = Strings.EMPTY_ARRAY;
     private String renamePattern;
     private String renameReplacement;
     private boolean waitForCompletion;
@@ -77,6 +79,9 @@ public RestoreSnapshotRequest(StreamInput in) throws IOException {
         repository = in.readString();
         indices = in.readStringArray();
         indicesOptions = IndicesOptions.readIndicesOptions(in);
+        if (in.getVersion().onOrAfter(FEATURE_STATES_VERSION)) {
+            featureStates = in.readStringArray();
+        }
         renamePattern = in.readOptionalString();
         renameReplacement = in.readOptionalString();
         waitForCompletion = in.readBoolean();
@@ -95,6 +100,9 @@ public void writeTo(StreamOutput out) throws IOException {
         out.writeString(repository);
         out.writeStringArray(indices);
         indicesOptions.writeIndicesOptions(out);
+        if (out.getVersion().onOrAfter(FEATURE_STATES_VERSION)) {
+            out.writeStringArray(featureStates);
+        }
         out.writeOptionalString(renamePattern);
         out.writeOptionalString(renameReplacement);
         out.writeBoolean(waitForCompletion);
@@ -121,6 +129,9 @@ public ActionRequestValidationException validate() {
         if (indicesOptions == null) {
             validationException = addValidationError("indicesOptions is missing", validationException);
         }
+        if (featureStates == null) {
+            validationException = addValidationError("featureStates is missing", validationException);
+        }
         if (indexSettings == null) {
             validationException = addValidationError("indexSettings are missing", validationException);
         }
@@ -448,6 +459,29 @@ public void skipOperatorOnlyState(boolean skipOperatorOnlyState) {
         this.skipOperatorOnlyState = skipOperatorOnlyState;
     }
 
+    /**
+     * @return Which feature states should be included in the snapshot
+     */
+    @Nullable
+    public String[] featureStates() {
+        return featureStates;
+    }
+
+    /**
+     * @param featureStates The feature states to be included in the snapshot
+     */
+    public RestoreSnapshotRequest featureStates(String[] featureStates) {
+        this.featureStates = featureStates;
+        return this;
+    }
+
+    /**
+     * @param featureStates The feature states to be included in the snapshot
+     */
+    public RestoreSnapshotRequest featureStates(List<String> featureStates) {
+        return featureStates(featureStates.toArray(Strings.EMPTY_ARRAY));
+    }
+
     /**
      * Parses restore definition
      *
@@ -466,6 +500,12 @@ public RestoreSnapshotRequest source(Map<String, Object> source) {
                 } else {
                     throw new IllegalArgumentException("malformed indices section, should be an array of strings");
                 }
+            } else if (name.equals("feature_states")) {
+                if (entry.getValue() instanceof List) {
+                    featureStates((List<String>) entry.getValue());
+                } else {
+                    throw new IllegalArgumentException("malformed feature_states section, should be an array of strings");
+                }
             } else if (name.equals("partial")) {
                 partial(nodeBooleanValue(entry.getValue(), "partial"));
             } else if (name.equals("include_global_state")) {
@@ -530,6 +570,13 @@ private void toXContentFragment(XContentBuilder builder, Params params) throws I
         if (renameReplacement != null) {
             builder.field("rename_replacement", renameReplacement);
         }
+        if (featureStates != null && featureStates.length > 0) {
+            builder.startArray("feature_states");
+            for (String plugin : featureStates) {
+                builder.value(plugin);
+            }
+            builder.endArray();
+        }
         builder.field("include_global_state", includeGlobalState);
         builder.field("partial", partial);
         builder.field("include_aliases", includeAliases);
@@ -565,6 +612,7 @@ public boolean equals(Object o) {
             Objects.equals(repository, that.repository) &&
             Arrays.equals(indices, that.indices) &&
             Objects.equals(indicesOptions, that.indicesOptions) &&
+            Arrays.equals(featureStates, that.featureStates) &&
             Objects.equals(renamePattern, that.renamePattern) &&
             Objects.equals(renameReplacement, that.renameReplacement) &&
             Objects.equals(indexSettings, that.indexSettings) &&
@@ -579,6 +627,7 @@ public int hashCode() {
             includeGlobalState, partial, includeAliases, indexSettings, snapshotUuid, skipOperatorOnlyState);
         result = 31 * result + Arrays.hashCode(indices);
         result = 31 * result + Arrays.hashCode(ignoreIndexSettings);
+        result = 31 * result + Arrays.hashCode(featureStates);
         return result;
     }
 
diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestBuilder.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestBuilder.java
index b3dd357f12ae0..ceab46e73fc63 100644
--- a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestBuilder.java
+++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestBuilder.java
@@ -223,4 +223,12 @@ public RestoreSnapshotRequestBuilder setIgnoreIndexSettings(List<String> ignoreI
         request.ignoreIndexSettings(ignoreIndexSettings);
         return this;
     }
+
+    /**
+     * Sets the list of features whose states should be restored as part of this snapshot
+     */
+    public RestoreSnapshotRequestBuilder setFeatureStates(String... featureStates) {
+        request.featureStates(featureStates);
+        return this;
+    }
 }
diff --git a/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java b/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java
index 090b6797fe500..54fb1d20a80a0 100644
--- a/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java
+++ b/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java
@@ -95,6 +95,7 @@ public class ClusterModule extends AbstractModule {
     private final AllocationDeciders allocationDeciders;
     private final AllocationService allocationService;
     private final List<ClusterPlugin> clusterPlugins;
+    private final MetadataDeleteIndexService metadataDeleteIndexService;
     // pkg private for tests
     final Collection<AllocationDecider> deciderList;
     final ShardsAllocator shardsAllocator;
@@ -108,6 +109,7 @@ public ClusterModule(Settings settings, ClusterService clusterService, List<Clus
         this.clusterService = clusterService;
         this.indexNameExpressionResolver = new IndexNameExpressionResolver(threadContext);
         this.allocationService = new AllocationService(allocationDeciders, shardsAllocator, clusterInfoService, snapshotsInfoService);
+        this.metadataDeleteIndexService = new MetadataDeleteIndexService(settings, clusterService, allocationService);
     }
 
     public static List<Entry> getNamedWriteables() {
@@ -248,13 +250,17 @@ public AllocationService getAllocationService() {
         return allocationService;
     }
 
+    public MetadataDeleteIndexService getMetadataDeleteIndexService() {
+        return metadataDeleteIndexService;
+    }
+
     @Override
     protected void configure() {
         bind(GatewayAllocator.class).asEagerSingleton();
         bind(AllocationService.class).toInstance(allocationService);
         bind(ClusterService.class).toInstance(clusterService);
         bind(NodeConnectionsService.class).asEagerSingleton();
-        bind(MetadataDeleteIndexService.class).asEagerSingleton();
+        bind(MetadataDeleteIndexService.class).toInstance(metadataDeleteIndexService);
         bind(MetadataIndexStateService.class).asEagerSingleton();
         bind(MetadataMappingService.class).asEagerSingleton();
         bind(MetadataIndexAliasesService.class).asEagerSingleton();
diff --git a/server/src/main/java/org/elasticsearch/cluster/SnapshotsInProgress.java b/server/src/main/java/org/elasticsearch/cluster/SnapshotsInProgress.java
index 3bfb26e1aae13..12906d755666c 100644
--- a/server/src/main/java/org/elasticsearch/cluster/SnapshotsInProgress.java
+++ b/server/src/main/java/org/elasticsearch/cluster/SnapshotsInProgress.java
@@ -25,10 +25,11 @@
 import org.elasticsearch.common.xcontent.XContentBuilder;
 import org.elasticsearch.index.shard.ShardId;
 import org.elasticsearch.repositories.IndexId;
-import org.elasticsearch.repositories.RepositoryShardId;
 import org.elasticsearch.repositories.RepositoryOperation;
+import org.elasticsearch.repositories.RepositoryShardId;
 import org.elasticsearch.snapshots.InFlightShardSnapshotStates;
 import org.elasticsearch.snapshots.Snapshot;
+import org.elasticsearch.snapshots.SnapshotFeatureInfo;
 import org.elasticsearch.snapshots.SnapshotId;
 import org.elasticsearch.snapshots.SnapshotsService;
 
@@ -42,6 +43,8 @@
 import java.util.Set;
 import java.util.stream.Collectors;
 
+import static org.elasticsearch.snapshots.SnapshotsService.FEATURE_STATES_VERSION;
+
 /**
  * Meta data about snapshots that are currently executing
  */
@@ -82,12 +85,11 @@ public String toString() {
      * will be in state {@link State#SUCCESS} right away otherwise it will be in state {@link State#STARTED}.
      */
     public static Entry startedEntry(Snapshot snapshot, boolean includeGlobalState, boolean partial, List<IndexId> indices,
-                                     List<String> dataStreams, long startTime, long repositoryStateId,
-                                     ImmutableOpenMap<ShardId, ShardSnapshotStatus> shards, Map<String, Object> userMetadata,
-                                     Version version) {
+        List<String> dataStreams, long startTime, long repositoryStateId, ImmutableOpenMap<ShardId, ShardSnapshotStatus> shards,
+        Map<String, Object> userMetadata, Version version, List<SnapshotFeatureInfo> featureStates) {
         return new SnapshotsInProgress.Entry(snapshot, includeGlobalState, partial,
                 completed(shards.values()) ? State.SUCCESS : State.STARTED,
-                indices, dataStreams, startTime, repositoryStateId, shards, null, userMetadata, version);
+                indices, dataStreams, featureStates, startTime, repositoryStateId, shards, null, userMetadata, version);
     }
 
     /**
@@ -104,8 +106,8 @@ public static Entry startedEntry(Snapshot snapshot, boolean includeGlobalState,
     public static Entry startClone(Snapshot snapshot, SnapshotId source, List<IndexId> indices, long startTime,
                                    long repositoryStateId, Version version) {
         return new SnapshotsInProgress.Entry(snapshot, true, false, State.STARTED, indices, Collections.emptyList(),
-                startTime, repositoryStateId, ImmutableOpenMap.of(), null, Collections.emptyMap(), version, source,
-                ImmutableOpenMap.of());
+            Collections.emptyList(), startTime, repositoryStateId, ImmutableOpenMap.of(), null, Collections.emptyMap(), version, source,
+            ImmutableOpenMap.of());
     }
 
     public static class Entry implements Writeable, ToXContent, RepositoryOperation {
@@ -119,6 +121,7 @@ public static class Entry implements Writeable, ToXContent, RepositoryOperation
         private final ImmutableOpenMap<ShardId, ShardSnapshotStatus> shards;
         private final List<IndexId> indices;
         private final List<String> dataStreams;
+        private final List<SnapshotFeatureInfo> featureStates;
         private final long startTime;
         private final long repositoryStateId;
         // see #useShardGenerations
@@ -141,24 +144,25 @@ public static class Entry implements Writeable, ToXContent, RepositoryOperation
 
         // visible for testing, use #startedEntry and copy constructors in production code
         public Entry(Snapshot snapshot, boolean includeGlobalState, boolean partial, State state, List<IndexId> indices,
-                     List<String> dataStreams, long startTime, long repositoryStateId,
+                     List<String> dataStreams, List<SnapshotFeatureInfo> featureStates, long startTime, long repositoryStateId,
                      ImmutableOpenMap<ShardId, ShardSnapshotStatus> shards, String failure, Map<String, Object> userMetadata,
                      Version version) {
-            this(snapshot, includeGlobalState, partial, state, indices, dataStreams, startTime, repositoryStateId, shards, failure,
-                    userMetadata, version, null, ImmutableOpenMap.of());
+            this(snapshot, includeGlobalState, partial, state, indices, dataStreams, featureStates, startTime, repositoryStateId, shards,
+                failure, userMetadata, version, null, ImmutableOpenMap.of());
         }
 
         private Entry(Snapshot snapshot, boolean includeGlobalState, boolean partial, State state, List<IndexId> indices,
-                     List<String> dataStreams, long startTime, long repositoryStateId,
-                     ImmutableOpenMap<ShardId, ShardSnapshotStatus> shards, String failure, Map<String, Object> userMetadata,
-                     Version version, @Nullable SnapshotId source,
-                     @Nullable ImmutableOpenMap<RepositoryShardId, ShardSnapshotStatus> clones) {
+                      List<String> dataStreams, List<SnapshotFeatureInfo> featureStates, long startTime, long repositoryStateId,
+                      ImmutableOpenMap<ShardId, ShardSnapshotStatus> shards, String failure, Map<String, Object> userMetadata,
+                      Version version, @Nullable SnapshotId source,
+                      @Nullable ImmutableOpenMap<RepositoryShardId, ShardSnapshotStatus> clones) {
             this.state = state;
             this.snapshot = snapshot;
             this.includeGlobalState = includeGlobalState;
             this.partial = partial;
             this.indices = indices;
             this.dataStreams = dataStreams;
+            this.featureStates = Collections.unmodifiableList(featureStates);
             this.startTime = startTime;
             this.shards = shards;
             this.repositoryStateId = repositoryStateId;
@@ -195,6 +199,11 @@ private Entry(StreamInput in) throws IOException {
                 source = null;
                 clones = ImmutableOpenMap.of();
             }
+            if (in.getVersion().onOrAfter(FEATURE_STATES_VERSION)) {
+                featureStates = Collections.unmodifiableList(in.readList(SnapshotFeatureInfo::new));
+            } else {
+                featureStates = Collections.emptyList();
+            }
         }
 
         private static boolean assertShardsConsistent(SnapshotId source, State state, List<IndexId> indices,
@@ -229,8 +238,8 @@ assert hasFailures(clones) == false || state == State.FAILED
         public Entry withRepoGen(long newRepoGen) {
             assert newRepoGen > repositoryStateId : "Updated repository generation [" + newRepoGen
                     + "] must be higher than current generation [" + repositoryStateId + "]";
-            return new Entry(snapshot, includeGlobalState, partial, state, indices, dataStreams, startTime, newRepoGen, shards, failure,
-                    userMetadata, version, source, clones);
+            return new Entry(snapshot, includeGlobalState, partial, state, indices, dataStreams, featureStates, startTime, newRepoGen,
+                shards, failure, userMetadata, version, source, clones);
         }
 
         public Entry withClones(ImmutableOpenMap<RepositoryShardId, ShardSnapshotStatus> updatedClones) {
@@ -239,8 +248,8 @@ public Entry withClones(ImmutableOpenMap<RepositoryShardId, ShardSnapshotStatus>
             }
             return new Entry(snapshot, includeGlobalState, partial,
                     completed(updatedClones.values()) ? (hasFailures(updatedClones) ? State.FAILED : State.SUCCESS) :
-                            state, indices, dataStreams, startTime, repositoryStateId, shards, failure, userMetadata, version, source,
-                    updatedClones);
+                            state, indices, dataStreams, featureStates, startTime, repositoryStateId, shards, failure, userMetadata,
+                            version, source, updatedClones);
         }
 
         /**
@@ -276,8 +285,8 @@ public Entry abort() {
         }
 
         public Entry fail(ImmutableOpenMap<ShardId, ShardSnapshotStatus> shards, State state, String failure) {
-            return new Entry(snapshot, includeGlobalState, partial, state, indices, dataStreams, startTime, repositoryStateId, shards,
-                    failure, userMetadata, version, source, clones);
+            return new Entry(snapshot, includeGlobalState, partial, state, indices, dataStreams, featureStates, startTime,
+                repositoryStateId, shards, failure, userMetadata, version, source, clones);
         }
 
         /**
@@ -290,8 +299,8 @@ public Entry fail(ImmutableOpenMap<ShardId, ShardSnapshotStatus> shards, State s
          */
         public Entry withShardStates(ImmutableOpenMap<ShardId, ShardSnapshotStatus> shards) {
             if (completed(shards.values())) {
-                return new Entry(snapshot, includeGlobalState, partial, State.SUCCESS, indices, dataStreams, startTime, repositoryStateId,
-                        shards, failure, userMetadata, version);
+                return new Entry(snapshot, includeGlobalState, partial, State.SUCCESS, indices, dataStreams, featureStates,
+                    startTime, repositoryStateId, shards, failure, userMetadata, version);
             }
             return withStartedShards(shards);
         }
@@ -302,7 +311,7 @@ public Entry withShardStates(ImmutableOpenMap<ShardId, ShardSnapshotStatus> shar
          */
         public Entry withStartedShards(ImmutableOpenMap<ShardId, ShardSnapshotStatus> shards) {
             final SnapshotsInProgress.Entry updated = new Entry(snapshot, includeGlobalState, partial, state, indices, dataStreams,
-                    startTime, repositoryStateId, shards, failure, userMetadata, version);
+                featureStates, startTime, repositoryStateId, shards, failure, userMetadata, version);
             assert updated.state().completed() == false && completed(updated.shards().values()) == false
                     : "Only running snapshots allowed but saw [" + updated + "]";
             return updated;
@@ -349,6 +358,10 @@ public List<String> dataStreams() {
             return dataStreams;
         }
 
+        public List<SnapshotFeatureInfo> featureStates() {
+            return featureStates;
+        }
+
         @Override
         public long repositoryStateId() {
             return repositoryStateId;
@@ -399,6 +412,7 @@ public boolean equals(Object o) {
             if (version.equals(entry.version) == false) return false;
             if (Objects.equals(source, ((Entry) o).source) == false) return false;
             if (clones.equals(((Entry) o).clones) == false) return false;
+            if (featureStates.equals(entry.featureStates) == false) return false;
 
             return true;
         }
@@ -419,6 +433,7 @@ public int hashCode() {
             result = 31 * result + version.hashCode();
             result = 31 * result + (source == null ? 0 : source.hashCode());
             result = 31 * result + clones.hashCode();
+            result = 31 * result + featureStates.hashCode();
             return result;
         }
 
@@ -461,6 +476,13 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
                 }
             }
             builder.endArray();
+            builder.startArray(FEATURE_STATES);
+            {
+                for (SnapshotFeatureInfo featureState : featureStates) {
+                    featureState.toXContent(builder, params);
+                }
+            }
+            builder.endArray();
             if (isClone()) {
                 builder.field(SOURCE, source);
                 builder.startArray(CLONES);
@@ -503,6 +525,9 @@ public void writeTo(StreamOutput out) throws IOException {
                 out.writeOptionalWriteable(source);
                 out.writeMap(clones);
             }
+            if (out.getVersion().onOrAfter(FEATURE_STATES_VERSION)) {
+                out.writeList(featureStates);
+            }
         }
 
         @Override
@@ -804,6 +829,7 @@ public void writeTo(StreamOutput out) throws IOException {
     private static final String INDEX = "index";
     private static final String SHARD = "shard";
     private static final String NODE = "node";
+    private static final String FEATURE_STATES = "feature_states";
 
     @Override
     public XContentBuilder toXContent(XContentBuilder builder, ToXContent.Params params) throws IOException {
diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexNameExpressionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexNameExpressionResolver.java
index 6bc2cf1d5d438..1a40e915e39c2 100644
--- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexNameExpressionResolver.java
+++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexNameExpressionResolver.java
@@ -125,6 +125,11 @@ public String[] concreteIndexNames(ClusterState state, IndicesOptions options, I
         return concreteIndexNames(context, request.indices());
     }
 
+    public String[] concreteIndexNamesWithSystemIndexAccess(ClusterState state, IndicesOptions options, String... indexExpressions) {
+        Context context = new Context(state, options, true);
+        return concreteIndexNames(context, indexExpressions);
+    }
+
     public List<String> dataStreamNames(ClusterState state, IndicesOptions options, String... indexExpressions) {
         // Allow system index access - they'll be filtered out below as there's no such thing (yet) as system data streams
         Context context = new Context(state, options, false, false, true, true, true);
diff --git a/server/src/main/java/org/elasticsearch/indices/IndicesService.java b/server/src/main/java/org/elasticsearch/indices/IndicesService.java
index 3fddd19f7d158..4f79f8a5a212e 100644
--- a/server/src/main/java/org/elasticsearch/indices/IndicesService.java
+++ b/server/src/main/java/org/elasticsearch/indices/IndicesService.java
@@ -866,19 +866,20 @@ public void afterIndexShardClosed(ShardId shardId, IndexShard indexShard, Settin
      * but does not deal with in-memory structures. For those call {@link #removeIndex(Index, IndexRemovalReason, String)}
      */
     @Override
-    public void deleteUnassignedIndex(String reason, IndexMetadata metadata, ClusterState clusterState) {
+    public void deleteUnassignedIndex(String reason, IndexMetadata oldIndexMetadata, ClusterState clusterState) {
         if (nodeEnv.hasNodeFile()) {
-            String indexName = metadata.getIndex().getName();
+            Index index = oldIndexMetadata.getIndex();
             try {
-                if (clusterState.metadata().hasIndex(indexName)) {
-                    final IndexMetadata index = clusterState.metadata().index(indexName);
-                    throw new IllegalStateException("Can't delete unassigned index store for [" + indexName + "] - it's still part of " +
-                                                    "the cluster state [" + index.getIndexUUID() + "] [" + metadata.getIndexUUID() + "]");
+                if (clusterState.metadata().hasIndex(index)) {
+                    final IndexMetadata currentMetadata = clusterState.metadata().index(index);
+                    throw new IllegalStateException("Can't delete unassigned index store for [" + index.getName() + "] - it's still part " +
+                        "of the cluster state [" + currentMetadata.getIndexUUID() + "] [" +
+                        oldIndexMetadata.getIndexUUID() + "]");
                 }
-                deleteIndexStore(reason, metadata);
+                deleteIndexStore(reason, oldIndexMetadata);
             } catch (Exception e) {
                 logger.warn(() -> new ParameterizedMessage("[{}] failed to delete unassigned index (reason [{}])",
-                    metadata.getIndex(), reason), e);
+                    oldIndexMetadata.getIndex(), reason), e);
             }
         }
     }
diff --git a/server/src/main/java/org/elasticsearch/indices/SystemIndexDescriptor.java b/server/src/main/java/org/elasticsearch/indices/SystemIndexDescriptor.java
index 728d110a59899..d880416868ba0 100644
--- a/server/src/main/java/org/elasticsearch/indices/SystemIndexDescriptor.java
+++ b/server/src/main/java/org/elasticsearch/indices/SystemIndexDescriptor.java
@@ -14,10 +14,14 @@
 import org.apache.lucene.util.automaton.RegExp;
 import org.elasticsearch.Version;
 import org.elasticsearch.cluster.metadata.IndexMetadata;
+import org.elasticsearch.cluster.metadata.Metadata;
 import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.common.xcontent.XContentBuilder;
 
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 import java.util.Locale;
 import java.util.Objects;
 
@@ -188,6 +192,26 @@ public boolean matchesIndexPattern(String index) {
         return indexPatternAutomaton.run(index);
     }
 
+    /**
+     * Retrieves a list of all indices which match this descriptor's pattern.
+     *
+     * This cannot be done via {@link org.elasticsearch.cluster.metadata.IndexNameExpressionResolver} because that class can only handle
+     * simple wildcard expressions, but system index name patterns may use full Lucene regular expression syntax,
+     *
+     * @param metadata The current metadata to get the list of matching indices from
+     * @return A list of index names that match this descriptor
+     */
+    public List<String> getMatchingIndices(Metadata metadata) {
+        ArrayList<String> matchingIndices = new ArrayList<>();
+        metadata.indices().keysIt().forEachRemaining(indexName -> {
+            if (matchesIndexPattern(indexName)) {
+                matchingIndices.add(indexName);
+            }
+        });
+
+        return Collections.unmodifiableList(matchingIndices);
+    }
+
     /**
      * @return A short description of the purpose of this system index.
      */
diff --git a/server/src/main/java/org/elasticsearch/indices/SystemIndices.java b/server/src/main/java/org/elasticsearch/indices/SystemIndices.java
index cd97756b16429..9eda85e963db5 100644
--- a/server/src/main/java/org/elasticsearch/indices/SystemIndices.java
+++ b/server/src/main/java/org/elasticsearch/indices/SystemIndices.java
@@ -16,9 +16,10 @@
 import org.elasticsearch.common.Nullable;
 import org.elasticsearch.common.collect.Tuple;
 import org.elasticsearch.index.Index;
-import org.elasticsearch.tasks.TaskResultsService;
+import org.elasticsearch.snapshots.SnapshotsService;
 
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
@@ -28,6 +29,7 @@
 
 import static java.util.stream.Collectors.toUnmodifiableList;
 import static org.elasticsearch.tasks.TaskResultsService.TASKS_DESCRIPTOR;
+import static org.elasticsearch.tasks.TaskResultsService.TASKS_FEATURE_NAME;
 
 /**
  * This class holds the {@link SystemIndexDescriptor} objects that represent system indices the
@@ -35,19 +37,18 @@
  * to reduce the locations within the code that need to deal with {@link SystemIndexDescriptor}s.
  */
 public class SystemIndices {
-    private static final Map<String, Collection<SystemIndexDescriptor>> SERVER_SYSTEM_INDEX_DESCRIPTORS = Map.of(
-        TaskResultsService.class.getName(), List.of(TASKS_DESCRIPTOR)
+    private static final Map<String, Feature> SERVER_SYSTEM_INDEX_DESCRIPTORS = Map.of(
+        TASKS_FEATURE_NAME, new Feature("Manages task results", List.of(TASKS_DESCRIPTOR))
     );
 
     private final CharacterRunAutomaton runAutomaton;
-    private final Collection<SystemIndexDescriptor> systemIndexDescriptors;
-
-    public SystemIndices(Map<String, Collection<SystemIndexDescriptor>> pluginAndModulesDescriptors) {
-        final Map<String, Collection<SystemIndexDescriptor>> descriptorsMap = buildSystemIndexDescriptorMap(pluginAndModulesDescriptors);
-        checkForOverlappingPatterns(descriptorsMap);
-        this.systemIndexDescriptors = descriptorsMap.values().stream().flatMap(Collection::stream).collect(Collectors.toUnmodifiableList());
-        checkForDuplicateAliases(this.systemIndexDescriptors);
-        this.runAutomaton = buildCharacterRunAutomaton(systemIndexDescriptors);
+    private final Map<String, Feature> featureDescriptors;
+
+    public SystemIndices(Map<String, Feature> pluginAndModulesDescriptors) {
+        featureDescriptors = buildSystemIndexDescriptorMap(pluginAndModulesDescriptors);
+        checkForOverlappingPatterns(featureDescriptors);
+        checkForDuplicateAliases(this.getSystemIndexDescriptors());
+        this.runAutomaton = buildCharacterRunAutomaton(featureDescriptors);
     }
 
     private void checkForDuplicateAliases(Collection<SystemIndexDescriptor> descriptors) {
@@ -97,7 +98,8 @@ public boolean isSystemIndex(String indexName) {
      * @throws IllegalStateException if multiple descriptors match the name
      */
     public @Nullable SystemIndexDescriptor findMatchingDescriptor(String name) {
-        final List<SystemIndexDescriptor> matchingDescriptors = systemIndexDescriptors.stream()
+        final List<SystemIndexDescriptor> matchingDescriptors = featureDescriptors.values().stream()
+            .flatMap(feature -> feature.getIndexDescriptors().stream())
             .filter(descriptor -> descriptor.matchesIndexPattern(name))
             .collect(toUnmodifiableList());
 
@@ -120,8 +122,13 @@ public boolean isSystemIndex(String indexName) {
         }
     }
 
-    private static CharacterRunAutomaton buildCharacterRunAutomaton(Collection<SystemIndexDescriptor> descriptors) {
-        Optional<Automaton> automaton = descriptors.stream()
+    public Map<String, Feature> getFeatures() {
+        return featureDescriptors;
+    }
+
+    private static CharacterRunAutomaton buildCharacterRunAutomaton(Map<String, Feature> descriptors) {
+        Optional<Automaton> automaton = descriptors.values().stream()
+            .flatMap(feature -> feature.getIndexDescriptors().stream())
             .map(descriptor -> SystemIndexDescriptor.buildAutomaton(descriptor.getIndexPattern(), descriptor.getAliasName()))
             .reduce(Operations::union);
         return new CharacterRunAutomaton(MinimizationOperations.minimize(automaton.orElse(Automata.makeEmpty()), Integer.MAX_VALUE));
@@ -134,9 +141,9 @@ private static CharacterRunAutomaton buildCharacterRunAutomaton(Collection<Syste
      * @param sourceToDescriptors A map of source (plugin) names to the SystemIndexDescriptors they provide.
      * @throws IllegalStateException Thrown if any of the index patterns overlaps with another.
      */
-    static void checkForOverlappingPatterns(Map<String, Collection<SystemIndexDescriptor>> sourceToDescriptors) {
+    static void checkForOverlappingPatterns(Map<String, Feature> sourceToDescriptors) {
         List<Tuple<String, SystemIndexDescriptor>> sourceDescriptorPair = sourceToDescriptors.entrySet().stream()
-            .flatMap(entry -> entry.getValue().stream().map(descriptor -> new Tuple<>(entry.getKey(), descriptor)))
+            .flatMap(entry -> entry.getValue().getIndexDescriptors().stream().map(descriptor -> new Tuple<>(entry.getKey(), descriptor)))
             .sorted(Comparator.comparing(d -> d.v1() + ":" + d.v2().getIndexPattern())) // Consistent ordering -> consistent error message
             .collect(Collectors.toUnmodifiableList());
 
@@ -165,14 +172,12 @@ private static boolean overlaps(SystemIndexDescriptor a1, SystemIndexDescriptor
         return Operations.isEmpty(Operations.intersection(a1Automaton, a2Automaton)) == false;
     }
 
-    private static Map<String, Collection<SystemIndexDescriptor>> buildSystemIndexDescriptorMap(
-        Map<String, Collection<SystemIndexDescriptor>> pluginAndModulesMap) {
-        final Map<String, Collection<SystemIndexDescriptor>> map =
-            new HashMap<>(pluginAndModulesMap.size() + SERVER_SYSTEM_INDEX_DESCRIPTORS.size());
-        map.putAll(pluginAndModulesMap);
+    private static Map<String, Feature> buildSystemIndexDescriptorMap(Map<String, Feature> featuresMap) {
+        final Map<String, Feature> map = new HashMap<>(featuresMap.size() + SERVER_SYSTEM_INDEX_DESCRIPTORS.size());
+        map.putAll(featuresMap);
         // put the server items last since we expect less of them
-        SERVER_SYSTEM_INDEX_DESCRIPTORS.forEach((source, descriptors) -> {
-            if (map.putIfAbsent(source, descriptors) != null) {
+        SERVER_SYSTEM_INDEX_DESCRIPTORS.forEach((source, feature) -> {
+            if (map.putIfAbsent(source, feature) != null) {
                 throw new IllegalArgumentException("plugin or module attempted to define the same source [" + source +
                     "] as a built-in system index");
             }
@@ -181,6 +186,43 @@ private static Map<String, Collection<SystemIndexDescriptor>> buildSystemIndexDe
     }
 
     Collection<SystemIndexDescriptor> getSystemIndexDescriptors() {
-        return this.systemIndexDescriptors;
+        return this.featureDescriptors.values().stream()
+            .flatMap(f -> f.getIndexDescriptors().stream())
+            .collect(Collectors.toList());
+    }
+
+    public static void validateFeatureName(String name, String plugin) {
+        if (SnapshotsService.NO_FEATURE_STATES_VALUE.equalsIgnoreCase(name)) {
+            throw new IllegalArgumentException("feature name cannot be reserved name [\"" + SnapshotsService.NO_FEATURE_STATES_VALUE +
+                "\"], but was for plugin [" + plugin + "]");
+        }
+    }
+
+    public static class Feature {
+        private final String description;
+        private final Collection<SystemIndexDescriptor> indexDescriptors;
+        private final Collection<String> associatedIndexPatterns;
+
+        public Feature(String description, Collection<SystemIndexDescriptor> indexDescriptors, Collection<String> associatedIndexPatterns) {
+            this.description = description;
+            this.indexDescriptors = indexDescriptors;
+            this.associatedIndexPatterns = associatedIndexPatterns;
+        }
+
+        public Feature(String description, Collection<SystemIndexDescriptor> indexDescriptors) {
+            this(description, indexDescriptors, Collections.emptyList());
+        }
+
+        public String getDescription() {
+            return description;
+        }
+
+        public Collection<SystemIndexDescriptor> getIndexDescriptors() {
+            return indexDescriptors;
+        }
+
+        public Collection<String> getAssociatedIndexPatterns() {
+            return associatedIndexPatterns;
+        }
     }
 }
diff --git a/server/src/main/java/org/elasticsearch/node/Node.java b/server/src/main/java/org/elasticsearch/node/Node.java
index f0ecdf761a8d6..cb51b0d7ccae8 100644
--- a/server/src/main/java/org/elasticsearch/node/Node.java
+++ b/server/src/main/java/org/elasticsearch/node/Node.java
@@ -99,7 +99,6 @@
 import org.elasticsearch.indices.IndicesModule;
 import org.elasticsearch.indices.IndicesService;
 import org.elasticsearch.indices.ShardLimitValidator;
-import org.elasticsearch.indices.SystemIndexDescriptor;
 import org.elasticsearch.indices.SystemIndexManager;
 import org.elasticsearch.indices.SystemIndices;
 import org.elasticsearch.indices.analysis.AnalysisModule;
@@ -496,13 +495,19 @@ protected Node(final Environment initialEnvironment,
                     .flatMap(m -> m.entrySet().stream())
                     .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
 
-            final Map<String, Collection<SystemIndexDescriptor>> systemIndexDescriptorMap = pluginsService
+            final Map<String, SystemIndices.Feature> featuresMap = pluginsService
                 .filterPlugins(SystemIndexPlugin.class)
                 .stream()
+                .peek(plugin -> SystemIndices.validateFeatureName(plugin.getFeatureName(), plugin.getClass().getCanonicalName()))
                 .collect(Collectors.toUnmodifiableMap(
-                    plugin -> plugin.getClass().getSimpleName(),
-                    plugin -> plugin.getSystemIndexDescriptors(settings)));
-            final SystemIndices systemIndices = new SystemIndices(systemIndexDescriptorMap);
+                    plugin -> plugin.getFeatureName(),
+                    plugin -> new SystemIndices.Feature(
+                        plugin.getFeatureDescription(),
+                        plugin.getSystemIndexDescriptors(settings),
+                        plugin.getAssociatedIndexPatterns()
+                    ))
+                );
+            final SystemIndices systemIndices = new SystemIndices(featuresMap);
 
             final SystemIndexManager systemIndexManager = new SystemIndexManager(systemIndices, client);
             clusterService.addListener(systemIndexManager);
@@ -592,11 +597,13 @@ protected Node(final Environment initialEnvironment,
             RepositoriesService repositoryService = repositoriesModule.getRepositoryService();
             repositoriesServiceReference.set(repositoryService);
             SnapshotsService snapshotsService = new SnapshotsService(settings, clusterService,
-                clusterModule.getIndexNameExpressionResolver(), repositoryService, transportService, actionModule.getActionFilters());
+                clusterModule.getIndexNameExpressionResolver(), repositoryService, transportService, actionModule.getActionFilters(),
+                    systemIndices.getFeatures());
             SnapshotShardsService snapshotShardsService = new SnapshotShardsService(settings, clusterService, repositoryService,
                 transportService, indicesService);
             RestoreService restoreService = new RestoreService(clusterService, repositoryService, clusterModule.getAllocationService(),
-                    metadataCreateIndexService, indexMetadataVerifier, shardLimitValidator);
+                    metadataCreateIndexService, clusterModule.getMetadataDeleteIndexService(), indexMetadataVerifier,
+                 shardLimitValidator, systemIndices);
             final DiskThresholdMonitor diskThresholdMonitor = new DiskThresholdMonitor(settings, clusterService::state,
                 clusterService.getClusterSettings(), client, threadPool::relativeTimeInMillis, rerouteService);
             clusterInfoService.addListener(diskThresholdMonitor::onNewInfo);
diff --git a/server/src/main/java/org/elasticsearch/plugins/SystemIndexPlugin.java b/server/src/main/java/org/elasticsearch/plugins/SystemIndexPlugin.java
index 861677e61e58f..c3a4a56f24ba1 100644
--- a/server/src/main/java/org/elasticsearch/plugins/SystemIndexPlugin.java
+++ b/server/src/main/java/org/elasticsearch/plugins/SystemIndexPlugin.java
@@ -29,4 +29,24 @@ public interface SystemIndexPlugin extends ActionPlugin {
     default Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings settings) {
         return Collections.emptyList();
     }
+
+    /**
+     * @return The name of the feature, as used for specifying feature states in snapshot creation and restoration.
+     */
+    String getFeatureName();
+
+    /**
+     * @return A description of the feature, as used for the Get Snapshottable Features API.
+     */
+    String getFeatureDescription();
+
+    /**
+     * Returns a list of index patterns for "associated indices": indices which depend on this plugin's system indices, but are not
+     * themselves system indices.
+     *
+     * @return A list of index patterns which depend on the contents of this plugin's system indices, but are not themselves system indices
+     */
+    default Collection<String> getAssociatedIndexPatterns() {
+        return Collections.emptyList();
+    }
 }
diff --git a/server/src/main/java/org/elasticsearch/rest/action/admin/cluster/RestSnapshottableFeaturesAction.java b/server/src/main/java/org/elasticsearch/rest/action/admin/cluster/RestSnapshottableFeaturesAction.java
new file mode 100644
index 0000000000000..50092106dd32b
--- /dev/null
+++ b/server/src/main/java/org/elasticsearch/rest/action/admin/cluster/RestSnapshottableFeaturesAction.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.rest.action.admin.cluster;
+
+import org.elasticsearch.action.admin.cluster.snapshots.features.GetSnapshottableFeaturesRequest;
+import org.elasticsearch.action.admin.cluster.snapshots.features.SnapshottableFeaturesAction;
+import org.elasticsearch.client.node.NodeClient;
+import org.elasticsearch.rest.BaseRestHandler;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.action.RestToXContentListener;
+
+import java.io.IOException;
+import java.util.List;
+
+public class RestSnapshottableFeaturesAction extends BaseRestHandler {
+    @Override
+    public List<Route> routes() {
+        return List.of(new Route(RestRequest.Method.GET, "/_snapshottable_features"));
+    }
+
+    @Override
+    public String getName() {
+        return "get_snapshottable_features";
+    }
+
+    @Override
+    protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException {
+        final GetSnapshottableFeaturesRequest req = new GetSnapshottableFeaturesRequest();
+        req.masterNodeTimeout(request.paramAsTime("master_timeout", req.masterNodeTimeout()));
+
+        return restChannel -> {
+            client.execute(SnapshottableFeaturesAction.INSTANCE, req, new RestToXContentListener<>(restChannel));
+        };
+    }
+}
diff --git a/server/src/main/java/org/elasticsearch/snapshots/RestoreService.java b/server/src/main/java/org/elasticsearch/snapshots/RestoreService.java
index b7d924da2310d..926dc140b7251 100644
--- a/server/src/main/java/org/elasticsearch/snapshots/RestoreService.java
+++ b/server/src/main/java/org/elasticsearch/snapshots/RestoreService.java
@@ -40,6 +40,7 @@
 import org.elasticsearch.cluster.metadata.IndexTemplateMetadata;
 import org.elasticsearch.cluster.metadata.Metadata;
 import org.elasticsearch.cluster.metadata.MetadataCreateIndexService;
+import org.elasticsearch.cluster.metadata.MetadataDeleteIndexService;
 import org.elasticsearch.cluster.metadata.MetadataIndexStateService;
 import org.elasticsearch.cluster.metadata.RepositoriesMetadata;
 import org.elasticsearch.cluster.node.DiscoveryNode;
@@ -55,6 +56,8 @@
 import org.elasticsearch.common.Priority;
 import org.elasticsearch.common.UUIDs;
 import org.elasticsearch.common.collect.ImmutableOpenMap;
+import org.elasticsearch.common.logging.DeprecationCategory;
+import org.elasticsearch.common.logging.DeprecationLogger;
 import org.elasticsearch.common.lucene.Lucene;
 import org.elasticsearch.common.regex.Regex;
 import org.elasticsearch.common.settings.ClusterSettings;
@@ -66,6 +69,7 @@
 import org.elasticsearch.index.shard.IndexShard;
 import org.elasticsearch.index.shard.ShardId;
 import org.elasticsearch.indices.ShardLimitValidator;
+import org.elasticsearch.indices.SystemIndices;
 import org.elasticsearch.repositories.IndexId;
 import org.elasticsearch.repositories.RepositoriesService;
 import org.elasticsearch.repositories.Repository;
@@ -81,6 +85,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.BiConsumer;
@@ -99,6 +104,8 @@
 import static org.elasticsearch.cluster.metadata.IndexMetadata.SETTING_VERSION_CREATED;
 import static org.elasticsearch.common.util.set.Sets.newHashSet;
 import static org.elasticsearch.snapshots.SnapshotUtils.filterIndices;
+import static org.elasticsearch.snapshots.SnapshotsService.FEATURE_STATES_VERSION;
+import static org.elasticsearch.snapshots.SnapshotsService.NO_FEATURE_STATES_VALUE;
 
 /**
  * Service responsible for restoring snapshots
@@ -123,6 +130,7 @@
 public class RestoreService implements ClusterStateApplier {
 
     private static final Logger logger = LogManager.getLogger(RestoreService.class);
+    private static final DeprecationLogger deprecationLogger = DeprecationLogger.getLogger(RestoreService.class);
 
     public static final Setting<Boolean> REFRESH_REPO_UUID_ON_RESTORE_SETTING = Setting.boolSetting(
             "snapshot.refresh_repo_uuid_on_restore",
@@ -158,27 +166,40 @@ public class RestoreService implements ClusterStateApplier {
 
     private final IndexMetadataVerifier indexMetadataVerifier;
 
+    private final MetadataDeleteIndexService metadataDeleteIndexService;
+
     private final ShardLimitValidator shardLimitValidator;
 
     private final ClusterSettings clusterSettings;
 
+    private final SystemIndices systemIndices;
+
     private volatile boolean refreshRepositoryUuidOnRestore;
 
     private static final CleanRestoreStateTaskExecutor cleanRestoreStateTaskExecutor = new CleanRestoreStateTaskExecutor();
 
-    public RestoreService(ClusterService clusterService, RepositoriesService repositoriesService,
-                          AllocationService allocationService, MetadataCreateIndexService createIndexService,
-                          IndexMetadataVerifier indexMetadataVerifier, ShardLimitValidator shardLimitValidator) {
+    public RestoreService(
+        ClusterService clusterService,
+        RepositoriesService repositoriesService,
+        AllocationService allocationService,
+        MetadataCreateIndexService createIndexService,
+        MetadataDeleteIndexService metadataDeleteIndexService,
+        IndexMetadataVerifier indexMetadataVerifier,
+        ShardLimitValidator shardLimitValidator,
+        SystemIndices systemIndices
+    ) {
         this.clusterService = clusterService;
         this.repositoriesService = repositoriesService;
         this.allocationService = allocationService;
         this.createIndexService = createIndexService;
         this.indexMetadataVerifier = indexMetadataVerifier;
+        this.metadataDeleteIndexService = metadataDeleteIndexService;
         if (DiscoveryNode.isMasterNode(clusterService.getSettings())) {
             clusterService.addStateApplier(this);
         }
         this.clusterSettings = clusterService.getClusterSettings();
         this.shardLimitValidator = shardLimitValidator;
+        this.systemIndices = systemIndices;
         this.refreshRepositoryUuidOnRestore = REFRESH_REPO_UUID_ON_RESTORE_SETTING.get(clusterService.getSettings());
         clusterService.getClusterSettings().addSettingsUpdateConsumer(
                 REFRESH_REPO_UUID_ON_RESTORE_SETTING,
@@ -238,55 +259,74 @@ public void restoreSnapshot(final RestoreSnapshotRequest request,
                 // Make sure that we can restore from this snapshot
                 validateSnapshotRestorable(repositoryName, snapshotInfo);
 
+                // Get the global state if necessary
                 Metadata globalMetadata = null;
-                // Resolve the indices from the snapshot that need to be restored
-                Map<String, DataStream> dataStreams;
-                List<String> requestIndices = new ArrayList<>(Arrays.asList(request.indices()));
-
-                List<String> requestedDataStreams = filterIndices(snapshotInfo.dataStreams(), requestIndices.toArray(String[]::new),
-                    IndicesOptions.fromOptions(true, true, true, true));
-                if (requestedDataStreams.isEmpty()) {
-                    dataStreams = new HashMap<>();
-                } else {
+                final Metadata.Builder metadataBuilder;
+                if (request.includeGlobalState()) {
                     globalMetadata = repository.getSnapshotGlobalMetadata(snapshotId);
-                    final Map<String, DataStream> dataStreamsInSnapshot = globalMetadata.dataStreams();
-                    dataStreams = new HashMap<>(requestedDataStreams.size());
-                    for (String requestedDataStream : requestedDataStreams) {
-                        final DataStream dataStreamInSnapshot = dataStreamsInSnapshot.get(requestedDataStream);
-                        assert dataStreamInSnapshot != null : "DataStream [" + requestedDataStream + "] not found in snapshot";
-                        dataStreams.put(requestedDataStream, dataStreamInSnapshot);
-                    }
+                    metadataBuilder = Metadata.builder(globalMetadata);
+                } else {
+                    metadataBuilder = Metadata.builder();
                 }
-                requestIndices.removeAll(dataStreams.keySet());
-                Set<String> dataStreamIndices = dataStreams.values().stream()
+
+                List<String> requestIndices = new ArrayList<>(Arrays.asList(request.indices()));
+
+                // Get data stream metadata for requested data streams
+                Map<String, DataStream> dataStreamsToRestore = getDataStreamsToRestore(repository, snapshotId, snapshotInfo, globalMetadata,
+                    requestIndices);
+
+                // Remove the data streams from the list of requested indices
+                requestIndices.removeAll(dataStreamsToRestore.keySet());
+
+                // And add the backing indices
+                Set<String> dataStreamIndices = dataStreamsToRestore.values().stream()
                     .flatMap(ds -> ds.getIndices().stream())
                     .map(Index::getName)
                     .collect(Collectors.toSet());
                 requestIndices.addAll(dataStreamIndices);
 
-                final List<String> indicesInSnapshot = filterIndices(snapshotInfo.indices(), requestIndices.toArray(String[]::new),
+                // Determine system indices to restore from requested feature states
+                final Map<String, List<String>> featureStatesToRestore = getFeatureStatesToRestore(request, snapshotInfo, snapshot);
+                final Set<String> featureStateIndices = featureStatesToRestore.values().stream()
+                    .flatMap(Collection::stream)
+                    .collect(Collectors.toSet());
+
+                // Resolve the indices that were directly requested
+                final List<String> requestedIndicesInSnapshot = filterIndices(snapshotInfo.indices(), requestIndices.toArray(String[]::new),
                     request.indicesOptions());
 
-                final Metadata.Builder metadataBuilder;
-                if (request.includeGlobalState()) {
-                    if (globalMetadata == null) {
-                        globalMetadata = repository.getSnapshotGlobalMetadata(snapshotId);
+                // Combine into the final list of indices to be restored
+                final List<String> requestedIndicesIncludingSystem = Stream.concat(
+                    requestedIndicesInSnapshot.stream(),
+                    featureStateIndices.stream()
+                ).distinct().collect(Collectors.toList());
+
+                final Set<String> explicitlyRequestedSystemIndices = new HashSet<>();
+                final List<IndexId> indexIdsInSnapshot = repositoryData.resolveIndices(requestedIndicesIncludingSystem);
+                for (IndexId indexId : indexIdsInSnapshot) {
+                    IndexMetadata snapshotIndexMetaData = repository.getSnapshotIndexMetaData(repositoryData, snapshotId, indexId);
+                    if (snapshotIndexMetaData.isSystem()) {
+                        if (requestedIndicesInSnapshot.contains(indexId.getName())) {
+                            explicitlyRequestedSystemIndices.add(indexId.getName());
+                        }
                     }
-                    metadataBuilder = Metadata.builder(globalMetadata);
-                } else {
-                    metadataBuilder = Metadata.builder();
+                    metadataBuilder.put(snapshotIndexMetaData, false);
                 }
 
-                final List<IndexId> indexIdsInSnapshot = repositoryData.resolveIndices(indicesInSnapshot);
-                for (IndexId indexId : indexIdsInSnapshot) {
-                    metadataBuilder.put(repository.getSnapshotIndexMetaData(repositoryData, snapshotId, indexId), false);
+                // log a deprecation warning if the any of the indexes to delete were included in the request and the snapshot
+                // is from a version that should have feature states
+                if (snapshotInfo.version().onOrAfter(FEATURE_STATES_VERSION) && explicitlyRequestedSystemIndices.isEmpty() == false) {
+                    deprecationLogger.deprecate(DeprecationCategory.API, "restore-system-index-from-snapshot",
+                        "Restoring system indices by name is deprecated. Use feature states instead. System indices: "
+                            + explicitlyRequestedSystemIndices);
                 }
 
-                final Metadata metadata = metadataBuilder.dataStreams(dataStreams).build();
+                final Metadata metadata = metadataBuilder.dataStreams(dataStreamsToRestore).build();
 
                 // Apply renaming on index names, returning a map of names where
                 // the key is the renamed index and the value is the original name
-                final Map<String, String> indices = renamedIndices(request, indicesInSnapshot, dataStreamIndices);
+                final Map<String, String> indices = renamedIndices(request, requestedIndicesIncludingSystem, dataStreamIndices,
+                    featureStateIndices);
 
                 // Now we can start the actual restore process by adding shards to be recovered in the cluster state
                 // and updating cluster metadata (global and index) as needed
@@ -306,6 +346,13 @@ public ClusterState execute(ClusterState currentState) {
                                     deletionsInProgress.getEntries().get(0) + "]");
                         }
 
+                        // Clear out all existing indices which fall within a system index pattern being restored
+                        final Set<Index> systemIndicesToDelete = resolveSystemIndicesToDelete(
+                            currentState,
+                            featureStatesToRestore.keySet()
+                        );
+                        currentState = metadataDeleteIndexService.deleteIndices(currentState, systemIndicesToDelete);
+
                         // Updating cluster state
                         ClusterState.Builder builder = ClusterState.builder(currentState);
                         Metadata.Builder mdBuilder = Metadata.builder(currentState.metadata());
@@ -355,7 +402,8 @@ public ClusterState execute(ClusterState currentState) {
                                         .put(IndexMetadata.SETTING_INDEX_UUID, UUIDs.randomBase64UUID()))
                                         .timestampRange(IndexLongFieldRange.NO_SHARDS);
                                     shardLimitValidator.validateShardLimit(snapshotIndexMetadata.getSettings(), currentState);
-                                    if (request.includeAliases() == false && snapshotIndexMetadata.getAliases().isEmpty() == false) {
+                                    if (request.includeAliases() == false && snapshotIndexMetadata.getAliases().isEmpty() == false
+                                            && isSystemIndex(snapshotIndexMetadata) == false) {
                                         // Remove all aliases - they shouldn't be restored
                                         indexMdBuilder.removeAllAliases();
                                     } else {
@@ -393,7 +441,7 @@ public ClusterState execute(ClusterState currentState) {
                                             Math.max(snapshotIndexMetadata.primaryTerm(shard), currentIndexMetadata.primaryTerm(shard)));
                                     }
 
-                                    if (request.includeAliases() == false) {
+                                    if (request.includeAliases() == false && isSystemIndex(snapshotIndexMetadata) == false) {
                                         // Remove all snapshot aliases
                                         if (snapshotIndexMetadata.getAliases().isEmpty() == false) {
                                             indexMdBuilder.removeAllAliases();
@@ -445,7 +493,7 @@ restoreUUID, snapshot, overallState(RestoreInProgress.State.INIT, shards),
                         checkAliasNameConflicts(indices, aliases);
 
                         Map<String, DataStream> updatedDataStreams = new HashMap<>(currentState.metadata().dataStreams());
-                        updatedDataStreams.putAll(dataStreams.values().stream()
+                        updatedDataStreams.putAll(dataStreamsToRestore.values().stream()
                             .map(ds -> updateDataStream(ds, mdBuilder, request))
                             .collect(Collectors.toMap(DataStream::getName, Function.identity())));
                         mdBuilder.dataStreams(updatedDataStreams);
@@ -706,6 +754,105 @@ public void onFailure(Exception e) {
 
     }
 
+    private boolean isSystemIndex(IndexMetadata indexMetadata) {
+        return indexMetadata.isSystem() || systemIndices.isSystemIndex(indexMetadata.getIndex());
+    }
+
+    private Map<String, DataStream> getDataStreamsToRestore(Repository repository, SnapshotId snapshotId, SnapshotInfo snapshotInfo,
+                                                           Metadata globalMetadata, List<String> requestIndices) {
+        Map<String, DataStream> dataStreams;
+        List<String> requestedDataStreams = filterIndices(snapshotInfo.dataStreams(), requestIndices.toArray(String[]::new),
+            IndicesOptions.fromOptions(true, true, true, true));
+        if (requestedDataStreams.isEmpty()) {
+            dataStreams = Collections.emptyMap();
+        } else {
+            if (globalMetadata == null) {
+                globalMetadata = repository.getSnapshotGlobalMetadata(snapshotId);
+            }
+            final Map<String, DataStream> dataStreamsInSnapshot = globalMetadata.dataStreams();
+            dataStreams = new HashMap<>(requestedDataStreams.size());
+            for (String requestedDataStream : requestedDataStreams) {
+                final DataStream dataStreamInSnapshot = dataStreamsInSnapshot.get(requestedDataStream);
+                assert dataStreamInSnapshot != null : "DataStream [" + requestedDataStream + "] not found in snapshot";
+                dataStreams.put(requestedDataStream, dataStreamInSnapshot);
+            }
+        }
+        return dataStreams;
+    }
+
+    private Map<String, List<String>> getFeatureStatesToRestore(RestoreSnapshotRequest request, SnapshotInfo snapshotInfo,
+                                                                Snapshot snapshot) {
+        if (snapshotInfo.featureStates() == null) {
+            return Collections.emptyMap();
+        }
+        final Map<String, List<String>> snapshotFeatureStates = snapshotInfo.featureStates().stream()
+            .collect(Collectors.toMap(SnapshotFeatureInfo::getPluginName, SnapshotFeatureInfo::getIndices));
+
+        final Map<String, List<String>> featureStatesToRestore;
+        final String[] requestedFeatureStates = request.featureStates();
+
+        if (requestedFeatureStates == null || requestedFeatureStates.length == 0) {
+            // Handle the default cases - defer to the global state value
+            if (request.includeGlobalState()) {
+                featureStatesToRestore = new HashMap<>(snapshotFeatureStates);
+            } else {
+                featureStatesToRestore = Collections.emptyMap();
+            }
+        } else if (requestedFeatureStates.length == 1 && NO_FEATURE_STATES_VALUE.equalsIgnoreCase(requestedFeatureStates[0])) {
+            // If there's exactly one value and it's "none", include no states
+            featureStatesToRestore = Collections.emptyMap();
+        } else {
+            // Otherwise, handle the list of requested feature states
+            final Set<String> requestedStates = Set.of(requestedFeatureStates);
+            if (requestedStates.contains(NO_FEATURE_STATES_VALUE)) {
+                throw new SnapshotRestoreException(snapshot, "the feature_states value [" + NO_FEATURE_STATES_VALUE +
+                    "] indicates that no feature states should be restored, but other feature states were requested: " + requestedStates);
+            }
+            if (snapshotFeatureStates.keySet().containsAll(requestedStates) == false) {
+                Set<String> nonExistingRequestedStates = new HashSet<>(requestedStates);
+                nonExistingRequestedStates.removeAll(snapshotFeatureStates.keySet());
+                throw new SnapshotRestoreException(snapshot, "requested feature states [" + nonExistingRequestedStates +
+                    "] are not present in snapshot");
+            }
+            featureStatesToRestore = new HashMap<>(snapshotFeatureStates);
+            featureStatesToRestore.keySet().retainAll(requestedStates);
+        }
+
+        final List<String> featuresNotOnThisNode = featureStatesToRestore.keySet().stream()
+            .filter(featureName -> systemIndices.getFeatures().containsKey(featureName) == false)
+            .collect(Collectors.toList());
+        if (featuresNotOnThisNode.isEmpty() == false) {
+            throw new SnapshotRestoreException(snapshot, "requested feature states " + featuresNotOnThisNode + " are present in " +
+                "snapshot but those features are not installed on the current master node");
+        }
+        return featureStatesToRestore;
+    }
+
+    /**
+     * Resolves a set of index names that currently exist in the cluster that are part of a feature state which is about to be restored,
+     * and should therefore be removed prior to restoring those feature states from the snapshot.
+     *
+     * @param currentState The current cluster state
+     * @param featureStatesToRestore A set of feature state names that are about to be restored
+     * @return A set of index names that should be removed based on the feature states being restored
+     */
+    private Set<Index> resolveSystemIndicesToDelete(ClusterState currentState, Set<String> featureStatesToRestore) {
+        if (featureStatesToRestore == null) {
+            return Collections.emptySet();
+        }
+
+        return featureStatesToRestore.stream()
+            .map(featureName -> systemIndices.getFeatures().get(featureName))
+            .filter(Objects::nonNull) // Features that aren't present on this node will be warned about in `getFeatureStatesToRestore`
+            .flatMap(feature -> feature.getIndexDescriptors().stream())
+            .flatMap(descriptor -> descriptor.getMatchingIndices(currentState.metadata()).stream())
+            .map(indexName -> {
+                assert currentState.metadata().hasIndex(indexName) : "index [" + indexName + "] not found in metadata but must be present";
+                return currentState.metadata().getIndices().get(indexName).getIndex();
+            })
+            .collect(Collectors.toUnmodifiableSet());
+    }
+
     //visible for testing
     static DataStream updateDataStream(DataStream dataStream, Metadata.Builder metadata, RestoreSnapshotRequest request) {
         String dataStreamName = dataStream.getName();
@@ -979,10 +1126,16 @@ public static int failedShards(ImmutableOpenMap<ShardId, RestoreInProgress.Shard
     }
 
     private static Map<String, String> renamedIndices(RestoreSnapshotRequest request, List<String> filteredIndices,
-                                                      Set<String> dataStreamIndices) {
+                                                      Set<String> dataStreamIndices, Set<String> featureIndices) {
         Map<String, String> renamedIndices = new HashMap<>();
         for (String index : filteredIndices) {
-            String renamedIndex = renameIndex(index, request, dataStreamIndices.contains(index));
+            String renamedIndex;
+            if (featureIndices.contains(index)) {
+                // Don't rename system indices
+                renamedIndex = index;
+            } else {
+                renamedIndex = renameIndex(index, request, dataStreamIndices.contains(index));
+            }
             String previousIndex = renamedIndices.put(renamedIndex, index);
             if (previousIndex != null) {
                 throw new SnapshotRestoreException(request.repository(), request.snapshot(),
diff --git a/server/src/main/java/org/elasticsearch/snapshots/SnapshotFeatureInfo.java b/server/src/main/java/org/elasticsearch/snapshots/SnapshotFeatureInfo.java
new file mode 100644
index 0000000000000..419d75a7226a5
--- /dev/null
+++ b/server/src/main/java/org/elasticsearch/snapshots/SnapshotFeatureInfo.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.snapshots;
+
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.ToXContentObject;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentParser;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+
+public class SnapshotFeatureInfo implements Writeable, ToXContentObject {
+    final String pluginName;
+    final List<String> indices;
+
+    static final ConstructingObjectParser<SnapshotFeatureInfo, Void> SNAPSHOT_FEATURE_INFO_PARSER =
+        new ConstructingObjectParser<>("feature_info", true, (a, name) -> {
+            String pluginName = (String) a[0];
+            List<String> indices = (List<String>) a[1];
+            return new SnapshotFeatureInfo(pluginName, indices);
+        });
+
+    static {
+        SNAPSHOT_FEATURE_INFO_PARSER.declareString(ConstructingObjectParser.constructorArg(), new ParseField("feature_name"));
+        SNAPSHOT_FEATURE_INFO_PARSER.declareStringArray(ConstructingObjectParser.constructorArg(), new ParseField("indices"));
+    }
+
+    public SnapshotFeatureInfo(String pluginName, List<String> indices) {
+        this.pluginName = pluginName;
+        this.indices = indices;
+    }
+
+    public SnapshotFeatureInfo(final StreamInput in) throws IOException {
+        this.pluginName = in.readString();
+        this.indices = in.readStringList();
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        out.writeString(pluginName);
+        out.writeStringCollection(indices);
+    }
+
+    public static SnapshotFeatureInfo fromXContent(XContentParser parser) throws IOException {
+        return SNAPSHOT_FEATURE_INFO_PARSER.parse(parser, null);
+    }
+
+    public String getPluginName() {
+        return pluginName;
+    }
+
+    public List<String> getIndices() {
+        return indices;
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        {
+            builder.field("feature_name", pluginName);
+            builder.startArray("indices");
+            for (String index : indices) {
+                builder.value(index);
+            }
+            builder.endArray();
+        }
+        builder.endObject();
+        return builder;
+    }
+
+    @Override
+    public String toString() {
+        return Strings.toString(this);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if ((o instanceof SnapshotFeatureInfo) == false) return false;
+        SnapshotFeatureInfo that = (SnapshotFeatureInfo) o;
+        return getPluginName().equals(that.getPluginName()) &&
+            getIndices().equals(that.getIndices());
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(getPluginName(), getIndices());
+    }
+}
diff --git a/server/src/main/java/org/elasticsearch/snapshots/SnapshotInfo.java b/server/src/main/java/org/elasticsearch/snapshots/SnapshotInfo.java
index bdb1954efa22b..d6f6ba03bd642 100644
--- a/server/src/main/java/org/elasticsearch/snapshots/SnapshotInfo.java
+++ b/server/src/main/java/org/elasticsearch/snapshots/SnapshotInfo.java
@@ -37,6 +37,8 @@
 import java.util.Objects;
 import java.util.stream.Collectors;
 
+import static org.elasticsearch.snapshots.SnapshotsService.FEATURE_STATES_VERSION;
+
 /**
  * Information about a snapshot
  */
@@ -69,6 +71,7 @@ public final class SnapshotInfo implements Comparable<SnapshotInfo>, ToXContent,
     private static final String SUCCESSFUL_SHARDS = "successful_shards";
     private static final String INCLUDE_GLOBAL_STATE = "include_global_state";
     private static final String USER_METADATA = "metadata";
+    private static final String FEATURE_STATES = "feature_states";
 
     private static final Comparator<SnapshotInfo> COMPARATOR =
         Comparator.comparing(SnapshotInfo::startTime).thenComparing(SnapshotInfo::snapshotId);
@@ -80,6 +83,7 @@ public static final class SnapshotInfoBuilder {
         private String reason = null;
         private List<String> indices = null;
         private List<String> dataStreams = null;
+        private List<SnapshotFeatureInfo> featureStates = null;
         private long startTime = 0L;
         private long endTime = 0L;
         private ShardStatsBuilder shardStatsBuilder = null;
@@ -112,6 +116,10 @@ private void setDataStreams(List<String> dataStreams) {
             this.dataStreams = dataStreams;
         }
 
+        private void setFeatureStates(List<SnapshotFeatureInfo> featureStates) {
+            this.featureStates = featureStates;
+        }
+
         private void setStartTime(long startTime) {
             this.startTime = startTime;
         }
@@ -151,6 +159,10 @@ public SnapshotInfo build() {
                 dataStreams = Collections.emptyList();
             }
 
+            if (featureStates == null) {
+                featureStates = Collections.emptyList();
+            }
+
             SnapshotState snapshotState = state == null ? null : SnapshotState.valueOf(state);
             Version version = this.version == -1 ? Version.CURRENT : Version.fromId(this.version);
 
@@ -161,8 +173,9 @@ public SnapshotInfo build() {
                 shardFailures = new ArrayList<>();
             }
 
-            return new SnapshotInfo(snapshotId, indices, dataStreams, snapshotState, reason, version, startTime, endTime,
-                    totalShards, successfulShards, shardFailures, includeGlobalState, userMetadata);
+            return new SnapshotInfo(snapshotId, indices, dataStreams, featureStates, reason, version, startTime, endTime, totalShards,
+                successfulShards, shardFailures, includeGlobalState, userMetadata, snapshotState
+            );
         }
     }
 
@@ -200,6 +213,8 @@ int getSuccessfulShards() {
         SNAPSHOT_INFO_PARSER.declareString(SnapshotInfoBuilder::setReason, new ParseField(REASON));
         SNAPSHOT_INFO_PARSER.declareStringArray(SnapshotInfoBuilder::setIndices, new ParseField(INDICES));
         SNAPSHOT_INFO_PARSER.declareStringArray(SnapshotInfoBuilder::setDataStreams, new ParseField(DATA_STREAMS));
+        SNAPSHOT_INFO_PARSER.declareObjectArray(SnapshotInfoBuilder::setFeatureStates, SnapshotFeatureInfo.SNAPSHOT_FEATURE_INFO_PARSER,
+            new ParseField(FEATURE_STATES));
         SNAPSHOT_INFO_PARSER.declareLong(SnapshotInfoBuilder::setStartTime, new ParseField(START_TIME_IN_MILLIS));
         SNAPSHOT_INFO_PARSER.declareLong(SnapshotInfoBuilder::setEndTime, new ParseField(END_TIME_IN_MILLIS));
         SNAPSHOT_INFO_PARSER.declareObject(SnapshotInfoBuilder::setShardStatsBuilder, SHARD_STATS_PARSER, new ParseField(SHARDS));
@@ -225,6 +240,8 @@ int getSuccessfulShards() {
 
     private final List<String> dataStreams;
 
+    private final List<SnapshotFeatureInfo> featureStates;
+
     private final long startTime;
 
     private final long endTime;
@@ -244,33 +261,40 @@ int getSuccessfulShards() {
 
     private final List<SnapshotShardFailure> shardFailures;
 
-    public SnapshotInfo(SnapshotId snapshotId, List<String> indices, List<String> dataStreams, SnapshotState state) {
-        this(snapshotId, indices, dataStreams, state, null, null, 0L, 0L, 0, 0, Collections.emptyList(), null, null);
+    public SnapshotInfo(SnapshotId snapshotId, List<String> indices, List<String> dataStreams, List<SnapshotFeatureInfo> featureStates,
+                        SnapshotState state) {
+        this(snapshotId, indices, dataStreams, featureStates, null, null, 0L, 0L, 0, 0, Collections.emptyList(), null, null, state);
     }
 
-    public SnapshotInfo(SnapshotId snapshotId, List<String> indices, List<String> dataStreams, SnapshotState state, Version version) {
-        this(snapshotId, indices, dataStreams, state, null, version, 0L, 0L, 0, 0, Collections.emptyList(), null, null);
+    public SnapshotInfo(SnapshotId snapshotId, List<String> indices, List<String> dataStreams, List<SnapshotFeatureInfo> featureStates,
+                        Version version, SnapshotState state) {
+        this(snapshotId, indices, dataStreams, featureStates, null, version, 0L, 0L, 0, 0, Collections.emptyList(), null, null, state);
     }
 
     public SnapshotInfo(SnapshotsInProgress.Entry entry) {
         this(entry.snapshot().getSnapshotId(),
-            entry.indices().stream().map(IndexId::getName).collect(Collectors.toList()), entry.dataStreams(), SnapshotState.IN_PROGRESS,
-            null, Version.CURRENT, entry.startTime(), 0L, 0, 0, Collections.emptyList(), entry.includeGlobalState(), entry.userMetadata());
+            entry.indices().stream().map(IndexId::getName).collect(Collectors.toList()), entry.dataStreams(), entry.featureStates(),
+            null, Version.CURRENT, entry.startTime(), 0L, 0, 0, Collections.emptyList(), entry.includeGlobalState(), entry.userMetadata(),
+            SnapshotState.IN_PROGRESS
+        );
     }
 
-    public SnapshotInfo(SnapshotId snapshotId, List<String> indices, List<String> dataStreams, long startTime, String reason,
-                        long endTime, int totalShards, List<SnapshotShardFailure> shardFailures, Boolean includeGlobalState,
-                        Map<String, Object> userMetadata) {
-        this(snapshotId, indices, dataStreams, snapshotState(reason, shardFailures), reason, Version.CURRENT,
-             startTime, endTime, totalShards, totalShards - shardFailures.size(), shardFailures, includeGlobalState, userMetadata);
+    public SnapshotInfo(SnapshotId snapshotId, List<String> indices, List<String> dataStreams, List<SnapshotFeatureInfo> featureStates,
+                        String reason, long endTime, int totalShards, List<SnapshotShardFailure> shardFailures, Boolean includeGlobalState,
+                        Map<String, Object> userMetadata, long startTime) {
+        this(snapshotId, indices, dataStreams, featureStates, reason, Version.CURRENT, startTime, endTime, totalShards,
+            totalShards - shardFailures.size(), shardFailures, includeGlobalState, userMetadata, snapshotState(reason, shardFailures)
+        );
     }
 
-    SnapshotInfo(SnapshotId snapshotId, List<String> indices, List<String> dataStreams, SnapshotState state, String reason,
-                         Version version, long startTime, long endTime, int totalShards, int successfulShards,
-                         List<SnapshotShardFailure> shardFailures, Boolean includeGlobalState, Map<String, Object> userMetadata) {
+    SnapshotInfo(SnapshotId snapshotId, List<String> indices, List<String> dataStreams, List<SnapshotFeatureInfo> featureStates,
+                 String reason, Version version, long startTime, long endTime, int totalShards, int successfulShards,
+                 List<SnapshotShardFailure> shardFailures, Boolean includeGlobalState, Map<String, Object> userMetadata,
+                 SnapshotState state) {
         this.snapshotId = Objects.requireNonNull(snapshotId);
         this.indices = Collections.unmodifiableList(Objects.requireNonNull(indices));
         this.dataStreams = Collections.unmodifiableList(Objects.requireNonNull(dataStreams));
+        this.featureStates = Collections.unmodifiableList(Objects.requireNonNull(featureStates));
         this.state = state;
         this.reason = reason;
         this.version = version;
@@ -300,6 +324,11 @@ public SnapshotInfo(final StreamInput in) throws IOException {
         includeGlobalState = in.readOptionalBoolean();
         userMetadata = in.readMap();
         dataStreams = in.readStringList();
+        if (in.getVersion().before(FEATURE_STATES_VERSION)) {
+            featureStates = Collections.emptyList();
+        } else {
+            featureStates = Collections.unmodifiableList(in.readList(SnapshotFeatureInfo::new));
+        }
     }
 
     /**
@@ -307,7 +336,7 @@ public SnapshotInfo(final StreamInput in) throws IOException {
      * all information stripped out except the snapshot id, state, and indices.
      */
     public SnapshotInfo basic() {
-        return new SnapshotInfo(snapshotId, indices, Collections.emptyList(), state);
+        return new SnapshotInfo(snapshotId, indices, Collections.emptyList(), featureStates, state);
     }
 
     /**
@@ -439,6 +468,10 @@ public Map<String, Object> userMetadata() {
         return userMetadata;
     }
 
+    public List<SnapshotFeatureInfo> featureStates() {
+        return featureStates;
+    }
+
     /**
      * Compares two snapshots by their start time; if the start times are the same, then
      * compares the two snapshots by their snapshot ids.
@@ -462,6 +495,7 @@ public String toString() {
             ", includeGlobalState=" + includeGlobalState +
             ", version=" + version +
             ", shardFailures=" + shardFailures +
+            ", featureStates=" + featureStates +
             '}';
     }
 
@@ -540,6 +574,14 @@ public XContentBuilder toXContent(final XContentBuilder builder, final Params pa
             builder.field(SUCCESSFUL, successfulShards);
             builder.endObject();
         }
+        if (verbose || featureStates.isEmpty() == false) {
+            builder.startArray(FEATURE_STATES);
+            for (SnapshotFeatureInfo snapshotFeatureInfo : featureStates) {
+                builder.value(snapshotFeatureInfo);
+            }
+            builder.endArray();
+
+        }
         builder.endObject();
         return builder;
     }
@@ -577,6 +619,12 @@ private XContentBuilder toXContentInternal(final XContentBuilder builder, final
             shardFailure.toXContent(builder, params);
         }
         builder.endArray();
+        builder.startArray(FEATURE_STATES);
+        for (SnapshotFeatureInfo snapshotFeatureInfo : featureStates) {
+            builder.value(snapshotFeatureInfo);
+        }
+        builder.endArray();
+
         builder.endObject();
         return builder;
     }
@@ -601,6 +649,7 @@ public static SnapshotInfo fromXContentInternal(final XContentParser parser) thr
         Boolean includeGlobalState = null;
         Map<String, Object> userMetadata = null;
         List<SnapshotShardFailure> shardFailures = Collections.emptyList();
+        List<SnapshotFeatureInfo> featureStates = Collections.emptyList();
         if (parser.currentToken() == null) { // fresh parser? move to the first token
             parser.nextToken();
         }
@@ -655,6 +704,12 @@ public static SnapshotInfo fromXContentInternal(final XContentParser parser) thr
                                 shardFailureArrayList.add(SnapshotShardFailure.fromXContent(parser));
                             }
                             shardFailures = Collections.unmodifiableList(shardFailureArrayList);
+                        } else if (FEATURE_STATES.equals(currentFieldName)) {
+                            ArrayList<SnapshotFeatureInfo> snapshotFeatureInfoArrayList = new ArrayList<>();
+                            while (parser.nextToken() != XContentParser.Token.END_ARRAY) {
+                                snapshotFeatureInfoArrayList.add(SnapshotFeatureInfo.fromXContent(parser));
+                            }
+                            featureStates = Collections.unmodifiableList(snapshotFeatureInfoArrayList);
                         } else {
                             // It was probably created by newer version - ignoring
                             parser.skipChildren();
@@ -677,7 +732,7 @@ public static SnapshotInfo fromXContentInternal(final XContentParser parser) thr
         return new SnapshotInfo(new SnapshotId(name, uuid),
                                 indices,
                                 dataStreams,
-                                state,
+                                featureStates,
                                 reason,
                                 version,
                                 startTime,
@@ -686,7 +741,9 @@ public static SnapshotInfo fromXContentInternal(final XContentParser parser) thr
                                 successfulShards,
                                 shardFailures,
                                 includeGlobalState,
-                                userMetadata);
+                                userMetadata,
+                                state
+        );
     }
 
     @Override
@@ -714,6 +771,9 @@ public void writeTo(final StreamOutput out) throws IOException {
         out.writeOptionalBoolean(includeGlobalState);
         out.writeMap(userMetadata);
         out.writeStringCollection(dataStreams);
+        if (out.getVersion().onOrAfter(FEATURE_STATES_VERSION)) {
+            out.writeList(featureStates);
+        }
     }
 
     private static SnapshotState snapshotState(final String reason, final List<SnapshotShardFailure> shardFailures) {
@@ -745,12 +805,15 @@ public boolean equals(Object o) {
             Objects.equals(includeGlobalState, that.includeGlobalState) &&
             Objects.equals(version, that.version) &&
             Objects.equals(shardFailures, that.shardFailures) &&
-            Objects.equals(userMetadata, that.userMetadata);
+            Objects.equals(userMetadata, that.userMetadata) &&
+            Objects.equals(featureStates, that.featureStates);
     }
 
     @Override
     public int hashCode() {
         return Objects.hash(snapshotId, state, reason, indices, dataStreams, startTime, endTime,
-                totalShards, successfulShards, includeGlobalState, version, shardFailures, userMetadata);
+                totalShards, successfulShards, includeGlobalState, version, shardFailures, userMetadata,
+            featureStates);
     }
+
 }
diff --git a/server/src/main/java/org/elasticsearch/snapshots/SnapshotsService.java b/server/src/main/java/org/elasticsearch/snapshots/SnapshotsService.java
index 913e99736323e..7f849ba68c043 100644
--- a/server/src/main/java/org/elasticsearch/snapshots/SnapshotsService.java
+++ b/server/src/main/java/org/elasticsearch/snapshots/SnapshotsService.java
@@ -69,6 +69,7 @@
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.index.Index;
 import org.elasticsearch.index.shard.ShardId;
+import org.elasticsearch.indices.SystemIndices;
 import org.elasticsearch.repositories.IndexId;
 import org.elasticsearch.repositories.RepositoriesService;
 import org.elasticsearch.repositories.Repository;
@@ -107,6 +108,7 @@
 
 import static java.util.Collections.emptySet;
 import static java.util.Collections.unmodifiableList;
+import static org.elasticsearch.action.support.IndicesOptions.LENIENT_EXPAND_OPEN_CLOSED_HIDDEN;
 import static org.elasticsearch.cluster.SnapshotsInProgress.completed;
 
 /**
@@ -129,6 +131,8 @@ public class SnapshotsService extends AbstractLifecycleComponent implements Clus
 
     public static final Version OLD_SNAPSHOT_FORMAT = Version.V_7_5_0;
 
+    public static final Version FEATURE_STATES_VERSION = Version.V_8_0_0;
+
     private static final Logger logger = LogManager.getLogger(SnapshotsService.class);
 
     public static final String UPDATE_SNAPSHOT_STATUS_ACTION_NAME = "internal:cluster/snapshot/update_snapshot_status";
@@ -153,6 +157,8 @@ public class SnapshotsService extends AbstractLifecycleComponent implements Clus
 
     public static final String CACHE_FILE_NAME = "shared_snapshot_cache";
 
+    public static final String NO_FEATURE_STATES_VALUE = "none";
+
     private final ClusterService clusterService;
 
     private final IndexNameExpressionResolver indexNameExpressionResolver;
@@ -184,6 +190,8 @@ public class SnapshotsService extends AbstractLifecycleComponent implements Clus
 
     private final OngoingRepositoryOperations repositoryOperations = new OngoingRepositoryOperations();
 
+    private final Map<String, SystemIndices.Feature> systemIndexDescriptorMap;
+
     /**
      * Setting that specifies the maximum number of allowed concurrent snapshot create and delete operations in the
      * cluster state. The number of concurrent operations in a cluster state is defined as the sum of the sizes of
@@ -195,7 +203,8 @@ public class SnapshotsService extends AbstractLifecycleComponent implements Clus
     private volatile int maxConcurrentOperations;
 
     public SnapshotsService(Settings settings, ClusterService clusterService, IndexNameExpressionResolver indexNameExpressionResolver,
-                            RepositoriesService repositoriesService, TransportService transportService, ActionFilters actionFilters) {
+                            RepositoriesService repositoriesService, TransportService transportService, ActionFilters actionFilters,
+                            Map<String, SystemIndices.Feature> systemIndexDescriptorMap) {
         this.clusterService = clusterService;
         this.indexNameExpressionResolver = indexNameExpressionResolver;
         this.repositoriesService = repositoriesService;
@@ -212,6 +221,7 @@ public SnapshotsService(Settings settings, ClusterService clusterService, IndexN
             clusterService.getClusterSettings().addSettingsUpdateConsumer(MAX_CONCURRENT_SNAPSHOT_OPERATIONS_SETTING,
                 i -> maxConcurrentOperations = i);
         }
+        this.systemIndexDescriptorMap = systemIndexDescriptorMap;
     }
 
     /**
@@ -267,6 +277,59 @@ public ClusterState execute(ClusterState currentState) {
                 // Store newSnapshot here to be processed in clusterStateProcessed
                 List<String> indices = Arrays.asList(indexNameExpressionResolver.concreteIndexNames(currentState, request));
 
+                List<SnapshotFeatureInfo> featureStates = Collections.emptyList();
+                final List<String> requestedStates = Arrays.asList(request.featureStates());
+
+                // We should only use the feature states logic if we're sure we'll be able to finish the snapshot without a lower-version
+                // node taking over and causing problems. Therefore, if we're in a mixed cluster with versions that don't know how to handle
+                // feature states, skip all feature states logic, and if `feature_states` is explicitly configured, throw an exception.
+                if (currentState.nodes().getMinNodeVersion().onOrAfter(FEATURE_STATES_VERSION)) {
+                    if (request.includeGlobalState() || requestedStates.isEmpty() == false) {
+                        final Set<String> featureStatesSet;
+                        if (request.includeGlobalState() && requestedStates.isEmpty()) {
+                            // If we're including global state and feature states aren't specified, include all of them
+                            featureStatesSet = new HashSet<>(systemIndexDescriptorMap.keySet());
+                        } else if (requestedStates.size() == 1 && NO_FEATURE_STATES_VALUE.equalsIgnoreCase(requestedStates.get(0))) {
+                            // If there's exactly one value and it's "none", include no states
+                            featureStatesSet = Collections.emptySet();
+                        } else {
+                            // Otherwise, check for "none" then use the list of requested states
+                            if (requestedStates.contains(NO_FEATURE_STATES_VALUE)) {
+                                throw new IllegalArgumentException("the feature_states value [" + SnapshotsService.NO_FEATURE_STATES_VALUE +
+                                    "] indicates that no feature states should be snapshotted, but other feature states were requested: " +
+                                    requestedStates);
+                            }
+                            featureStatesSet = new HashSet<>(requestedStates);
+                        }
+
+                        featureStates = systemIndexDescriptorMap.keySet().stream()
+                            .filter(feature -> featureStatesSet.contains(feature))
+                            .map(feature -> new SnapshotFeatureInfo(feature, resolveFeatureIndexNames(currentState, feature)))
+                            .filter(featureInfo -> featureInfo.getIndices().isEmpty() == false) // Omit any empty featureStates
+                            .collect(Collectors.toList());
+                        final Stream<String> featureStateIndices = featureStates.stream().flatMap(feature -> feature.getIndices().stream());
+
+                        final Stream<String> associatedIndices = systemIndexDescriptorMap.keySet().stream()
+                            .filter(feature -> featureStatesSet.contains(feature))
+                            .flatMap(feature -> resolveAssociatedIndices(currentState, feature).stream());
+
+                        // Add all resolved indices from the feature states to the list of indices
+                        indices = Stream.of(indices.stream(), featureStateIndices, associatedIndices)
+                            .flatMap(s -> s)
+                            .distinct()
+                            .collect(Collectors.toList());
+                    }
+                } else if (requestedStates.isEmpty() == false) {
+                    throw new SnapshotException(
+                        new Snapshot(repositoryName, snapshotId),
+                        "feature_states can only be used when all nodes in cluster are version ["
+                            + FEATURE_STATES_VERSION
+                            + "] or higher, but at least one node in this cluster is on version ["
+                            + currentState.nodes().getMinNodeVersion()
+                            + "]"
+                    );
+                }
+
                 final List<String> dataStreams =
                         indexNameExpressionResolver.dataStreamNames(currentState, request.indicesOptions(), request.indices());
 
@@ -291,7 +354,8 @@ public ClusterState execute(ClusterState currentState) {
                 }
                 newEntry = SnapshotsInProgress.startedEntry(
                         new Snapshot(repositoryName, snapshotId), request.includeGlobalState(), request.partial(),
-                        indexIds, dataStreams, threadPool.absoluteTimeInMillis(), repositoryData.getGenId(), shards, userMeta, version);
+                        indexIds, dataStreams, threadPool.absoluteTimeInMillis(), repositoryData.getGenId(), shards,
+                        userMeta, version, featureStates);
                 return ClusterState.builder(currentState).putCustom(SnapshotsInProgress.TYPE,
                         SnapshotsInProgress.of(CollectionUtils.appendToCopy(runningSnapshots, newEntry))).build();
             }
@@ -316,6 +380,29 @@ public void clusterStateProcessed(String source, ClusterState oldState, final Cl
         }, "create_snapshot [" + snapshotName + ']', listener::onFailure);
     }
 
+    private List<String> resolveFeatureIndexNames(ClusterState currentState, String featureName) {
+        if (systemIndexDescriptorMap.containsKey(featureName) == false) {
+            throw new IllegalArgumentException("requested snapshot of feature state for unknown feature [" + featureName + "]");
+        }
+
+        final SystemIndices.Feature feature = systemIndexDescriptorMap.get(featureName);
+        return feature.getIndexDescriptors().stream()
+            .flatMap(descriptor -> descriptor.getMatchingIndices(currentState.metadata()).stream())
+            .collect(Collectors.toList());
+    }
+
+    private List<String> resolveAssociatedIndices(ClusterState currentState, String featureName) {
+        if (systemIndexDescriptorMap.containsKey(featureName) == false) {
+            throw new IllegalArgumentException("requested associated indices for feature state for unknown feature [" + featureName + "]");
+        }
+
+        final SystemIndices.Feature feature = systemIndexDescriptorMap.get(featureName);
+        return feature.getAssociatedIndexPatterns().stream()
+            .flatMap(pattern -> Arrays.stream(indexNameExpressionResolver.concreteIndexNamesWithSystemIndexAccess(currentState,
+                LENIENT_EXPAND_OPEN_CLOSED_HIDDEN, pattern)))
+            .collect(Collectors.toList());
+    }
+
     private static void ensureSnapshotNameNotRunning(List<SnapshotsInProgress.Entry> runningSnapshots, String repositoryName,
                                                      String snapshotName) {
         if (runningSnapshots.stream().anyMatch(s -> {
@@ -1210,14 +1297,18 @@ private void finalizeSnapshotEntry(SnapshotsInProgress.Entry entry, Metadata met
             }
             metadataListener.whenComplete(meta -> {
                         final Metadata metaForSnapshot = metadataForSnapshot(entry, meta);
+                        final List<String> finalIndices = shardGenerations.indices().stream()
+                            .map(IndexId::getName)
+                            .collect(Collectors.toList());
                         final SnapshotInfo snapshotInfo = new SnapshotInfo(snapshot.getSnapshotId(),
-                                shardGenerations.indices().stream().map(IndexId::getName).collect(Collectors.toList()),
+                                finalIndices,
                                 entry.partial() ? entry.dataStreams().stream()
                                         .filter(metaForSnapshot.dataStreams()::containsKey)
                                         .collect(Collectors.toList()) : entry.dataStreams(),
-                                entry.startTime(), failure, threadPool.absoluteTimeInMillis(),
+                                entry.partial() ? onlySuccessfulFeatureStates(entry, finalIndices) : entry.featureStates(),
+                                failure, threadPool.absoluteTimeInMillis(),
                                 entry.partial() ? shardGenerations.totalShards() : entry.shards().size(), shardFailures,
-                                entry.includeGlobalState(), entry.userMetadata());
+                                entry.includeGlobalState(), entry.userMetadata(), entry.startTime());
                         repo.finalizeSnapshot(
                                 shardGenerations,
                                 repositoryData.getGenId(),
@@ -1239,6 +1330,31 @@ private void finalizeSnapshotEntry(SnapshotsInProgress.Entry entry, Metadata met
         }
     }
 
+    /**
+     * Removes all feature states which have missing or failed shards, as they are no longer safely restorable.
+     * @param entry The "in progress" entry with a list of feature states and one or more failed shards.
+     * @param finalIndices The final list of indices in the snapshot, after any indices that were concurrently deleted are removed.
+     * @return The list of feature states which were completed successfully in the given entry.
+     */
+    private List<SnapshotFeatureInfo> onlySuccessfulFeatureStates(SnapshotsInProgress.Entry entry, List<String> finalIndices) {
+        assert entry.partial() : "should not try to filter feature states from a non-partial entry";
+
+        // Figure out which indices have unsuccessful shards
+        Set<String> indicesWithUnsuccessfulShards = new HashSet<>();
+        entry.shards().keysIt().forEachRemaining(shardId -> {
+            final ShardState shardState = entry.shards().get(shardId).state();
+            if (shardState.failed() || shardState.completed() == false) {
+                indicesWithUnsuccessfulShards.add(shardId.getIndexName());
+            }
+        });
+
+        // Now remove any feature states which contain any of those indices, as the feature state is not intact and not safely restorable
+        return entry.featureStates().stream()
+            .filter(stateInfo -> finalIndices.containsAll(stateInfo.getIndices()))
+            .filter(stateInfo -> stateInfo.getIndices().stream().anyMatch(indicesWithUnsuccessfulShards::contains) == false)
+            .collect(Collectors.toList());
+    }
+
     /**
      * Remove a snapshot from {@link #endingSnapshots} set and return its completion listeners that must be resolved.
      */
diff --git a/server/src/main/java/org/elasticsearch/tasks/TaskResultsService.java b/server/src/main/java/org/elasticsearch/tasks/TaskResultsService.java
index 8b125d95fa35c..3d2f9dfc182cc 100644
--- a/server/src/main/java/org/elasticsearch/tasks/TaskResultsService.java
+++ b/server/src/main/java/org/elasticsearch/tasks/TaskResultsService.java
@@ -45,6 +45,8 @@ public class TaskResultsService {
 
     private static final Logger logger = LogManager.getLogger(TaskResultsService.class);
 
+    public static final String TASKS_FEATURE_NAME = "tasks";
+
     public static final String TASK_INDEX = ".tasks";
     public static final String TASK_RESULT_MAPPING_VERSION_META_FIELD = "version";
 
diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequestTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequestTests.java
index 333757add027b..f40aaeeda51b1 100644
--- a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequestTests.java
+++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequestTests.java
@@ -55,6 +55,18 @@ public void testToXContent() throws IOException {
             original.indices(indices);
         }
 
+        if (randomBoolean()) {
+            List<String> featureStates = new ArrayList<>();
+            int count = randomInt(3) + 1;
+
+            for (int i = 0; i < count; ++i) {
+                featureStates.add(randomAlphaOfLength(randomInt(3) + 2));
+            }
+
+            original.featureStates(featureStates);
+        }
+
+
         if (randomBoolean()) {
             original.partial(randomBoolean());
         }
diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotResponseTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotResponseTests.java
index d2a202568ebbb..62d8dbf593f7a 100644
--- a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotResponseTests.java
+++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotResponseTests.java
@@ -10,6 +10,8 @@
 
 import org.elasticsearch.common.xcontent.XContentParser;
 import org.elasticsearch.index.shard.ShardId;
+import org.elasticsearch.snapshots.SnapshotFeatureInfo;
+import org.elasticsearch.snapshots.SnapshotFeatureInfoTests;
 import org.elasticsearch.snapshots.SnapshotId;
 import org.elasticsearch.snapshots.SnapshotInfo;
 import org.elasticsearch.snapshots.SnapshotInfoTests;
@@ -44,6 +46,9 @@ protected CreateSnapshotResponse createTestInstance() {
         List<String> dataStreams = new ArrayList<>();
         dataStreams.add("test0");
         dataStreams.add("test1");
+
+        List<SnapshotFeatureInfo> featureStates = randomList(5, SnapshotFeatureInfoTests::randomSnapshotFeatureInfo);
+
         String reason = "reason";
         long startTime = System.currentTimeMillis();
         long endTime = startTime + 10000;
@@ -59,8 +64,9 @@ protected CreateSnapshotResponse createTestInstance() {
         boolean globalState = randomBoolean();
 
         return new CreateSnapshotResponse(
-            new SnapshotInfo(snapshotId, indices, dataStreams, startTime, reason, endTime, totalShards, shardFailures,
-                globalState, SnapshotInfoTests.randomUserMetadata()));
+            new SnapshotInfo(snapshotId, indices, dataStreams, featureStates, reason, endTime, totalShards, shardFailures,
+                globalState, SnapshotInfoTests.randomUserMetadata(), startTime
+            ));
     }
 
     @Override
diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/features/GetSnapshottableFeaturesResponseTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/features/GetSnapshottableFeaturesResponseTests.java
new file mode 100644
index 0000000000000..40702a8b51a71
--- /dev/null
+++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/features/GetSnapshottableFeaturesResponseTests.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.action.admin.cluster.snapshots.features;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+
+import java.io.IOException;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public class GetSnapshottableFeaturesResponseTests extends AbstractWireSerializingTestCase<GetSnapshottableFeaturesResponse> {
+
+    @Override
+    protected Writeable.Reader instanceReader() {
+        return GetSnapshottableFeaturesResponse::new;
+    }
+
+    @Override
+    protected GetSnapshottableFeaturesResponse createTestInstance() {
+        return new GetSnapshottableFeaturesResponse(randomList(10,
+            () -> new GetSnapshottableFeaturesResponse.SnapshottableFeature(
+                randomAlphaOfLengthBetween(4, 10),
+                randomAlphaOfLengthBetween(5,10))));
+    }
+
+    @Override
+    protected GetSnapshottableFeaturesResponse mutateInstance(GetSnapshottableFeaturesResponse instance) throws IOException {
+        int minSize = 0;
+        if (instance.getSnapshottableFeatures().size() == 0) {
+            minSize = 1;
+        }
+        Set<String> existingFeatureNames = instance.getSnapshottableFeatures().stream()
+            .map(feature -> feature.getFeatureName())
+            .collect(Collectors.toSet());
+        return new GetSnapshottableFeaturesResponse(randomList(minSize, 10,
+            () -> new GetSnapshottableFeaturesResponse.SnapshottableFeature(
+                randomValueOtherThanMany(existingFeatureNames::contains, () -> randomAlphaOfLengthBetween(4, 10)),
+                randomAlphaOfLengthBetween(5, 10))));
+    }
+}
diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/get/GetSnapshotsResponseTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/get/GetSnapshotsResponseTests.java
index 44afc5cdb38f9..f31f037deebc7 100644
--- a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/get/GetSnapshotsResponseTests.java
+++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/get/GetSnapshotsResponseTests.java
@@ -16,6 +16,7 @@
 import org.elasticsearch.common.xcontent.ToXContent;
 import org.elasticsearch.common.xcontent.XContentParser;
 import org.elasticsearch.index.shard.ShardId;
+import org.elasticsearch.snapshots.SnapshotFeatureInfo;
 import org.elasticsearch.snapshots.SnapshotId;
 import org.elasticsearch.snapshots.SnapshotInfo;
 import org.elasticsearch.snapshots.SnapshotInfoTests;
@@ -33,6 +34,7 @@
 import java.util.function.Predicate;
 import java.util.regex.Pattern;
 
+import static org.elasticsearch.snapshots.SnapshotFeatureInfoTests.randomSnapshotFeatureInfo;
 import static org.elasticsearch.test.AbstractXContentTestCase.xContentTester;
 import static org.hamcrest.CoreMatchers.containsString;
 
@@ -70,9 +72,11 @@ private List<SnapshotInfo> createSnapshotInfos() {
             String reason = randomBoolean() ? null : "reason";
             ShardId shardId = new ShardId("index", UUIDs.base64UUID(), 2);
             List<SnapshotShardFailure> shardFailures = Collections.singletonList(new SnapshotShardFailure("node-id", shardId, "reason"));
+            List<SnapshotFeatureInfo> featureInfos = randomList(0, () -> randomSnapshotFeatureInfo());
             snapshots.add(new SnapshotInfo(snapshotId, Arrays.asList("index1", "index2"), Collections.singletonList("ds"),
-                System.currentTimeMillis(), reason, System.currentTimeMillis(), randomIntBetween(2, 3), shardFailures, randomBoolean(),
-                SnapshotInfoTests.randomUserMetadata()));
+                featureInfos, reason, System.currentTimeMillis(), randomIntBetween(2, 3), shardFailures, randomBoolean(),
+                SnapshotInfoTests.randomUserMetadata(), System.currentTimeMillis()
+            ));
 
         }
         return snapshots;
diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestTests.java
index a8a52931ad888..68db9b6b58e4e 100644
--- a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestTests.java
+++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestTests.java
@@ -44,6 +44,18 @@ private RestoreSnapshotRequest randomState(RestoreSnapshotRequest instance) {
 
             instance.indices(indices);
         }
+
+        if (randomBoolean()) {
+            List<String> plugins = new ArrayList<>();
+            int count = randomInt(3) + 1;
+
+            for (int i = 0; i < count; ++i) {
+                plugins.add(randomAlphaOfLength(randomInt(3) + 2));
+            }
+
+            instance.featureStates(plugins);
+        }
+
         if (randomBoolean()) {
             instance.renamePattern(randomUnicodeOfLengthBetween(1, 100));
         }
diff --git a/server/src/test/java/org/elasticsearch/action/admin/indices/alias/get/TransportGetAliasesActionTests.java b/server/src/test/java/org/elasticsearch/action/admin/indices/alias/get/TransportGetAliasesActionTests.java
index a9a316f29061f..e85cafdca2fa0 100644
--- a/server/src/test/java/org/elasticsearch/action/admin/indices/alias/get/TransportGetAliasesActionTests.java
+++ b/server/src/test/java/org/elasticsearch/action/admin/indices/alias/get/TransportGetAliasesActionTests.java
@@ -151,8 +151,10 @@ public void testDeprecationWarningNotEmittedWhenOnlyNonsystemIndexRequested() {
 
     public void testDeprecationWarningEmittedWhenRequestingNonExistingAliasInSystemPattern() {
         ClusterState state = systemIndexTestClusterState();
-        SystemIndices systemIndices = new SystemIndices(Collections.singletonMap(this.getTestName(),
-            Collections.singletonList(new SystemIndexDescriptor(".y", "an index that doesn't exist"))));
+        SystemIndices systemIndices = new SystemIndices(Collections.singletonMap(
+            this.getTestName(),
+            new SystemIndices.Feature("test feature",
+                Collections.singletonList(new SystemIndexDescriptor(".y", "an index that doesn't exist")))));
 
         GetAliasesRequest request = new GetAliasesRequest(".y");
         ImmutableOpenMap<String, List<AliasMetadata>> aliases = ImmutableOpenMap.<String, List<AliasMetadata>>builder()
diff --git a/server/src/test/java/org/elasticsearch/action/bulk/TransportBulkActionTests.java b/server/src/test/java/org/elasticsearch/action/bulk/TransportBulkActionTests.java
index b14adc7d8eee8..000b1ce3b4e14 100644
--- a/server/src/test/java/org/elasticsearch/action/bulk/TransportBulkActionTests.java
+++ b/server/src/test/java/org/elasticsearch/action/bulk/TransportBulkActionTests.java
@@ -243,7 +243,8 @@ public void testOnlySystem() {
             new Index(IndexMetadata.builder(".foo").settings(settings).system(true).numberOfShards(1).numberOfReplicas(0).build()));
         indicesLookup.put(".bar",
             new Index(IndexMetadata.builder(".bar").settings(settings).system(true).numberOfShards(1).numberOfReplicas(0).build()));
-        SystemIndices systemIndices = new SystemIndices(Map.of("plugin", List.of(new SystemIndexDescriptor(".test", ""))));
+        SystemIndices systemIndices = new SystemIndices(
+            Map.of("plugin", new SystemIndices.Feature("test feature", List.of(new SystemIndexDescriptor(".test", "")))));
         List<String> onlySystem = List.of(".foo", ".bar");
         assertTrue(bulkAction.isOnlySystem(buildBulkRequest(onlySystem), indicesLookup, systemIndices));
 
diff --git a/server/src/test/java/org/elasticsearch/action/support/AutoCreateIndexTests.java b/server/src/test/java/org/elasticsearch/action/support/AutoCreateIndexTests.java
index 8b2a92f83e12b..808eaa6d614e4 100644
--- a/server/src/test/java/org/elasticsearch/action/support/AutoCreateIndexTests.java
+++ b/server/src/test/java/org/elasticsearch/action/support/AutoCreateIndexTests.java
@@ -300,7 +300,8 @@ private static ClusterState buildClusterState(String... indices) {
     }
 
     private AutoCreateIndex newAutoCreateIndex(Settings settings) {
-        SystemIndices systemIndices = new SystemIndices(Map.of("plugin", List.of(new SystemIndexDescriptor(TEST_SYSTEM_INDEX_NAME, ""))));
+        SystemIndices systemIndices = new SystemIndices(Map.of(
+            "plugin", new SystemIndices.Feature("test feature", List.of(new SystemIndexDescriptor(TEST_SYSTEM_INDEX_NAME, "")))));
         return new AutoCreateIndex(settings, new ClusterSettings(settings,
             ClusterSettings.BUILT_IN_CLUSTER_SETTINGS), new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)), systemIndices);
     }
diff --git a/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataCreateIndexServiceTests.java b/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataCreateIndexServiceTests.java
index 12bc4b8c66e41..0f2e77fb18b54 100644
--- a/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataCreateIndexServiceTests.java
+++ b/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataCreateIndexServiceTests.java
@@ -517,7 +517,7 @@ public void testValidateDotIndex() {
                 null,
                 threadPool,
                 null,
-                new SystemIndices(Collections.singletonMap("foo", systemIndexDescriptors)),
+                new SystemIndices(Collections.singletonMap("foo", new SystemIndices.Feature("test feature", systemIndexDescriptors))),
                 false
             );
             // Check deprecations
diff --git a/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataDeleteIndexServiceTests.java b/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataDeleteIndexServiceTests.java
index 76718a8ba383f..7c43273490bcc 100644
--- a/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataDeleteIndexServiceTests.java
+++ b/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataDeleteIndexServiceTests.java
@@ -73,8 +73,8 @@ public void testDeleteSnapshotting() {
         Snapshot snapshot = new Snapshot("doesn't matter", new SnapshotId("snapshot name", "snapshot uuid"));
         SnapshotsInProgress snaps = SnapshotsInProgress.of(List.of(new SnapshotsInProgress.Entry(snapshot, true, false,
                 SnapshotsInProgress.State.INIT, singletonList(new IndexId(index, "doesn't matter")),
-                Collections.emptyList(), System.currentTimeMillis(), (long) randomIntBetween(0, 1000), ImmutableOpenMap.of(), null,
-                SnapshotInfoTests.randomUserMetadata(), VersionUtils.randomVersion(random()))));
+                Collections.emptyList(), Collections.emptyList(), System.currentTimeMillis(), (long) randomIntBetween(0, 1000),
+                ImmutableOpenMap.of(), null, SnapshotInfoTests.randomUserMetadata(), VersionUtils.randomVersion(random()))));
         ClusterState state = ClusterState.builder(clusterState(index))
                 .putCustom(SnapshotsInProgress.TYPE, snaps)
                 .build();
diff --git a/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataIndexStateServiceTests.java b/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataIndexStateServiceTests.java
index de1bcdef4dcd7..95dce9bfe556a 100644
--- a/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataIndexStateServiceTests.java
+++ b/server/src/test/java/org/elasticsearch/cluster/metadata/MetadataIndexStateServiceTests.java
@@ -386,8 +386,10 @@ private static ClusterState addSnapshotIndex(final String index, final int numSh
         final Snapshot snapshot = new Snapshot(randomAlphaOfLength(10), new SnapshotId(randomAlphaOfLength(5), randomAlphaOfLength(5)));
         final SnapshotsInProgress.Entry entry =
             new SnapshotsInProgress.Entry(snapshot, randomBoolean(), false, SnapshotsInProgress.State.INIT,
-                Collections.singletonList(new IndexId(index, index)), Collections.emptyList(), randomNonNegativeLong(), randomLong(),
-                    shardsBuilder.build(), null, SnapshotInfoTests.randomUserMetadata(), VersionUtils.randomVersion(random()));
+                Collections.singletonList(new IndexId(index, index)), Collections.emptyList(), Collections.emptyList(),
+                randomNonNegativeLong(), randomLong(), shardsBuilder.build(), null, SnapshotInfoTests.randomUserMetadata(),
+                VersionUtils.randomVersion(random())
+            );
         return ClusterState.builder(newState).putCustom(SnapshotsInProgress.TYPE, SnapshotsInProgress.of(List.of(entry))).build();
     }
 
diff --git a/server/src/test/java/org/elasticsearch/indices/SystemIndexManagerTests.java b/server/src/test/java/org/elasticsearch/indices/SystemIndexManagerTests.java
index 70461b3281329..f85dd64022752 100644
--- a/server/src/test/java/org/elasticsearch/indices/SystemIndexManagerTests.java
+++ b/server/src/test/java/org/elasticsearch/indices/SystemIndexManagerTests.java
@@ -72,6 +72,8 @@ public class SystemIndexManagerTests extends ESTestCase {
         .setOrigin("FAKE_ORIGIN")
         .build();
 
+    private static final SystemIndices.Feature FEATURE = new SystemIndices.Feature("a test feature", List.of(DESCRIPTOR));
+
     private Client client;
 
     @Before
@@ -98,7 +100,9 @@ public void testManagerSkipsDescriptorsThatAreNotManaged() {
             .setOrigin("FAKE_ORIGIN")
             .build();
 
-        SystemIndices systemIndices = new SystemIndices(Map.of("index 1", List.of(d1), "index 2", List.of(d2)));
+        SystemIndices systemIndices = new SystemIndices(Map.of(
+            "index 1", new SystemIndices.Feature("index 1 feature", List.of(d1)),
+            "index 2", new SystemIndices.Feature("index 2 feature", List.of(d2))));
         SystemIndexManager manager = new SystemIndexManager(systemIndices, client);
 
         final List<SystemIndexDescriptor> eligibleDescriptors = manager.getEligibleDescriptors(
@@ -134,7 +138,9 @@ public void testManagerSkipsDescriptorsForIndicesThatDoNotExist() {
             .setOrigin("FAKE_ORIGIN")
             .build();
 
-        SystemIndices systemIndices = new SystemIndices(Map.of("index 1", List.of(d1), "index 2", List.of(d2)));
+        SystemIndices systemIndices = new SystemIndices(Map.of(
+            "index 1", new SystemIndices.Feature("index 1 feature", List.of(d1)),
+            "index 2", new SystemIndices.Feature("index 2 feature", List.of(d2))));;
         SystemIndexManager manager = new SystemIndexManager(systemIndices, client);
 
         final List<SystemIndexDescriptor> eligibleDescriptors = manager.getEligibleDescriptors(
@@ -149,7 +155,7 @@ public void testManagerSkipsDescriptorsForIndicesThatDoNotExist() {
      * Check that the manager won't try to upgrade closed indices.
      */
     public void testManagerSkipsClosedIndices() {
-        SystemIndices systemIndices = new SystemIndices(Map.of("MyIndex", List.of(DESCRIPTOR)));
+        SystemIndices systemIndices = new SystemIndices(Map.of("MyIndex", FEATURE));
         SystemIndexManager manager = new SystemIndexManager(systemIndices, client);
 
         final ClusterState.Builder clusterStateBuilder = createClusterState(IndexMetadata.State.CLOSE);
@@ -161,7 +167,7 @@ public void testManagerSkipsClosedIndices() {
      * Check that the manager won't try to upgrade unhealthy indices.
      */
     public void testManagerSkipsIndicesWithRedStatus() {
-        SystemIndices systemIndices = new SystemIndices(Map.of("MyIndex", List.of(DESCRIPTOR)));
+        SystemIndices systemIndices = new SystemIndices(Map.of("MyIndex", FEATURE));
         SystemIndexManager manager = new SystemIndexManager(systemIndices, client);
 
         final ClusterState.Builder clusterStateBuilder = createClusterState();
@@ -175,7 +181,7 @@ public void testManagerSkipsIndicesWithRedStatus() {
      * is earlier than an expected value.
      */
     public void testManagerSkipsIndicesWithOutdatedFormat() {
-        SystemIndices systemIndices = new SystemIndices(Map.of("MyIndex", List.of(DESCRIPTOR)));
+        SystemIndices systemIndices = new SystemIndices(Map.of("MyIndex", FEATURE));
         SystemIndexManager manager = new SystemIndexManager(systemIndices, client);
 
         final ClusterState.Builder clusterStateBuilder = createClusterState(5);
@@ -188,7 +194,7 @@ public void testManagerSkipsIndicesWithOutdatedFormat() {
      * Check that the manager won't try to upgrade indices where their mappings are already up-to-date.
      */
     public void testManagerSkipsIndicesWithUpToDateMappings() {
-        SystemIndices systemIndices = new SystemIndices(Map.of("MyIndex", List.of(DESCRIPTOR)));
+        SystemIndices systemIndices = new SystemIndices(Map.of("MyIndex", FEATURE));
         SystemIndexManager manager = new SystemIndexManager(systemIndices, client);
 
         final ClusterState.Builder clusterStateBuilder = createClusterState();
@@ -201,7 +207,7 @@ public void testManagerSkipsIndicesWithUpToDateMappings() {
      * Check that the manager will try to upgrade indices where their mappings are out-of-date.
      */
     public void testManagerProcessesIndicesWithOutdatedMappings() {
-        SystemIndices systemIndices = new SystemIndices(Map.of("MyIndex", List.of(DESCRIPTOR)));
+        SystemIndices systemIndices = new SystemIndices(Map.of("MyIndex", FEATURE));
         SystemIndexManager manager = new SystemIndexManager(systemIndices, client);
 
         final ClusterState.Builder clusterStateBuilder = createClusterState(Strings.toString(getMappings("1.0.0")));
@@ -214,7 +220,7 @@ public void testManagerProcessesIndicesWithOutdatedMappings() {
      * Check that the manager submits the expected request for an index whose mappings are out-of-date.
      */
     public void testManagerSubmitsPutRequest() {
-        SystemIndices systemIndices = new SystemIndices(Map.of("MyIndex", List.of(DESCRIPTOR)));
+        SystemIndices systemIndices = new SystemIndices(Map.of("MyIndex", FEATURE));
         SystemIndexManager manager = new SystemIndexManager(systemIndices, client);
 
         final ClusterState.Builder clusterStateBuilder = createClusterState(Strings.toString(getMappings("1.0.0")));
diff --git a/server/src/test/java/org/elasticsearch/indices/SystemIndicesTests.java b/server/src/test/java/org/elasticsearch/indices/SystemIndicesTests.java
index 16fadd3870ff0..23b48b4284035 100644
--- a/server/src/test/java/org/elasticsearch/indices/SystemIndicesTests.java
+++ b/server/src/test/java/org/elasticsearch/indices/SystemIndicesTests.java
@@ -8,14 +8,13 @@
 
 package org.elasticsearch.indices;
 
-import org.elasticsearch.tasks.TaskResultsService;
 import org.elasticsearch.test.ESTestCase;
 
-import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
+import static org.elasticsearch.tasks.TaskResultsService.TASKS_FEATURE_NAME;
 import static org.elasticsearch.tasks.TaskResultsService.TASK_INDEX;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.equalTo;
@@ -34,9 +33,10 @@ public void testBasicOverlappingPatterns() {
         // across tests
         String broadPatternSource = "AAA" + randomAlphaOfLength(5);
         String otherSource = "ZZZ" + randomAlphaOfLength(6);
-        Map<String, Collection<SystemIndexDescriptor>> descriptors = new HashMap<>();
-        descriptors.put(broadPatternSource, List.of(broadPattern));
-        descriptors.put(otherSource, List.of(notOverlapping, overlapping1, overlapping2, overlapping3));
+        Map<String, SystemIndices.Feature> descriptors = new HashMap<>();
+        descriptors.put(broadPatternSource, new SystemIndices.Feature("test feature", List.of(broadPattern)));
+        descriptors.put(otherSource,
+            new SystemIndices.Feature("test 2", List.of(notOverlapping, overlapping1, overlapping2, overlapping3)));
 
         IllegalStateException exception = expectThrows(IllegalStateException.class,
             () -> SystemIndices.checkForOverlappingPatterns(descriptors));
@@ -61,9 +61,9 @@ public void testComplexOverlappingPatterns() {
         // across tests
         String source1 = "AAA" + randomAlphaOfLength(5);
         String source2 = "ZZZ" + randomAlphaOfLength(6);
-        Map<String, Collection<SystemIndexDescriptor>> descriptors = new HashMap<>();
-        descriptors.put(source1, List.of(pattern1));
-        descriptors.put(source2, List.of(pattern2));
+        Map<String, SystemIndices.Feature> descriptors = new HashMap<>();
+        descriptors.put(source1, new SystemIndices.Feature("test", List.of(pattern1)));
+        descriptors.put(source2, new SystemIndices.Feature("test", List.of(pattern2)));
 
         IllegalStateException exception = expectThrows(IllegalStateException.class,
             () -> SystemIndices.checkForOverlappingPatterns(descriptors));
@@ -83,8 +83,8 @@ public void testBuiltInSystemIndices() {
     }
 
     public void testPluginCannotOverrideBuiltInSystemIndex() {
-        Map<String, Collection<SystemIndexDescriptor>> pluginMap = Map.of(
-            TaskResultsService.class.getName(), List.of(new SystemIndexDescriptor(TASK_INDEX, "Task Result Index"))
+        Map<String, SystemIndices.Feature> pluginMap = Map.of(
+            TASKS_FEATURE_NAME, new SystemIndices.Feature("test", List.of(new SystemIndexDescriptor(TASK_INDEX, "Task Result Index")))
         );
         IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> new SystemIndices(pluginMap));
         assertThat(e.getMessage(), containsString("plugin or module attempted to define the same source"));
@@ -92,7 +92,9 @@ public void testPluginCannotOverrideBuiltInSystemIndex() {
 
     public void testPatternWithSimpleRange() {
 
-        final SystemIndices systemIndices = new SystemIndices(Map.of("test", List.of(new SystemIndexDescriptor(".test-[abc]", ""))));
+        final SystemIndices systemIndices = new SystemIndices(Map.of(
+            "test", new SystemIndices.Feature("test feature", List.of(new SystemIndexDescriptor(".test-[abc]", "")))
+        ));
 
         assertThat(systemIndices.isSystemIndex(".test-a"), equalTo(true));
         assertThat(systemIndices.isSystemIndex(".test-b"), equalTo(true));
@@ -105,7 +107,9 @@ public void testPatternWithSimpleRange() {
     }
 
     public void testPatternWithSimpleRangeAndRepeatOperator() {
-        final SystemIndices systemIndices = new SystemIndices(Map.of("test", List.of(new SystemIndexDescriptor(".test-[a]+", ""))));
+        final SystemIndices systemIndices = new SystemIndices(Map.of(
+            "test", new SystemIndices.Feature("test feature", List.of(new SystemIndexDescriptor(".test-[a]+", "")))
+        ));
 
         assertThat(systemIndices.isSystemIndex(".test-a"), equalTo(true));
         assertThat(systemIndices.isSystemIndex(".test-aa"), equalTo(true));
@@ -115,7 +119,9 @@ public void testPatternWithSimpleRangeAndRepeatOperator() {
     }
 
     public void testPatternWithComplexRange() {
-        final SystemIndices systemIndices = new SystemIndices(Map.of("test", List.of(new SystemIndexDescriptor(".test-[a-c]", ""))));
+        final SystemIndices systemIndices = new SystemIndices(Map.of(
+            "test", new SystemIndices.Feature("test feature", List.of(new SystemIndexDescriptor(".test-[a-c]", "")))
+        ));
 
         assertThat(systemIndices.isSystemIndex(".test-a"), equalTo(true));
         assertThat(systemIndices.isSystemIndex(".test-b"), equalTo(true));
@@ -134,9 +140,9 @@ public void testOverlappingDescriptorsWithRanges() {
         SystemIndexDescriptor pattern1 = new SystemIndexDescriptor(".test-[ab]*", "");
         SystemIndexDescriptor pattern2 = new SystemIndexDescriptor(".test-a*", "");
 
-        Map<String, Collection<SystemIndexDescriptor>> descriptors = new HashMap<>();
-        descriptors.put(source1, List.of(pattern1));
-        descriptors.put(source2, List.of(pattern2));
+        Map<String, SystemIndices.Feature> descriptors = new HashMap<>();
+        descriptors.put(source1, new SystemIndices.Feature("source 1", List.of(pattern1)));
+        descriptors.put(source2, new SystemIndices.Feature("source 2", List.of(pattern2)));
 
         IllegalStateException exception = expectThrows(IllegalStateException.class,
             () -> SystemIndices.checkForOverlappingPatterns(descriptors));
diff --git a/server/src/test/java/org/elasticsearch/repositories/blobstore/BlobStoreRepositoryRestoreTests.java b/server/src/test/java/org/elasticsearch/repositories/blobstore/BlobStoreRepositoryRestoreTests.java
index edbf259780f08..ad4442dfff486 100644
--- a/server/src/test/java/org/elasticsearch/repositories/blobstore/BlobStoreRepositoryRestoreTests.java
+++ b/server/src/test/java/org/elasticsearch/repositories/blobstore/BlobStoreRepositoryRestoreTests.java
@@ -171,9 +171,21 @@ public void testSnapshotWithConflictingName() throws Exception {
                     shardGenerations,
                     RepositoryData.EMPTY_REPO_GEN,
                     Metadata.builder().put(shard.indexSettings().getIndexMetadata(), false).build(),
-                    new SnapshotInfo(snapshot.getSnapshotId(), shardGenerations.indices().stream()
-                        .map(IndexId::getName).collect(Collectors.toList()), Collections.emptyList(), 0L, null, 1L, 6,
-                        Collections.emptyList(), true, Collections.emptyMap()),
+                    new SnapshotInfo(
+                        snapshot.getSnapshotId(),
+                        shardGenerations.indices().stream()
+                            .map(IndexId::getName)
+                            .collect(Collectors.toList()),
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        null,
+                        1L,
+                        6,
+                        Collections.emptyList(),
+                        true,
+                        Collections.emptyMap(),
+                        0L
+                    ),
                     Version.CURRENT, Function.identity(), f));
             IndexShardSnapshotFailedException isfe = expectThrows(IndexShardSnapshotFailedException.class,
                 () -> snapshotShard(shard, snapshotWithSameName, repository));
diff --git a/server/src/test/java/org/elasticsearch/snapshots/SnapshotFeatureInfoTests.java b/server/src/test/java/org/elasticsearch/snapshots/SnapshotFeatureInfoTests.java
new file mode 100644
index 0000000000000..b16aa56293cf9
--- /dev/null
+++ b/server/src/test/java/org/elasticsearch/snapshots/SnapshotFeatureInfoTests.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.snapshots;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.test.AbstractSerializingTestCase;
+
+import java.io.IOException;
+import java.util.List;
+
+public class SnapshotFeatureInfoTests extends AbstractSerializingTestCase<SnapshotFeatureInfo> {
+    @Override
+    protected SnapshotFeatureInfo doParseInstance(XContentParser parser) throws IOException {
+        return SnapshotFeatureInfo.fromXContent(parser);
+    }
+
+    @Override
+    protected Writeable.Reader<SnapshotFeatureInfo> instanceReader() {
+        return SnapshotFeatureInfo::new;
+    }
+
+    @Override
+    protected SnapshotFeatureInfo createTestInstance() {
+        return randomSnapshotFeatureInfo();
+    }
+
+    public static SnapshotFeatureInfo randomSnapshotFeatureInfo() {
+        String feature = randomAlphaOfLengthBetween(5,20);
+        List<String> indices = randomList(1, 10, () -> randomAlphaOfLengthBetween(5, 20));
+        return new SnapshotFeatureInfo(feature, indices);
+    }
+
+    @Override
+    protected SnapshotFeatureInfo mutateInstance(SnapshotFeatureInfo instance) throws IOException {
+        if (randomBoolean()) {
+            return new SnapshotFeatureInfo(randomValueOtherThan(instance.getPluginName(), () -> randomAlphaOfLengthBetween(5, 20)),
+                instance.getIndices());
+        } else {
+            return new SnapshotFeatureInfo(instance.getPluginName(),
+                randomList(1, 10, () -> randomValueOtherThanMany(instance.getIndices()::contains,
+                    () -> randomAlphaOfLengthBetween(5, 20))));
+        }
+    }
+}
diff --git a/server/src/test/java/org/elasticsearch/snapshots/SnapshotInfoTests.java b/server/src/test/java/org/elasticsearch/snapshots/SnapshotInfoTests.java
index 4ec9ea1f8bf82..e16005373aca2 100644
--- a/server/src/test/java/org/elasticsearch/snapshots/SnapshotInfoTests.java
+++ b/server/src/test/java/org/elasticsearch/snapshots/SnapshotInfoTests.java
@@ -14,6 +14,7 @@
 import org.elasticsearch.test.ESTestCase;
 
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -48,8 +49,9 @@ protected SnapshotInfo createTestInstance() {
 
         Map<String, Object> userMetadata = randomUserMetadata();
 
-        return new SnapshotInfo(snapshotId, indices, dataStreams, startTime, reason, endTime, totalShards, shardFailures,
-            includeGlobalState, userMetadata);
+        return new SnapshotInfo(snapshotId, indices, dataStreams, Collections.emptyList(), reason, endTime, totalShards, shardFailures,
+            includeGlobalState, userMetadata, startTime
+        );
     }
 
     @Override
@@ -64,29 +66,36 @@ protected SnapshotInfo mutateInstance(SnapshotInfo instance) {
                 SnapshotId snapshotId = new SnapshotId(
                     randomValueOtherThan(instance.snapshotId().getName(), () -> randomAlphaOfLength(5)),
                     randomValueOtherThan(instance.snapshotId().getUUID(), () -> randomAlphaOfLength(5)));
-                return new SnapshotInfo(snapshotId, instance.indices(), instance.dataStreams(), instance.startTime(), instance.reason(),
+                return new SnapshotInfo(snapshotId, instance.indices(), instance.dataStreams(), Collections.emptyList(), instance.reason(),
                     instance.endTime(), instance.totalShards(), instance.shardFailures(), instance.includeGlobalState(),
-                    instance.userMetadata());
+                    instance.userMetadata(), instance.startTime()
+                );
             case 1:
                 int indicesSize = randomValueOtherThan(instance.indices().size(), () -> randomIntBetween(1, 10));
                 List<String> indices = Arrays.asList(randomArray(indicesSize, indicesSize, String[]::new,
                     () -> randomAlphaOfLengthBetween(2, 20)));
-                return new SnapshotInfo(instance.snapshotId(), indices, instance.dataStreams(), instance.startTime(), instance.reason(),
+                return new SnapshotInfo(instance.snapshotId(), indices, instance.dataStreams(), Collections.emptyList(), instance.reason(),
                     instance.endTime(), instance.totalShards(), instance.shardFailures(), instance.includeGlobalState(),
-                    instance.userMetadata());
+                    instance.userMetadata(), instance.startTime()
+                );
             case 2:
                 return new SnapshotInfo(instance.snapshotId(), instance.indices(), instance.dataStreams(),
-                    randomValueOtherThan(instance.startTime(), ESTestCase::randomNonNegativeLong), instance.reason(),
-                    instance.endTime(), instance.totalShards(), instance.shardFailures(), instance.includeGlobalState(),
-                    instance.userMetadata());
+                    Collections.emptyList(), instance.reason(), instance.endTime(), instance.totalShards(), instance.shardFailures(),
+                    instance.includeGlobalState(), instance.userMetadata(), randomValueOtherThan(instance.startTime(),
+                    ESTestCase::randomNonNegativeLong)
+                );
             case 3:
-                return new SnapshotInfo(instance.snapshotId(), instance.indices(), instance.dataStreams(), instance.startTime(),
+                return new SnapshotInfo(instance.snapshotId(), instance.indices(), instance.dataStreams(), Collections.emptyList(),
                     randomValueOtherThan(instance.reason(), () -> randomAlphaOfLengthBetween(5, 15)), instance.endTime(),
-                    instance.totalShards(), instance.shardFailures(), instance.includeGlobalState(), instance.userMetadata());
+                    instance.totalShards(), instance.shardFailures(), instance.includeGlobalState(), instance.userMetadata(),
+                    instance.startTime()
+                );
             case 4:
                 return new SnapshotInfo(instance.snapshotId(), instance.indices(), instance.dataStreams(),
-                    instance.startTime(), instance.reason(), randomValueOtherThan(instance.endTime(), ESTestCase::randomNonNegativeLong),
-                    instance.totalShards(), instance.shardFailures(), instance.includeGlobalState(), instance.userMetadata());
+                    Collections.emptyList(), instance.reason(), randomValueOtherThan(instance.endTime(), ESTestCase::randomNonNegativeLong),
+                    instance.totalShards(), instance.shardFailures(), instance.includeGlobalState(), instance.userMetadata(),
+                    instance.startTime()
+                );
             case 5:
                 int totalShards = randomValueOtherThan(instance.totalShards(), () -> randomIntBetween(0, 100));
                 int failedShards = randomIntBetween(0, totalShards);
@@ -99,23 +108,27 @@ protected SnapshotInfo mutateInstance(SnapshotInfo instance) {
 
                         return new SnapshotShardFailure(randomAlphaOfLengthBetween(5, 10), shardId, randomAlphaOfLengthBetween(5, 10));
                     }));
-                return new SnapshotInfo(instance.snapshotId(), instance.indices(), instance.dataStreams(), instance.startTime(),
+                return new SnapshotInfo(instance.snapshotId(), instance.indices(), instance.dataStreams(), Collections.emptyList(),
                     instance.reason(), instance.endTime(), totalShards, shardFailures, instance.includeGlobalState(),
-                    instance.userMetadata());
+                    instance.userMetadata(), instance.startTime()
+                );
             case 6:
-                return new SnapshotInfo(instance.snapshotId(), instance.indices(), instance.dataStreams(), instance.startTime(),
+                return new SnapshotInfo(instance.snapshotId(), instance.indices(), instance.dataStreams(), Collections.emptyList(),
                     instance.reason(), instance.endTime(), instance.totalShards(), instance.shardFailures(),
-                    Boolean.FALSE.equals(instance.includeGlobalState()), instance.userMetadata());
+                    Boolean.FALSE.equals(instance.includeGlobalState()), instance.userMetadata(), instance.startTime()
+                );
             case 7:
-                return new SnapshotInfo(instance.snapshotId(), instance.indices(), instance.dataStreams(), instance.startTime(),
+                return new SnapshotInfo(instance.snapshotId(), instance.indices(), instance.dataStreams(), Collections.emptyList(),
                     instance.reason(), instance.endTime(), instance.totalShards(), instance.shardFailures(), instance.includeGlobalState(),
-                    randomValueOtherThan(instance.userMetadata(), SnapshotInfoTests::randomUserMetadata));
+                    randomValueOtherThan(instance.userMetadata(), SnapshotInfoTests::randomUserMetadata), instance.startTime()
+                );
             case 8:
                 List<String> dataStreams = randomValueOtherThan(instance.dataStreams(),
                     () -> Arrays.asList(randomArray(1, 10, String[]::new, () -> randomAlphaOfLengthBetween(2, 20))));
                 return new SnapshotInfo(instance.snapshotId(), instance.indices(), dataStreams,
-                    instance.startTime(), instance.reason(), instance.endTime(), instance.totalShards(), instance.shardFailures(),
-                    instance.includeGlobalState(), instance.userMetadata());
+                    Collections.emptyList(), instance.reason(), instance.endTime(), instance.totalShards(), instance.shardFailures(),
+                    instance.includeGlobalState(), instance.userMetadata(), instance.startTime()
+                );
             default:
                 throw new IllegalArgumentException("invalid randomization case");
         }
diff --git a/server/src/test/java/org/elasticsearch/snapshots/SnapshotResiliencyTests.java b/server/src/test/java/org/elasticsearch/snapshots/SnapshotResiliencyTests.java
index 1f5c53e9271c2..155824dfb682d 100644
--- a/server/src/test/java/org/elasticsearch/snapshots/SnapshotResiliencyTests.java
+++ b/server/src/test/java/org/elasticsearch/snapshots/SnapshotResiliencyTests.java
@@ -1449,7 +1449,7 @@ protected NamedWriteableRegistry writeableRegistry() {
                 );
                 final ActionFilters actionFilters = new ActionFilters(emptySet());
                 snapshotsService = new SnapshotsService(settings, clusterService, indexNameExpressionResolver, repositoriesService,
-                        transportService, actionFilters);
+                        transportService, actionFilters, Collections.emptyMap());
                 nodeEnv = new NodeEnvironment(settings, environment);
                 final NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList());
                 final ScriptService scriptService = new ScriptService(settings, emptyMap(), emptyMap());
@@ -1562,13 +1562,14 @@ clusterService, indicesService, threadPool, shardStateAction, mappingUpdatedActi
                 final RestoreService restoreService = new RestoreService(
                     clusterService, repositoriesService, allocationService,
                     metadataCreateIndexService,
+                    new MetadataDeleteIndexService(settings, clusterService, allocationService),
                     new IndexMetadataVerifier(
                         settings, namedXContentRegistry,
                         mapperRegistry,
                         indexScopedSettings,
                         null),
-                        shardLimitValidator
-                );
+                    shardLimitValidator,
+                    systemIndices);
                 actions.put(PutMappingAction.INSTANCE,
                     new TransportPutMappingAction(transportService, clusterService, threadPool, metadataMappingService,
                         actionFilters, indexNameExpressionResolver, new RequestValidators<>(Collections.emptyList()),
diff --git a/server/src/test/java/org/elasticsearch/snapshots/SnapshotsInProgressSerializationTests.java b/server/src/test/java/org/elasticsearch/snapshots/SnapshotsInProgressSerializationTests.java
index e81d1490f514d..e6aff993d1b84 100644
--- a/server/src/test/java/org/elasticsearch/snapshots/SnapshotsInProgressSerializationTests.java
+++ b/server/src/test/java/org/elasticsearch/snapshots/SnapshotsInProgressSerializationTests.java
@@ -72,9 +72,10 @@ private Entry randomSnapshot() {
                                         shardState.failed() ? randomAlphaOfLength(10) : null, "1"));
             }
         }
+        List<SnapshotFeatureInfo> featureStates = randomList(5, SnapshotFeatureInfoTests::randomSnapshotFeatureInfo);
         ImmutableOpenMap<ShardId, SnapshotsInProgress.ShardSnapshotStatus> shards = builder.build();
-        return new Entry(snapshot, includeGlobalState, partial, randomState(shards), indices, dataStreams,
-                startTime, repositoryStateId, shards, null, SnapshotInfoTests.randomUserMetadata(), VersionUtils.randomVersion(random()));
+        return new Entry(snapshot, includeGlobalState, partial, randomState(shards), indices, dataStreams, featureStates,
+            startTime, repositoryStateId, shards, null, SnapshotInfoTests.randomUserMetadata(), VersionUtils.randomVersion(random()));
     }
 
     @Override
@@ -141,38 +142,40 @@ protected Custom mutateInstance(Custom instance) {
     }
 
     private Entry mutateEntry(Entry entry) {
-        switch (randomInt(7)) {
+        switch (randomInt(8)) {
             case 0:
                 boolean includeGlobalState = entry.includeGlobalState() == false;
                 return new Entry(entry.snapshot(), includeGlobalState, entry.partial(), entry.state(), entry.indices(), entry.dataStreams(),
-                    entry.startTime(), entry.repositoryStateId(), entry.shards(), entry.failure(), entry.userMetadata(), entry.version());
+                    entry.featureStates(), entry.repositoryStateId(), entry.startTime(), entry.shards(), entry.failure(),
+                    entry.userMetadata(), entry.version());
             case 1:
                 boolean partial = entry.partial() == false;
                 return new Entry(entry.snapshot(), entry.includeGlobalState(), partial, entry.state(), entry.indices(), entry.dataStreams(),
-                    entry.startTime(), entry.repositoryStateId(), entry.shards(), entry.failure(), entry.userMetadata(), entry.version());
+                    entry.featureStates(), entry.startTime(), entry.repositoryStateId(), entry.shards(), entry.failure(),
+                    entry.userMetadata(), entry.version());
             case 2:
                 List<String> dataStreams = Stream.concat(
                     entry.dataStreams().stream(),
                     Stream.of(randomAlphaOfLength(10)))
                     .collect(Collectors.toList());
                 return new Entry(entry.snapshot(), entry.includeGlobalState(), entry.partial(), entry.state(), entry.indices(),
-                    dataStreams, entry.startTime(), entry.repositoryStateId(), entry.shards(), entry.failure(), entry.userMetadata(),
-                    entry.version());
+                    dataStreams, entry.featureStates(), entry.startTime(), entry.repositoryStateId(), entry.shards(), entry.failure(),
+                    entry.userMetadata(), entry.version());
             case 3:
                 long startTime = randomValueOtherThan(entry.startTime(), ESTestCase::randomLong);
                 return new Entry(entry.snapshot(), entry.includeGlobalState(), entry.partial(), entry.state(), entry.indices(),
-                    entry.dataStreams(), startTime, entry.repositoryStateId(), entry.shards(), entry.failure(), entry.userMetadata(),
-                    entry.version());
+                    entry.dataStreams(), entry.featureStates(), startTime, entry.repositoryStateId(), entry.shards(), entry.failure(),
+                    entry.userMetadata(), entry.version());
             case 4:
                 long repositoryStateId = randomValueOtherThan(entry.startTime(), ESTestCase::randomLong);
                 return new Entry(entry.snapshot(), entry.includeGlobalState(), entry.partial(), entry.state(), entry.indices(),
-                    entry.dataStreams(), entry.startTime(), repositoryStateId, entry.shards(), entry.failure(), entry.userMetadata(),
-                    entry.version());
+                    entry.dataStreams(), entry.featureStates(), entry.startTime(), repositoryStateId, entry.shards(), entry.failure(),
+                    entry.userMetadata(), entry.version());
             case 5:
                 String failure = randomValueOtherThan(entry.failure(), () -> randomAlphaOfLengthBetween(2, 10));
                 return new Entry(entry.snapshot(), entry.includeGlobalState(), entry.partial(), entry.state(), entry.indices(),
-                    entry.dataStreams(), entry.startTime(), entry.repositoryStateId(), entry.shards(), failure, entry.userMetadata(),
-                    entry.version());
+                    entry.dataStreams(), entry.featureStates(), entry.startTime(), entry.repositoryStateId(), entry.shards(), failure,
+                    entry.userMetadata(), entry.version());
             case 6:
                 List<IndexId> indices = entry.indices();
                 ImmutableOpenMap<ShardId, SnapshotsInProgress.ShardSnapshotStatus> shards = entry.shards();
@@ -192,8 +195,8 @@ private Entry mutateEntry(Entry entry) {
                 }
                 shards = builder.build();
                 return new Entry(entry.snapshot(), entry.includeGlobalState(), entry.partial(), randomState(shards), indices,
-                    entry.dataStreams(), entry.startTime(), entry.repositoryStateId(), shards, entry.failure(), entry.userMetadata(),
-                    entry.version());
+                    entry.dataStreams(), entry.featureStates(), entry.startTime(), entry.repositoryStateId(), shards, entry.failure(),
+                    entry.userMetadata(), entry.version());
             case 7:
                 Map<String, Object> userMetadata = entry.userMetadata() != null ? new HashMap<>(entry.userMetadata()) : new HashMap<>();
                 String key = randomAlphaOfLengthBetween(2, 10);
@@ -203,8 +206,17 @@ private Entry mutateEntry(Entry entry) {
                     userMetadata.put(key, randomAlphaOfLengthBetween(2, 10));
                 }
                 return new Entry(entry.snapshot(), entry.includeGlobalState(), entry.partial(), entry.state(), entry.indices(),
-                    entry.dataStreams(), entry.startTime(), entry.repositoryStateId(), entry.shards(), entry.failure(), userMetadata,
-                    entry.version());
+                    entry.dataStreams(), entry.featureStates(), entry.startTime(), entry.repositoryStateId(), entry.shards(),
+                    entry.failure(), userMetadata, entry.version());
+            case 8:
+                logger.error("randomizing feature states");
+                List<SnapshotFeatureInfo> featureStates = randomList(1, 5,
+                    () -> randomValueOtherThanMany(entry.featureStates()::contains, SnapshotFeatureInfoTests::randomSnapshotFeatureInfo));
+                final Entry newEntry = new Entry(entry.snapshot(), entry.includeGlobalState(), entry.partial(), entry.state(),
+                    entry.indices(),
+                    entry.dataStreams(), featureStates, entry.startTime(), entry.repositoryStateId(), entry.shards(), entry.failure(),
+                    entry.userMetadata(), entry.version());
+                return newEntry;
             default:
                 throw new IllegalArgumentException("invalid randomization case");
         }
diff --git a/server/src/test/java/org/elasticsearch/snapshots/SnapshotsServiceTests.java b/server/src/test/java/org/elasticsearch/snapshots/SnapshotsServiceTests.java
index 651961e8624a8..e7109516ca2b9 100644
--- a/server/src/test/java/org/elasticsearch/snapshots/SnapshotsServiceTests.java
+++ b/server/src/test/java/org/elasticsearch/snapshots/SnapshotsServiceTests.java
@@ -379,8 +379,8 @@ private static ClusterState applyUpdates(ClusterState state, SnapshotsService.Sh
 
     private static SnapshotsInProgress.Entry snapshotEntry(Snapshot snapshot, List<IndexId> indexIds,
                                                            ImmutableOpenMap<ShardId, SnapshotsInProgress.ShardSnapshotStatus> shards) {
-        return SnapshotsInProgress.startedEntry(snapshot, randomBoolean(), randomBoolean(), indexIds, Collections.emptyList(),
-                1L, randomNonNegativeLong(), shards, Collections.emptyMap(), Version.CURRENT);
+        return SnapshotsInProgress.startedEntry(snapshot, randomBoolean(), randomBoolean(), indexIds, Collections.emptyList(), 1L,
+            randomNonNegativeLong(), shards, Collections.emptyMap(), Version.CURRENT, Collections.emptyList());
     }
 
     private static SnapshotsInProgress.Entry cloneEntry(
diff --git a/server/src/test/java/org/elasticsearch/snapshots/mockstore/MockEventuallyConsistentRepositoryTests.java b/server/src/test/java/org/elasticsearch/snapshots/mockstore/MockEventuallyConsistentRepositoryTests.java
index 67ec1a3a3847b..c4250fb728526 100644
--- a/server/src/test/java/org/elasticsearch/snapshots/mockstore/MockEventuallyConsistentRepositoryTests.java
+++ b/server/src/test/java/org/elasticsearch/snapshots/mockstore/MockEventuallyConsistentRepositoryTests.java
@@ -148,7 +148,7 @@ blobStoreContext, random())) {
                 // We try to write another snap- blob for "foo" in the next generation. It fails because the content differs.
                 repository.finalizeSnapshot(ShardGenerations.EMPTY, RepositoryData.EMPTY_REPO_GEN, Metadata.EMPTY_METADATA,
                     new SnapshotInfo(snapshotId, Collections.emptyList(), Collections.emptyList(),
-                        0L, null, 1L, 5, Collections.emptyList(), true, Collections.emptyMap()),
+                        Collections.emptyList(), null, 1L, 5, Collections.emptyList(), true, Collections.emptyMap(), 0L),
                     Version.CURRENT, Function.identity(), f));
 
             // We try to write another snap- blob for "foo" in the next generation. It fails because the content differs.
@@ -156,7 +156,7 @@ blobStoreContext, random())) {
                 () -> PlainActionFuture.<RepositoryData, Exception>get(f ->
                     repository.finalizeSnapshot(ShardGenerations.EMPTY, 0L, Metadata.EMPTY_METADATA,
                         new SnapshotInfo(snapshotId, Collections.emptyList(), Collections.emptyList(),
-                            0L, null, 1L, 6, Collections.emptyList(), true, Collections.emptyMap()),
+                            Collections.emptyList(), null, 1L, 6, Collections.emptyList(), true, Collections.emptyMap(), 0L),
                         Version.CURRENT, Function.identity(), f)));
             assertThat(assertionError.getMessage(), equalTo("\nExpected: <6>\n     but: was <5>"));
 
@@ -165,7 +165,7 @@ blobStoreContext, random())) {
             PlainActionFuture.<RepositoryData, Exception>get(f ->
                 repository.finalizeSnapshot(ShardGenerations.EMPTY, 0L, Metadata.EMPTY_METADATA,
                     new SnapshotInfo(snapshotId, Collections.emptyList(), Collections.emptyList(),
-                        0L, null, 2L, 5, Collections.emptyList(), true, Collections.emptyMap()),
+                        Collections.emptyList(), null, 2L, 5, Collections.emptyList(), true, Collections.emptyMap(), 0L),
                     Version.CURRENT, Function.identity(), f));
         }
     }
diff --git a/test/framework/src/main/java/org/elasticsearch/snapshots/AbstractSnapshotIntegTestCase.java b/test/framework/src/main/java/org/elasticsearch/snapshots/AbstractSnapshotIntegTestCase.java
index 658d7a5acae6a..8554de99e685b 100644
--- a/test/framework/src/main/java/org/elasticsearch/snapshots/AbstractSnapshotIntegTestCase.java
+++ b/test/framework/src/main/java/org/elasticsearch/snapshots/AbstractSnapshotIntegTestCase.java
@@ -79,6 +79,7 @@
 import java.util.stream.StreamSupport;
 
 import static org.elasticsearch.repositories.blobstore.BlobStoreRepository.READONLY_SETTING_KEY;
+import static org.elasticsearch.snapshots.SnapshotsService.NO_FEATURE_STATES_VALUE;
 import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked;
 import static org.hamcrest.Matchers.empty;
 import static org.hamcrest.Matchers.equalTo;
@@ -382,6 +383,7 @@ protected SnapshotInfo createSnapshot(String repositoryName, String snapshot, Li
                 .prepareCreateSnapshot(repositoryName, snapshot)
                 .setIndices(indices.toArray(Strings.EMPTY_ARRAY))
                 .setWaitForCompletion(true)
+                .setFeatureStates(NO_FEATURE_STATES_VALUE) // Exclude all feature states to ensure only specified indices are included
                 .get();
 
         final SnapshotInfo snapshotInfo = response.getSnapshotInfo();
@@ -437,9 +439,9 @@ protected void addBwCFailedSnapshot(String repoName, String snapshotName, Map<St
         logger.info("--> adding old version FAILED snapshot [{}] to repository [{}]", snapshotId, repoName);
         final SnapshotInfo snapshotInfo = new SnapshotInfo(snapshotId,
                 Collections.emptyList(), Collections.emptyList(),
-                SnapshotState.FAILED, "failed on purpose",
-                SnapshotsService.OLD_SNAPSHOT_FORMAT, 0L,0L, 0, 0, Collections.emptyList(),
-                randomBoolean(), metadata);
+                Collections.emptyList(), "failed on purpose", SnapshotsService.OLD_SNAPSHOT_FORMAT, 0L, 0L, 0, 0, Collections.emptyList(),
+                randomBoolean(), metadata, SnapshotState.FAILED
+        );
         PlainActionFuture.<RepositoryData, Exception>get(f -> repo.finalizeSnapshot(
                 ShardGenerations.EMPTY, getRepositoryData(repoName).getGenId(), state.metadata(), snapshotInfo,
                 SnapshotsService.OLD_SNAPSHOT_FORMAT, Function.identity(), f));
diff --git a/x-pack/plugin/async/src/main/java/org/elasticsearch/xpack/async/AsyncResultsIndexPlugin.java b/x-pack/plugin/async/src/main/java/org/elasticsearch/xpack/async/AsyncResultsIndexPlugin.java
index 4d946067b79b7..fdc2abb909617 100644
--- a/x-pack/plugin/async/src/main/java/org/elasticsearch/xpack/async/AsyncResultsIndexPlugin.java
+++ b/x-pack/plugin/async/src/main/java/org/elasticsearch/xpack/async/AsyncResultsIndexPlugin.java
@@ -48,6 +48,16 @@ public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings sett
         return List.of(AsyncTaskIndexService.getSystemIndexDescriptor());
     }
 
+    @Override
+    public String getFeatureName() {
+        return "async_search";
+    }
+
+    @Override
+    public String getFeatureDescription() {
+        return "Manages results of async searches";
+    }
+
     @Override
     public Collection<Object> createComponents(
         Client client,
diff --git a/x-pack/plugin/ccr/src/main/java/org/elasticsearch/xpack/ccr/repository/CcrRepository.java b/x-pack/plugin/ccr/src/main/java/org/elasticsearch/xpack/ccr/repository/CcrRepository.java
index ef5d17b352114..c44e064641154 100644
--- a/x-pack/plugin/ccr/src/main/java/org/elasticsearch/xpack/ccr/repository/CcrRepository.java
+++ b/x-pack/plugin/ccr/src/main/java/org/elasticsearch/xpack/ccr/repository/CcrRepository.java
@@ -182,8 +182,9 @@ public SnapshotInfo getSnapshotInfo(SnapshotId snapshotId) {
         ArrayList<String> indices = new ArrayList<>(indicesMap.size());
         indicesMap.keysIt().forEachRemaining(indices::add);
 
-        return new SnapshotInfo(snapshotId, indices, new ArrayList<>(metadata.dataStreams().keySet()), SnapshotState.SUCCESS,
-            response.getState().getNodes().getMaxNodeVersion());
+        return new SnapshotInfo(snapshotId, indices, new ArrayList<>(metadata.dataStreams().keySet()), Collections.emptyList(),
+            response.getState().getNodes().getMaxNodeVersion(), SnapshotState.SUCCESS
+        );
     }
 
     @Override
diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/snapshots/SourceOnlySnapshotShardTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/snapshots/SourceOnlySnapshotShardTests.java
index c396456b9bb9d..dacb5af3f0a12 100644
--- a/x-pack/plugin/core/src/test/java/org/elasticsearch/snapshots/SourceOnlySnapshotShardTests.java
+++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/snapshots/SourceOnlySnapshotShardTests.java
@@ -228,9 +228,9 @@ public void testRestoreMinmal() throws IOException {
                     Metadata.builder().put(shard.indexSettings().getIndexMetadata(), false).build(),
                     new SnapshotInfo(snapshotId,
                         shardGenerations.indices().stream()
-                        .map(IndexId::getName).collect(Collectors.toList()), Collections.emptyList(), 0L, null, 1L,
-                        shardGenerations.totalShards(),
-                        Collections.emptyList(), true, Collections.emptyMap()),
+                        .map(IndexId::getName).collect(Collectors.toList()), Collections.emptyList(), Collections.emptyList(), null, 1L,
+                        shardGenerations.totalShards(), Collections.emptyList(), true, Collections.emptyMap(), 0L
+                    ),
                     Version.CURRENT, Function.identity(), finFuture);
                 finFuture.actionGet();
             });
diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/LocalStateCompositeXPackPlugin.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/LocalStateCompositeXPackPlugin.java
index a7758ae071c28..3aae4c0d5917b 100644
--- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/LocalStateCompositeXPackPlugin.java
+++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/LocalStateCompositeXPackPlugin.java
@@ -564,4 +564,14 @@ public Map<String, MetadataFieldMapper.TypeParser> getMetadataMappers() {
             .flatMap (map -> map.entrySet().stream())
             .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
     }
+
+    @Override
+    public String getFeatureName() {
+        return this.getClass().getSimpleName();
+    }
+
+    @Override
+    public String getFeatureDescription() {
+        return this.getClass().getCanonicalName();
+    }
 }
diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/async/AsyncTaskServiceTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/async/AsyncTaskServiceTests.java
index 5ca58cac912ed..3e486d46a5cf1 100644
--- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/async/AsyncTaskServiceTests.java
+++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/async/AsyncTaskServiceTests.java
@@ -63,6 +63,16 @@ public static class TestPlugin extends Plugin implements SystemIndexPlugin {
         public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings settings) {
             return List.of(AsyncTaskIndexService.getSystemIndexDescriptor());
         }
+
+        @Override
+        public String getFeatureName() {
+            return this.getClass().getSimpleName();
+        }
+
+        @Override
+        public String getFeatureDescription() {
+            return this.getClass().getCanonicalName();
+        }
     }
 
     public void testEnsuredAuthenticatedUserIsSame() throws IOException {
diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/slm/SnapshotRetentionConfigurationTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/slm/SnapshotRetentionConfigurationTests.java
index a14b043a1c555..7d2c6a319363f 100644
--- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/slm/SnapshotRetentionConfigurationTests.java
+++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/slm/SnapshotRetentionConfigurationTests.java
@@ -288,13 +288,8 @@ private SnapshotInfo makeInfo(long startTime) {
         SnapshotInfo snapInfo = new SnapshotInfo(new SnapshotId("snap-" + randomAlphaOfLength(3), "uuid"),
             Collections.singletonList("foo"),
             Collections.singletonList("bar"),
-            startTime,
-            null,
-            startTime + between(1, 10000),
-            totalShards,
-            new ArrayList<>(),
-            false,
-            meta);
+            Collections.emptyList(), null, startTime + between(1, 10000), totalShards, new ArrayList<>(), false, meta, startTime
+        );
         assertThat(snapInfo.state(), equalTo(SnapshotState.SUCCESS));
         return snapInfo;
     }
@@ -320,13 +315,9 @@ private SnapshotInfo makeFailureInfo(long startTime) {
         SnapshotInfo snapInfo = new SnapshotInfo(new SnapshotId("snap-fail-" + randomAlphaOfLength(3), "uuid-fail"),
             Collections.singletonList("foo-fail"),
             Collections.singletonList("bar-fail"),
-            startTime,
-            "forced-failure",
-            startTime + between(1, 10000),
-            totalShards,
-            failures,
-            randomBoolean(),
-            meta);
+            Collections.emptyList(),
+            "forced-failure", startTime + between(1, 10000), totalShards, failures, randomBoolean(), meta, startTime
+        );
         assertThat(snapInfo.state(), equalTo(SnapshotState.FAILED));
         return snapInfo;
     }
@@ -344,13 +335,8 @@ private SnapshotInfo makePartialInfo(long startTime) {
         SnapshotInfo snapInfo = new SnapshotInfo(new SnapshotId("snap-fail-" + randomAlphaOfLength(3), "uuid-fail"),
             Collections.singletonList("foo-fail"),
             Collections.singletonList("bar-fail"),
-            startTime,
-            null,
-            startTime + between(1, 10000),
-            totalShards,
-            failures,
-            randomBoolean(),
-            meta);
+            Collections.emptyList(), null, startTime + between(1, 10000), totalShards, failures, randomBoolean(), meta, startTime
+        );
         assertThat(snapInfo.state(), equalTo(SnapshotState.PARTIAL));
         return snapInfo;
     }
diff --git a/x-pack/plugin/data-streams/src/test/java/org/elasticsearch/xpack/datastreams/action/DeleteDataStreamTransportActionTests.java b/x-pack/plugin/data-streams/src/test/java/org/elasticsearch/xpack/datastreams/action/DeleteDataStreamTransportActionTests.java
index 4d97513137595..27b43fa554a2a 100644
--- a/x-pack/plugin/data-streams/src/test/java/org/elasticsearch/xpack/datastreams/action/DeleteDataStreamTransportActionTests.java
+++ b/x-pack/plugin/data-streams/src/test/java/org/elasticsearch/xpack/datastreams/action/DeleteDataStreamTransportActionTests.java
@@ -116,6 +116,7 @@ private SnapshotsInProgress.Entry createEntry(String dataStreamName, String repo
             SnapshotsInProgress.State.SUCCESS,
             Collections.emptyList(),
             List.of(dataStreamName),
+            Collections.emptyList(),
             0,
             1,
             ImmutableOpenMap.of(),
diff --git a/x-pack/plugin/enrich/src/main/java/org/elasticsearch/xpack/enrich/EnrichPlugin.java b/x-pack/plugin/enrich/src/main/java/org/elasticsearch/xpack/enrich/EnrichPlugin.java
index 5079319e9e4ee..cf1cb00e1c0fe 100644
--- a/x-pack/plugin/enrich/src/main/java/org/elasticsearch/xpack/enrich/EnrichPlugin.java
+++ b/x-pack/plugin/enrich/src/main/java/org/elasticsearch/xpack/enrich/EnrichPlugin.java
@@ -240,4 +240,14 @@ public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings sett
             new SystemIndexDescriptor(ENRICH_INDEX_PATTERN, "Contains data to support enrich ingest processors.")
         );
     }
+
+    @Override
+    public String getFeatureName() {
+        return "enrich";
+    }
+
+    @Override
+    public String getFeatureDescription() {
+        return "Manages data related to Enrich policies";
+    }
 }
diff --git a/x-pack/plugin/fleet/src/main/java/org/elasticsearch/xpack/fleet/Fleet.java b/x-pack/plugin/fleet/src/main/java/org/elasticsearch/xpack/fleet/Fleet.java
index 475960a7153eb..dad4be17c80ad 100644
--- a/x-pack/plugin/fleet/src/main/java/org/elasticsearch/xpack/fleet/Fleet.java
+++ b/x-pack/plugin/fleet/src/main/java/org/elasticsearch/xpack/fleet/Fleet.java
@@ -31,4 +31,14 @@ public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings sett
             new SystemIndexDescriptor(".fleet-actions*", "Fleet actions")
         );
     }
+
+    @Override
+    public String getFeatureName() {
+        return "fleet";
+    }
+
+    @Override
+    public String getFeatureDescription() {
+        return "Manages configuration for Fleet";
+    }
 }
diff --git a/x-pack/plugin/ilm/src/test/java/org/elasticsearch/xpack/slm/SnapshotLifecycleTaskTests.java b/x-pack/plugin/ilm/src/test/java/org/elasticsearch/xpack/slm/SnapshotLifecycleTaskTests.java
index 6dee0f5d9ee52..7998e4a5dd597 100644
--- a/x-pack/plugin/ilm/src/test/java/org/elasticsearch/xpack/slm/SnapshotLifecycleTaskTests.java
+++ b/x-pack/plugin/ilm/src/test/java/org/elasticsearch/xpack/slm/SnapshotLifecycleTaskTests.java
@@ -245,14 +245,9 @@ public void testPartialFailureSnapshot() throws Exception {
                              new SnapshotId(req.snapshot(), "uuid"),
                              Arrays.asList(req.indices()),
                              Collections.emptyList(),
-                             startTime,
-                             "snapshot started",
-                             endTime,
-                             3,
-                             Collections.singletonList(
+                             Collections.emptyList(), "snapshot started", endTime, 3, Collections.singletonList(
                                  new SnapshotShardFailure("nodeId", new ShardId("index", "uuid", 0), "forced failure")),
-                             req.includeGlobalState(),
-                             req.userMetadata()
+                             req.includeGlobalState(), req.userMetadata(), startTime
                          ));
                  })) {
             final AtomicBoolean historyStoreCalled = new AtomicBoolean(false);
diff --git a/x-pack/plugin/ilm/src/test/java/org/elasticsearch/xpack/slm/SnapshotRetentionTaskTests.java b/x-pack/plugin/ilm/src/test/java/org/elasticsearch/xpack/slm/SnapshotRetentionTaskTests.java
index afa3cd4eef55c..631a9aceec02c 100644
--- a/x-pack/plugin/ilm/src/test/java/org/elasticsearch/xpack/slm/SnapshotRetentionTaskTests.java
+++ b/x-pack/plugin/ilm/src/test/java/org/elasticsearch/xpack/slm/SnapshotRetentionTaskTests.java
@@ -111,35 +111,37 @@ public void testSnapshotEligibleForDeletion() {
 
         // Test when user metadata is null
         SnapshotInfo info = new SnapshotInfo(new SnapshotId("name", "uuid"), Collections.singletonList("index"),
-            Collections.emptyList(),0L, null, 1L, 1, Collections.emptyList(), true, null);
+            Collections.emptyList(), Collections.emptyList(), null, 1L, 1, Collections.emptyList(), true, null, 0L);
         assertThat(SnapshotRetentionTask.snapshotEligibleForDeletion(info, mkInfos.apply(info), policyMap), equalTo(false));
 
         // Test when no retention is configured
         info = new SnapshotInfo(new SnapshotId("name", "uuid"), Collections.singletonList("index"), Collections.emptyList(),
-            0L, null, 1L, 1, Collections.emptyList(), true, null);
+            Collections.emptyList(), null, 1L, 1, Collections.emptyList(), true, null, 0L);
         assertThat(SnapshotRetentionTask.snapshotEligibleForDeletion(info, mkInfos.apply(info), policyWithNoRetentionMap), equalTo(false));
 
         // Test when user metadata is a map that doesn't contain "policy"
         info = new SnapshotInfo(new SnapshotId("name", "uuid"), Collections.singletonList("index"), Collections.emptyList(),
-            0L, null, 1L, 1, Collections.emptyList(), true, Collections.singletonMap("foo", "bar"));
+            Collections.emptyList(), null, 1L, 1, Collections.emptyList(), true, Collections.singletonMap("foo", "bar"), 0L);
         assertThat(SnapshotRetentionTask.snapshotEligibleForDeletion(info, mkInfos.apply(info), policyMap), equalTo(false));
 
         // Test with an ancient snapshot that should be expunged
         info = new SnapshotInfo(new SnapshotId("name", "uuid"), Collections.singletonList("index"), Collections.emptyList(),
-            0L, null, 1L, 1, Collections.emptyList(), true, Collections.singletonMap("policy", "policy"));
+            Collections.emptyList(), null, 1L, 1, Collections.emptyList(), true, Collections.singletonMap("policy", "policy"), 0L);
         assertThat(SnapshotRetentionTask.snapshotEligibleForDeletion(info, mkInfos.apply(info), policyMap), equalTo(true));
 
         // Test with a snapshot that's start date is old enough to be expunged (but the finish date is not)
         long time = System.currentTimeMillis() - TimeValue.timeValueDays(30).millis() - 1;
         info = new SnapshotInfo(new SnapshotId("name", "uuid"), Collections.singletonList("index"), Collections.emptyList(),
-            time, null, time + TimeValue.timeValueDays(4).millis(), 1, Collections.emptyList(),
-            true, Collections.singletonMap("policy", "policy"));
+            Collections.emptyList(), null, time + TimeValue.timeValueDays(4).millis(), 1, Collections.emptyList(), true,
+            Collections.singletonMap("policy", "policy"), time
+        );
         assertThat(SnapshotRetentionTask.snapshotEligibleForDeletion(info, mkInfos.apply(info), policyMap), equalTo(true));
 
         // Test with a fresh snapshot that should not be expunged
         info = new SnapshotInfo(new SnapshotId("name", "uuid"), Collections.singletonList("index"), Collections.emptyList(),
-            System.currentTimeMillis(), null, System.currentTimeMillis() + 1,
-            1, Collections.emptyList(), true, Collections.singletonMap("policy", "policy"));
+            Collections.emptyList(), null, System.currentTimeMillis() + 1, 1, Collections.emptyList(), true,
+            Collections.singletonMap("policy", "policy"), System.currentTimeMillis()
+        );
         assertThat(SnapshotRetentionTask.snapshotEligibleForDeletion(info, mkInfos.apply(info), policyMap), equalTo(false));
     }
 
@@ -165,10 +167,12 @@ private void retentionTaskTest(final boolean deletionSuccess) throws Exception {
             ClusterServiceUtils.setState(clusterService, state);
 
             final SnapshotInfo eligibleSnapshot = new SnapshotInfo(new SnapshotId("name", "uuid"), Collections.singletonList("index"),
-                Collections.emptyList(), 0L, null, 1L, 1, Collections.emptyList(), true, Collections.singletonMap("policy", policyId));
+                Collections.emptyList(), Collections.emptyList(), null, 1L, 1, Collections.emptyList(), true,
+                Collections.singletonMap("policy", policyId), 0L);
             final SnapshotInfo ineligibleSnapshot = new SnapshotInfo(new SnapshotId("name2", "uuid2"), Collections.singletonList("index"),
-                Collections.emptyList(), System.currentTimeMillis(), null, System.currentTimeMillis() + 1, 1,
-                Collections.emptyList(), true, Collections.singletonMap("policy", policyId));
+                Collections.emptyList(), Collections.emptyList(), null, System.currentTimeMillis() + 1, 1, Collections.emptyList(), true,
+                Collections.singletonMap("policy", policyId), System.currentTimeMillis()
+            );
 
             Set<SnapshotId> deleted = ConcurrentHashMap.newKeySet();
             Set<String> deletedSnapshotsInHistory = ConcurrentHashMap.newKeySet();
diff --git a/x-pack/plugin/logstash/src/main/java/org/elasticsearch/xpack/logstash/Logstash.java b/x-pack/plugin/logstash/src/main/java/org/elasticsearch/xpack/logstash/Logstash.java
index 5fc59607bba77..f7b9b90467f20 100644
--- a/x-pack/plugin/logstash/src/main/java/org/elasticsearch/xpack/logstash/Logstash.java
+++ b/x-pack/plugin/logstash/src/main/java/org/elasticsearch/xpack/logstash/Logstash.java
@@ -176,4 +176,14 @@ private XContentBuilder getIndexMappings() {
             throw new UncheckedIOException("Failed to build " + LOGSTASH_CONCRETE_INDEX_NAME + " index mappings", e);
         }
     }
+
+    @Override
+    public String getFeatureName() {
+        return "logstash_management";
+    }
+
+    @Override
+    public String getFeatureDescription() {
+        return "Enables Logstash Central Management pipeline storage";
+    }
 }
diff --git a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MachineLearning.java b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MachineLearning.java
index 6dda8ee2fba5f..74a2fd36cce40 100644
--- a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MachineLearning.java
+++ b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MachineLearning.java
@@ -1196,6 +1196,16 @@ public static SystemIndexDescriptor getInferenceIndexSecurityDescriptor() {
             .build();
     }
 
+    @Override
+    public String getFeatureName() {
+        return "machine_learning";
+    }
+
+    @Override
+    public String getFeatureDescription() {
+        return "Provides anomaly detection and forecasting functionality";
+    }
+
     @Override
     public BreakerSettings getCircuitBreaker(Settings settings) {
         return BreakerSettings.updateFromSettings(
diff --git a/x-pack/plugin/searchable-snapshots/src/internalClusterTest/java/org/elasticsearch/xpack/searchablesnapshots/LocalStateSearchableSnapshots.java b/x-pack/plugin/searchable-snapshots/src/internalClusterTest/java/org/elasticsearch/xpack/searchablesnapshots/LocalStateSearchableSnapshots.java
index 5efc8df21f9f8..c91734e83871a 100644
--- a/x-pack/plugin/searchable-snapshots/src/internalClusterTest/java/org/elasticsearch/xpack/searchablesnapshots/LocalStateSearchableSnapshots.java
+++ b/x-pack/plugin/searchable-snapshots/src/internalClusterTest/java/org/elasticsearch/xpack/searchablesnapshots/LocalStateSearchableSnapshots.java
@@ -10,12 +10,13 @@
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.indices.SystemIndexDescriptor;
 import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.plugins.SystemIndexPlugin;
 import org.elasticsearch.xpack.core.LocalStateCompositeXPackPlugin;
 
 import java.nio.file.Path;
 import java.util.Collection;
 
-public class LocalStateSearchableSnapshots extends LocalStateCompositeXPackPlugin {
+public class LocalStateSearchableSnapshots extends LocalStateCompositeXPackPlugin implements SystemIndexPlugin {
 
     private final SearchableSnapshots plugin;
 
@@ -36,4 +37,14 @@ protected XPackLicenseState getLicenseState() {
     public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings settings) {
         return plugin.getSystemIndexDescriptors(settings);
     }
+
+    @Override
+    public String getFeatureName() {
+        return plugin.getFeatureName();
+    }
+
+    @Override
+    public String getFeatureDescription() {
+        return plugin.getFeatureDescription();
+    }
 }
diff --git a/x-pack/plugin/searchable-snapshots/src/internalClusterTest/java/org/elasticsearch/xpack/searchablesnapshots/SearchableSnapshotsSystemIndicesIntegTests.java b/x-pack/plugin/searchable-snapshots/src/internalClusterTest/java/org/elasticsearch/xpack/searchablesnapshots/SearchableSnapshotsSystemIndicesIntegTests.java
index 3372f64f843ae..7fa3dccb4c4d7 100644
--- a/x-pack/plugin/searchable-snapshots/src/internalClusterTest/java/org/elasticsearch/xpack/searchablesnapshots/SearchableSnapshotsSystemIndicesIntegTests.java
+++ b/x-pack/plugin/searchable-snapshots/src/internalClusterTest/java/org/elasticsearch/xpack/searchablesnapshots/SearchableSnapshotsSystemIndicesIntegTests.java
@@ -91,5 +91,15 @@ public static class TestSystemIndexPlugin extends Plugin implements SystemIndexP
         public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings settings) {
             return List.of(new SystemIndexDescriptor(INDEX_NAME, "System index for [" + getTestClass().getName() + ']'));
         }
+
+        @Override
+        public String getFeatureName() {
+            return SearchableSnapshotsSystemIndicesIntegTests.class.getSimpleName();
+        }
+
+        @Override
+        public String getFeatureDescription() {
+            return "test plugin";
+        }
     }
 }
diff --git a/x-pack/plugin/searchable-snapshots/src/main/java/org/elasticsearch/xpack/searchablesnapshots/SearchableSnapshots.java b/x-pack/plugin/searchable-snapshots/src/main/java/org/elasticsearch/xpack/searchablesnapshots/SearchableSnapshots.java
index 1b76d6b3312b8..60ccf155b9780 100644
--- a/x-pack/plugin/searchable-snapshots/src/main/java/org/elasticsearch/xpack/searchablesnapshots/SearchableSnapshots.java
+++ b/x-pack/plugin/searchable-snapshots/src/main/java/org/elasticsearch/xpack/searchablesnapshots/SearchableSnapshots.java
@@ -335,6 +335,16 @@ public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings sett
         );
     }
 
+    @Override
+    public String getFeatureName() {
+        return "searchable_snapshots";
+    }
+
+    @Override
+    public String getFeatureDescription() {
+        return "Manages caches and configuration for searchable snapshots";
+    }
+
     @Override
     public Map<String, DirectoryFactory> getDirectoryFactories() {
         return Map.of(SNAPSHOT_DIRECTORY_FACTORY_KEY, (indexSettings, shardPath) -> {
diff --git a/x-pack/plugin/searchable-snapshots/src/test/java/org/elasticsearch/xpack/searchablesnapshots/AbstractSearchableSnapshotsRestTestCase.java b/x-pack/plugin/searchable-snapshots/src/test/java/org/elasticsearch/xpack/searchablesnapshots/AbstractSearchableSnapshotsRestTestCase.java
index 65289a375b33a..b485c6c72d1a1 100644
--- a/x-pack/plugin/searchable-snapshots/src/test/java/org/elasticsearch/xpack/searchablesnapshots/AbstractSearchableSnapshotsRestTestCase.java
+++ b/x-pack/plugin/searchable-snapshots/src/test/java/org/elasticsearch/xpack/searchablesnapshots/AbstractSearchableSnapshotsRestTestCase.java
@@ -259,6 +259,7 @@ public void testSnapshotOfSearchableSnapshot() throws Exception {
             try (XContentBuilder builder = jsonBuilder()) {
                 builder.startObject();
                 builder.field("indices", restoredIndexName);
+                builder.field("include_global_state", "false");
                 builder.endObject();
                 snapshotRequest.setEntity(new StringEntity(Strings.toString(builder), ContentType.APPLICATION_JSON));
             }
diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java
index 61b43ac12d278..ec6daa1b44b77 100644
--- a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java
+++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java
@@ -81,6 +81,7 @@ public class Constants {
         "cluster:admin/snapshot/restore",
         "cluster:admin/snapshot/status",
         "cluster:admin/snapshot/status[nodes]",
+        "cluster:admin/snapshot/features/get",
         "cluster:admin/tasks/cancel",
         "cluster:admin/transform/delete",
         "cluster:admin/transform/preview",
diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/integration/SecurityFeatureStateIntegTests.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/integration/SecurityFeatureStateIntegTests.java
new file mode 100644
index 0000000000000..c3967c799c224
--- /dev/null
+++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/integration/SecurityFeatureStateIntegTests.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.integration;
+
+import org.elasticsearch.action.admin.cluster.snapshots.status.SnapshotsStatusResponse;
+import org.elasticsearch.action.admin.cluster.state.ClusterStateRequest;
+import org.elasticsearch.action.index.IndexAction;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.RequestOptions;
+import org.elasticsearch.client.Response;
+import org.elasticsearch.client.ResponseException;
+import org.elasticsearch.cluster.SnapshotsInProgress;
+import org.elasticsearch.common.settings.SecureString;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.test.SecuritySettingsSourceField;
+import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
+import org.hamcrest.Matchers;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+import java.nio.file.Path;
+
+import static org.elasticsearch.test.SecuritySettingsSource.TEST_SUPERUSER;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+
+public class SecurityFeatureStateIntegTests extends AbstractPrivilegeTestCase {
+
+    private static final String LOCAL_TEST_USER_NAME = "feature_state_user";
+    private static final String LOCAL_TEST_USER_PASSWORD = "my_password";
+    private static Path repositoryLocation;
+
+    @BeforeClass
+    public static void setupRepositoryPath() {
+        repositoryLocation = createTempDir();
+    }
+
+    @AfterClass
+    public static void cleanupRepositoryPath() {
+        repositoryLocation = null;
+    }
+
+    @Override
+    protected boolean addMockHttpTransport() {
+        return false; // enable http
+    }
+
+    @Override
+    protected Settings nodeSettings() {
+        return Settings.builder().put(super.nodeSettings())
+            .put("path.repo", repositoryLocation)
+            .build();
+    }
+
+    /**
+     * Test that, when the security system index is restored as a feature state,
+     * the security plugin's listeners detect the state change and reload native
+     * realm privileges.
+     *
+     * We use the admin client to handle snapshots and the rest API to manage
+     * security roles and users. We use the native realm instead of the file
+     * realm because this test relies on dynamically changing privileges.
+     */
+    public void testSecurityFeatureStateSnapshotAndRestore() throws Exception {
+        // set up a snapshot repository
+        final String repositoryName = "test-repo";
+        client().admin().cluster().preparePutRepository(repositoryName)
+            .setType("fs")
+            .setSettings(Settings.builder().put("location", repositoryLocation))
+            .get();
+
+        // create a new role
+        final String roleName = "extra_role";
+        final Request createRoleRequest = new Request("PUT", "/_security/role/" + roleName);
+        createRoleRequest.addParameter("refresh", "wait_for");
+        createRoleRequest.setJsonEntity("{" +
+            "  \"indices\": [" +
+            "    {" +
+            "      \"names\": [ \"test_index\" ]," +
+            "      \"privileges\" : [ \"create\", \"create_index\", \"create_doc\" ]" +
+            "    }" +
+            "  ]" +
+            "}");
+        performSuperuserRequest(createRoleRequest);
+
+        // create a test user
+        final Request createUserRequest = new Request("PUT", "/_security/user/" + LOCAL_TEST_USER_NAME);
+        createUserRequest.addParameter("refresh", "wait_for");
+        createUserRequest.setJsonEntity("{" +
+            "  \"password\": \"" + LOCAL_TEST_USER_PASSWORD + "\"," +
+            "  \"roles\": [ \"" + roleName + "\" ]" +
+            "}");
+        performSuperuserRequest(createUserRequest);
+
+        // test user posts a document
+        final Request postTestDocument1 = new Request("POST", "/test_index/_doc");
+        postTestDocument1.setJsonEntity("{\"message\": \"before snapshot\"}");
+        performTestUserRequest(postTestDocument1);
+
+        // snapshot state
+        final String snapshotName = "security-state";
+        client().admin().cluster().prepareCreateSnapshot(repositoryName, snapshotName)
+            .setIndices("test_index")
+            .setFeatureStates("LocalStateSecurity")
+            .get();
+        waitForSnapshotToFinish(repositoryName, snapshotName);
+
+        // modify user's roles
+        final Request modifyUserRequest = new Request("PUT", "/_security/user/" + LOCAL_TEST_USER_NAME);
+        modifyUserRequest.addParameter("refresh", "wait_for");
+        modifyUserRequest.setJsonEntity("{\"roles\": [] }");
+        performSuperuserRequest(modifyUserRequest);
+
+        // new user has lost privileges and can't post a document
+        final Request postDocumentRequest2 = new Request("POST", "/test_index/_doc");
+        postDocumentRequest2.setJsonEntity("{\"message\": \"between snapshot and restore\"}");
+        ResponseException exception = expectThrows(ResponseException.class, () -> performTestUserRequest(postDocumentRequest2));
+
+        assertThat(exception.getResponse().getStatusLine().getStatusCode(), equalTo(403));
+        assertThat(exception.getMessage(),
+            containsString("action [" + IndexAction.NAME + "] is unauthorized for user [" + LOCAL_TEST_USER_NAME + "]"));
+
+        client().admin().indices().prepareClose("test_index").get();
+
+        // restore state
+        client().admin().cluster().prepareRestoreSnapshot(repositoryName, snapshotName)
+            .setFeatureStates("LocalStateSecurity")
+            .setIndices("test_index")
+            .setWaitForCompletion(true)
+            .get();
+
+        // user has privileges again
+        final Request postDocumentRequest3 = new Request("POST", "/test_index/_doc");
+        postDocumentRequest3.setJsonEntity("{\"message\": \"after restore\"}");
+        performTestUserRequest(postDocumentRequest3);
+    }
+
+    private Response performSuperuserRequest(Request request) throws Exception {
+        String token = UsernamePasswordToken.basicAuthHeaderValue(
+            TEST_SUPERUSER, new SecureString(SecuritySettingsSourceField.TEST_PASSWORD.toCharArray()));
+        return performAuthenticatedRequest(request, token);
+    }
+
+    private Response performTestUserRequest(Request request) throws Exception {
+        String token = UsernamePasswordToken.basicAuthHeaderValue(
+            LOCAL_TEST_USER_NAME, new SecureString(LOCAL_TEST_USER_PASSWORD.toCharArray()));
+        return performAuthenticatedRequest(request, token);
+    }
+
+    private Response performAuthenticatedRequest(Request request, String token) throws Exception {
+        RequestOptions.Builder options = RequestOptions.DEFAULT.toBuilder();
+        options.addHeader("Authorization", token);
+        request.setOptions(options);
+        return getRestClient().performRequest(request);
+    }
+
+    private void waitForSnapshotToFinish(String repo, String snapshot) throws Exception {
+        assertBusy(() -> {
+            SnapshotsStatusResponse response = client().admin().cluster().prepareSnapshotStatus(repo).setSnapshots(snapshot).get();
+            assertThat(response.getSnapshots().get(0).getState(), is(SnapshotsInProgress.State.SUCCESS));
+            // The status of the snapshot in the repository can become SUCCESS before it is fully finalized in the cluster state so wait for
+            // it to disappear from the cluster state as well
+            SnapshotsInProgress snapshotsInProgress =
+                client().admin().cluster().state(new ClusterStateRequest()).get().getState().custom(SnapshotsInProgress.TYPE);
+            assertThat(snapshotsInProgress.entries(), Matchers.empty());
+        });
+    }
+}
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
index 80d7e9ce5cc39..0eb105ca5c688 100644
--- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
@@ -217,10 +217,10 @@
 import org.elasticsearch.xpack.security.authz.store.NativePrivilegeStore;
 import org.elasticsearch.xpack.security.authz.store.NativeRolesStore;
 import org.elasticsearch.xpack.security.ingest.SetSecurityUserProcessor;
+import org.elasticsearch.xpack.security.operator.FileOperatorUsersStore;
 import org.elasticsearch.xpack.security.operator.OperatorOnlyRegistry;
 import org.elasticsearch.xpack.security.operator.OperatorPrivileges;
 import org.elasticsearch.xpack.security.operator.OperatorPrivileges.OperatorPrivilegesService;
-import org.elasticsearch.xpack.security.operator.FileOperatorUsersStore;
 import org.elasticsearch.xpack.security.rest.SecurityRestFilter;
 import org.elasticsearch.xpack.security.rest.action.RestAuthenticateAction;
 import org.elasticsearch.xpack.security.rest.action.RestDelegatePkiAuthenticationAction;
@@ -1773,5 +1773,13 @@ private static XContentBuilder getTokenIndexMappings() {
         }
     }
 
-}
+    @Override
+    public String getFeatureName() {
+        return "security";
+    }
 
+    @Override
+    public String getFeatureDescription() {
+        return "Manages configuration for Security features, such as users and roles";
+    }
+}
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java
index 719e0f90e1b9c..6fbf64c5376eb 100644
--- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java
@@ -206,7 +206,9 @@ public void expireAll() {
 
     public void onSecurityIndexStateChange(SecurityIndexManager.State previousState, SecurityIndexManager.State currentState) {
         if (lastSuccessfulAuthCache != null) {
-            if (isMoveFromRedToNonRed(previousState, currentState) || isIndexDeleted(previousState, currentState)) {
+            if (isMoveFromRedToNonRed(previousState, currentState)
+                || isIndexDeleted(previousState, currentState)
+                || Objects.equals(previousState.indexUUID, currentState.indexUUID) == false) {
                 expireAll();
             }
         }
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/esnative/NativeRealm.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/esnative/NativeRealm.java
index 642e24b4f1585..3f7643762e7d1 100644
--- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/esnative/NativeRealm.java
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/esnative/NativeRealm.java
@@ -16,6 +16,7 @@
 import org.elasticsearch.xpack.security.support.SecurityIndexManager;
 
 import java.util.Map;
+import java.util.Objects;
 
 import static org.elasticsearch.xpack.security.support.SecurityIndexManager.isIndexDeleted;
 import static org.elasticsearch.xpack.security.support.SecurityIndexManager.isMoveFromRedToNonRed;
@@ -43,7 +44,9 @@ protected void doAuthenticate(UsernamePasswordToken token, ActionListener<Authen
     }
 
     public void onSecurityIndexStateChange(SecurityIndexManager.State previousState, SecurityIndexManager.State currentState) {
-        if (isMoveFromRedToNonRed(previousState, currentState) || isIndexDeleted(previousState, currentState)) {
+        if (isMoveFromRedToNonRed(previousState, currentState)
+            || isIndexDeleted(previousState, currentState)
+            || Objects.equals(previousState.indexUUID, currentState.indexUUID) == false) {
             clearCache();
         }
     }
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/mapper/NativeRoleMappingStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/mapper/NativeRoleMappingStore.java
index 10dc984f9e6c3..dfe9f28289307 100644
--- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/mapper/NativeRoleMappingStore.java
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/mapper/NativeRoleMappingStore.java
@@ -326,8 +326,10 @@ private void reportStats(ActionListener<Map<String, Object>> listener, List<Expr
     }
 
     public void onSecurityIndexStateChange(SecurityIndexManager.State previousState, SecurityIndexManager.State currentState) {
-        if (isMoveFromRedToNonRed(previousState, currentState) || isIndexDeleted(previousState, currentState) ||
-            previousState.isIndexUpToDate != currentState.isIndexUpToDate) {
+        if (isMoveFromRedToNonRed(previousState, currentState)
+            || isIndexDeleted(previousState, currentState)
+            || Objects.equals(previousState.indexUUID, currentState.indexUUID) == false
+            || previousState.isIndexUpToDate != currentState.isIndexUpToDate) {
             refreshRealms(NO_OP_ACTION_LISTENER, null);
         }
     }
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java
index 959ecb6906669..fee763b07e156 100644
--- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java
@@ -506,8 +506,10 @@ public void usageStats(ActionListener<Map<String, Object>> listener) {
     }
 
     public void onSecurityIndexStateChange(SecurityIndexManager.State previousState, SecurityIndexManager.State currentState) {
-        if (isMoveFromRedToNonRed(previousState, currentState) || isIndexDeleted(previousState, currentState) ||
-            previousState.isIndexUpToDate != currentState.isIndexUpToDate) {
+        if (isMoveFromRedToNonRed(previousState, currentState)
+            || isIndexDeleted(previousState, currentState)
+            || Objects.equals(previousState.indexUUID, currentState.indexUUID) == false
+            || previousState.isIndexUpToDate != currentState.isIndexUpToDate) {
             invalidateAll();
         }
     }
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStore.java
index 869969af2c4cc..3b55d029d314e 100644
--- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStore.java
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStore.java
@@ -57,6 +57,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.locks.ReadWriteLock;
@@ -419,7 +420,9 @@ private static String toDocId(String application, String name) {
     }
 
     public void onSecurityIndexStateChange(SecurityIndexManager.State previousState, SecurityIndexManager.State currentState) {
-        if (isMoveFromRedToNonRed(previousState, currentState) || isIndexDeleted(previousState, currentState)
+        if (isMoveFromRedToNonRed(previousState, currentState)
+            || isIndexDeleted(previousState, currentState)
+            || Objects.equals(previousState.indexUUID, currentState.indexUUID) == false
             || previousState.isIndexUpToDate != currentState.isIndexUpToDate) {
             invalidateAll();
         }
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/CacheInvalidatorRegistry.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/CacheInvalidatorRegistry.java
index 4a487cae15360..9c68e92d9331c 100644
--- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/CacheInvalidatorRegistry.java
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/CacheInvalidatorRegistry.java
@@ -9,6 +9,7 @@
 
 import java.util.Collection;
 import java.util.Map;
+import java.util.Objects;
 import java.util.concurrent.ConcurrentHashMap;
 
 import static org.elasticsearch.xpack.security.support.SecurityIndexManager.isIndexDeleted;
@@ -34,6 +35,7 @@ public void registerCacheInvalidator(String name, CacheInvalidator cacheInvalida
     public void onSecurityIndexStageChange(SecurityIndexManager.State previousState, SecurityIndexManager.State currentState) {
         if (isMoveFromRedToNonRed(previousState, currentState)
             || isIndexDeleted(previousState, currentState)
+            || Objects.equals(previousState.indexUUID, currentState.indexUUID) == false
             || previousState.isIndexUpToDate != currentState.isIndexUpToDate) {
             cacheInvalidators.values().forEach(CacheInvalidator::invalidateAll);
         }
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/SecurityIndexManager.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/SecurityIndexManager.java
index b84f40547bf23..e74f62937a9a9 100644
--- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/SecurityIndexManager.java
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/SecurityIndexManager.java
@@ -189,8 +189,9 @@ public void clusterChanged(ClusterChangedEvent event) {
             final IndexRoutingTable routingTable = event.state().getRoutingTable().index(indexMetadata.getIndex());
             indexHealth = new ClusterIndexHealth(indexMetadata, routingTable).getStatus();
         }
+        final String indexUUID = indexMetadata != null ? indexMetadata.getIndexUUID() : null;
         final State newState = new State(creationTime, isIndexUpToDate, indexAvailable, mappingIsUpToDate, mappingVersion,
-                concreteIndexName, indexHealth, indexState, event.state().nodes().getMinNodeVersion());
+                concreteIndexName, indexHealth, indexState, event.state().nodes().getMinNodeVersion(), indexUUID);
         this.indexState = newState;
 
         if (newState.equals(previousState) == false) {
@@ -414,7 +415,7 @@ public static boolean isIndexDeleted(State previousState, State currentState) {
      * State of the security index.
      */
     public static class State {
-        public static final State UNRECOVERED_STATE = new State(null, false, false, false, null, null, null, null, null);
+        public static final State UNRECOVERED_STATE = new State(null, false, false, false, null, null, null, null, null, null);
         public final Instant creationTime;
         public final boolean isIndexUpToDate;
         public final boolean indexAvailable;
@@ -424,10 +425,11 @@ public static class State {
         public final ClusterHealthStatus indexHealth;
         public final IndexMetadata.State indexState;
         public final Version minimumNodeVersion;
+        public final String indexUUID;
 
         public State(Instant creationTime, boolean isIndexUpToDate, boolean indexAvailable,
                      boolean mappingUpToDate, Version mappingVersion, String concreteIndexName, ClusterHealthStatus indexHealth,
-                     IndexMetadata.State indexState, Version minimumNodeVersion) {
+                     IndexMetadata.State indexState, Version minimumNodeVersion, String indexUUID) {
             this.creationTime = creationTime;
             this.isIndexUpToDate = isIndexUpToDate;
             this.indexAvailable = indexAvailable;
@@ -437,6 +439,7 @@ public State(Instant creationTime, boolean isIndexUpToDate, boolean indexAvailab
             this.indexHealth = indexHealth;
             this.indexState = indexState;
             this.minimumNodeVersion = minimumNodeVersion;
+            this.indexUUID = indexUUID;
         }
 
         @Override
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java
index b4fc0d5a0abce..1840ac4bf522b 100644
--- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java
@@ -2029,6 +2029,6 @@ private void setCompletedToTrue(AtomicBoolean completed) {
 
     private SecurityIndexManager.State dummyState(ClusterHealthStatus indexStatus) {
         return new SecurityIndexManager.State(
-            Instant.now(), true, true, true, null, concreteSecurityIndexName, indexStatus, IndexMetadata.State.OPEN, null);
+            Instant.now(), true, true, true, null, concreteSecurityIndexName, indexStatus, IndexMetadata.State.OPEN, null, "my_uuid");
     }
 }
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/esnative/NativeRealmTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/esnative/NativeRealmTests.java
index a5e087e1cd8a0..3556f826c9e33 100644
--- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/esnative/NativeRealmTests.java
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/esnative/NativeRealmTests.java
@@ -31,7 +31,7 @@ public class NativeRealmTests extends ESTestCase {
 
     private SecurityIndexManager.State dummyState(ClusterHealthStatus indexStatus) {
         return new SecurityIndexManager.State(
-            Instant.now(), true, true, true, null, concreteSecurityIndexName, indexStatus, IndexMetadata.State.OPEN, null);
+            Instant.now(), true, true, true, null, concreteSecurityIndexName, indexStatus, IndexMetadata.State.OPEN, null, "my_uuid");
     }
 
     public void testCacheClearOnIndexHealthChange() {
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/mapper/NativeRoleMappingStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/mapper/NativeRoleMappingStoreTests.java
index f657662ccf11c..ebdcfd6f2c399 100644
--- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/mapper/NativeRoleMappingStoreTests.java
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/mapper/NativeRoleMappingStoreTests.java
@@ -151,7 +151,8 @@ private SecurityIndexManager.State dummyState(ClusterHealthStatus indexStatus) {
 
     private SecurityIndexManager.State indexState(boolean isUpToDate, ClusterHealthStatus healthStatus) {
         return new SecurityIndexManager.State(
-            Instant.now(), isUpToDate, true, true, null, concreteSecurityIndexName, healthStatus, IndexMetadata.State.OPEN, null);
+            Instant.now(), isUpToDate, true, true, null, concreteSecurityIndexName, healthStatus, IndexMetadata.State.OPEN, null, "my_uuid"
+        );
     }
 
     public void testCacheClearOnIndexHealthChange() {
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java
index dcccf991c2841..4b57ce3547dcf 100644
--- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java
@@ -810,7 +810,8 @@ private SecurityIndexManager.State dummyState(ClusterHealthStatus indexStatus) {
 
     public SecurityIndexManager.State dummyIndexState(boolean isIndexUpToDate, ClusterHealthStatus healthStatus) {
         return new SecurityIndexManager.State(
-            Instant.now(), isIndexUpToDate, true, true, null, concreteSecurityIndexName, healthStatus, IndexMetadata.State.OPEN, null);
+            Instant.now(), isIndexUpToDate, true, true, null, concreteSecurityIndexName, healthStatus, IndexMetadata.State.OPEN, null,
+            "my_uuid");
     }
 
     public void testCacheClearOnIndexHealthChange() {
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStoreTests.java
index fc9a3824893d1..adab224deb4b6 100644
--- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStoreTests.java
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStoreTests.java
@@ -611,7 +611,7 @@ private SecurityIndexManager.State dummyState(
         String concreteSecurityIndexName, boolean isIndexUpToDate, ClusterHealthStatus healthStatus) {
         return new SecurityIndexManager.State(
             Instant.now(), isIndexUpToDate, true, true, null,
-            concreteSecurityIndexName, healthStatus, IndexMetadata.State.OPEN, null
+            concreteSecurityIndexName, healthStatus, IndexMetadata.State.OPEN, null, "my_uuid"
         );
     }
 
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/CacheInvalidatorRegistryTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/CacheInvalidatorRegistryTests.java
index 46c444afd738d..137fedbddf5e0 100644
--- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/CacheInvalidatorRegistryTests.java
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/CacheInvalidatorRegistryTests.java
@@ -49,7 +49,7 @@ public void testSecurityIndexStateChangeWillInvalidateAllRegisteredInvalidators(
         final SecurityIndexManager.State previousState = SecurityIndexManager.State.UNRECOVERED_STATE;
         final SecurityIndexManager.State currentState = new SecurityIndexManager.State(
             Instant.now(), true, true, true, Version.CURRENT,
-            ".security", ClusterHealthStatus.GREEN, IndexMetadata.State.OPEN, null);
+            ".security", ClusterHealthStatus.GREEN, IndexMetadata.State.OPEN, null, "my_uuid");
 
         cacheInvalidatorRegistry.onSecurityIndexStageChange(previousState, currentState);
         verify(invalidator1).invalidateAll();
diff --git a/x-pack/plugin/transform/src/main/java/org/elasticsearch/xpack/transform/Transform.java b/x-pack/plugin/transform/src/main/java/org/elasticsearch/xpack/transform/Transform.java
index 54a2b3fd56974..3c36c03186518 100644
--- a/x-pack/plugin/transform/src/main/java/org/elasticsearch/xpack/transform/Transform.java
+++ b/x-pack/plugin/transform/src/main/java/org/elasticsearch/xpack/transform/Transform.java
@@ -370,4 +370,14 @@ public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings sett
             throw new UncheckedIOException(e);
         }
     }
+
+    @Override
+    public String getFeatureName() {
+        return "transform";
+    }
+
+    @Override
+    public String getFeatureDescription() {
+        return "Manages configuration and state for transforms";
+    }
 }
diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/Watcher.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/Watcher.java
index 7ba91d93bd90f..e7b1ef0aa81cb 100644
--- a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/Watcher.java
+++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/Watcher.java
@@ -700,4 +700,14 @@ public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings sett
             new SystemIndexDescriptor(TriggeredWatchStoreField.INDEX_NAME, "Used to track current and queued Watch execution")
         );
     }
+
+    @Override
+    public String getFeatureName() {
+        return "watcher";
+    }
+
+    @Override
+    public String getFeatureDescription() {
+        return "Manages Watch definitions and state";
+    }
 }